v1.6.1-rc.9
Full Changelog: v1.7.0-rc.10...v1.6.1-rc.9
[1.6.1-rc.9] — 2026-09-05
Changed
- The release cut now verifies a base image pin is a multi-platform index before building, and checks the published arm64 and amd64 images' own binaries before signing and tagging them. The v1.7 line shipped an arm64 image that was actually amd64 wearing an arm64 label, because a base image digest pin named a single-platform manifest instead of the multi-arch index, and buildx resolved that digest the same way for every
--platform(#1021). The 1.6 line was never affected — itsnode:24-alpineandalpine:3.24pins were never rolled to the bad digests — but the release cut now runscheck-dockerfile-base-indexes.shagainst the Dockerfile'sFROMpins andcheck-image-arch.shagainst each published platform's/sbin/tini,/usr/local/bin/nodeand/bin/healthcheckbefore promoting, so the same class of bug can't ship silently again.
Fixed
watch()still pruned on an empty container list, the same gap the rc.7 fix closed forhandleWatcherSnapshotEvent()but never actually closed here. The rc.7 CHANGELOG entry claimedwatch()was already among the guarded entry points; it wasn't. It ranprocessAuthoritativeContainers()first and thenpruneOldContainers()unconditionally, so a manual recheck of a watcher that came back with zero containers — the same ambiguous-empty-list case as the other three entry points — pruned every container that watcher owns, deleting them withoutreplacementExpectedand losing whatever update policy override they had with nothing left to restore it from.watch()now builds the container list first, prunes it before ingest (so a same-identity replacement's stashed update policy exists forinsertContainer()to restore), and skips the prune entirely on an empty list, warning instead once the agent has connected before. Ported from the v1.7 line (#922). (#565)- The other three container-reconcile entry points had the same insert-before-prune ordering
watch()was fixed for above, so an agent-owned container recreated while the agent was disconnected still lost its update policy on reconnect._doHandshake(),handleWatcherSnapshotEvent()andhandleContainerSync()all ingested the incoming inventory throughprocessAuthoritativeContainers()before callingpruneOldContainers(), soinsertContainer()'s restore of a same-identity replacement's stashed update policy ran beforedeleteContainer(..., {replacementExpected: true})had written anything to restore. A snooze, a maturity mode and minimum age, and skipped tags or digests all silently reverted to the declarative default the moment a recreated container's next report arrived through any of these three paths, on a handshake, a watcher snapshot, or an edge agent's container sync. Each now prunes first, same aswatch(), with the same zero-container guard on the two paths where an empty inventory is ambiguous (a fresh agent-side store versus a genuinely empty host): the prune is skipped and a warning logged instead. ReorderinghandleWatcherSnapshotEvent()also moves thedd:container-removedevent for a container going away ahead of thedd:container-added/dd:container-updatedevents for the batch replacing it, which is what the v1.7 line already does; the audit subscriber's per-identity dedupe state benefits from the same reorder, since a removal that used to run after the replacement's own container-added had already reseeded its state now clears only the stale entry the removal actually belongs to. Ported from the v1.7 line (#922). - The ownership gate described in the Security entry below ships already keyed on the container ids the controller's own watchers have actually enumerated, never on watcher name. The v1.7 line's original rc.6 gate (#922) decided a no-record container's ownership by matching the reporting watcher's name against the controller's own registered watchers, and that name collides by default: a controller with no
DD_WATCHER_*registers its default watcher aslocal, and the agents quickstart setsDD_WATCHER_LOCAL_SOCKETon the agent, so both sides are routinely calledlocalwhile watching entirely different hosts. On v1.7 that refused every agent container on a fresh store, and fed the prune keep-set besides, so a recreated agent container permanently lost its stored row the first time it changed id. That watcher-name version never shipped in a 1.6 release, so there is no upgrade regression to describe here: this line's gate goes straight to the corrected form. Each controller-local Docker watcher now records the container ids itslistContainers()call returns, replacing that set every enumeration cycle and clearing it when the watcher is deregistered, and a no-record claim from an agent is refused only when its id is one of those, naming the watcher currently holding it. That enumeration also runs once at watcherinit(), awaited before the startup timer arms the watcher's first scheduled scan, because an agent handshake reaches the ownership gate as soon as the registry finishes initializing, well before that first scan would otherwise have run; without the seed, a fresh store had a window where an agent could claim a container id that actually lived on the controller's own host. A daemon that cannot be reached during that seed logs a warning and does not block registration; the watcher's regular enumeration cycle gets another chance once it starts. - A rollback of a compose-managed container redeployed the update it was undoing.
Dockercompose.recreateContainerwrote the image it was handed into the compose file and then handed the runtime refresh no image at all, so the refresh re-derived one from the container's own update candidate. The compose file said the backup and the container that came up ran the update, while the API answeredContainer rolled back successfullyand the audit row recorded a success. Clearing the update result did not help either, because the fast resync that follows an update had already moved the container's tag to the new version by the time anyone rolled back. The recreate now passes the caller's image through to the refresh, so the container, the compose file and the report agree. Automatic rollback on a failed healthcheck never got that far on a compose stack at all: the health monitor called the trigger with only the container's id and name, and the compose action read the registry, the watcher and the compose labels off the container to find the service to rewrite, so it threw a TypeError before it reached the compose file. That failure was logged as an auto-rollback failure and recorded in anauto-rollbackaudit row carrying the error, neither of which stopped the unhealthy container from staying up on the version that had just failed its healthcheck. The monitor now hands over the whole container, carrying the id of the replacement it is rolling back, and the container type it arrives as requires the registry and the watcher, so the stand-in that caused this cannot compile again. - An automatic rollback after a failed healthcheck never pulled the image it was rolling back to, and could leave the failing update running instead. The health monitor logged
Auto-rollback: pulling backup image <ref>and then went straight to the recreate. Neither recreate pulls for itself: the Docker one goes straight to container creation, and the compose one runs its runtime refresh with the pull skipped, because the manual rollback inapp/api/backup.tsalready pulls before it calls in and a second pull there would be wasted work. A backup image no longer on the host therefore failed at create, and on both paths the running container was already gone by then. On the Docker path the stop and remove ran first. On the compose path the runtime refresh removed the container itself, and when the create failed its rollback net restored the container from the spec captured beforehand, whose image is the failing update the rollback existed to undo. The automatic rollback now pulls the backup reference before it touches anything, resolving pull credentials the same way every other pull on the trigger does, and the compose refresh refuses an image it cannot find locally before the stop and remove rather than at create, so a rollback that cannot happen leaves the running container where it is. That pull is skipped when the backup image is already on the host, so a rollback on a host with no route to the registry, or one hitting a Docker Hub anonymous rate limit, still restores from the image the prune retained. - A container that moved to an agent stayed stranded when the controller's local watcher was turned off.
DD_LOCAL_WATCHER=falseleaves the controller with no local watcher registered, and the startup prune that clears records naming a watcher that no longer exists skipped that case entirely, so the old controller-owned record survived every restart. The agent's report for the same container was then refused as owned by the controller, the record was never rewritten to name the agent, and the container sat on whatever it last said. The prune now runs when no local watcher was configured in the first place. It still leaves the store alone when watchers were configured and all of them failed to register, which looks identical in the registry but means something transient rather than an operator decision. A snooze, a maturity mode and any skipped tags or digests set from the UI live on that record, and they travel with the container rather than dying with it: the prune stashes them under the container's Docker id and the agent's copy inherits them when it arrives. - A container handed from the controller to an agent came up with its update policy cleared. The startup prune deletes the controller's record so the agent can claim the container, and it deleted it the way it deletes a container that is gone for good. Nothing would have helped if it had asked for a replacement either: the stash that carries an update policy across a delete is keyed on agent plus watcher plus name, and a move to an agent changes all three, so the incoming record looked up a key nothing had ever written. A snooze, a maturity mode and minimum age, and skipped tags or digests were all silently back to defaults, which for a maturity gate means the next update goes out with no soak at all. The prune now stashes the policy under the container's Docker id, which is the one thing the move leaves alone because it is the same physical container on both sides, and a record arriving under a new identity with that id inherits it. The identity-keyed stash is untouched and still covers the opposite move, a recreate, where the id changes and the identity does not. The same fix covers a watcher rename, which loses the policy for the same reason. A container recreated as part of the move mints a new Docker id and matches neither key, so that one still starts from the declarative policy.
- One local watcher failing to register deleted its containers' records when a second watcher registered fine. The startup prune keeps records whose watcher is in the registry, and #1025 only protected the case where every configured watcher failed. With two configured and one of them broken by an unreadable CA file, a socket that is not there, or a value the schema rejects, the surviving watcher keeps the registered set non-empty, so the broken watcher's records were pruned as though the operator had renamed it away, and it came back to an empty slate once its configuration was fixed. The prune now keeps any record whose watcher is still present in
DD_WATCHER_*, whether or not it registered this run, and only prunes a watcher that is neither registered nor configured.registerWatchersalso waits for every registration to settle instead of returning the moment one rejects, so the prune reads the registry the run actually produced rather than whichever watchers happened to have landed by the time another one failed.
Security
- Bulk and incremental container ingestion had no ownership check at all, and the prune that runs ahead of bulk ingestion had none either. Handshake, the watcher-snapshot fallback, on-demand
watch/watchContainer, edgehandleContainerSync, and the incrementaldd:container-added/dd:container-updated/dd:container-removedevent handlers all reached the store with nothing checking that the reporting agent actually owns the container id it named, so a connected agent could take over, or delete, a container id already held by another agent or by the controller's own local watcher.processAuthoritativeContainernow runs a no-existing-record and different-owner check before any of those paths insert, update, or delete a row, and the incremental event handlers run the same check, including its shapeless-payload guard (a warning is logged rather than the payload being dropped silently).pruneOldContainersruns the identical check before building its keep-set and its#496replacement-identity match, closing a second gap: an agent reporting only a container id it does not own, that happened to reuse the name of a container the agent does own, previously took part in that replacement match and caused the agent's own row to be deleted withreplacementExpected: trueinstead of surviving the cycle untouched. The incremental event handlers additionally reject awatchermismatch against an existing owned record, which the bulk ingestion paths deliberately do not: bulk ingestion is exactly how a legitimate watcher rename propagates (an operator renaming aDD_WATCHER_<NAME>_SOCKETkey), and rejecting on that mismatch there would silently stop every future report for that container id from landing, with nothing left to prune or retry it. An incremental event names one container directly rather than reporting the agent's whole inventory, so a mismatched watcher on that path is far more likely to be a stale or spoofed event than a rename in flight. A no-record claim is decided by the container ids the controller's own watchers have actually enumerated rather than by watcher name; see the Fixed entry above for why that distinction matters. Ported from the v1.7 line. (#922)
Note: this is a maintenance cut, built from dev/v1.6 at ee6690284402e557ec35c7af8c7a1d7aa10262a6, not from main.
- The container image and release artifact are cosign-signed (identity
release-cut.yml@refs/heads/main, since the workflow run itself always executes at that ref), but carry no SLSA build-provenance attestation — this workflow's own OIDC token always claimsmainHEAD as the build source, which would be false for this artifact, so attestation is skipped rather than publish a false claim.