Skip to content

Commit ca2e54e

Browse files
IRusclaude
andcommitted
Release 0.4.0: liveness-gated updates, self-recovery and a contract the fake answers to
Cuts the accumulated Unreleased section as 0.4.0 and brings the version in build.gradle.kts back to reality: it had read 1.0.0 since the rewrite while the tags went v0.2.0/v0.3.0, which was TODO #6 — now resolved by picking reality over the placeholder. Also records what the last three commits changed but did not document: - the DockerClient contract both implementations answer to, and the randomized parse/dechunk properties, in CHANGELOG "Added"; - the three chunk headers that got past the truncation guard (a signed "-1" reaching a byte-range copy, "-0" read as a terminator, and pos+size overflowing the bounds check into its own opposite), in CHANGELOG "Fixed"; - DockerApiContractTest in E2E_TESTING.md, including why it is the one test in the suite that does not run by default; - the contract as an enforced invariant in CLAUDE.md, so that behaviour the fake grows lands in the contract rather than in the fake alone. TODO gains the gap this leaves: the daemon half of the contract runs nowhere in CI, because DockerApi is unix-socket-only and the suite's DinD is TCP-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9d137c3 commit ca2e54e

5 files changed

Lines changed: 56 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [0.4.0] - 2026-07-28
10+
911
### Added
1012
- Liveness gate before anything irreversible: a replacement container is probed for
1113
`KODKOD_UPDATE_VERIFY_SECONDS` (default 15) after `start`, and the old container and image are only
@@ -53,6 +55,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5355
daemon, before the dind suite. It cannot run against dind — `DockerApi` speaks only to a unix socket
5456
— so without it the half that catches the fake inventing behaviour runs nowhere automatically, which
5557
is how both inventions it has found so far were found by hand.
58+
- One `DockerClientContract` that both implementations have to answer to: `FakeDockerClient` runs it on
59+
every `./gradlew test`, and `DockerApi` runs the same class against a real daemon under
60+
`-Pkodkod.e2e.useCurrentDocker=true`. The fake can no longer be more forgiving than the daemon it
61+
stands in for, which is what the unit suite has been trusting it to be — the first run of the pair
62+
caught it reporting a just-created container as `running`, where Docker (and the fake's own listing)
63+
call that state `created`.
64+
- Randomized round-trip properties over the hand-rolled HTTP/1.1 parsing, seeded so that a red run
65+
names the one seed needed to reproduce it. The parser reads undertrusted bytes off a socket and every
66+
caller treats what comes back as the daemon's word, so it is held to two outcomes only — a whole
67+
answer or an error, never a short answer that looks whole. These found the chunk-header defects below.
68+
- Model-based testing of the update cycle (`UpdateCycleModelTest`): stacks are generated rather than
69+
written down — random monitored containers, random staleness, random shared network namespaces,
70+
unmanaged bystanders and unmanaged sidecars, and injected create/start/remove failures — and each
71+
generated world is checked against what the daemon may never look like once a cycle has finished,
72+
whatever that cycle did. Four properties: no two live containers hold one name, no running container
73+
is left joined to a namespace that is gone (a provider that was destroyed is always kodkod's fault; a
74+
provider the daemon refuses to start is only its fault if it says nothing), a container kodkod does
75+
not manage is never touched, and whatever a cycle leaves stopped the next cycle reaches for again.
76+
Every defect this project has had to unship was three or four events deep, which is the depth a
77+
generator reaches and a story-teller does not. Each property is mutation-proven rather than assumed:
78+
disabling the create-time dependent refresh, the enable-label filter, or the tracking of stopped
79+
containers turns exactly one of them red. Failures print the seed and the world, so a red run is a
80+
fixture that can be pasted into `UpdaterTest` as a story somebody has now thought of.
81+
- `DockerClientContract` gained the other half of the name index: `create` under a name another
82+
container already holds is refused, exactly as `rename` is. The fake had been allowing it, so a
83+
recreate could put a second container on the service name — which no daemon permits, and which the
84+
ordering of the recreate path (rename the original away *first*) exists precisely to avoid.
85+
Confirmed against Docker 29.6.2. A payload a test registers under the id `create` is about to assign
86+
stays legal: that is the container being described before it exists, not a second holder of its name.
5687

5788
### Changed
5889
- The update cycle is split into a read-only `plan()` (list, inspect, registry probe, pull) and a
@@ -154,6 +185,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
154185
its own name, which was refused and reported as two ERRORs about a container that never moved.
155186
- Chunked responses that were cut off mid-body are reported as transport errors instead of being
156187
returned as short-but-complete answers (an empty container list, an image with no tags).
188+
- Three chunk headers that got past that guard. `toIntOrNull(16)` accepts a sign RFC 9112 does not
189+
allow: `-1` reached a byte-range copy as a negative length — an `IndexOutOfBoundsException` no caller
190+
is written against — and `-0` read as the terminating chunk, so the prefix that had arrived was
191+
returned as the whole answer, which is precisely the silent truncation the guard exists to prevent. A
192+
size near `Int.MAX_VALUE` overflowed `pos + size` to a negative number, turning the bounds check into
193+
its own opposite and handing the range straight to the copy. The size token is now required to be
194+
`1*HEXDIG`, and the bounds check subtracts instead of adding.
157195
- Image references are escaped into request paths, an empty environment variable no longer reads as
158196
`false`, and the compose service key no longer embeds a literal NUL byte in the source.
159197

CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@ recreate containers whose image tag moved (update). Kotlin, one runtime dependen
4343
that runs more than one cycle. Keep it honest: a fake that keeps a removed container, or that
4444
matches every `health` filter regardless of the modelled health, makes the code that tells those
4545
cases apart untestable.
46+
- **That honesty is now enforced, not just asked for.** `DockerClientContract` (`src/testFixtures`)
47+
is one suite both implementations answer to: `FakeDockerClientContractTest` runs it against the fake
48+
on every `./gradlew test`, `DockerApiContractTest` runs the same class against a real daemon under
49+
`-Pkodkod.e2e.useCurrentDocker=true`. A behaviour the unit suite is allowed to assume therefore has
50+
to be one Docker was made to demonstrate. New behaviour the fake grows belongs in the contract, not
51+
in the fake alone — that is the only thing standing between a green unit suite and a fake that
52+
invented the daemon it stands in for.
4653

4754
## Fixture corpus (record/replay)
4855

E2E_TESTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ src/e2eTest/kotlin/io/heapy/kodkod/e2e/KodkodE2eTest.kt # the suite + E2eHa
336336
src/e2eTest/kotlin/io/heapy/kodkod/e2e/DockerFixtureRecorder.kt
337337
src/e2eTest/kotlin/io/heapy/kodkod/e2e/FixtureWriter.kt
338338
src/e2eTest/kotlin/io/heapy/kodkod/e2e/FixtureWriterTest.kt
339+
src/e2eTest/kotlin/io/heapy/kodkod/e2e/DockerApiContractTest.kt # the contract, against a real daemon
340+
src/testFixtures/kotlin/io/heapy/kodkod/DockerClientContract.kt # shared by both contract tests
341+
src/test/kotlin/io/heapy/kodkod/FakeDockerClientContractTest.kt # the contract, against the fake
339342
src/main/kotlin/io/heapy/kodkod/DockerTransport.kt # the seam both sides plug into
340343
src/main/kotlin/io/heapy/kodkod/UnixSocketTransport.kt # production transport, what the recorder wraps
341344
src/testFixtures/kotlin/io/heapy/kodkod/DockerRecording.kt # recording/replay transports + the corpus types

TODO.md

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,22 @@
1616
5. `--link` заявлен как поддерживаемая create-time зависимость, и зависимые теперь пересоздаются вместе с
1717
провайдером, но сам `HostConfig.Links` при recreate передаётся как есть — watchtower нормализует его в
1818
отдельном `GetCreateHostConfig`.
19-
6. Версии разъехались: `build.gradle.kts` = 1.0.0, а теги — v0.2.0/v0.3.0. Решить: резать 1.0.0 или вернуть
20-
версию к реальности.
2119

2220
## Надёжность и корректность
2321

24-
7. `subtractImageDefaultsByKey` теряет пользовательские `Cmd`/`Entrypoint`/`User`/`WorkingDir`.
25-
8. `pull` буферизует весь прогресс-стрим в памяти.
26-
9. `DockerApi.create` бросает NPE без сообщения, когда в ответе нет `Id`.
27-
10. `isSelf` использует `id.startsWith(selfId)` — префиксное сравнение id.
22+
6. `subtractImageDefaultsByKey` теряет пользовательские `Cmd`/`Entrypoint`/`User`/`WorkingDir`.
23+
7. `pull` буферизует весь прогресс-стрим в памяти.
24+
8. `DockerApi.create` бросает NPE без сообщения, когда в ответе нет `Id`.
25+
9. `isSelf` использует `id.startsWith(selfId)` — префиксное сравнение id.
2826

2927
## Тесты и покрытие
3028

31-
11. Multi-arch digest не проверен: `UpdaterTest` гоняет одиночные digest'ы, e2e — single-arch busybox. Сравнение
29+
10. Multi-arch digest не проверен: `UpdaterTest` гоняет одиночные digest'ы, e2e — single-arch busybox. Сравнение
3230
index-digest, скорее всего, корректно, но это классический watchtower-footgun без теста.
33-
12. Health-ветка liveness-гейта (`unhealthy` / `starting`) покрыта только юнит-тестами: и рекордер, и e2e идут с
31+
11. Health-ветка liveness-гейта (`unhealthy` / `starting`) покрыта только юнит-тестами: и рекордер, и e2e идут с
3432
`KODKOD_UPDATE_VERIFY_HEALTH=false`, потому что иначе число проб — гонка между интервалом проб и
3533
healthcheck'ом замены. Нужен детерминированный e2e (образ с предсказуемым healthcheck'ом).
36-
13. Grace-хук шатдауна в `Main.kt` (`awaitTermination``shutdownNow`) не покрыт тестом: `main()` не
34+
12. Grace-хук шатдауна в `Main.kt` (`awaitTermination``shutdownNow`) не покрыт тестом: `main()` не
3735
разбирается на тестируемые части. Из него проверен только `ConfigTest.reads_the_shutdown_grace_period`.
3836
Чтобы покрыть — выделить планировщик и хук в отдельную тестируемую функцию.
3937
14. Дублирование в тестах: хелперы `updateConfig`/`autohealConfig` рекордера и `config(...)` юнит-тестов.

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ plugins {
1313
}
1414

1515
group = "io.heapy"
16-
version = "1.0.0"
16+
version = "0.4.0"
1717

1818
val junitVersion = "6.1.2"
1919
val serializationVersion = "1.11.0"

0 commit comments

Comments
 (0)