Repository navigation
chore(release): sync v1.8.0-rc.3 to main - #1437
Conversation
* docs(hooks): say the pre-hook abort is the default and name the opt-out (#1001) (cherry picked from commit d62e2d4) * chore(release): carry the v1.7.0-rc.10 release identity into 1.8 Same shape as #993 carried rc.8 and rc.9. The CHANGELOG keeps 1.8's own [Unreleased] on top and gains a [1.7.0-rc.10] section below it, copied verbatim from dev/v1.7; the once-reservation and cron-deadline bullet that section carries is removed from [Unreleased], where it landed with the 1.8-side backport in #997. The comparison-link chain gains the tag and [Unreleased] now compares from v1.7.0-rc.10. The README and the six translations gain the rc.10 highlights block, the updates page gains the matching release notes, and the demo mocks, site config, quickstart tag table, and the agent/app/portwing API samples move from 1.7.0-rc.9 to 1.7.0-rc.10. The readme-translations, changelog-links, release-identity, release-docs-identity and release-precut-check tests move with them. The 1.8-only surfaces stay 1.8's: the trigger and action counts, the Portainer rows, and the v1.8.0 roadmap line in README.md are untouched.
…ot (#1007) * refactor(triggers): fold the once gate into reserveOnceNotificationSlot * refactor(triggers): mirror the once gate in releaseOnceNotificationSlot
…#1009) * fix(rollback): prefer the backup's captured digest when pinning the rollback image selectPulledRepoDigest disambiguates a local image carrying several RepoDigests under one repository by preferring container.result.digest, the manifest the watcher resolved for the image being pulled. That is the right answer on the update path and the wrong one on a manual rollback, which pulls the backup's retained image: the candidate digest describes the update the rollback exists to undo. A republished multi-arch index leaves both digests on the same local image, which is exactly when the tie-break runs, so the rollback could pull, gate and recreate from the candidate's manifest while reporting it had restored the backup. The identity binder now takes the preferred digest from its caller. Passing the option at all replaces the candidate digest, including when the value inside it is undefined, so resolveRollbackImageReference hands over what its backup record captured and nothing else. With nothing to match, the tie-break falls through to the registry manifest lookup for the backup's own tag, which is the manifest the rollback wants. The update path passes no option and is unchanged. * test(compose): pin the per-replica hook and prune rate under compose-file-once DR-66 reported that a compose-file-once replica never runs the deferred pre-update hook or the prune/backup step, because buildPerformContainerUpdateOptions passes no post-pull hook once postPullGateCompleted is set. It does not: createTriggerContext returns deferPreRuntimeUpdateLifecycle false for exactly that case, so the lifecycle runs the pre-update hook and runPreRuntimeUpdateLifecycle up front instead of behind a hook, and both still run for every replica. What no test covered is the rate. Compose-file-once pulls once per service and recreates every replica, so the gate, the hook and the prune/backup step each run at one of two rates and nothing in the lifecycle names either. This pins them for a two-replica service: one pull, a gate per replica in the preflight and never again inside a refresh, and a pre-update hook, prune and backup record per replica. Verified as a guard by forcing deferPreRuntimeUpdateLifecycle true, which moves those steps behind a hook the compose-file-once path does not pass and fails the test. * fix(rollback): fail closed when the preferred digest cannot be matched selectPulledRepoDigest never returned undefined once it had two or more candidates. Past the reference check and the registry manifest lookup it picked the lexicographically first RepoDigest and only warned, so handleMissingPulledImageIdentity was unreachable from there. On the rollback path that is a coin flip: the retained image can carry both the backup's manifest and the update candidate's, and with the registry unreachable or the backup's tag gone, the rollback would pull, gate and recreate from whichever digest sorted first while reporting it had restored the backup, which contradicts the contract in app/util/backup.ts. The tie-break now knows whether the caller named the image this pull is for. When it did and nothing matched, the digest resolves to undefined, the binder goes through handleMissingPulledImageIdentity, and the policy decides: refuse the rollback under `required`, warn and fall back to the mutable tag under the permissive value. The update path passes no options and is byte-for-byte unchanged, deterministic pick included. PulledImageIdentityOptions.preferredDigest is now required and nullable rather than optional, so `{}` can no longer drop the update path's candidate tie-break while looking like it asked for nothing. capturePulledImageIdentity normalises null to undefined and passes the caller-pinned flag alongside it; resolveRollbackImageReference sends `{ preferredDigest: backup.imageDigest ?? null }`. - test(rollback): pin the required/permissive outcomes of an unbreakable tie, and model the integration test on production — a running image ID that differs from the retained one, so the same-image guard actually runs, and a container tag distinct from the backup's, so the manifest-lookup assertion pins the backup's tag - test(compose): assert the full per-replica call order under compose-file-once instead of bare membership - docs(changelog): reword the DR-64 entry as hardening, since no path was found where a rollback actually reached the old tie-break
…1012) * feat(config): add a maintenance window scope vocabulary and env alias Adds DD_WATCHER_{name}_MAINTENANCE_WINDOW_SCOPE, parsed by the same longest-suffix-first alias table that now also maps _MAINTENANCE_WINDOW and _MAINTENANCE_WINDOW_TZ, plus the shared install/scan vocabulary the watcher and the trigger both resolve against. Default is install. Refs: #946 * feat(triggers): defer automatic installs until the maintenance window opens Under the install scope a closed window no longer stops discovery or notification; it only holds the automatic install back. The deferral queues a maintenance-window catch-up watch on the container's watcher and counts the deferred install on the existing maintenance skip metric, whose help text now names both cases. Batch and digest sends stop recording an update as notified when it was deferred, so the once=true dedupe cannot swallow the rescan that fires when the window opens. Manual API updates stay admitted under either scope. Refs: #946 * feat(watchers): scope the maintenance window to installs by default The scheduled scan no longer stops at a closed maintenance window unless the watcher sets maintenancewindowscope=scan. Under the new install default the cron scan runs on schedule, so new containers appear, container state stays fresh and update notifications go out on time, and only the automatic install waits for the window. nextRunAt reports the plain cron time under install and the next in-window time under scan. Set DD_WATCHER_{name}_MAINTENANCE_WINDOW_SCOPE=scan to keep the pre-v1.8.0 behaviour byte for byte. Refs: #946 * fix(triggers): gate command actions on the maintenance window A `command` action was not window-gated at all. UPDATE_ACTION_TRIGGER_TYPES deliberately excludes `command` because it does not run the Docker update lifecycle, and the deferral hung off that set, so the window only reached docker, dockercompose and portainer. Under the old scan scope the window held a command action back indirectly: no scan, no report, no command. Under the install scope the scan always runs, so DD_ACTION_COMMAND_* fired its shell command the instant an update was detected, at any hour. The deferral now hangs off the action category instead of the update-action subset, so every action trigger defers and notification triggers keep sending. UPDATE_ACTION_TRIGGER_TYPES is untouched: it still means "routes through the admission queue" and has other consumers that depend on that meaning. The window pass and the dependency pass move out of runAcceptedUpdateBatch into collectMaintenanceWindowDeferredIds so the batch and digest paths that call triggerBatch directly (which is how a command action sends) share exactly the same decision. A batch with every container deferred sends nothing rather than an empty batch. * fix(watcher): stop an ordinary cron tick destroying a queued catch-up runCronWatch cleared the maintenance-window queue on every tick that got past the scan gate. Under the scan scope that was safe: the only arming site was the gate ten lines above, so reaching the clear meant the window was open, unconfigured, or explicitly ignored. Under the install scope the gate never fires and the only arming site is the trigger deferral, which runs from paths outside watch(). A digest flush at 08:00 armed the catch-up, the 12:00 cron scan cleared it, the digest handler only buffers so nothing re-armed it, and 02:00 arrived with no queue. The install never landed, every day. An arm taken by a webhook or a manual single-container scan was discarded the same way. The clear is now conditional on the scan being able to deliver what the queue holds: the catch-up scan itself, or a scan that runs while the window is open. That is exactly the set the scan scope could already reach, so its behaviour is unchanged. * fix(triggers): apply a digest action's deferred installs when the window opens The catch-up scan re-runs watch(), which is enough for the simple and batch paths: their dispatch hangs off the container report the scan emits, so the deferred install is applied there and then. A digest trigger's report handler only re-buffers. The install sat in the buffer until the next digest cron, up to a day after the window that was supposed to apply it and quite possibly after it closed again, which is not what the CHANGELOG and the docs promise. Delivery, not a documentation climbdown. A window that opens at 02:00 and a digest that flushes at 08:00 would otherwise install outside the window on every cycle, which is the thing the window exists to prevent, and telling operators "digest installs wait for the next digest cron" would leave the feature correct only for two of the three modes. The watcher now announces MaintenanceWindowOpened after its catch-up scan finishes, so the deferred containers are back in the store with fresh state before anything reads them. A digest trigger records which watchers held its installs back during a flush and flushes again when one of those announces, so a window opening on an unrelated watcher never brings a digest forward. * fix(triggers): do not arm the window catch-up on a deregistered watcher deferAutoUpdateForMaintenanceWindow called queueMaintenanceWindowWatch with no isWatcherDeregistered check, unlike every other arming site. A watcher sets that flag in its own teardown and clears its queue there, but registry deregisterComponent only removes the entry from the state map after component.deregister() resolves, so a trigger evaluating in that window resolves a torn-down watcher and publishes a fresh refed 60s timer on it. checkQueuedMaintenanceWindowWatch re-queues itself for as long as the window is closed, so nothing would ever clear it again. Also, deregisterAll tears watchers down before triggers, which is exactly the ordering that hits this. The counter still moves: the install really was held back. * fix(watcher): count deferred installs on their own metric dd_watcher_maintenance_skip_total kept its name and labels but silently changed unit: it meant one per skipped scan cycle per watcher, and the install-scope deferral made it one per deferred container per trigger evaluation. Fifty pending updates on a five-minute cron is roughly 14,400 samples a day against 288, so every existing rate alert and the Grafana panel built on it were measuring something else without anyone touching them. The skip counter goes back to its original meaning and help text. Under the install scope no scan is ever skipped, so it stays at zero there, which is correct rather than a regression. dd_watcher_maintenance_deferred_updates_total is the new one: same type,name labels, one sample per container per dispatch evaluation, help text saying so. A container held back because something it depends on was deferred is counted too, labelled by its own watcher, and the help text says that as well. It counts under either scope, because an install held back is an install held back. No double counting from a batch+digest trigger: it registers a batch handler and a digest handler, but the digest handler only buffers, so one scan cycle is one evaluation and one sample. The digest cron flush later is a second evaluation and a second sample, which is the documented unit. Pinned by a test. * fix(triggers): key the window deferral set on the business id An id-less container record put `undefined` in the deferred set, so every other id-less container in the same batch read as deferred too. The one that really was sent then kept its retry-buffer entry and went out again on the next cycle, and under `once=true` a genuinely deferred one could be recorded as notified. Key the returned set on the same stable business id the retry and digest buffers use, and keep a separate id-keyed view for the dependency-graph walk, which only ever sees ids buildDependencyGraph emitted. The literal "skip containers with no id" guard from recordNotifiedForResult would have been worse here: a deferred container with no id would read as not deferred and install outside the window. * docs(maintenance-window): say what the window actually gates The changelog and the watchers page still described the window as holding back installs, and the changelog claimed `dd_watcher_maintenance_skip_total` counts them. Both now match the code: every action trigger is gated, `command` included, notification triggers never are; the catch-up scan carries a simple or batch trigger and a window-opened announcement makes a digest trigger flush what it deferred; and deferred work has its own counter, `dd_watcher_maintenance_deferred_updates_total`, while the skip counter keeps its one-per-skipped-scan unit and stays at zero under the install scope. * style(maintenance-window): drop em dashes from the new prose and comments * fix(watcher): announce the window opening from the scan that consumes the catch-up The emit had one call site, the once-a-minute poll, but the poll is not the only thing that consumes an armed catch-up: any scan reaching runCronWatch while the window is open clears the queue, which also cancels the poll. With CRON=0 * * * * and a 02:00 window the cron tick fires at 02:00:00, a full minute before the poll would have, so it cleared the arm in silence, the digest handler only re-buffered, and the deferred install waited for the next digest cron. Every day, forever. runCronWatch now reads the armed flag before the clear and calls announceMaintenanceWindowOpened once the scan has finished, so the emit belongs to whichever scan actually consumed the arm. That makes the ordering claim true by construction rather than by luck: the announce runs inside the scan that just awaited watch(), so a coalesced caller can no longer be told the window opened before the catch-up ran. A failed announce is logged and swallowed, since the scan has already done its work by then. * fix(triggers): gate a command action's lifecycle dispatch on the window dispatchContainerForEvent short-circuits update-action types and nothing else, so a command action reached this.trigger() from the maturity-cleared path, driven by the maturity gate's own scheduler rather than by the watcher's scan. A window closed at 3pm did not stop it, which made the docs claim that all four action triggers are held back false. Gate the action category there the same way the simple dispatch path does: defer and arm the catch-up under the install scope. Deliberately not widening the update-action short-circuit instead, which would stop command actions firing on lifecycle events at all, with or without a window configured, and nothing has asked for that. A deferred lifecycle event is not re-delivered. The update itself still lands through the ordinary update-available path once the window opens, and replaying an update-applied hours after the fact would be worse than skipping it. * fix(triggers): keep the deferral marker when the digest flush is refused The window-opened handler deleted the watcher's marker and then called a flush that returns immediately while another flush is in progress. The marker is the entire record that this trigger deferred an install for that watcher, so spending it on a flush that never ran lost the announcement and left the install for the next digest cron, which is the wait the event exists to skip. Check the marker, bail while a flush is in progress, and only delete it on the path that actually flushes. Nothing is stranded: the in-flight flush re-arms the watcher's catch-up queue for whatever it defers, and the next scan to consume that queue announces again about a minute later. * fix(triggers): contain a failed window-opened flush inside its own handler emitOrderedHandlers awaits each handler in turn and does not catch, so a throw from this handler aborted the whole announcement: every trigger ordered after it was skipped, and the watcher logged a failed announcement rather than one trigger's failed flush. The pre-dispatch region of the digest flush sits outside its own try, so this was reachable. Catch inside the handler, log, and re-arm the marker, which keeps the same rule the refusal path uses: the marker is spent only by a flush that delivered. Deliberately not changing emitOrderedHandlers itself. Propagation is a documented contract there, asserted by two tests, and every handler registered for this event is this method, so containing it here covers the event completely. * docs(maintenance-window): say that agent containers are not deferred yet The deferral resolves the container's watcher out of the component registry and needs a live isMaintenanceWindowOpen on it, which only the controller's own Docker watcher has. Eligibility falls back to the masked maintenancewindowopen boolean an agent reports, so the dialog shows `maintenance-window-closed` for an agent-owned container while nothing actually holds its update back. The docs promised the deferred update is applied when the window opens, with no qualification. One sentence in each place that made the promise: the eligibility page, the getting-started window section, the watchers reference, the changelog and the release notes. * fix(triggers): only gate the window on a rule that still has an install ahead The gate sat below the rule, threshold and must-trigger checks, so it caught every rule a command trigger handles, not just maturity-cleared. None of the others is an install: update-applied and update-failed report on an update that already ran, security-alert on a scan, container-unhealthy on a health transition, agent-disconnect and agent-reconnect on connectivity. Deferring one of those destroys the report rather than postponing it, and nothing re-delivers it. A command action used as a post-update hook, with a 02:00 to 03:59 window and an operator clicking Update anyway at 15:00, applied the update and then never ran the hook, while the gate armed the catch-up poll and counted a deferred update for work that had already finished. That also contradicted the changelog and the watchers page, which both say manual updates are unaffected. Gate on an explicit PRE_INSTALL_NOTIFICATION_RULE_IDS set, currently maturity-cleared alone, checked before the defer call so a reporting rule touches neither the counter nor the queue. update-available never reaches this method; its own paths carry the gate. The set is named rather than an inline comparison so the next rule added has to make the decision on purpose. The synthetic agent containers are not relied on here: they escape today only because buildAgentContainer sets watcher: 'agent', which resolves to no registered watcher. * fix(prometheus): finish the deferred-updates metric help text
#1010, #1011) (#1014) * docs: cover registries on agents and what the maintenance window gates (#1010) * docs: cover registries on agents and what the maintenance window gates * docs: use TLS in the agent registry example and name the recheck action (cherry picked from commit 4d1a2f2) * docs(repo): retire the finished refactor plans and fix the docs README version table (#1011) * docs(repo): retire the finished refactor plans and fix the docs README version table queue-ux-refactor-plan.md and update-path-refactor-plan.md described work that has shipped in full (phase-only queue status a7e5263; requestContainerUpdate admission, recovery reconciliation, self-update operation kind). Their history is kept in the planning archive. docs/README.md still listed current as v1.5 with v1.4 and v1.3 snapshots. It now points at apps/web/scripts/docs-versions.mjs, which is what sync-docs.mjs and next.config.mjs actually read, instead of restating the list. * docs(readme): realign the roadmap table with the planning roadmap The v1.7.0 row still promised selective bulk updates, image prune and static image monitoring, all of which the roadmap moved to v1.8 after the GA re-rank. The v1.8.0 row listed the volume browser and upstream-base tracking for local images, both now post-v1.8, and the v2.0+ row promised a native Podman provider the roadmap explicitly does not plan. Rows now mirror the roadmap's version targets in the English README and the six translations, which the readme-translations test holds to the same link set. * docs(readme): fix the translation terms flagged in review docs/README.md now says the first docs-versions entry maps the current source directory to the active public slug. Polish: update ordering uses the feature name the comparison table already uses, the SQLite store is a magazyn not a sklep, and the web terminal is a terminal webowy. Portuguese: health gates keeps gate, matching the existing gate de imagem wording. (cherry picked from commit b0683bb) * docs(agents): mount the CA file the registries example points at
…ngle-container update (#1013) * fix(compose): honour the availability policy when a tag moves on a single-container update The post-create image-ID check from #984 exists so every container of a compose-file-once service runs the image the preflight's one gate decision cleared. It also fires on the ordinary compose path, where a local tag that moves between the pull and the create now refuses the update, removes the new container and rolls back. That is a new fail-closed mode: before #984 the update went through on whatever the tag pointed at. A moved tag on a single container is an availability-versus-certainty call, which is the one DD_SECURITY_AVAILABILITY_POLICY already settles for the security gate. The ordinary path now defers to it. Under warn the mismatch is logged with the expected and actual image IDs and the container stands; under block, the default, the refusal, the cleanup and the rollback are unchanged. The policy is read off the security gate the same way the Docker trigger reads it for an unbindable pulled image, so it applies whether or not a scanner is configured. Compose-file-once ignores the policy and still refuses. Its containers are all created against one preflight decision, so keeping a mismatched one would leave that container on an image the gate never cleared while its siblings run the image it did, which is a split service rather than a slower one. * fix(compose): key the moved-tag refusal on a preflighted identity The post-create image-ID check keyed its refusal on whether compose-file-once was enabled, which is the wrong question on both sides. Without compose-file-once every container of a service pulls and resolves its own image (skipPull is false), so under DD_SECURITY_AVAILABILITY_POLICY=warn a tag moving mid-batch left web-1, web-2 and web-3 on different builds while every operation recorded success. With it enabled, recreateContainer() and a direct updateContainerWithCompose() reach the same check carrying no preflight context at all and were refused for a shared decision nothing had made. The discriminator is now whether the identity being checked against arrived preflighted: runtimeContext.pulledImageId or runtimeContext.imageIdentity set at the entry of refreshComposeServiceWithDockerApi, read before the local capturePulledImageIdentity recovery can fill expectedImageId in for this container alone. It cannot be derived afterwards, because that recovery publishes the first replica's ID as the service's shared one (DR-54). Preflighted refuses whatever the policy says, since those containers all run on the one gate decision the preflight made. Otherwise the availability policy decides. On the unpreflighted path a moved tag mid-batch keeps going container by container under warn rather than aborting the batch: each container is already resolving its own image, so refusing the rest would trade a visible per-container divergence for a half-rolled-back service, and each kept container names its own actual image in the log. * test(compose): make the moved-tag cases match reachable security postures The DR-54 refusal test injected securityGateUnboundWarn: true and relied on the file-level configuration mock returning { enabled: false } for the availability policy to read as undefined. That combination cannot occur: the warning is only ever set under the optional identity binding policy, which needs a scanner enabled and the policy at warn. The test now spells out the default no-scanner posture with the policy at block, which is exactly what produces the preflight context it injects, and drops the impossible flag. Adds the case nothing covered: a scanner configured, availability policy warn, signature verification off, gate on, and an image with no matching RepoDigests. That is the only posture that reaches the skipSecurityGate branch in production, and the moved tag afterwards is kept rather than rolled back. The two DR-67 tests are renamed off "single-container": the discriminator is whether an identity arrived preflighted, not how many containers the service has. * feat(compose): audit a container kept on a moved tag Keeping the container under DD_SECURITY_AVAILABILITY_POLICY=warn leaves it running an image nothing verified or scanned, and the log line was the only trace. The path this branch is actually reached from is an install with no scanner, where the identity binding policy is disabled and never records the unbound-image skip, so no other audit row exists for it either. The keep branch now writes a security-scan-skipped row naming both the image the pull resolved to and the one the container came up on, matching what the unbound-image skip already records under the same policy. * fix(compose): guard the availability policy read against a missing config The read went through securityGate.securityConfig.getSecurityConfiguration() unguarded, where its sibling in Docker.getPostPullIdentityBindingPolicy uses optional chaining for the same access. A gate carrying no securityConfig would raise a TypeError from inside the recreate's own try, and the rollback net would report it as a failed replacement, so a wiring bug would be indistinguishable from a tag that actually moved. It now reads the same way, an unset policy is fail-closed, and the docblock records why the policy is read without gating on security.enabled. * docs(compose): scope the moved-tag check to what actually reaches it The compose trigger page claimed Drydock inspects every container it creates and compares its image ID against the pull. It only does that when the create reference fell back to the mutable tag; a container created from a bound digest never reaches the comparison. The paragraph also sat under Security Gate (Update Bouncer), where an install with no scanner has no reason to read it, even though that is the install the behaviour is aimed at. Reworded and moved to Failure safety, with a one-line cross-reference left in the gate section. The compose-file-once paragraph now says its unconditional refusal is unconditional because of the preflight, not in general. The DD_SECURITY_AVAILABILITY_POLICY row in the security reference says it also governs the moved-tag case and is read with no scanner configured. The unreleased #975/#984 changelog bullet said the refusal was not specific to any policy, which the new bullet contradicts. Since #984 has not shipped, that bullet is amended to scope the unconditional refusal to preflighted compose-file-once batches and hand the rest to the bullet below it. * fix(compose): treat the recovered-ID sink as a preflighted batch marker The preflighted predicate read only pulledImageId and imageIdentity, which leaves one replica out. A compose-file-once preflight whose own inspect throws records neither, so its first replica arrives with both undefined, recovers an image ID itself, and publishes it to the rest of the batch. Under DD_SECURITY_AVAILABILITY_POLICY=warn with no scanner that replica read as unpreflighted and was kept on a moved tag, while every replica after it was compared against the ID it had just published and refused: replica one on the moved image, its siblings rolled back, which is the split service the check exists to prevent. onPulledImageIdResolved is only ever attached by attachRecoveredPulledImageIdSink, so its presence is the batch marker that survives a failed preflight inspect. Adding it to the predicate refuses the recovering replica too. The covering test pins the exact case, and without the new clause it fails by refusing nginx-b instead of nginx-a, which is that split spelled out. * test(compose): pin the preflight marker and kept-audit timing against the pre-fix behavior Add three regression tests for the moved-tag review round: a compose-file-once update with no preflighted identity in its runtime context is kept under availability policy warn rather than refused by the old isComposeFileOnceEnabled() guard; a runtime context carrying pulledImageId alone (without imageIdentity or onPulledImageIdResolved) is refused under warn, pinning that clause of the preflight marker independently; and a kept decision whose startContainer call then fails records no security-scan-skipped audit row, since the container never actually ran. * fix(compose): record the kept-container audit row after the recreate succeeds assertReplacementContainerImage recorded the security-scan-skipped audit row for a kept moved tag before the recreated container had actually started. If startContainer then threw, the rollback net removed the candidate and restored the previous container, but the audit trail still said the container had been kept under DD_SECURITY_AVAILABILITY_POLICY=warn. The assert now returns a kept descriptor instead of recording anything, and refreshComposeServiceWithDockerApi records the row itself once recreateReplacementContainerWithCleanup has returned successfully, so a row only exists for a container that actually ran. Also rewrites three comments that had drifted from what the code does: the audit-row comment now hedges the way the docs already do instead of claiming it is the only possible row on that path, the preflightedServiceIdentity comment no longer claims a read-order hazard between it and the ID-recovery sink that isn't there, and the CHANGELOG and docs entries for the moved-tag policy now key on whether a call was preflighted rather than on compose-file-once being on, which was backwards for the off case. * docs(compose): say the kept-container audit entry follows a successful start
… installs (#1017) * feat(monitoring): add a Grafana panel for deferred maintenance-window installs * fix(monitoring): place the deferred-updates panel under Maintenance Skips
…te candidate (#1020) * fix(compose): recreate a rollback from the backup image, not the update candidate Dockercompose.recreateContainer wrote the image it was handed into the compose file and then called refreshComposeServiceWithDockerApi with no runtime context, so the refresh re-derived its own target from the container's updateKind and created the container from the update candidate. The compose file said the backup, the container ran the update, and the rollback reported success. Clearing the update result does not help: the fast resync after an update has already moved the container's tag to the candidate by the time a rollback runs. The recreate now passes runtimeContext.newImage through, and with it the digest its own reference pins, so the identity binder cannot break a RepoDigests tie with container.result.digest, which describes the manifest this recreate is undoing. It deliberately passes none of the compose-file-once identity fields, so the post-create image check still treats the path as unpreflighted. * fix(rollback): hand the health monitor the full container startHealthMonitor built its container reference from the id and name alone and passed it to the trigger as the container, so a compose action threw a TypeError reading container.image.registry.name before it reached the compose file. Auto-rollback on a failed healthcheck therefore never ran on a compose stack; the only trace was an auto-rollback audit row carrying the error. RollbackMonitor already held the whole container and dropped everything but the name, so it now passes it through and the monitor overlays the replacement's id on it. HealthMonitor.test.ts drives a real Dockercompose through the rollback, which is what the wholesale trigger mock could not catch. * refactor(compose): mark a rollback caller-pinned without re-parsing its digest selectPulledRepoDigest already matches a digest-pinned pull off the reference before it ever reads tieBreak.preferredDigest, so getPinnedManifestDigest's parsed value was dead code; the options bag only needed to exist to mark the rollback caller-pinned. Tighten MonitoredContainer (and the RollbackContainer type feeding it) so a bare { id, name } stub can't typecheck in place of the real container shape the compose action reads. Fix the DR-101 changelog entry's tense mismatch, note the failed auto-rollback also logs an error, and drop the now-redundant digest-preference sentence. * fix(rollback): pull the backup image before an automatic rollback recreates the container The health monitor logged "Auto-rollback: pulling backup image X" and then never pulled it. Neither recreate path fetches for itself: Docker's goes straight to createContainer, and the compose one runs its runtime refresh with skipPull so the manual rollback in app/api/backup.ts, which pulls before it calls in, is not made to pull twice. That left the caller owning the pull, and the automatic caller never did one. A backup image no longer on the host therefore failed at create, and on both paths the running container was already gone by then. Docker's performRollback calls stopAndRemoveContainer before recreateContainer. On compose, stopAndRemoveContainer is a no-op override, but the runtime refresh does the real stop/remove itself and then, when the create fails, the rollback net restores the container from currentContainerSpec.Config .Image, which on an automatic rollback is the failing update the rollback existed to undo. DR-110 recorded the compose half as failing safe. It only does under a `required` identity binding policy, which needs security enabled and either signature verification or a non-warn availability policy. On a stock install the policy is `disabled`, so capturePulledImageIdentity swallows the failed inspect, verifyPulledImageCompatibility swallows its own, and getCloneRuntimeConfigOptions swallows both of its inspects. Nothing between the identity capture and the stop/remove throws. The pull goes in the health monitor rather than in the compose refresh. recreateContainer is shared with the manual API rollback, so flipping skipPull would pull twice on that route; putting it in the monitor also covers the Docker action's rollback, which has the same bug, and it makes the existing log line true. Docker.pullRollbackImage resolves the registry manager with the anonymous fallback the compose refresh already uses, takes its getAuthPull, and pulls whatever buildRollbackImageReference produced, so a record with a captured digest pulls repo:tag@sha256:... and the daemon resolves it by digest. Second, the compose refresh now refuses a missing image before the removal instead of at create: verifyPulledImageCompatibility takes requireLocalImage and the refresh passes skipPull, so behind a pull a failed inspect is still Docker's to report, and without one it is a hard error that leaves the running container alone. Refs DR-110
…name (1.8) (#1022) * fix(agent): decide container ownership by enumerated id, not watcher name The rc.6 ownership gate (#922) refused an agent's container that had no store row whenever the controller had a watcher registered under the same name. That name collides by default: a controller with no DD_WATCHER_* registers its default watcher as `local`, and the agents quickstart sets DD_WATCHER_LOCAL_SOCKET on the agent, so both sides are called `local` while watching different hosts. On a fresh store every agent container was refused on every ingest path and the API showed no agent rows at all. An install with the rows already stored lost a container permanently the first time it was recreated under a new id, because the same gate feeds the prune keep-set. A watcher name is not evidence about who owns an id; the ids the controller's own daemon hands back are. Each controller-local Docker watcher now publishes the ids its listContainers() call returned into watchers/controller-local-container-ids.ts, replacing its set on every enumeration and clearing it on deregister, and the gate refuses a no-record ingest only when the incoming id is in one of those sets, naming the watcher that holds it. Recording happens off the raw enumeration, before the watch filter and the discovery settle window, so the id is still known before the row exists, which is the race the gate exists to close. A controller-Docker-transport agent watcher records nothing, since it enumerates the agent's daemon rather than the controller's. (cherry picked from commit b50bb7e) * fix(agent): seed the controller-local id set at watcher init and document the same-daemon case Docker.init() now seeds a controller-local watcher's enumerated id set with one listContainers() call before the startup cron tick is scheduled. Without it, the set stayed empty from registration until START_WATCHER_DELAY_MS later, while the agent SSE connection starts immediately after registry.init() resolves, so an agent could claim a controller-host container id during that window. A daemon that's unreachable at boot only logs a warning through the watcher's logger; registration still succeeds. Also: - inline the enumerated-id list into the record call in getContainers() and bump the Docker.ts structure test's line cap to fit both changes - replace the stray #1013 PR reference (an unrelated PR) with (DR-106) in AgentClient.ts/test.ts and Docker.watch.test.ts - correct the CHANGELOG workaround wording: renaming the watcher on either side also avoided the bug, not just disabling the controller's watcher - document the same-daemon topology, where an agent watches a daemon a controller-local watcher already watches: the agents doc callout now says DD_LOCAL_WATCHER=false is required (not just optional) in that case, and test/qa-agent-test.yml is updated to match (DD_LOCAL_WATCHER=false, DD_AGENT_ALLOW_INSECURE_SECRET=true, drydock:dev instead of the stale v1.4.1-fix pin, a pullable redis:7.2-alpine instead of a never-pulled pin) (cherry picked from commit dd201b9) * fix(agent): drop the controller's explicit local watcher from the QA agent compose file DD_LOCAL_WATCHER=false only skips the default watcher; the DD_WATCHER_LOCAL_* keys still registered one, so the controller kept enumerating the shared daemon and refused the agent's containers. The docs callout now says to remove those keys too. (cherry picked from commit 4432273) * fix(watchers): correct off-by-one in Docker.ts structure test line cap split('\n').length on a file ending in a trailing newline counts one more than wc -l does, so the 1650 cap set while forward-porting the DR-106 fixes undercounted by one against the actual file. * docs(watchers): drop em dash from controller-local-container-ids comment * fix(watchers): keep a deregistered watcher from restoring its container claims getContainers() can settle after deregisterComponent() already cleared the controller-local claim set via forgetControllerLocalEnumeration(). Recording the claim set once the pending listContainers() call finally resolves would resurrect it for a dead watcher, permanently blocking any agent that reuses that container id since nothing else ever clears a claim for a watcher that's gone. Guard the record call with the existing isWatcherDeregistered flag and add a test that holds getContainers() open across deregisterComponent(). Bumps Docker.structure.test.ts's line cap per its own convention. Also fixes test/qa-agent-test.yml's gitleaksignore entry, which fingerprints the demo API key by line number: the line moved from 52 to 54 in an earlier change and broke the working-tree scan. Add an inline `gitleaks:allow` comment so a future line move can't break it again, and update the working-tree ignore entry to match; the history entry is untouched. Adds a sentence to the agents doc's shared-daemon warning noting that DD_WATCHER_{name}_WATCHEVENTS=false widens the window where a container created between the controller's cron scans can be claimed by an agent. (cherry picked from commit 52ebd3e) * style(test): two spaces before the gitleaks allow comment (cherry picked from commit 25bd733) * fix(watchers): refresh remote auth and bound the startup ownership seed Docker.init() now refreshes a remote watcher's OIDC auth headers before the DR-106 controller-local seed runs, so the seed's listContainers() call no longer goes out unauthenticated and gets silently swallowed on an OIDC watcher. Guarded by wouldRefreshRequireInteractiveOidcDeviceFlow() so a first-time device-code authorization is deferred to the first scheduled scan instead of blocking registerWatchers()'s Promise.all() on user interaction. seedControllerLocalEnumeration() also now races its listContainers() call against CONTROLLER_LOCAL_SEED_TIMEOUT_MS (10s), so a wedged daemon can no longer hang Docker.init() and, with it, the whole controller's startup. Bumps the Docker.ts structure test's line cap to fit both changes. (cherry picked from commit 020b86f) * fix(watchers): record only the newest container enumeration for ownership claims The agent API can call watcher.watch() directly while scheduled and event scans go through watchFromCronOrchestration(), so two getContainers() calls can run concurrently. If the older call's listContainers() result settles after the newer one, it was overwriting the newer call's claim set with stale data. Add a per-instance generation counter, bumped before the first await in getContainers(), so only the result from the newest call gets recorded. (cherry picked from commit b5772ab)
…s configured (#1025) fix(registry): prune controller-owned records when no local watcher is registered pruneOrphanedLocalContainers() early-returned whenever the controller had no local (non-agent) watcher registered, so with DD_LOCAL_WATCHER=false the record a container left behind when it moved to a remote agent survived every restart. The agent's report for that id is then refused by AgentClient's ownership gate as owned by the controller, and the record is never rewritten to name the agent. registerWatchers() now reports how many registrations it attempted, and the prune skips only when registrations were attempted and none of them landed. An unreachable socket or an unreadable CA file empties the registry for a transient reason, so that case must not delete every controller-owned record; a controller configured with no local watcher at all is a real operator decision and every controller-owned record it holds is orphaned.
#1028) The pull DR-110 added runs before any destructive step, and pullImage throws on any registry failure, so performRollback's catch turns a registry that cannot be reached into "Auto-rollback failed" with the unhealthy container still running. That is a regression on exactly the hosts the pull was supposed to help: an airgapped host, or one hitting a Docker Hub anonymous 429, rolled back fine before, because the backup image was still on disk. Prune retains it, so the pull is redundant in precisely the case that now fails. pullRollbackImage now inspects the rollback reference first and skips the pull when the image is already on the host, logging that it is. The registry resolution and getAuthPull move behind the same check, since getAuthPull is itself a network call on most registries. A failed inspect answers "not on the host" whatever the failure was, not just a 404: a briefly unreachable daemon has to fall through to the pull rather than skip it, because pulling an image that is already there costs a round trip while skipping one that is absent is the create failure this path exists to prevent. An image that is neither on the host nor pullable still refuses, now with a message that says so, and the compose refresh's requireLocalImage still catches the genuinely missing case before the stop and remove. The order in performRollback and the manual API rollback in app/api/backup.ts, which pulls on its own, are unchanged. On this line the rollback reference is the hybrid repo:tag@sha256:... that buildRollbackImageReference builds, and inspecting that exact form is already load-bearing here rather than something this change introduces: the compose refresh calls verifyPulledImageCompatibility(dockerApi, pinnedImage, ...) under requireLocalImage, which does dockerApi.getImage(pinnedImage).inspect() on the same hybrid reference, and capturePulledImageIdentity inspects it too on the way to resolving pinnedImage. isImageOnHost asks the daemon the same question in the same form, so the reference that skips the pull is the reference the refresh then verifies. Refs DR-110 (cherry picked from commit f4cda64)
…esh checks (#1026) Compose-file-once gates in its batch preflight, so createTriggerContext told the lifecycle not to defer the pre-update hook and the prune/backup step. They ran ahead of the refresh, which still has two guards of its own: the container has vanished since the scan, or its inspect came back without a boolean State.Running. Either aborts the refresh, and by then the update had already fired an operator hook, deleted cached images and written a backup row for something that never happened. Repeating it evicted real rollback points. That path now defers like the ordinary one. performContainerUpdate forwards the post-pull hook instead of dropping it, wrapped so skipSecurityGate is always set, because the preflight already gated the service. The gate suppression is unchanged, it just moves from "no hook at all" to an explicit flag, and the deferred steps land inside the refresh after both guards and before anything is stopped or removed. Compose refreshes on this path are now covered by the executor's missed-hook guard too. The mocks that stand in for the refresh had to start calling the hook, which they now do through a shared helper forwarding the operation id and bound image identity the real refresh passes. The compose-file-once ordering test expects hook, prune and backup after the refresh's guards rather than before the refresh.
… on prune (#1033) The startup prune of controller-owned container records got two things wrong. DR-112: it deleted the record with a plain deleteContainer(), so nothing was stashed, and the stash it would have written could not have been read back anyway. deleteContainer's replacementExpected stash is keyed on agent plus watcher plus name, which is exactly what a hand-off to an agent (or a watcher rename) changes, so the incoming record looked up a key nothing had written. A snooze, a maturity mode and minimum age, and skipped tags or digests all went back to defaults, and an absent maturity policy reads as no gate rather than the default gate. deleteContainer now takes identityChangeExpected, which stashes the same overrides under the container's Docker id: the id is the one thing the move leaves alone, because it is the same physical container on both sides, while the identity is what it changes. insertContainer tries the identity key first and falls back to the id key, so a recreate (new id, same identity) still takes the #496 path untouched. A container recreated as part of the move mints a new id and matches neither key. DR-113: the prune kept only records whose watcher was registered, and #1025 protected the all-failed case only. With two configured local watchers where one fails on an unreadable CA file, a missing socket or a rejected value, the survivor keeps the registered set non-empty, so the failed watcher's records were pruned as though it had been renamed away. registerWatchers now returns the configured watcher names rather than a count, and the prune keeps a record whose watcher is registered or still configured. It also awaits allSettled rather than all: Promise.all resolves the await on the first rejection while the other registrations are still in flight, so the prune could read a registry emptier than the one the run actually produced.
…binaries at the cut (1.8) (#1031) * build(docker): pin base images to multi-arch index digests #881 rolled the node:24-alpine and alpine:3.24 pins to digests that name a single amd64 manifest rather than the image index. buildx resolves a digest pin the same way for every --platform, so the arm64 stage built on an amd64 rootfs and every arm64 image from v1.7.0-rc.4 onward shipped x86-64 tini, node and healthcheck binaries under an arm64 label (#1021). Both pins are back on image index digests that carry linux/amd64 and linux/arm64. openssl stays at 3.5.8-r0 on arm64, verified in a local linux/arm64 build, so the six CVEs #881 fixed stay fixed. (cherry picked from commit 2b50613) * ci(release): verify base image indexes and platform binaries at the cut Nothing in the pipeline noticed that rc.4 through rc.10 published x86-64 binaries under an arm64 label (#1021): the digests were real, the manifest list named both platforms, and the scans read the amd64 image. Two guards close that. check-dockerfile-base-indexes.sh inspects every digest-pinned FROM and fails unless it is an image index carrying every platform we build for, wired into ci-verify's build job (which already has Docker Hub egress and runs on every PR) and into release-cut before the staging build. check-image-arch.sh reads the ELF e_machine of /sbin/tini, /usr/local/bin/node and /bin/healthcheck inside the image, per platform, and runs after the staging build resolves a digest and before anything is signed, tagged or promoted, so a mismatched image cannot reach a public tag. Both are exercised by node:test suites with a fake docker on PATH. (cherry picked from commit 5a5f85b) * ci(release): tighten the platform guards from review - docs(changelog): tighten the arm64-index CHANGELOG bullet's spacing to match the list - fix(scripts): pull the per-platform image fresh in check-image-arch.sh instead of relying on the daemon's mismatch re-pull - ci(workflows): retry the base-image-index check in ci-verify.yml so a transient Docker Hub 429 doesn't fail the build job (cherry picked from commit 4bab5a7) * ci(release): fold Dockerfile continuations in the base index guard check-dockerfile-base-indexes.sh matched FROM against physical lines, so a pin written as `FROM --platform=... \` with the reference on the next line was never inspected, and another valid pin was enough for the guard to pass. Fold each logical instruction first: a line ending in the escape character continues on the next one, comment and blank lines inside a continuation are dropped (BuildKit warns on an empty continuation line and keeps folding), CRLF endings are tolerated, and the escape character comes from a `# escape=` parser directive when one precedes the first instruction. Tests cover the continued FROM with a single-platform pin on the continued line, the backtick escape, a late directive that must be ignored, and a continuation that ends the file. Refs DR-111, review on #1024 (cherry picked from commit 87c40b7)
* feat(portainer): persist recovery state for stack updates (cherry picked from commit 8abef2ffcac3999fb244a69303dab4326685fabf) * fix(portainer): reconcile recovery descriptors correctly on restart Fixes six defects found in review of the cherry-picked recovery-state commit before it can be considered mergeable: 1. normalizeImageReference returned undefined for an env-interpolated image, so an undefined-vs-undefined comparison matched "original" and recovery re-PUT over an operator's manual edit. The compose branch now throws when the running service image cannot be resolved to a concrete reference, treating it as unverifiable rather than a match, and expires instead of mutating the stack. Covered by the "unresolvable compose image" test. 2. Env-mode restore wrote TAG="" when the variable was absent at capture. originalValue is now string | null, capture records null when the variable is missing, and a new removeStackEnv helper deletes the variable on restore instead of writing it back empty. Covered by the null-originalValue capture and restore tests plus the removeStackEnv-with-no-Env-array test. 3. A transient Portainer API failure at boot (stack or stack-file fetch) is now caught and rethrown as PortainerRecoveryUnavailableError, which recovery.ts already classifies as expired rather than failed, so it can't fire a false update-failed notification. Covered by both "Portainer API is unavailable" tests. 4. The reissue redeploy had no restore-on-failure, and markOperationTerminal wiped the descriptor before recovery could use it. Reconciliation now keeps the pre-fetched stack file/env around, restores the previous definition when the reissue redeploy or its wait fails, and no longer calls markOperationTerminal itself so the descriptor survives until recovery.ts records the terminal state. Covered by the reissue-fails-then-restore-succeeds and combined-error (restore-put fails, restore-wait fails) tests. 5. The validator dereferenced field.slice on a missing field. New describePortainerRecoveryDescriptorProblems reports every missing or malformed field by name instead of crashing, and the caller throws a message naming them. Covered by the field-name-reporting test and the malformed-field-format variants (env missing prefix, env empty suffix, compose missing prefix). 6. Portainer.ts coverage was 95.75/91.48 (restore branch and both terminal short-circuits untested). Added ~25 tests bringing Portainer.ts, update-operation.ts, container-update-operation.ts, and recovery.ts to 100% lines/branches/functions/statements, including a new recovery.ts test for an agent-owned Portainer operation expiring instead of being reconciled. Also declares portainerRecovery on UpdateOperationBase (opaque to the store, never surfaced via toApiUpdateOperation, cleared by markOperationTerminal), and updates the CHANGELOG Unreleased/Fixed section to describe the reconcile-or-expire behavior in place of the prior always-expires behavior.
…enance window (#1035) fix(agent): gate an agent-owned container's auto-install by its maintenance window (DR-96) AgentWatcher implemented no isMaintenanceWindowOpen(), so Trigger.deferAutoUpdateForMaintenanceWindow treated every agent-owned watcher as unresolvable and failed open: an agent-owned container's automatic install ran regardless of the watcher's configured maintenance window, while the container panel (reading the same watcher's masked maintenancewindowopen field for display only) still reported the update as deferred until the window opened. AgentWatcher.isMaintenanceWindowOpen() now answers the same question a local DockerWatcher does. Under controller Docker transport the delegate watcher IS a live DockerWatcher, so its own state is authoritative. Otherwise the real watcher runs on the remote agent process, so the freshest state available to the controller is the masked maintenancewindowopen boolean from that agent's most recent watcher-snapshot report (AgentClient.getWatcherSnapshot), refreshed on every one of the agent's own scan cycles; before a snapshot has arrived it falls back to the handshake-time configuration, and with neither it fails open, matching DockerWatcher's own "unconfigured window lets updates through" default and keeping an older agent that omits the field ungated rather than newly and silently blocked. Once a closed window opens, the agent's own next scheduled report (not a controller-side poll) re-evaluates the previously deferred container's eligibility, the same way a local watcher's own next scan would - the deferral gate already lives in the generic Trigger.deferAutoUpdateForMaintenanceWindow / getContainerMaintenanceWindowOpen code, which AgentWatcher now satisfies the same interface for, so no changes were needed there. Also updates the four docs pages (getting-started, configuration/watchers, configuration/actions/update-eligibility, updates) that documented the old "not deferred for agent watchers yet" behavior as a known limitation.
…expiring once reservations, SPA fallback allowlist (#1038) * fix(agents): stash update policy on the agent-side prunes too under its Docker id (deleteContainer's identityChangeExpected option) so the record that replaces it under a new identity inherits the snooze, maturity mode/min-age, and skipped tags or digests. Two other prunes that delete a record ahead of an identity change still called deleteContainer plainly and lost the policy the same way the startup prune used to: pruneOrphanedAgentContainers in app/registry/index.ts, which runs when an agent is removed from config or renamed, and AgentClient.pruneOldContainers in app/agent/AgentClient.ts, which runs when a container is missing from an agent's incoming report because it moved to another agent. Both now pass identityChangeExpected: true, so a container that reappears under the same Docker id but a different agent, watcher, or name carries its update policy across the move instead of resetting to defaults. * chore(ci): drop the no-op pre-commit coverage hook lefthook.yml ran ./scripts/pre-commit-coverage.sh with no {staged_files} argument, so the script's `for f in "$@"` loop always saw an empty list and printed "No app/ or ui/ files staged; skipping tests" on every commit regardless of what was staged. Commit-time checks are meant to stay cheap, so rather than wiring {staged_files} through and turning this into a per-commit test run, the command and the script are gone. Pre-push coverage already runs the full suite on every push. Removes the coverage command from lefthook.yml's pre-commit block, deletes scripts/pre-commit-coverage.sh and its test, updates the two .github/tests/ci-verify-workflow.test.ts assertions that expected the hook to exist, and corrects CONTRIBUTING.md's description of what pre-commit actually runs now. * fix(notifications): expire once-notification reservations that never settle reserveOnceNotificationSlot/releaseOnceNotificationSlot guard once=true sends against an overlapping evaluation of the same result, but the release only ran when the handler's own promise settled. runHandlerWithTimeout detaches a handler that misses its 30s deadline instead of awaiting it forever, so a provider that never resolves or rejects held its reservation for the process lifetime, and every later scan for that exact result read it as still in flight and skipped the send silently. Give each reservation its own expiry timer, four times the handler timeout (event.getHandlerTimeoutMs(), a new production-facing getter alongside the existing test-only one), that deletes the map entry and logs a warn naming the key if nothing released it first. releaseOnceNotificationSlot and deregisterComponent now clear that timer wherever they clear the reservation, so a normal settle or an explicit teardown never leaves it to fire later against a reused key. * fix(notifications): route manual scans through the single-flight cron scan watch() has no re-entrancy guard of its own. POST /api/v1/containers/watch, POST /api/v1/webhook/watch, and the agent's own watcher API (the endpoint the controller polls) each called it directly, so a manual "check now" landing while a cron scan (or another manual scan) was already running started a second, fully independent handleContainerReports pass. Under once=true both passes could read "not yet notified" before either had finished writing its own history, so a trigger sent the same update twice. All three now route through triggerManualWatch() (app/watchers/manual-watch.ts), which calls the Docker watcher's existing watchFromCron()/ watchFromCronOrchestration() single-flight orchestration instead of watch() when a watcher exposes it, falling back to watch() for any watcher type that does not. ignoreMaintenanceWindow: true preserves manual/API scans' existing behavior of running regardless of a configured maintenance window. Whether the call coalesced into an already-running scan is surfaced back: a `coalesced` field in the JSON body for the containers/webhook endpoints (added to the WebhookWatchAllResponse OpenAPI schema), and an X-Drydock-Watch-Coalesced header for the agent endpoint, which keeps its existing bare ContainerReport[] body so a controller and agent on either side of a version skew keep working unchanged. Also, handleMaturityGateClearedEvent's once=true check read notification history with a bare hasAlreadyNotifiedForResult(), with the matching write landing an await later once delivery confirms -- the same window reserveOnceNotificationSlot()/releaseOnceNotificationSlot() exist to close for the generic update-available path. Two overlapping evaluations of the same maturity-cleared event could both read "not notified" and both dispatch. It now takes the same reservation before dispatching, released from recordEventDeliverySuccess() on success, a new releaseEventDeliveryReservation() on failure (since a maturity-cleared dispatch can be optimistic-and-detached or deferred to a later batch flush, so handleMaturityGateClearedEvent's own call stack returns long before the send settles), or locally when dispatchContainerForEvent() returns false without ever queuing or sending anything. * fix(ui): allowlist SPA routes for the service worker navigation fallback The workbox navigateFallback denylist only ever excluded /api from the SW's offline app-shell fallback. Express also owns /auth's OIDC subpaths, /health, and /metrics: a document navigation to any of them (in particular the identity provider's /auth/oidc/<name>/cb redirect, #939) was served index.html instead of reaching the server, so an OIDC login's code exchange never ran and the SPA silently bounced back to /login. Replace the denylist with navigateFallbackAllowlist, built directly from ui/src/router/routes.ts (buildSpaNavigateFallbackPattern, both exported from vite.config.ts). A route path segment starting with `:` (e.g. CONTAINER_LOGS's `:id`) becomes a `[^/]+` wildcard; every other segment is escaped and matched literally, with the trailing slash and query string both optional to match workbox's NavigationRoute (matched against pathname + search). A route added to the router without a matching allowlist entry is now impossible by construction: the allowlist is Object.values(ROUTES) run through the pattern builder, not a second list someone has to remember to update in step. Bare /auth is still an SPA view and still matches; its OIDC subpaths, /health and /metrics do not, and now correctly fall through to Express, along with any future server-owned path added without a change here. ui/tests/config/vite.config.spec.ts pins: every ROUTES path (concrete, trailing-slash, and query-string variants) matches; known server-owned paths (the OIDC callback, /health, /metrics, /api) do not; bare /auth matches but its subpaths do not; and the CONTAINER_LOGS wildcard matches exactly one path segment. * fix(notifications): key once-reservation release on the reservation that took it reserveOnceNotificationSlot's key alone did not prove ownership: once reservation A expired and its timer deleted the key, a fresh reservation B could take the same key, and A's own release (settling later, success or failure) would clear B's entry by key lookup, letting a third evaluation dispatch while B was still supposed to be held. Every reservation now carries a unique token, and release (from either the expiry timer or a settling caller) only clears the entry when its token matches. Threaded the token through every reserve/release pair: the simple, batch, digest, retry-buffer and maturity-gate paths, plus the batch dispatcher's queue/flush split via a side map since BatchDispatcher has no room to carry it on its own entries. * fix(ui): keep query delimiters out of route parameter wildcards buildSpaNavigateFallbackPattern's :param wildcard was [^/]+, which accepts "?", so /containers/:id/logs matched /containers/abc?x/logs (actual pathname /containers/abc, the rest is query text). Narrow the wildcard to [^/?]+ so a param segment can't swallow a literal "?" and the literal path segment that follows it.
… to 1.8 (#1048) * docs(agents): serve TLS on the agent in the paired registry example * test(rollback): pass backupScope to every health monitor case
…1052) * feat(ui): add the approval-queue service layer Wraps the /api/v1/approvals REST endpoints (list, summary, get, approve, reject, defer) behind a typed service module, matching the pattern used by every other view's service. Nothing consumes it yet. Part of spec-ca-2-approval-queue.md, slice 5. * feat(ui): add the /approvals route and queue view Lists every row the reconciler has sighted with its release notes and soft-blocker hold reasons inline, and approve/reject/defer actions that call straight through the approval service added earlier. Each row expands in place for detail rather than opening a separate panel. Part of spec-ca-2-approval-queue.md, slice 5. * feat(ui): add a sidebar approvals badge and its SSE wiring The sidebar nav gets an Approvals entry with a live pending-count badge, fed by the three approval SSE events the API already emits rather than polling; eventStream.ts parses each payload and AppLayout re-dispatches it as a window CustomEvent for any component to consume. Part of spec-ca-2-approval-queue.md, slice 5. * feat(ui): add an approvals stat tile to the dashboard Wires a pending-count stat card into the existing generic dashboard widget system (own DASHBOARD_WIDGET_IDS entry, layout constraints, and default position) rather than a bespoke component, so the widget-order and hide/show machinery already covers it. Fetches once on mount and then follows the same three approval SSE events as the sidebar badge. Part of spec-ca-2-approval-queue.md, slice 5. * feat(notifications): add the update-pending-approval rule Registers update-pending-approval in DEFAULT_NOTIFICATION_RULES so it can be enabled and assigned triggers like any other rule. It ships enabled: false and bellEnabled: false: no audit action names it yet and no event dispatches to it in this release, so it is reserved for an upcoming one rather than wired end to end here. Part of spec-ca-2-approval-queue.md, slice 6. * docs(approvals): document the approval queue and its API New configuration/actions/approvals.mdx explains what puts a candidate in the queue (soft blockers become hold reasons, hard blockers keep it out) and what each of the three decisions does; new api/approvals.mdx covers the list, summary, get-one and decision endpoints. Replaces the inline "## Approvals" section in api/index.mdx (a leftover from the read-only API slice) with a proper overview-table row pointing at the dedicated page, and cross-links update-eligibility.mdx and the trigger Notification Rules table for update-pending-approval, noted there as shipped disabled with no dispatch wired yet. Part of spec-ca-2-approval-queue.md, slices 5 and 6. * docs(changelog): add the approval queue view to the Unreleased notes Adds the CA-2 slice 5/6 bullet (route, sidebar badge, dashboard tile, the reserved-but-undispatched notification rule) and closes out the prior slice's "the queue view itself lands next" line now that it has. Flips the README feature-comparison rows and roadmap table entry for the approval queue from partial to shipped to match. * refactor(ui): keep the approval service types module-private knip flags exported types with no importer; the view and composable only use the service functions. * chore(web): regenerate the analytics route manifest for the approvals docs pages
Alpine replaced cosign 3.0.6-r1 with r2, so every image build fails at apk add with 'unable to select packages' until the pin follows.
…icy (#1051) * fix(rollback): resolve auto-rollback image through the identity binding policy Auto-rollback used a fixed fallback (pre-update digest, then mutable tag) that ignored the required/optional/disabled post-pull identity policy DR-43 gave the manual rollback API. It now resolves through the same resolveRollbackImageReference helper, so a required policy that cannot establish a digest refuses the rollback as a rollback-failure audit event instead of silently deploying the mutable tag. DR-59 * fix(portainer): preserve an explicit docker.io/ prefix when pinning the stack image applyPortainerDigestPin rewrote the stack file's image using the identity binder's normalised repository, so docker.io/library/nginx:1.27 came back as library/nginx:1.27@sha256:.... Reuse preserveExplicitDockerIoPrefix, exported from Dockercompose.ts, so the pinned reference keeps the prefix the stack file wrote whenever it wrote one. DR-65 * test(api): build the api-key enforcement app once per file, not once per case Each of the ~30 cases in api-key-enforcement.integration.test.ts rebuilt a Basic authenticator (a real argon2id hash), a LokiJS-backed session store and a listening HTTP server from scratch. That's cheap in isolation but pushed a single case past the 5000ms default under the CPU contention of the parallel app+ui coverage gate. The app, its Basic authenticator and its server are now built once in beforeAll; beforeEach only resets the mutable state a case can leave behind: the api-key collection, the session store's rows, and the authenticator chain a couple of cases deliberately swap out. DR-63 * fix(demo): send Cross-Origin-Opener-Policy so the weekly DAST scan passes ZAP rule 90004 checks both COOP and COEP. #878 added the embedder policy; the 2026-09-02 weekly run still flagged the missing opener policy and fail_action turned the warning into exit 2.
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
…validation (#1061) * fix(tag): stop isTagPinned treating every non-rolling tag as pinned The tail of isTagPinned() fell through to an unconditional return true after checking for a numeric version shape, so a bare distro or variant tag with no version number (redis:alpine, node:slim, postgres:bookworm, fpm-alpine, production) was reported as pinned and hidden by the Hide Pinned UI filter alongside genuinely pinned releases like 1.4.5 or 16-alpine. It now returns the numeric-shape check's own result. Also corrects the watchers doc, which described Hide Pinned as reading tagPrecision when it actually reads the shape-based tagPinned field. * fix(store): emit containerUpdated on a name or displayName delta hasContainerChangedWithSecurityHashes compared neither name nor displayName, so a docker rename persisted to the store without ever satisfying the check that gates containerUpdated. SSE rows, Prometheus gauge labels, and MQTT topics kept the old name until an unrelated field also changed. Both fields are now part of the comparison. * fix(agent): reject a non-array handshake containers body before deregistering triggers AgentClient._doHandshake validated nothing about the /api/containers response body, so an object, string, or number body threw inside processAuthoritativeContainers after deregisterAgentComponents had already run and before trigger re-registration reached it. handleAckEvent only logs the failure, and dd:ack fires once per connection, so the agent's triggers stayed deregistered with no retry. The body is now checked with Array.isArray immediately after the request and rejected before any registry mutation, with a warning naming the agent and the body's type.
…t framework (#1059) * feat(store): add a swappable node:sqlite driver for the v1.8 store Groundwork for 7-STORE. Nothing imports it yet. The driver owns transaction(), pragma(), error normalisation and backup() rather than exposing the engine's own API, because those are exactly the places node:sqlite and better-sqlite3 disagree. Keeping them here is what makes the driver choice a one-file swap later instead of a rewrite spread across every store module. Opening applies the store's pragmas in a fixed order and probes for WAL, falling back to TRUNCATE with a warning when the filesystem refuses it, since /store is routinely an NFS or CIFS mount with no shared memory. A memory database skips the probe so agent mode never warns. * feat(store): add the v1.8 SQLite schema and its migration runner The full DDL for every collection in the store, plus schema_migrations and a store_metadata table for bookkeeping that is not schema versioning. Every table is STRICT, so a type mistake is a write-time error rather than a silently coerced value, which is the class of bug the JSON store could not catch at all. A field is a column when the code queries, filters, sorts or individually patches it; everything else is JSON in a TEXT column, and the five payloads the store is documented never to inspect are never indexed. The runner records applied versions and applies only what is missing, so running it against an already migrated database does nothing. Each run is one transaction, so a migration that throws leaves the schema and the bookkeeping consistent with each other. * feat(store): add the first-start import framework for the v1.8 store Reads a serialised LokiJS database, serves both the flat and the {data} enveloped document shapes, and runs a registry of per-collection importers into SQLite. secrets is implemented as the worked example; later slices add one entry to the registry each. The whole import runs into <database>.importing inside one transaction and is renamed into place only once it has succeeded, so a partial import is never visible under the real filename and a crash anywhere before the rename leaves dd.json untouched for the next start to retry. A leftover .importing file is discarded rather than resumed: resuming correctly would need per-collection progress tracking, which a one-shot operation does not justify. A marker row in store_metadata makes a second import into the same database a no-op even when it is called directly. dd.json becomes dd.json.pre-1.8.bak once the rename lands, and an existing backup is never overwritten, since that file is the whole rollback story for a downgrade to v1.7. Sessions are read from the dedicated sessions file when DR-121 has already split them out, and from the Sessions collection in dd.json otherwise. * docs(changelog): note the v1.8 SQLite store groundwork * chore(store): hold the new store db exports to what something reaches knip runs over the app workspace and reports an export nothing imports. Three of these were real: the import status union is only ever read through FirstStartImportResult, so it stops being exported, and the reader's two public constants were duplicated as string literals in their own test instead of being used. The rest are the driver's type surface, which is the point of the module and stays exported. A test now pins it by annotating each of Statement, Row, SqlValue, SqlBinding, StatementRunResult and TransactionMode at a real call site, which also covers the exclusive transaction mode nothing else exercised. * fix(store): fsync the store directory after each import rename The directory fsync ran before the temp-database rename and the legacy backup rename, so neither directory entry change was durably persisted on crash. Move the sync after each rename instead. * fix(store): normalize errors thrown while iterating a statement node:sqlite evaluates iterate() lazily: the call that produces the iterator never touches the database, only each next() does. Wrapping just that call left every later step unguarded, so an error thrown mid-scan surfaced as the raw ERR_SQLITE_ERROR instead of a StoreError. Wrap next() and return() through the same guard. * test(store): cover the no-return-method branch of the iterator wrapper wrapIterator only sets return() when the underlying iterator has one; every real and previously-mocked iterator in the suite had one, so that branch never ran and the 100% coverage gate caught it.
…1055) * fix(store): stop the session store and main store clobbering dd.json DR-121: express-session's connect-loki store opened its own LokiJS instance directly on ${configuration.path}/${configuration.file}, the exact file the main store already used. LokiJS's saveDatabase() always serializes the whole in-memory database, so whichever instance saved last erased the other's writes: a session autosave (every 5 seconds, armed by any authenticated request touching its session via express-session's touch) reverted every container and setting written since boot, while the main store's own autosave deleted the Sessions collection out from under active logins. store.getSessionStorePath() now derives a sibling file from the main store's own configured path and file name (dd.json -> dd-sessions.json by default), so a custom DD_STORE_FILE still lands on a distinct sibling instead of colliding with anything else on the volume. auth.ts's LokiStore now points at that path instead of building the main store's own file path by hand. The main store also drops and logs a stale Sessions collection left inside dd.json by an older build instead of continuing to re-save it forever. Replaces the shared-file-clobber reproduction test with session-store-file.test.ts, asserting the two stores use different files and that saving one never erases what the other wrote, including the touch-driven case, a restart reload, and the legacy-collection cleanup. * docs(store): note the session store's own file and its DR-121 fix The storage page said mounting /store persists "the state" without naming what state, and now that sessions live in dd-sessions.json rather than inside dd.json itself, that sentence needs to say so explicitly so an operator mounting /store knows both files ride along. CHANGELOG bullet describes the user-visible effect of DR-121: containers or settings written after startup could vanish from dd.json after a crash or hard stop, and every restart logged users out, because the session store rewrote the same file. Notes that backups stay scoped to the main store file and sessions are not included. * fix(store): persist the legacy Sessions cleanup during init dropLegacySessionsCollection() only removed the collection from the in-memory Loki database. loadDb() resolved without a save(), and autosave only fires on a later dirty write, so a store that never writes again kept the stale Sessions collection in dd.json across a restart. createCollections() now reports whether it dropped the collection, and loadDb() flushes once when it did.
… lookup-label helper (#1067) * fix(agent): keep dd.watch.digest for controller-Docker-transport containers across reports preserveControllerDockerEnrichment restores a fixed list of controller-owned top-level fields onto an incoming agent report, but image isn't among them. Portwing hardcodes image.digest.watch to false on every "live runtime state" report since digest watching is the controller's own bridged Docker watcher's job, not Portwing's. Without restoring the field, that report wholesale- replaces the stored image and silently turns digest watching back off for any container the dd.watch.digest label had enabled. * refactor(agent): share resolveLookupImageFromContainerLabels with the agent path AgentClient's applyRegistryLookupLabels duplicated container-init.ts's unexported resolveLookupImageFromContainerLabels. Export the helper and call it from both sites instead; each site's own precedence check (agent-reported lookupUrl/lookupImage beats the label, checked before the helper runs) is unchanged. * docs(changelog): note the DR-38 digest-watch fix * test(agent): cover the report-without-image arm of the digest-watch restore preserveControllerDockerEnrichment's digest.watch restore (DR-38) guards on merged.image existing before spreading it, since EdgeAgentAdapter casts an incoming dd:container_sync frame straight to Container[] with no schema validation, so image can be missing at runtime despite the type. Add the missing case: a stored container with image.digest.watch: true and an incoming report with no image at all, asserting the restore does not throw and does not fabricate an image.
#1065) * fix(store): keep synchronous=FULL when the WAL fallback lands on a rollback journal applyOpenPragmas always set synchronous=NORMAL, which is only safe under WAL: under a rollback journal (applyJournalMode's fallback when WAL is refused, e.g. on NFS/CIFS) a crash between deleting and recreating the journal can corrupt the database under NORMAL. It now takes the journal mode applyJournalMode actually granted and uses FULL for a writable file-backed database that isn't in WAL mode, keeping NORMAL under WAL and for read-only or memory connections that never probed a journal mode at all. * feat(store): open dd.sqlite alongside dd.json and add DD_STORE_DB_FILE Slice 2 of the SQLite store plan (roadmap 7-STORE). init() now opens the slice 1 SQLite driver at DD_STORE_DB_FILE (default dd.sqlite, next to DD_STORE_FILE in the same store directory; :memory: in agent mode) and runs the slice 1 migrations, so the schema exists from a fresh install onward. save() checkpoints the SQLite WAL (PRAGMA wal_checkpoint(TRUNCATE)) everywhere it already flushes dd.json, getDebugSnapshot() reports the SQLite path, and isMemoryStore() covers both engines. The same 0600/0700 permission contract dd.json gets now also applies to dd.sqlite and its -wal/-shm sidecars. Nothing reads from SQLite yet. GET /api/v1/store gains configuration.dbFile next to the unchanged path and file, documented in the OpenAPI schema and asserted in the store e2e feature. DD_STORE_DB_FILE is parsed as its own flat field in getStoreConfiguration() rather than through the generic dotted-path walk, which would otherwise nest it under configuration.db.file.
* ci: publish a job and step timing summary * ci: keep the timing job from failing the run * ci: escape backslashes in timing table cells
* fix(auth): make a recovery-code login review existing API keys (#1403) * fix(registry): match GitLab registries on exact host (#1407) * fix(agent): run batch triggers on the agent's own container records (#1413) * feat(audit): record ignored image hook labels (#1416) * feat(agent): warn when an agent still trusts image hook labels (#1415) * fix(auth): only accept OIDC bearer tokens issued for this client (#1421)
* fix(agent): detect half-open agent event streams (#1405) * fix(compose): fill the operation record like the Docker action (#1406) * fix(watcher): don't hold update results for release notes (#1409) * perf(store): use the stats projection for counts (#1411) * fix(store): keep a retained policy with the replacement on local renames (#1412) * fix(api): build the dependency graph from the listed containers (#1419) * fix(agent): tell Portwing timeouts apart from unreachable agents (#1422) * feat(api): include versions in update-operation-changed events (#1427) * feat(notifications): show why a notification was skipped (#1429) * docs(changelog): sort the batch B entries into their sections
* refactor(app): share error, sleep and status helpers (#1423) * refactor(component): share one secret-mask helper (#1414) * feat(api): name the caller behind removed-API hits (#1402) * refactor(ui): share the search-from-query logic (#1418) * refactor(ui): seed the operations store from the container list (#1428) * build(docker): use Alpine's patched zlib and drop the custom build (#1417) * chore(license): relicense from AGPL-3.0 to MIT (#1410) * docs(changelog): one heading per section in Unreleased
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* chore(deps): update npm minor/patch (e2e) * test(deps): track the csv-parse 7.0.3 override * test(deps): track the yaml 2.9.1 e2e override --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: scttbnsn <80784472+scttbnsn@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
biggest-littlest
left a comment
There was a problem hiding this comment.
Tree matches dev/v1.8 at ddc8eee; local gate green.
ALARGECOMPANY
left a comment
There was a problem hiding this comment.
Tree matches dev/v1.8 at ddc8eee; local gate green.
|
Important Review skippedToo many files! This PR contains 429 files, which is 329 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configuration
⛔ Files ignored due to path filters: 13
📒 Files selected for processing: 429
You can disable this status message by setting the
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…#1438) * fix(api): sanitize every legacy alias log field with sanitizeLogParam * fix(deps): update follow-redirects to 1.16.1 for GHSA-8r9p-f939-6h3c
6909b9b
d5d7b75 to
6909b9b
Compare
|
Deployment failed for project drydockdemo-website with the following error: Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit |
|
Deployment failed for project drydock-website with the following error: Learn More: https://vercel.com/codeswhat?upgradeToPro=build-rate-limit |
biggest-littlest
left a comment
There was a problem hiding this comment.
Tree matches dev/v1.8 at 87d20e9; local gate green.
ALARGECOMPANY
left a comment
There was a problem hiding this comment.
Tree matches dev/v1.8 at 87d20e9; local gate green.
Promotes
dev/v1.8at87d20e9c1for v1.8.0-rc.3. Head6909b9bc7has exactly the same tree as dev (47daa8824), with main and dev as parents. Every content change landed on dev first through reviewed PRs.What's new since rc.2: the three fold-in batches (#1432 security and auth, #1433 agents, watcher, store and API, #1434 UI, refactors, build and the MIT relicense), the two Renovate minor/patch updates (#1398, #1399), the biome schema match (#1436), test and CI work (#1420, #1424, #1425, #1426, #1430, #1431) and the rc.3 prep (#1435).
The rc.3 prep moves current docs, translated README warnings, website, demo and support policy to rc.3. Feature and deprecation introduction versions stay on rc.1, and rc.1/rc.2 changelog history and archived docs are unchanged. rc.3 is still opt-in testing and starts a new seven-day soak from its publish time. Stable stays v1.7.0.
The local pre-push gate passed on this exact head (coverage, builds, static checks, repo and workflow tests, UI typecheck).