detached 9ee3e24

refactor(deployment): use durable sequential app deploys (#284)

* refactor(deployment): use sequential service replacement

Replace the rolling-versus-interrupted split for app services with one
sequential replacement applied to every service:

  preflight -> withdraw traffic (confirmed) -> retire the superseded
  container (confirmed gone) -> create and start the replacement ->
  readiness probe -> publish ACTIVE -> rebuild traffic

A deploy may briefly interrupt a service, and two Gordon-managed
generations of one service never run at the same time.

* refactor(deployment): drop httpEligible, deployHTTP, deployInterrupted,
  cutoverService, retireContainerID, drainWithDeadline and the
  retire-after-publish path
* refactor(deployment): restart always restarts the same pinned container
  in place (withdraw, restart, readiness, republish) and no longer creates
  a second container
* refactor(deployment): resolve the implicit HTTP readiness probe from the
  service spec so readiness no longer depends on how a service is
  replaced, keeping the check for stateless public HTTP services
* refactor(deployment): remove DeployResult.Interrupted and
  ServiceResult.Retire/RetireGrace
* test(deployment): replace the rolling-behaviour tests with sequential
  replacement, in-place restart, readiness-ordering, withdrawal-failure,
  cancellation, recovery-inhibition and multi-service failure tests
* docs: describe sequential replacement, the expected short interruption,
  preflight protection and manifest-based rollback

* fix(deployment): recover interrupted replacements safely

Close the crash window left by sequential replacement and the recovery
dead ends the review of it exposed.

* fix(deployment): journal the replacement's container ID before it is
  started, and keep it in the journal when a replacement fails after
  creating its container, so a leftover stays traceable
* fix(deployment): reconcile an unfinished operation at boot and before
  every mutation of an app: remove a never-published candidate (releasing
  its loopback claims) before creating or rebuilding a generation, keep
  the published generation, retry the leftovers of an operation that
  already failed, and finalize the operation (failure, or success when
  every step had already completed) so a keyed retry never reads a
  permanent in-flight claim
* fix(deployment): refuse a new journal while a different operation of the
  same app is unfinished, so a claim can never mask an interrupted
  predecessor
* fix(deployment): a restart that rebuilds a missing generation clears the
  recovery inhibition it wrote for the container proven gone, which
  otherwise refused every later start, recovery pass, and restart
* refactor(deployment): recover the store before reconciling in deploy,
  move the preflight store recovery to its callers, and extract
  runtimeImageRef, convergeCandidate, and interruptedOutcome
* refactor(config): remove the dead [deploy] configuration surface
  (pull_policy, readiness_*, health_timeout, stabilization_delay, probe
  timeouts, drain_*) from the viper defaults, the example configuration,
  and the docs: no code ever read these keys
* test(deployment): cover the candidate journal write before start, boot
  and mutation-time reconciliation, terminal-operation leftovers, an
  interruption after the last step, the claim guard, restart rebuilding a
  missing container, and the inhibition cleanup
* docs: document crash recovery, the 15 s republish cadence, and restart
  rebuilding from the pinned digest

* fix(deployment): fail closed on orphan replacements and repair recovery

Close the recovery findings left by the sequential-replacement review.

* fix(deployment): fail closed when a replacement candidate cannot be removed.
  Reconciliation returns an error, keeps the journal (and its latest position)
  instead of finalizing it, and no later mutation proceeds, so a newer
  operation can never mask an orphan that may still run.
* fix(deployment): clear the superseded container's stale replacement-pending
  inhibition once its never-published candidate is confirmed gone, so
  boot/start/restart can rebuild the generation ACTIVE records. The inhibition
  is kept while candidate cleanup fails, preserving the single writer.
* fix(deployment): set Service on start/restart placeholder journal steps so a
  recovered step is attributable and its inhibition can be cleared.
* fix(deployment): repair the Preflight(key)->Deploy(same key) contract:
  Preflight reconciles before resolving and never claims the caller's request
  key, so a later Deploy of the same key executes instead of replaying the
  preflight journal as a conflict.
* refactor(deployment): drop the unused bool result of verifyRunningService.
* test(deployment): cover fail-closed candidate removal, stale inhibition
  removal with a volume-owning rebuild, the repaired preflight/deploy key
  contract, and preflight reconciliation of an unfinished predecessor.
* docs: describe fail-closed reconciliation and inhibition removal.

* refactor(deployment): split deploy engine into start and execute phases

Factor the synchronous deploy engine into a durable two-phase internal
API. StartDeploy runs under the app lock: it reconciles any interrupted
predecessor, resolves the revision and runs the convergence checks,
atomically claims the request key, and persists a non-terminal journal.
It performs no image pull, pinning, or workload mutation, and reports
whether the caller newly owns the operation or is an idempotent replay.

ExecuteDeploy reacquires the app lock, loads and verifies the claimed
journal by app/op identity, runs image pull/pinning and the existing
sequential replacement, and records the terminal outcome. A terminal or
mismatched claim is never executed: it replays instead. Cancellation or
shutdown mid-execution leaves the non-terminal journal for reconciliation.

Service.Deploy stays the synchronous composition of both phases, so all
existing callers and behavior are unchanged. Existing preflight is
re-expressed as claimDeploymentLocked plus pinPreflightLocked.

* feat(apps): orchestrate two-phase deploy in the background

AppServiceImpl.Deploy now claims and persists the operation through
StartDeploy and, for the key owner only, schedules ExecuteDeploy on a
daemon-owned context so the request returns the running journal promptly
and request cancellation never aborts the replacement. Duplicate keys
replay their stored journal and never spawn a second execution.

A daemon context plus WaitGroup on AppServiceImpl gives the lifecycle
its graceful shutdown hook: Shutdown cancels in-flight executions,
which leave the persisted non-terminal journal for reconciliation, and
waits for them to unwind.

* feat(app): wire daemon lifecycle context into app administration

Build AppServiceImpl through newAppDaemonService with the
daemon/supervisor lifecycle context, so background deploy executions are
owned by the daemon and never by a request context.

Graceful shutdown registers AppServiceImpl.Shutdown on the bounded 30s
shutdown context as Phase 0.5, cancelling and joining in-flight deploy
executions before traffic, runtime, or the app state store is torn down.
A failed unwind is logged and never aborts teardown.

* feat(apps): answer 202 for running deploys and quiesce kernel app admin

The admin deploy handler now answers 202 Accepted with the durable running journal when it newly owns the operation, so the request returns promptly and the client observes completion via GET operations/by-key. A terminal replay stays 200, and conflicts, preflight failures, and failed replays keep their existing 409 mapping. AppDeployResponse carries a stable status field (running until terminal, then the persisted outcome); the by-key lookup keeps answering 200 and remains the recovery source. Permission checks and Idempotency-Key validation are unchanged, and the remote client's generic 2xx path already accepts 202.

Kernel.Close now cancels and joins the daemon-owned AppServiceImpl on a bounded context before any remaining kernel resource is torn down, so a background deploy execution cannot outlive the state and runtime it uses. A nil implementation is never stored in the lifecycle interface.

* feat(apps): poll 202 deploy operations and add operations watch

`gordon apps deploy` now follows a backend 202 through the existing
operations/by-key endpoint until the operation is terminal, printing
concise progress only when operation or step state changes. JSON mode
keeps stdout to a single final document (progress goes to stderr) and
terminal partial/failed outcomes exit nonzero.

Add `gordon apps operations watch APP --key KEY` to resume polling an
existing operation without reissuing the mutation. Ctrl-C stops only
local polling, never the daemon-side operation, and prints the resumable
command in human mode. 404 fails fast; transient polling errors are
bounded; local and remote planes share the same client path.

Docs: apps deploy/operations sections and CLI index.

* fix(deployment): keep live deploy claims out of foreground reconciliation

Track the operations this process owns between the StartDeploy claim and the
ExecuteDeploy exit, so a foreground mutation landing in the hand-off window
cannot reconcile and terminalize a live claim. ExecuteDeploy now always
reloads the operation from the store by app/op identity and refuses a
terminal or mismatched record, never executing a stale in-process journal.

* fix(apps): settle deploy claims raced by shutdown before scheduling

Gate AppServiceImpl.Deploy on the daemon lifecycle before StartDeploy, so a
request arriving after shutdown began is refused with a wrapped
domain.ErrAppStateConflict and never claims a journal or answers 202. Enroll
the Deploy hand-off with the lifecycle so Shutdown waits for an in-flight claim
to be either scheduled or settled, closing the window where shutdown starts
after StartDeploy but before scheduleExecution.

When shutdown wins that race, settle the owned claim instead of dropping it:
add deployment.Service.AbandonDeploy, which releases the in-process live marker
and converges the still non-terminal journal as interrupted, so no accepted
durable claim stays marked live forever and a later claim recovers cleanly.

Add deterministic tests for stopping before a deploy and for shutdown racing
between the claim and scheduling, plus an engine test proving the settled claim
is terminal and never blocks the next owner.

* fix(apps): reuse deploy watch for accepted apply --deploy

`apps apply --deploy` now follows a 202 accepted deploy through the same
by-key watch as `apps deploy`: progress only when the operation or step
state changes, one terminal journal, and a nonzero exit for a terminal
partial/failed outcome without hiding the successful apply.

JSON mode emits exactly one final combined {apply,deploy} document and
sends progress, transient warnings, and Ctrl-C resume guidance to stderr;
no initial running document is written and the deploy is never re-POSTed.
The command now threads cmd.ErrOrStderr so stderr routing is exercised.

Add focused tests for 202 to success, partial/failure with preserved
apply, unchanged-state deduplication, JSON purity, single mutation, and
Ctrl-C resume in both human and JSON modes.

* fix(apps): return journal shape for running deploy replays

A same-key deploy replay whose operation is still in flight now always answers 409 with the stored operation journal, before or after any service step has started. Only an absent or terminal journal may map a preflight error envelope, so a non-terminal replay never degrades to the mapped error response.

* refactor(deployment): remove dead exported Service.Preflight

Remove the production-dead deployment.Service.Preflight entry point and
its now-unreachable preflightLocked wrapper. No production caller and no
boundary interface exposed it; only direct tests did. The private
preflight gates (claimDeploymentLocked/pinPreflightLocked) used by
StartDeploy/ExecuteDeploy are preserved.

Delete the tests and helpers that existed only for the removed entry
point.

* test(deployment): restore preflight gate coverage through deploy phases

Retarget the live preflight gates that were only reachable through the removed Service.Preflight onto StartDeploy/ExecuteDeploy and the synchronous Deploy composition: missing required secret (ErrAppSecretMissing), env/secret collision (ErrInvalidAppSpec), unmanaged image volume (ErrAppUnmanagedImageVolume), R2 unowned existing volume refusal (ErrAppStateConflict), the targeted-deploy diff policy, and the shared GC barrier held across resource selection.

Every failing gate proves no workload mutation before the failure, and the execute-phase gates prove a terminal failed journal. No production code changes.

* fix(deployment): address HTTP readiness at declared container port

qBittorrent rejects the readiness probe with 401 "Invalid Host header, port mismatch" because the probe dials the random loopback published port and Go derives the Host header from it. Keep dialing the published bind but send 127.0.0.1:<declared container readiness port> as the Host authority, matching what the workload listens on.

Thread the Host authority through the injectable httpGet dependency so tests can assert both the dial URL and the Host header, and cover the mismatch with a server that rejects published-port authorities.

* fix(deployment): harden sequential deploy lifecycle

Address the review findings on the sequential service replacement work:

- keep the revision resolved at claim time authoritative for the whole
  execution instead of re-resolving desired state in the second phase
- record successful candidate convergence by clearing the step After and
  keeping the container id in Detail, so a converged step is not retried
  while its audit trail survives
- normalize the persisted service-step plan when a claim is resumed and
  converge recorded candidates before the plan is replaced
- fail closed when app administration does not quiesce: skip state and
  runtime teardown and propagate the error to the process entry point
- carry app and op fields through zerowrap contexts for the deploy
  scheduling logs instead of the service logger
- move the operations watch command into apps_operations_watch.go
- exercise the new admin deploy handler cases over a real httptest server
commit
9ee3e24246214643171d754a5f228c663b145cbf
author
bnema · · 2026-09-13 18:16 UTC
committer
GitHub · · 2026-09-13 18:16 UTC
parent
a6eb480
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
60 files · +5676 -1715
M README.md +1 -1
@@ -157,7 +157,7 @@ gordon apps deploy blog --remote prod
157 - Private Docker/Podman registry on your VPS157 - Private Docker/Podman registry on your VPS
158 - Declarative apps: one TOML file per app, explicit apply then deploy158 - Declarative apps: one TOML file per app, explicit apply then deploy
159 - Domain-to-container routing through a smart TCP edge reverse proxy159 - Domain-to-container routing through a smart TCP edge reverse proxy
160-- HTTP zero-downtime updates (old container serves until replacement passes readiness)160+- Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish)
161 - Remote CLI management (daemon is the sole writer)161 - Remote CLI management (daemon is the sole writer)
162 - Declarative per-service volumes, retained across lifecycle operations162 - Declarative per-service volumes, retained across lifecycle operations
163 - Per-service secrets in pass, app-wide public env in the manifest163 - Per-service secrets in pass, app-wide public env in the manifest
M docs/cli/apps.md +61 -1
@@ -55,6 +55,27 @@ With `--deploy`, chains exactly the accepted revision into a deploy after
55 persistence succeeds; the two outcomes are reported separately because a55 persistence succeeds; the two outcomes are reported separately because a
56 deploy may fail after the apply succeeded.56 deploy may fail after the apply succeeded.
57 57
58+When the daemon accepts the deploy asynchronously (HTTP 202), the command
59+reuses the same by-key watch as `gordon apps deploy`: it polls the
60+operation journal until terminal, prints progress only when the operation
61+or step state changes, and never reissues the deploy. A terminal
62+`partial`/`failed` deploy exits nonzero while still reporting the
63+successful apply. Ctrl-C stops only local polling: the daemon-side
64+operation keeps running and the command prints how to resume it.
65+
66+With `--json`, stdout carries exactly one final combined document after the
67+deploy reaches a terminal state:
68+
69+```json
70+{
71+ "apply": { "app": "blog", "resulting_revision": "rev-b", "pending": true },
72+ "deploy": { "op": "op-1", "app": "blog", "status": "success", "outcome": "success" }
73+}
74+```
75+
76+No initial running document is emitted, and progress, transient warnings,
77+and Ctrl-C resume guidance go to stderr.
78+
58 `--dry-run` and `--deploy` cannot be combined.79 `--dry-run` and `--deploy` cannot be combined.
59 80
60 ---81 ---
@@ -102,6 +123,33 @@ reused for a different request is refused.
102 123
103 ---124 ---
104 125
126+## gordon apps operations watch
127+
128+```bash
129+gordon apps operations watch APP --key KEY [--json]
130+```
131+
132+Polls the operation journal by request key until the operation reaches a
133+terminal state, then renders the terminal journal. It never reissues the
134+mutation, so it is the safe way to resume after an interrupted deploy or to
135+follow an operation started elsewhere. Progress is printed only when the
136+operation or step state changes; unchanged polls are not repeated.
137+
138+Exit status is nonzero for terminal `partial`/`failed` outcomes and for an
139+interrupted watch. Ctrl-C stops only local polling: the daemon-side
140+operation is not cancelled and keeps running, and the command prints the
141+command to resume. With `--json`, stdout carries exactly one final document
142+and progress goes to stderr.
143+
144+### Flags
145+
146+| Flag | Description |
147+|------|-------------|
148+| `--key` | Request key (required) |
149+| `--json` | Output as JSON |
150+
151+---
152+
105 ## gordon apps diff153 ## gordon apps diff
106 154
107 ```bash155 ```bash
@@ -205,6 +253,14 @@ Activates a revision (default: desired head). Fail-fast across services:
205 the first failure stops the deploy, successful services are preserved,253 the first failure stops the deploy, successful services are preserved,
206 later services stay unchanged.254 later services stay unchanged.
207 255
256+When the daemon accepts the deploy and runs the two phases in the
257+background (HTTP 202), the command polls the operation journal by key until
258+it is terminal and prints concise progress only when the operation or step
259+state changes. It never reissues the mutation. In `--json` mode stdout
260+carries one final document and progress goes to stderr. Ctrl-C stops only
261+local polling (the daemon-side operation keeps running) and prints the
262+`gordon apps operations watch` command to resume.
263+
208 ---264 ---
209 265
210 ## gordon apps restart266 ## gordon apps restart
@@ -288,9 +344,12 @@ gordon apps apply --file blog.toml --remote prod
288 # Write secret values for registered names344 # Write secret values for registered names
289 gordon apps secrets set blog --service web DATABASE_URL=... --remote prod345 gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
290 346
291-# Deploy the accepted revision347+# Deploy the accepted revision (polls 202 operations to terminal)
292 gordon apps deploy blog --remote prod348 gordon apps deploy blog --remote prod
293 349
350+# Resume watching an interrupted or externally started operation
351+gordon apps operations watch blog --key <operation-key> --remote prod
352+
294 # Inspect353 # Inspect
295 gordon apps show blog --remote prod354 gordon apps show blog --remote prod
296 gordon apps status blog --remote prod355 gordon apps status blog --remote prod
@@ -300,3 +359,4 @@ gordon apps status blog --remote prod
300 359
301 - [CLI Overview](./index.md)360 - [CLI Overview](./index.md)
302 - [Push Command](./push.md)361 - [Push Command](./push.md)
362+- [Deployment](../deployment/index.md)
M docs/cli/index.md +1 -0
@@ -68,6 +68,7 @@ gordon apps stop blog
68 gordon apps start blog68 gordon apps start blog
69 gordon apps remove blog69 gordon apps remove blog
70 gordon apps operations show blog --key <operation-key>70 gordon apps operations show blog --key <operation-key>
71+gordon apps operations watch blog --key <operation-key>
71 gordon apps secrets list blog72 gordon apps secrets list blog
72 gordon config validate --file /path/to/gordon.toml73 gordon config validate --file /path/to/gordon.toml
73 74
M docs/concepts.md +11 -8
@@ -69,18 +69,21 @@ DATABASE_URL = "database-url"
69 69
70 ## Updates70 ## Updates
71 71
72-For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains with a bounded deadline, and retires that service's old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged.72+Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time.
73 73
74-```74+Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new `ACTIVE` state is published; and traffic is rebuilt and published for the new container.
75-Time ─────────────────────────────────────────────>
76 75
77-Old Container: [═══════════════════]
78- ↓ retire
79-New Container: [═════════════════════════>
80- ↑ start ↑ traffic routed
81 ```76 ```
77+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
78+```
79+
80+If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed.
81+
82+Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data.
83+
84+A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed.
82 85
83-TCP, UDP, mixed, and volume-owning services replace with interruption — no zero-downtime promise. An open UDP socket is not application readiness. Gordon never restarts an old volume-owning image automatically after a replacement may have written data.86+`gordon apps restart` is separate from deploy and stays in place: it withdraws traffic, restarts the same pinned container, verifies readiness, and republishes traffic. No second container is created. When the recorded container is gone, restart rebuilds the service from its pinned `ACTIVE` digest and publishes it.
84 87
85 ## Deletion and Cleanup Lifecycle88 ## Deletion and Cleanup Lifecycle
86 89
D docs/config/deploy.md +0 -121
@@ -1,121 +0,0 @@
1-# Deploy Configuration
2-
3-Controls how Gordon pulls images during deployment.
4-
5-## Example
6-
7-```toml
8-[deploy]
9-pull_policy = "if-tag-changed"
10-readiness_mode = "auto"
11-health_timeout = "90s"
12-readiness_delay = "5s"
13-drain_mode = "auto"
14-drain_timeout = "30s"
15-drain_delay = "2s"
16-```
17-
18-## Settings
19-
20-### `deploy.pull_policy`
21-
22-How to decide when to pull an image:
23-
24-- `always`: always pull, even if the tag exists locally.
25-- `if-not-present`: only pull when the tag is missing locally.
26-- `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`).
27-
28-Default: `if-tag-changed`
29-
30-### `deploy.readiness_delay`
31-
32-How long Gordon waits after a container first reports `running` before it is
33-considered ready for traffic.
34-
35-- Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`).
36-- If the container briefly exits right after this delay, Gordon now waits up to
37- 30 additional seconds for it to recover before failing the deploy.
38-
39-Default: `"5s"`
40-
41-### `deploy.readiness_mode`
42-
43-How Gordon determines that a newly started container is ready for traffic.
44-
45-- `auto`: use Docker health status if the container has a healthcheck; otherwise
46- fall back to `deploy.readiness_delay`.
47-- `docker-health`: require a Docker healthcheck and wait for `healthy`.
48- Deploy fails with `no healthcheck detected` if absent.
49-- `delay`: always use `deploy.readiness_delay`.
50-
51-Default: `"auto"`
52-
53-### `deploy.health_timeout`
54-
55-Maximum time Gordon waits for health-based readiness in `auto`/`docker-health`
56-mode before failing the deploy.
57-
58-- Uses Go duration format (examples: `"30s"`, `"90s"`, `"2m"`).
59-- Default `"90s"` balances typical app startup time with fast failure feedback.
60-- Increase for slow cold-starts, migrations, or heavy warmup workloads.
61-
62-Default: `"90s"`
63-
64-### `deploy.drain_delay`
65-
66-How long Gordon waits after synchronous proxy cache invalidation before stopping
67-the previous container during zero-downtime replacement.
68-
69-- Uses Go duration format (examples: `"2s"`, `"10s"`, `"1m"`).
70-- Applied only when a previous container exists and cache invalidation was
71- triggered for the deployed domain.
72-- If explicitly set to `"0s"` (or a negative duration), delay is disabled and
73- Gordon switches immediately after invalidation.
74-
75-Default: `"2s"`
76-
77-### `deploy.drain_mode`
78-
79-How Gordon decides when it is safe to stop the previous container after routing
80-traffic to the new one.
81-
82-- `auto`: wait for in-flight proxy requests to drain when available; otherwise
83- fall back to `deploy.drain_delay`.
84-- `inflight`: wait for in-flight proxy requests on the previous container to
85- reach zero (bounded by `deploy.drain_timeout`).
86-- `delay`: always use `deploy.drain_delay`.
87-
88-Default: `"auto"`
89-
90-### `deploy.drain_timeout`
91-
92-Maximum time Gordon waits for in-flight request drain before continuing with old
93-container shutdown.
94-
95-- Uses Go duration format (examples: `"10s"`, `"30s"`, `"1m"`).
96-- Default `"30s"` is a practical upper bound for most HTTP workloads.
97-- Increase for long-lived requests (large uploads, SSE, long polling).
98-
99-Default: `"30s"`
100-
101-## Container security profile
102-
103-Runtime hardening is configured under `[containers]`:
104-
105-```toml
106-[containers]
107-security_profile = "compat" # "compat" or "strict"
108-```
109-
110-- `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults.
111-- `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile.
112-
113-## Notes
114-
115-- Changes to `deploy.pull_policy` require a restart to take effect.
116-- Changes to `deploy.readiness_mode` require a restart to take effect.
117-- Changes to `deploy.health_timeout` require a restart to take effect.
118-- Changes to `deploy.readiness_delay` require a restart to take effect.
119-- Changes to `deploy.drain_mode` require a restart to take effect.
120-- Changes to `deploy.drain_timeout` require a restart to take effect.
121-- Changes to `deploy.drain_delay` require a restart to take effect.
M docs/config/index.md +0 -23
@@ -60,16 +60,6 @@ per_ip_rps = 50 # Max requests/second per IP
60 burst = 100 # Burst size60 burst = 100 # Burst size
61 trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For61 trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
62 62
63-# Deploy behavior
64-[deploy]
65-pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
66-readiness_mode = "auto" # auto, docker-health, delay
67-health_timeout = "90s" # Max wait for health-based readiness
68-readiness_delay = "5s" # Wait after running before ready
69-drain_mode = "auto" # auto, inflight, delay
70-drain_timeout = "30s" # Max wait for in-flight drain
71-drain_delay = "2s" # Wait after proxy invalidation before stopping the old container
72-
73 # Container runtime profile63 # Container runtime profile
74 [containers]64 [containers]
75 security_profile = "compat" # compat or strict65 security_profile = "compat" # compat or strict
@@ -151,7 +141,6 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
151 | `[server]` | Core server settings | [Server](./server.md) |141 | `[server]` | Core server settings | [Server](./server.md) |
152 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |142 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
153 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |143 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
154-| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
155 | `[logging]` | Logging configuration | [Logging](./logging.md) |144 | `[logging]` | Logging configuration | [Logging](./logging.md) |
156 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |145 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
157 | `[env]` | Installation secret store location | [Environment](./env.md) |146 | `[env]` | Installation secret store location | [Environment](./env.md) |
@@ -180,13 +169,6 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G
180 | `api.rate_limit.per_ip_rps` | `50` |169 | `api.rate_limit.per_ip_rps` | `50` |
181 | `api.rate_limit.burst` | `100` |170 | `api.rate_limit.burst` | `100` |
182 | `api.rate_limit.trusted_proxies` | `[]` |171 | `api.rate_limit.trusted_proxies` | `[]` |
183-| `deploy.pull_policy` | `"if-tag-changed"` |
184-| `deploy.readiness_mode` | `"auto"` |
185-| `deploy.health_timeout` | `"90s"` |
186-| `deploy.readiness_delay` | `"5s"` |
187-| `deploy.drain_mode` | `"auto"` |
188-| `deploy.drain_timeout` | `"30s"` |
189-| `deploy.drain_delay` | `"2s"` |
190 | `containers.security_profile` | `"compat"` |172 | `containers.security_profile` | `"compat"` |
191 | `logging.level` | `"info"` |173 | `logging.level` | `"info"` |
192 | `logging.format` | `"console"` |174 | `logging.format` | `"console"` |
@@ -244,11 +226,6 @@ gordon reload
244 | `server.max_blob_chunk_size` |226 | `server.max_blob_chunk_size` |
245 | `server.max_blob_size` |227 | `server.max_blob_size` |
246 | `auth.*` |228 | `auth.*` |
247-| `deploy.readiness_mode` |
248-| `deploy.readiness_delay` |
249-| `deploy.health_timeout` |
250-| `deploy.drain_mode` |
251-| `deploy.drain_timeout` |
252 229
253 ## Environment Variable Override230 ## Environment Variable Override
254 231
M docs/config/reference.md +0 -19
@@ -116,18 +116,6 @@ trace_sample_rate = 1.0 # Fraction of traces to sample (0.0
116 [env]116 [env]
117 dir = "" # Env files directory (default: {data_dir}/env)117 dir = "" # Env files directory (default: {data_dir}/env)
118 118
119-# =============================================================================
120-# DEPLOYMENT
121-# =============================================================================
122-[deploy]
123-pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never"
124-readiness_mode = "auto" # "auto", "docker-health", "delay"
125-health_timeout = "90s" # Max wait for health-based readiness
126-readiness_delay = "5s" # Wait after running before considered ready
127-drain_mode = "auto" # "auto", "inflight", "delay"
128-drain_timeout = "30s" # Max wait for in-flight request drain
129-drain_delay = "2s" # Wait after cache invalidation before old stop
130-
131 # =============================================================================119 # =============================================================================
132 # CONTAINERS120 # CONTAINERS
133 # =============================================================================121 # =============================================================================
@@ -287,13 +275,6 @@ keep_last = 3 # Keep N newest tags per repository
287 | `telemetry.metrics` | `true` | Export metrics |275 | `telemetry.metrics` | `true` | Export metrics |
288 | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs |276 | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs |
289 | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |277 | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) |
290-| `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change |
291-| `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) |
292-| `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails |
293-| `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready |
294-| `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) |
295-| `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop |
296-| `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation |
297 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |278 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
298 | `network_isolation.enabled` | `true` | Network isolation enabled |279 | `network_isolation.enabled` | `true` | Network isolation enabled |
299 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |280 | `network_isolation.network_prefix` | `"gordon"` | Network prefix |
M docs/config/security-hardening.md +0 -1
@@ -161,6 +161,5 @@ Use `strict` for images designed to write only to mounted volumes and run withou
161 - [Auth](./auth.md)161 - [Auth](./auth.md)
162 - [Images](./images.md)162 - [Images](./images.md)
163 - [Network Isolation](./network-isolation.md)163 - [Network Isolation](./network-isolation.md)
164-- [Deploy](./deploy.md)
165 - [Volumes](./volumes.md)164 - [Volumes](./volumes.md)
166 - [Reference](./reference.md)165 - [Reference](./reference.md)
M docs/deployment/index.md +6 -7
@@ -141,17 +141,16 @@ docker push gordon.example.com/myapp:$VERSION
141 141
142 ## Updates142 ## Updates
143 143
144-For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged.144+Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services, and Gordon never runs two Gordon-managed generations of the same service at the same time.
145 145
146 ```146 ```
147-Timeline ─────────────────────────────────────────>147+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
148-
149-Old Container: [═══════════════════]
150- ↓ retire
151-New Container: [═════════════════════════>
152- ↑ start ↑ traffic routed
153 ```148 ```
154 149
150+Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back.
151+
152+If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default.
153+
155 ## Related154 ## Related
156 155
157 - [GitHub Actions](./github-actions.md)156 - [GitHub Actions](./github-actions.md)
M docs/deployment/rollback.md +2 -2
@@ -1,6 +1,6 @@
1 # Rollback1 # Rollback
2 2
3-Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in v2.50: rolling back means applying a manifest that references the previous tag (or re-pushing it) and deploying again.3+Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in v2.50: rolling back means applying and deploying a previous manifest or image reference. Redeploying a mutable tag (for example `latest`) does not guarantee the previous image bytes; keep immutable versioned tags.
4 4
5 ## Roll Forward to a Previous Tag5 ## Roll Forward to a Previous Tag
6 6
@@ -12,7 +12,7 @@ gordon apps apply --file blog.toml --remote prod
12 gordon apps deploy blog --remote prod12 gordon apps deploy blog --remote prod
13 ```13 ```
14 14
15-For HTTP services without volumes, the previous (broken) container keeps serving until the replacement passes readiness, so the recovery itself is zero-downtime.15+A rollback deploy replaces services one at a time like any other deploy: a short service interruption is possible, and Gordon never runs two generations of the same service at the same time. If the replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback.
16 16
17 ## Re-push the Previous Image as latest17 ## Re-push the Previous Image as latest
18 18
M docs/getting-started.md +2 -2
@@ -176,7 +176,7 @@ What these commands do:
176 176
177 - `gordon push` builds, uploads, and stores the image. It never deploys.177 - `gordon push` builds, uploads, and stores the image. It never deploys.
178 - `gordon apps apply` validates the manifest and persists it as desired state.178 - `gordon apps apply` validates the manifest and persists it as desired state.
179-- `gordon apps deploy` activates the accepted revision: pulls the image, starts the container, waits for readiness, switches traffic, retires the old container.179+- `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it.
180 180
181 If the app needs secrets, register their names in the manifest (`[service.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file:181 If the app needs secrets, register their names in the manifest (`[service.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file:
182 182
@@ -198,7 +198,7 @@ gordon push myapp --build --remote prod
198 gordon apps deploy blog --remote prod198 gordon apps deploy blog --remote prod
199 ```199 ```
200 200
201-For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. TCP/UDP and volume-owning services replace with interruption.201+Gordon deploys services one at a time in sorted order. A deploy may cause a short service interruption, and Gordon never runs two Gordon-managed generations of the same service at the same time. If preflight fails, the running service is left untouched. This applies to every service, including TCP, UDP, and volume-owning services.
202 202
203 ## Next Steps203 ## Next Steps
204 204
M docs/index.md +1 -1
@@ -10,7 +10,7 @@ Gordon runs on your VPS and provides:
10 - **Declarative Apps** - One TOML file per app, explicit apply then deploy10 - **Declarative Apps** - One TOML file per app, explicit apply then deploy
11 - **HTTP Reverse Proxy** - Routes app hosts to containers11 - **HTTP Reverse Proxy** - Routes app hosts to containers
12 - **Push, Apply, Deploy** - Push stores images; deploy is always explicit12 - **Push, Apply, Deploy** - Push stores images; deploy is always explicit
13-- **HTTP Zero-Downtime Updates** - Old containers serve until replacements pass readiness13+- **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish
14 - **Single Binary** - ~15MB RAM footprint14 - **Single Binary** - ~15MB RAM footprint
15 15
16 ## How It Works16 ## How It Works
M gordon.toml.example +0 -9
@@ -55,15 +55,6 @@ per_ip_rps = 50
55 burst = 10055 burst = 100
56 trusted_proxies = []56 trusted_proxies = []
57 57
58-[deploy]
59-pull_policy = "if-tag-changed"
60-readiness_mode = "auto"
61-health_timeout = "90s"
62-readiness_delay = "5s"
63-drain_mode = "auto"
64-drain_timeout = "30s"
65-drain_delay = "2s"
66-
67 [containers]58 [containers]
68 # "compat" preserves broad image compatibility. "strict" enables a read-only59 # "compat" preserves broad image compatibility. "strict" enables a read-only
69 # root filesystem and narrower Linux capabilities.60 # root filesystem and narrower Linux capabilities.
M internal/adapters/dto/admin_apps.go +6 -0
@@ -103,6 +103,11 @@ type AppSecretMetadataDTO struct {
103 Presence string `json:"presence"`103 Presence string `json:"presence"`
104 }104 }
105 105
106+// AppStatusRunning is the wire status of an operation whose effects are
107+// still executing in the background. A terminal operation reports its
108+// persisted outcome as the status instead.
109+const AppStatusRunning = "running"
110+
106 // AppDeployResponse reports deploy outcome with terminal results.111 // AppDeployResponse reports deploy outcome with terminal results.
107 // Effective and Retained are filled from current app state when the app112 // Effective and Retained are filled from current app state when the app
108 // still exists; they are omitted when it does not.113 // still exists; they are omitted when it does not.
@@ -110,6 +115,7 @@ type AppDeployResponse struct {
110 Op string `json:"op"`115 Op string `json:"op"`
111 App string `json:"app"`116 App string `json:"app"`
112 Revision string `json:"revision"`117 Revision string `json:"revision"`
118+ Status string `json:"status"`
113 Outcome string `json:"outcome"`119 Outcome string `json:"outcome"`
114 Services map[string]AppServiceResultDTO `json:"services"`120 Services map[string]AppServiceResultDTO `json:"services"`
115 Steps []AppStepDTO `json:"steps"`121 Steps []AppStepDTO `json:"steps"`
M internal/adapters/dto/admin_apps_test.go +2 -1
@@ -63,6 +63,7 @@ func TestAppDeployResponseKeys(t *testing.T) {
63 Op: "op-abc",63 Op: "op-abc",
64 App: "blog",64 App: "blog",
65 Revision: "rev-new",65 Revision: "rev-new",
66+ Status: AppStatusRunning,
66 Outcome: "success",67 Outcome: "success",
67 Services: map[string]AppServiceResultDTO{68 Services: map[string]AppServiceResultDTO{
68 "web": {Result: "deployed", EffectiveRevision: "rev-new"},69 "web": {Result: "deployed", EffectiveRevision: "rev-new"},
@@ -75,7 +76,7 @@ func TestAppDeployResponseKeys(t *testing.T) {
75 Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},76 Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
76 }77 }
77 keys := dtoJSONKeys(t, resp)78 keys := dtoJSONKeys(t, resp)
78- for _, key := range []string{"op", "app", "revision", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} {79+ for _, key := range []string{"op", "app", "revision", "status", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} {
79 assert.Contains(t, keys, key)80 assert.Contains(t, keys, key)
80 }81 }
81 svc := keys["services"].(map[string]any)["web"].(map[string]any)82 svc := keys["services"].(map[string]any)["web"].(map[string]any)
M internal/adapters/in/cli/apps.go +90 -21
@@ -95,10 +95,16 @@ func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut
95 } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil {95 } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil {
96 return rerr, true96 return rerr, true
97 }97 }
98+ return appOpConflictMessage(op, app, key, conflict), true
99+}
100+
101+// appOpConflictMessage is the by-key recovery guidance shared by the human
102+// and JSON renderings of a journaled 409 conflict.
103+func appOpConflictMessage(op, app, key string, conflict *remote.AppOpConflictError) error {
98 return fmt.Errorf(104 return fmt.Errorf(
99 "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+105 "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+
100 "gordon apps operations show %s --key %s before retrying with the same key: %w",106 "gordon apps operations show %s --key %s before retrying with the same key: %w",
101- op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true107+ op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, conflict)
102 }108 }
103 109
104 // newAppsCmd creates the `apps` parent command.110 // newAppsCmd creates the `apps` parent command.
@@ -156,7 +162,7 @@ deploy may fail after the apply succeeded.`,
156 }162 }
157 defer handle.close()163 defer handle.close()
158 plane := handle.plane164 plane := handle.plane
159- return runAppsApply(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), file, dryRun, chainDeploy, jsonOut)165+ return runAppsApply(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), file, dryRun, chainDeploy, jsonOut)
160 },166 },
161 }167 }
162 cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)")168 cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)")
@@ -166,7 +172,7 @@ deploy may fail after the apply succeeded.`,
166 return cmd172 return cmd
167 }173 }
168 174
169-func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error {175+func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out, errOut io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error {
170 if dryRun && chainDeploy {176 if dryRun && chainDeploy {
171 return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy")177 return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy")
172 }178 }
@@ -190,29 +196,86 @@ func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.W
190 }196 }
191 return renderAppApply(out, resp, dryRun)197 return renderAppApply(out, resp, dryRun)
192 }198 }
193- if !jsonOut {199+ if jsonOut {
194- if err := renderAppApply(out, resp, dryRun); err != nil {200+ return runAppsApplyDeployJSON(ctx, plane, resp, out, errOut)
195- return err201+ }
196- }202+ if err := renderAppApply(out, resp, dryRun); err != nil {
203+ return err
197 }204 }
198- deployResp, key, deployErr := plane.DeployApp(ctx, resp.App, dto.AppDeployRequest{Revision: resp.ResultingRevision})205+ return runAppsApplyDeploy(ctx, plane, resp, out, errOut)
199- if deployErr != nil {206+}
200- if conflictErr, ok := renderAppOpConflict(out, "deploy", resp.App, key, deployErr, jsonOut); ok {207+
201- return fmt.Errorf("apply of %s succeeded (%s); %w",208+// appApplyDeployDocument is the single machine-readable apply+deploy result.
202- resp.App, resp.ResultingRevision, conflictErr)209+// The apply is always included so a failed chained deploy never hides that
210+// the persist itself succeeded.
211+type appApplyDeployDocument struct {
212+ Apply *dto.AppApplyResponse `json:"apply"`
213+ Deploy *dto.AppDeployResponse `json:"deploy"`
214+}
215+
216+// runAppsApplyDeploy follows a chained deploy in human mode. The apply outcome
217+// is already rendered, then an accepted (202 running) deploy reuses the shared
218+// by-key watch: progress only on change, terminal journal, and Ctrl-C guidance
219+// that the daemon-side operation keeps running. A terminal partial/failed
220+// outcome exits nonzero without hiding the successful apply.
221+func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
222+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision})
223+ if err != nil {
224+ if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok {
225+ return applySucceededError(apply, conflictErr)
203 }226 }
204- return fmt.Errorf("apply of %s succeeded (%s); %w",227+ return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
205- resp.App, resp.ResultingRevision, appMutationError("deploy", resp.App, key, deployErr))
206 }228 }
207- if jsonOut {229+ if deployResp.Status == dto.AppStatusRunning {
208- return writeJSON(out, struct {230+ if werr := watchOperation(ctx, plane, apply.App, key, deployResp, out, errOut, false); werr != nil {
209- Apply *dto.AppApplyResponse `json:"apply"`231+ return applySucceededError(apply, werr)
210- Deploy *dto.AppDeployResponse `json:"deploy"`232+ }
211- }{Apply: resp, Deploy: deployResp})233+ return nil
212 }234 }
213 return renderAppDeployResponse(out, deployResp)235 return renderAppDeployResponse(out, deployResp)
214 }236 }
215 237
238+// runAppsApplyDeployJSON follows a chained deploy and writes exactly one final
239+// {apply,deploy} document once the operation is terminal. The mutation is
240+// issued once and observed through the existing by-key endpoint; no initial
241+// running document is emitted, and progress, transient warnings, and Ctrl-C
242+// resume guidance go to errOut so stdout stays machine-readable.
243+func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error {
244+ deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision})
245+ if err != nil {
246+ var conflict *remote.AppOpConflictError
247+ if errors.As(err, &conflict) {
248+ if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: &conflict.Response}); werr != nil {
249+ return werr
250+ }
251+ return applySucceededError(apply, appOpConflictMessage("deploy", apply.App, key, conflict))
252+ }
253+ return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
254+ }
255+ terminal := deployResp
256+ if deployResp.Status == dto.AppStatusRunning {
257+ op, werr := awaitOperation(ctx, plane, apply.App, key, deployResp, errOut)
258+ if werr != nil {
259+ return applySucceededError(apply, werr)
260+ }
261+ terminal = op
262+ }
263+ if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: terminal}); werr != nil {
264+ return werr
265+ }
266+ if terminal.Outcome == domain.AppOutcomeSuccess {
267+ return nil
268+ }
269+ return applySucceededError(apply, &OperationFailedError{App: apply.App, Op: terminal.Op, Outcome: terminal.Outcome})
270+}
271+
272+// applySucceededError preserves the apply outcome in every chained-deploy
273+// failure path: the desired state was already persisted, so the caller must
274+// not be told the apply failed.
275+func applySucceededError(apply *dto.AppApplyResponse, cause error) error {
276+ return fmt.Errorf("apply of %s succeeded (%s); %w", apply.App, apply.ResultingRevision, cause)
277+}
278+
216 func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error {279 func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error {
217 if resp.Noop {280 if resp.Noop {
218 return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision))))281 return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision))))
@@ -719,7 +782,7 @@ func newAppDeployCmd() *cobra.Command {
719 }782 }
720 defer handle.close()783 defer handle.close()
721 plane := handle.plane784 plane := handle.plane
722- return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), jsonOut)785+ return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut)
723 },786 },
724 }787 }
725 cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")788 cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
@@ -728,7 +791,10 @@ func newAppDeployCmd() *cobra.Command {
728 return cmd791 return cmd
729 }792 }
730 793
731-func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out io.Writer, jsonOut bool) error {794+// runAppDeploy issues one deploy mutation and, when the daemon answers 202
795+// with a running journal, polls the existing by-key endpoint to terminal
796+// rather than reissuing the mutation or hiding the outcome.
797+func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out, errOut io.Writer, jsonOut bool) error {
732 resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service})798 resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service})
733 if err != nil {799 if err != nil {
734 if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {800 if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok {
@@ -736,6 +802,9 @@ func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, servic
736 }802 }
737 return appMutationError("deploy", app, key, err)803 return appMutationError("deploy", app, key, err)
738 }804 }
805+ if resp.Status == dto.AppStatusRunning {
806+ return watchOperation(ctx, plane, app, key, resp, out, errOut, jsonOut)
807+ }
739 if jsonOut {808 if jsonOut {
740 return writeJSON(out, resp)809 return writeJSON(out, resp)
741 }810 }
A internal/adapters/in/cli/apps_apply_watch_test.go +187 -0
@@ -0,0 +1,187 @@
1+package cli
2+
3+// Tests for the `apps apply --deploy` chain over a 202 accepted deploy:
4+// it must reuse the shared by-key watch (progress only on change, terminal
5+// journal, Ctrl-C resume) and must never reissue the deploy POST. JSON mode
6+// keeps stdout to exactly one final combined {apply,deploy} document while
7+// progress and resume guidance go to stderr.
8+
9+import (
10+ "bytes"
11+ "context"
12+ "encoding/json"
13+ "io"
14+ "strings"
15+ "testing"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/internal/adapters/dto"
22+)
23+
24+func acceptedApply() *dto.AppApplyResponse {
25+ return &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}
26+}
27+
28+func TestRunAppsApply_DeployAcceptedPollsToSuccess(t *testing.T) {
29+ shortenOperationPoll(t)
30+ plane := appPlane(t)
31+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
32+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
33+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
34+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
35+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
36+
37+ var out, errOut bytes.Buffer
38+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
39+ writeManifest(t, "[app]\nname = \"blog\"\n"), false, true, false))
40+ text := out.String()
41+ assert.Contains(t, text, "Applied blog (rev-b)", "the apply outcome stays visible")
42+ assert.Contains(t, text, "op op-1:", "the accepted deploy reports progress through the watch path")
43+ assert.Contains(t, text, "op-1 blog: success", "the terminal journal is rendered")
44+}
45+
46+func TestRunAppsApply_DeployAcceptedNoSecondMutation(t *testing.T) {
47+ shortenOperationPoll(t)
48+ plane := appPlane(t)
49+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
50+ // .Once() proves the accepted deploy is observed by key, never re-POSTed.
51+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
52+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
53+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
54+
55+ var out, errOut bytes.Buffer
56+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
57+ writeManifest(t, "x"), false, true, false))
58+}
59+
60+func TestRunAppsApply_DeployAcceptedUnchangedStateNotReprinted(t *testing.T) {
61+ shortenOperationPoll(t)
62+ plane := appPlane(t)
63+ step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
64+ advanced := runningOp("op-1",
65+ dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
66+ dto.AppStepDTO{ID: "service.web.health", State: "pending"})
67+
68+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
69+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
70+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(runningOp("op-1", step), nil).Once()
71+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
72+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
73+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
74+
75+ var out, errOut bytes.Buffer
76+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
77+ writeManifest(t, "x"), false, true, false))
78+ assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
79+ "initial and one advanced poll render progress; the unchanged poll is not reprinted")
80+}
81+
82+func TestRunAppsApply_DeployAcceptedPartialFailsButKeepsApply(t *testing.T) {
83+ shortenOperationPoll(t)
84+ plane := appPlane(t)
85+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
86+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
87+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
88+ Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
89+ Services: map[string]dto.AppServiceResultDTO{
90+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
91+ },
92+ }, nil).Once()
93+
94+ var out, errOut bytes.Buffer
95+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
96+ writeManifest(t, "x"), false, true, false)
97+ require.Error(t, err, "a terminal partial deploy must exit nonzero")
98+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
99+ assert.Contains(t, err.Error(), "partial")
100+ assert.Contains(t, out.String(), "Applied blog (rev-b)", "the apply outcome stays visible")
101+ assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
102+}
103+
104+func TestRunAppsApply_DeployAcceptedJSONKeepsStdoutSingleCombinedDocument(t *testing.T) {
105+ shortenOperationPoll(t)
106+ plane := appPlane(t)
107+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
108+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
109+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
110+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
111+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
112+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
113+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
114+
115+ var out, errOut bytes.Buffer
116+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
117+ writeManifest(t, "x"), false, true, true))
118+
119+ dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
120+ var got appApplyDeployDocument
121+ require.NoError(t, dec.Decode(&got))
122+ assert.Equal(t, "apply-1", got.Apply.Intent)
123+ assert.Equal(t, "op-1", got.Deploy.Op)
124+ assert.Equal(t, "success", got.Deploy.Outcome)
125+ var extra json.RawMessage
126+ assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
127+ assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
128+ assert.NotContains(t, out.String(), dto.AppStatusRunning, "no initial running JSON is emitted")
129+ assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130+}
131+
132+func TestRunAppsApply_DeployAcceptedJSONFailureKeepsApplyDocument(t *testing.T) {
133+ shortenOperationPoll(t)
134+ plane := appPlane(t)
135+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
136+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
137+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
138+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "failed", Outcome: "failed"}, nil).Once()
139+
140+ var out, errOut bytes.Buffer
141+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut,
142+ writeManifest(t, "x"), false, true, true)
143+ require.Error(t, err)
144+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
145+
146+ dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
147+ var got appApplyDeployDocument
148+ require.NoError(t, dec.Decode(&got))
149+ assert.Equal(t, "rev-b", got.Apply.ResultingRevision, "the failed deploy must not hide the successful apply")
150+ assert.Equal(t, "failed", got.Deploy.Outcome)
151+ var extra json.RawMessage
152+ assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
153+}
154+
155+func TestRunAppsApply_DeployAcceptedCancelPrintsResume(t *testing.T) {
156+ plane := appPlane(t)
157+ ctx, cancel := context.WithCancel(context.Background())
158+ cancel()
159+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
160+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
161+
162+ var out, errOut bytes.Buffer
163+ err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
164+ writeManifest(t, "x"), false, true, false)
165+ require.Error(t, err)
166+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
167+ text := out.String()
168+ assert.Contains(t, text, "not cancelled", "Ctrl-C must not imply a server-side abort")
169+ assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
170+}
171+
172+func TestRunAppsApply_DeployAcceptedCancelKeepsJSONStdoutClean(t *testing.T) {
173+ plane := appPlane(t)
174+ ctx, cancel := context.WithCancel(context.Background())
175+ cancel()
176+ plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once()
177+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
178+
179+ var out, errOut bytes.Buffer
180+ err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut,
181+ writeManifest(t, "x"), false, true, true)
182+ require.Error(t, err)
183+ assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)")
184+ assert.Empty(t, out.String(), "JSON stdout stays empty when interrupted before terminal")
185+ assert.Contains(t, errOut.String(), "not cancelled")
186+ assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
187+}
M internal/adapters/in/cli/apps_findings_test.go +2 -2
@@ -24,7 +24,7 @@ func TestRunAppsApply_DryRunOmitsEmptyRevisionAndRendersDiff(t *testing.T) {
24 Intent: "apply-7",24 Intent: "apply-7",
25 }, nil).Once()25 }, nil).Once()
26 var out bytes.Buffer26 var out bytes.Buffer
27- require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,27+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
28 writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false))28 writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false))
29 text := out.String()29 text := out.String()
30 assert.Contains(t, text, "Validated (dry-run) blog")30 assert.Contains(t, text, "Validated (dry-run) blog")
@@ -38,7 +38,7 @@ func TestRunAppsApply_NoopOmitsEmptyRevision(t *testing.T) {
38 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).38 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).
39 Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once()39 Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once()
40 var out bytes.Buffer40 var out bytes.Buffer
41- require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,41+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
42 writeManifest(t, "x"), true, false, false))42 writeManifest(t, "x"), true, false, false))
43 assert.Contains(t, out.String(), "No changes for blog")43 assert.Contains(t, out.String(), "No changes for blog")
44 assert.NotContains(t, out.String(), "()")44 assert.NotContains(t, out.String(), "()")
M internal/adapters/in/cli/apps_operations.go +1 -1
@@ -10,7 +10,7 @@ import (
10 10
11 func newAppsOperationsCmd() *cobra.Command {11 func newAppsOperationsCmd() *cobra.Command {
12 cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"}12 cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"}
13- cmd.AddCommand(newAppsOperationsShowCmd())13+ cmd.AddCommand(newAppsOperationsShowCmd(), newAppsOperationsWatchCmd())
14 return cmd14 return cmd
15 }15 }
16 16
A internal/adapters/in/cli/apps_operations_watch.go +46 -0
@@ -0,0 +1,46 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+)
10+
11+// newAppsOperationsWatchCmd creates `operations watch APP --key KEY`: it
12+// resumes observing an existing operation through the by-key endpoint until
13+// it is terminal, never reissuing the mutation.
14+func newAppsOperationsWatchCmd() *cobra.Command {
15+ var key string
16+ var jsonOut bool
17+ cmd := &cobra.Command{
18+ Use: "watch APP",
19+ Short: "Poll an app operation until it is terminal",
20+ Long: `Polls the by-key operation journal until the operation is terminal.
21+
22+Use it to resume watching an operation from an earlier interrupted run
23+without reissuing the mutation. Ctrl-C stops only local polling; the
24+daemon-side operation keeps running and the command prints how to resume.`,
25+ Args: cobra.ExactArgs(1),
26+ RunE: func(cmd *cobra.Command, args []string) error {
27+ handle, err := resolveAppPlane()
28+ if err != nil {
29+ return err
30+ }
31+ defer handle.close()
32+ return runAppsOperationsWatch(cmd.Context(), handle.plane, cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], key, jsonOut)
33+ },
34+ }
35+ cmd.Flags().StringVar(&key, "key", "", "Request key (required)")
36+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
37+ _ = cmd.MarkFlagRequired("key")
38+ return cmd
39+}
40+
41+func runAppsOperationsWatch(ctx context.Context, plane ControlPlane, out, errOut io.Writer, app, key string, jsonOut bool) error {
42+ if app == "" || key == "" {
43+ return fmt.Errorf("APP and --key are required")
44+ }
45+ return watchOperation(ctx, plane, app, key, nil, out, errOut, jsonOut)
46+}
M internal/adapters/in/cli/apps_test.go +8 -8
@@ -41,7 +41,7 @@ func writeManifest(t *testing.T, content string) string {
41 41
42 func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {42 func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
43 plane := appPlane(t)43 plane := appPlane(t)
44- err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "x.toml", true, true, false)44+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "x.toml", true, true, false)
45 require.Error(t, err)45 require.Error(t, err)
46 assert.Contains(t, err.Error(), "--dry-run")46 assert.Contains(t, err.Error(), "--dry-run")
47 assert.Contains(t, err.Error(), "--deploy")47 assert.Contains(t, err.Error(), "--deploy")
@@ -49,7 +49,7 @@ func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
49 49
50 func TestRunAppsApply_RequiresFile(t *testing.T) {50 func TestRunAppsApply_RequiresFile(t *testing.T) {
51 plane := appPlane(t)51 plane := appPlane(t)
52- err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "", false, false, false)52+ err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", false, false, false)
53 require.Error(t, err)53 require.Error(t, err)
54 assert.Contains(t, err.Error(), "--file")54 assert.Contains(t, err.Error(), "--file")
55 }55 }
@@ -63,7 +63,7 @@ func TestRunAppsApply_JSONParity(t *testing.T) {
63 plane := appPlane(t)63 plane := appPlane(t)
64 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()64 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()
65 var out bytes.Buffer65 var out bytes.Buffer
66- require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,66+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
67 writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))67 writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))
68 var got dto.AppApplyResponse68 var got dto.AppApplyResponse
69 require.NoError(t, json.Unmarshal(out.Bytes(), &got))69 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
@@ -77,7 +77,7 @@ func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) {
77 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(77 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
78 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()78 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
79 var out bytes.Buffer79 var out bytes.Buffer
80- require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,80+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
81 writeManifest(t, "x"), false, true, false))81 writeManifest(t, "x"), false, true, false))
82 assert.Contains(t, out.String(), "rev-b")82 assert.Contains(t, out.String(), "rev-b")
83 }83 }
@@ -89,7 +89,7 @@ func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) {
89 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(89 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
90 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()90 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
91 var out bytes.Buffer91 var out bytes.Buffer
92- require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out,92+ require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
93 writeManifest(t, "x"), false, true, true))93 writeManifest(t, "x"), false, true, true))
94 var got struct {94 var got struct {
95 Apply dto.AppApplyResponse `json:"apply"`95 Apply dto.AppApplyResponse `json:"apply"`
@@ -204,7 +204,7 @@ func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
204 plane := appPlane(t)204 plane := appPlane(t)
205 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(205 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
206 nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()206 nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
207- err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, false)207+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, &bytes.Buffer{}, false)
208 require.Error(t, err)208 require.Error(t, err)
209 assert.Contains(t, err.Error(), "outcome-unknown")209 assert.Contains(t, err.Error(), "outcome-unknown")
210 assert.Contains(t, err.Error(), "key-abc")210 assert.Contains(t, err.Error(), "key-abc")
@@ -223,7 +223,7 @@ func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
223 },223 },
224 }).Once()224 }).Once()
225 var out bytes.Buffer225 var out bytes.Buffer
226- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, false)226+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, false)
227 require.Error(t, err, "conflict must retain failure semantics")227 require.Error(t, err, "conflict must retain failure semantics")
228 text := out.String()228 text := out.String()
229 assert.Contains(t, text, "web:")229 assert.Contains(t, text, "web:")
@@ -246,7 +246,7 @@ func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
246 },246 },
247 }).Once()247 }).Once()
248 var out bytes.Buffer248 var out bytes.Buffer
249- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, true)249+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, true)
250 require.Error(t, err)250 require.Error(t, err)
251 var got dto.AppDeployResponse251 var got dto.AppDeployResponse
252 require.NoError(t, json.Unmarshal(out.Bytes(), &got))252 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
A internal/adapters/in/cli/apps_watch.go +225 -0
@@ -0,0 +1,225 @@
1+package cli
2+
3+// Operation watch: a 202 mutation (or an explicit resume) is observed
4+// through the existing by-key journal endpoint until it reaches a terminal
5+// state. Polling is intentionally local: the CLI never reissues the
6+// mutation and never asks the daemon to cancel the operation, so Ctrl-C
7+// only stops this process from watching.
8+
9+import (
10+ "context"
11+ "fmt"
12+ "io"
13+ "strings"
14+ "time"
15+
16+ "github.com/bnema/gordon/internal/adapters/dto"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+// operationPollInterval is the pause between by-key polls of a running
21+// operation. It is a package variable (no flag) so command tests can
22+// shorten it; production uses a modest one-second cadence.
23+var operationPollInterval = time.Second
24+
25+// operationPollMaxTransientErrors bounds consecutive transient polling
26+// failures so a broken connection fails loudly instead of spinning or
27+// hiding the error.
28+const operationPollMaxTransientErrors = 5
29+
30+// OperationFailedError reports a terminal non-success watch outcome. The
31+// terminal journal is rendered before this error is returned so the
32+// command exits nonzero without hiding the terminal detail.
33+type OperationFailedError struct {
34+ App string
35+ Op string
36+ Outcome string
37+}
38+
39+func (e *OperationFailedError) Error() string {
40+ return fmt.Sprintf("operation %s for app %s did not succeed (outcome %s)", e.Op, e.App, e.Outcome)
41+}
42+
43+// operationResumeCommand is the concise command that resumes polling an
44+// existing operation without reissuing the mutation.
45+func operationResumeCommand(app, key string) string {
46+ cmd := fmt.Sprintf("gordon apps operations watch %s --key %s", app, key)
47+ if remoteFlag != "" {
48+ cmd += " --remote " + remoteFlag
49+ }
50+ return cmd
51+}
52+
53+// watchOperation polls the by-key journal until the operation is terminal,
54+// reports progress while it runs, then renders the terminal journal. In
55+// JSON mode stdout carries exactly one final document and progress goes to
56+// errOut. A terminal non-success outcome renders the journal and returns
57+// OperationFailedError.
58+func watchOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, out, errOut io.Writer, jsonOut bool) error {
59+ if app == "" || key == "" {
60+ return fmt.Errorf("APP and --key are required")
61+ }
62+ progressOut := out
63+ if jsonOut {
64+ progressOut = errOut
65+ }
66+ op, err := awaitOperation(ctx, plane, app, key, initial, progressOut)
67+ if err != nil {
68+ return err
69+ }
70+ if jsonOut {
71+ if err := writeJSON(out, op); err != nil {
72+ return err
73+ }
74+ } else if err := renderAppDeployResponse(out, op); err != nil {
75+ return err
76+ }
77+ if op.Outcome == domain.AppOutcomeSuccess {
78+ return nil
79+ }
80+ return &OperationFailedError{App: app, Op: op.Op, Outcome: op.Outcome}
81+}
82+
83+// awaitOperation polls OperationByKey until the journal is terminal. The
84+// optional initial response (the 202 mutation body) is reported before the
85+// first poll so its state is not lost. Progress is written only when the
86+// operation/step fingerprint changes.
87+func awaitOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, progressOut io.Writer) (*dto.AppDeployResponse, error) {
88+ lastProgress := ""
89+ transient := 0
90+ for {
91+ op := initial
92+ initial = nil
93+ if op == nil {
94+ got, err := plane.OperationByKey(ctx, app, key)
95+ if err != nil {
96+ if perr := handlePollError(ctx, progressOut, app, key, err, &transient); perr != nil {
97+ return nil, perr
98+ }
99+ continue
100+ }
101+ transient = 0
102+ op = got
103+ }
104+ if op.Status != dto.AppStatusRunning {
105+ return op, nil
106+ }
107+ if sig := operationProgressSignature(op); sig != lastProgress {
108+ lastProgress = sig
109+ if err := renderOperationProgress(progressOut, op); err != nil {
110+ return nil, err
111+ }
112+ }
113+ if err := waitOperationPoll(ctx); err != nil {
114+ return nil, stopOperationWatch(progressOut, app, key, err)
115+ }
116+ }
117+}
118+
119+// handlePollError classifies one by-key lookup error. It returns nil when
120+// the caller should poll again and a terminal error otherwise: 404 is
121+// terminal, cancellation stops local polling, and any other failure is
122+// retried up to operationPollMaxTransientErrors consecutive times.
123+func handlePollError(ctx context.Context, progressOut io.Writer, app, key string, err error, transient *int) error {
124+ if ctxErr := ctx.Err(); ctxErr != nil {
125+ return stopOperationWatch(progressOut, app, key, ctxErr)
126+ }
127+ if isRemoteNotFoundError(err) {
128+ return fmt.Errorf("operation %s for app %s is no longer recorded; nothing to resume: %w", key, app, err)
129+ }
130+ *transient++
131+ if *transient > operationPollMaxTransientErrors {
132+ return fmt.Errorf(
133+ "gave up watching operation %s for app %s after %d consecutive polling errors: %w; resume with %s",
134+ key, app, *transient, err, operationResumeCommand(app, key))
135+ }
136+ if werr := cliWriteLine(progressOut, cliRenderWarning(fmt.Sprintf(
137+ "operation watch: transient polling error (%d/%d): %v", *transient, operationPollMaxTransientErrors, err))); werr != nil {
138+ return werr
139+ }
140+ if werr := waitOperationPoll(ctx); werr != nil {
141+ return stopOperationWatch(progressOut, app, key, werr)
142+ }
143+ return nil
144+}
145+
146+// waitOperationPoll sleeps for one polling interval, aborting early when
147+// the command context is cancelled.
148+func waitOperationPoll(ctx context.Context) error {
149+ if err := ctx.Err(); err != nil {
150+ return err
151+ }
152+ timer := time.NewTimer(operationPollInterval)
153+ defer timer.Stop()
154+ select {
155+ case <-ctx.Done():
156+ return ctx.Err()
157+ case <-timer.C:
158+ return nil
159+ }
160+}
161+
162+// stopOperationWatch reports an interrupted watch without implying any
163+// server-side cancellation, then returns the interruption as an error. The
164+// resume command is printed so the user can continue watching later.
165+func stopOperationWatch(progressOut io.Writer, app, key string, cause error) error {
166+ if err := cliWriteLine(progressOut, cliRenderWarning("polling stopped; the operation is not cancelled and keeps running on the daemon")); err != nil {
167+ return err
168+ }
169+ if err := cliWriteLine(progressOut, cliRenderMeta("resume:", operationResumeCommand(app, key))); err != nil {
170+ return err
171+ }
172+ return fmt.Errorf("operation watch interrupted: %w", cause)
173+}
174+
175+// operationProgressSignature fingerprints the visible operation/step state
176+// so unchanged polls are not reprinted.
177+func operationProgressSignature(op *dto.AppDeployResponse) string {
178+ var b strings.Builder
179+ b.WriteString(op.Status)
180+ b.WriteByte(0)
181+ b.WriteString(op.Outcome)
182+ for _, step := range op.Steps {
183+ b.WriteByte(0)
184+ b.WriteString(step.ID)
185+ b.WriteByte('=')
186+ b.WriteString(step.State)
187+ b.WriteByte(':')
188+ b.WriteString(step.Detail)
189+ b.WriteByte(':')
190+ b.WriteString(step.Error)
191+ }
192+ return b.String()
193+}
194+
195+// renderOperationProgress writes one concise progress line for the current
196+// operation/step state.
197+func renderOperationProgress(out io.Writer, op *dto.AppDeployResponse) error {
198+ detail := op.Status
199+ if step, ok := activeOperationStep(op.Steps); ok {
200+ detail = fmt.Sprintf("%s %s: %s", op.Status, step.ID, step.State)
201+ if step.Error != "" {
202+ detail += ": " + sanitizeTerminalText(step.Error)
203+ } else if step.Detail != "" {
204+ detail += ": " + sanitizeTerminalText(step.Detail)
205+ }
206+ }
207+ return cliWriteLine(out, cliRenderMeta("op "+op.Op+":", detail))
208+}
209+
210+// activeOperationStep returns the step still to run (pending) when one
211+// exists, otherwise the last journaled step.
212+func activeOperationStep(steps []dto.AppStepDTO) (dto.AppStepDTO, bool) {
213+ if len(steps) == 0 {
214+ return dto.AppStepDTO{}, false
215+ }
216+ for _, step := range steps {
217+ switch step.State {
218+ case domain.AppStepSucceeded, domain.AppStepFailed, domain.AppStepNotRun:
219+ continue
220+ default:
221+ return step, true
222+ }
223+ }
224+ return steps[len(steps)-1], true
225+}
A internal/adapters/in/cli/apps_watch_test.go +208 -0
@@ -0,0 +1,208 @@
1+package cli
2+
3+// Tests for 202 deploy polling and `apps operations watch`: state-change
4+// progress, machine-readable JSON, terminal rendering/exit, bounded
5+// transient failures, fast 404, and Ctrl-C resume without server abort.
6+
7+import (
8+ "bytes"
9+ "context"
10+ "encoding/json"
11+ "errors"
12+ "io"
13+ "net/http"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "github.com/bnema/gordon/internal/adapters/dto"
23+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
24+)
25+
26+// shortenOperationPoll makes the polling cadence near-instant for tests.
27+func shortenOperationPoll(t *testing.T) {
28+ t.Helper()
29+ prev := operationPollInterval
30+ operationPollInterval = time.Millisecond
31+ t.Cleanup(func() { operationPollInterval = prev })
32+}
33+
34+func runningOp(op string, steps ...dto.AppStepDTO) *dto.AppDeployResponse {
35+ return &dto.AppDeployResponse{Op: op, App: "blog", Status: dto.AppStatusRunning, Steps: steps}
36+}
37+
38+func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) {
39+ shortenOperationPoll(t)
40+ plane := appPlane(t)
41+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
42+ runningOp("op-1"), "key-1", nil).Once()
43+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
44+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
45+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
46+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
47+
48+ var out, errOut bytes.Buffer
49+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))
50+ text := out.String()
51+ assert.Contains(t, text, "op op-1:")
52+ assert.Contains(t, text, "running")
53+ assert.Contains(t, text, "success")
54+}
55+
56+func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) {
57+ shortenOperationPoll(t)
58+ plane := appPlane(t)
59+ step := dto.AppStepDTO{ID: "service.web.start", State: "pending"}
60+ unchanged := runningOp("op-1", step)
61+ advanced := runningOp("op-1",
62+ dto.AppStepDTO{ID: "service.web.start", State: "succeeded"},
63+ dto.AppStepDTO{ID: "service.web.health", State: "pending"})
64+
65+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once()
66+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(unchanged, nil).Once()
67+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once()
68+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
69+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
70+
71+ var out, errOut bytes.Buffer
72+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))
73+ assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"),
74+ "initial and one advanced poll render progress; the unchanged poll is not reprinted")
75+}
76+
77+func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) {
78+ shortenOperationPoll(t)
79+ plane := appPlane(t)
80+ // .Once() on DeployApp proves the accepted path never re-POSTs; the
81+ // by-key lookups are the only way the outcome is observed.
82+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
83+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
84+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
85+
86+ var out, errOut bytes.Buffer
87+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false))
88+}
89+
90+func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) {
91+ shortenOperationPoll(t)
92+ plane := appPlane(t)
93+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once()
94+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{
95+ Op: "op-1", App: "blog", Status: "partial", Outcome: "partial",
96+ Services: map[string]dto.AppServiceResultDTO{
97+ "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"},
98+ },
99+ }, nil).Once()
100+
101+ var out, errOut bytes.Buffer
102+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false)
103+ require.Error(t, err, "a terminal partial outcome must exit nonzero")
104+ assert.Contains(t, err.Error(), "partial")
105+ assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing")
106+}
107+
108+func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) {
109+ shortenOperationPoll(t)
110+ plane := appPlane(t)
111+ plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
112+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once()
113+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
114+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once()
115+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
116+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
117+
118+ var out, errOut bytes.Buffer
119+ require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, true))
120+
121+ dec := json.NewDecoder(bytes.NewReader(out.Bytes()))
122+ var got dto.AppDeployResponse
123+ require.NoError(t, dec.Decode(&got))
124+ assert.Equal(t, "op-1", got.Op)
125+ assert.Equal(t, "success", got.Outcome)
126+ var extra json.RawMessage
127+ assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document")
128+ assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout")
129+ assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode")
130+}
131+
132+func TestWatchOperation_CancelPrintsResumeWithoutAbort(t *testing.T) {
133+ plane := appPlane(t)
134+ ctx, cancel := context.WithCancel(context.Background())
135+ cancel()
136+
137+ var out, errOut bytes.Buffer
138+ err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, false)
139+ require.Error(t, err)
140+ assert.ErrorIs(t, err, context.Canceled)
141+ text := out.String()
142+ assert.Contains(t, text, "gordon apps operations watch blog --key key-1")
143+ assert.Contains(t, text, "not cancelled")
144+ assert.NotContains(t, text, "cancel op")
145+}
146+
147+func TestWatchOperation_CancelKeepsJSONStdoutClean(t *testing.T) {
148+ plane := appPlane(t)
149+ ctx, cancel := context.WithCancel(context.Background())
150+ cancel()
151+
152+ var out, errOut bytes.Buffer
153+ err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, true)
154+ require.Error(t, err)
155+ assert.Empty(t, out.String(), "JSON stdout stays empty on interruption")
156+ assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1")
157+}
158+
159+func TestRunAppsOperationsWatch_ResumesExisting(t *testing.T) {
160+ shortenOperationPoll(t)
161+ plane := appPlane(t)
162+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
163+ runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once()
164+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(
165+ &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once()
166+
167+ var out, errOut bytes.Buffer
168+ require.NoError(t, runAppsOperationsWatch(context.Background(), plane, &out, &errOut, "blog", "key-1", false))
169+ assert.Contains(t, out.String(), "success")
170+}
171+
172+func TestAwaitOperation_TransientErrorsAreBounded(t *testing.T) {
173+ shortenOperationPoll(t)
174+ plane := appPlane(t)
175+ boom := errors.New("connection refused")
176+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(nil, boom).Times(operationPollMaxTransientErrors + 1)
177+
178+ var progress bytes.Buffer
179+ _, err := awaitOperation(context.Background(), plane, "blog", "key-1", nil, &progress)
180+ require.Error(t, err)
181+ assert.Contains(t, err.Error(), "gave up watching")
182+ assert.Contains(t, err.Error(), "gordon apps operations watch blog --key key-1")
183+ assert.Contains(t, progress.String(), "transient polling error")
184+}
185+
186+func TestAwaitOperation_NotFoundFailsFast(t *testing.T) {
187+ plane := appPlane(t)
188+ plane.EXPECT().OperationByKey(mock.Anything, "blog", "gone").Return(nil,
189+ &remote.HTTPError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Body: "unknown operation"}).Once()
190+
191+ _, err := awaitOperation(context.Background(), plane, "blog", "gone", nil, &bytes.Buffer{})
192+ require.Error(t, err)
193+ assert.Contains(t, err.Error(), "no longer recorded")
194+}
195+
196+func TestAppsOperationsWatchCmd_Flags(t *testing.T) {
197+ cmd := newAppsOperationsWatchCmd()
198+ assert.Equal(t, "watch APP", cmd.Use)
199+ assert.NotNil(t, cmd.Flags().Lookup("key"))
200+ assert.NotNil(t, cmd.Flags().Lookup("json"))
201+
202+ subs := map[string]bool{}
203+ for _, sub := range newAppsOperationsCmd().Commands() {
204+ subs[sub.Name()] = true
205+ }
206+ assert.True(t, subs["show"])
207+ assert.True(t, subs["watch"])
208+}
M internal/adapters/in/cli/remote/apps.go +7 -5
@@ -214,11 +214,13 @@ func (e *AppOpConflictError) Error() string {
214 return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome)214 return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome)
215 }215 }
216 216
217-// parseDeployResponse decodes a deploy/lifecycle mutation response. A 409217+// parseDeployResponse decodes a deploy/lifecycle mutation response. A 202
218-// carries the journaled AppDeployResponse body (per-service results,218+// Accepted is a success shape: it carries the same running AppDeployResponse
219-// steps, effective/observed state) instead of an error envelope; it is219+// journal as a 200 and is polled via OperationByKey, so it decodes through
220-// decoded and surfaced as AppOpConflictError so the CLI can render the220+// the generic 2xx path. A 409 carries the journaled AppDeployResponse body
221-// journal while retaining failure semantics.221+// (per-service results, steps, effective/observed state) instead of an error
222+// envelope; it is decoded and surfaced as AppOpConflictError so the CLI can
223+// render the journal while retaining failure semantics.
222 func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {224 func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {
223 if resp.StatusCode == http.StatusConflict {225 if resp.StatusCode == http.StatusConflict {
224 return decodeDeployConflict(resp, action, app)226 return decodeDeployConflict(resp, action, app)
M internal/adapters/in/cli/remote/apps_test.go +24 -0
@@ -105,6 +105,30 @@ func TestClientLifecycle_MutationsCarryKeys(t *testing.T) {
105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106 }106 }
107 107
108+// TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts
109+// the 202 running response: it decodes the journal and returns the key so the
110+// caller can poll operations/by-key.
111+func TestClientDeployApp_AcceptedRunningJournal(t *testing.T) {
112+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113+ require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path)
114+ require.Equal(t, http.MethodPost, r.Method)
115+ require.NotEmpty(t, r.Header.Get("Idempotency-Key"))
116+ w.Header().Set("Content-Type", "application/json")
117+ w.WriteHeader(http.StatusAccepted)
118+ _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{
119+ Op: "op-run", App: "blog", Revision: "rev-b", Status: dto.AppStatusRunning,
120+ })
121+ }))
122+ defer srv.Close()
123+
124+ resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{})
125+ require.NoError(t, err)
126+ require.NotNil(t, resp)
127+ assert.Equal(t, "op-run", resp.Op)
128+ assert.Equal(t, dto.AppStatusRunning, resp.Status)
129+ assert.NotEmpty(t, key)
130+}
131+
108 func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) {132 func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) {
109 journal := dto.AppDeployResponse{133 journal := dto.AppDeployResponse{
110 Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",134 Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
M internal/adapters/in/http/admin/handler_apps.go +21 -1
@@ -309,8 +309,15 @@ func (h *Handler) handleAppDeploy(w http.ResponseWriter, r *http.Request, app st
309 return309 return
310 }310 }
311 status := http.StatusOK311 status := http.StatusOK
312- if err != nil {312+ switch {
313+ case err != nil:
313 status = http.StatusConflict314 status = http.StatusConflict
315+ case op != nil && !op.Terminal():
316+ // Newly owned: the claim is durable and its effects run on the
317+ // daemon context, so answer 202 with the running operation and let
318+ // the client observe completion through operations/by-key. A
319+ // terminal replay instead answers 200 with the stored outcome.
320+ status = http.StatusAccepted
314 }321 }
315 h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false))322 h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false))
316 }323 }
@@ -498,6 +505,13 @@ func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message,
498 }505 }
499 506
500 func isMappedPreflightError(err error, op *domain.AppOperation) bool {507 func isMappedPreflightError(err error, op *domain.AppOperation) bool {
508+ // A non-terminal journal is a running replay, never a preflight
509+ // failure: its stored operation is the response regardless of which
510+ // steps have started, so the mapped error envelope must not replace
511+ // it. Only an absent or terminal journal can map a preflight error.
512+ if op != nil && !op.Terminal() {
513+ return false
514+ }
501 for _, step := range op.Steps {515 for _, step := range op.Steps {
502 if strings.HasPrefix(step.ID, "service.") {516 if strings.HasPrefix(step.ID, "service.") {
503 return false517 return false
@@ -561,6 +575,12 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics
561 resp.Op = op.Op575 resp.Op = op.Op
562 resp.Revision = op.InputRevision576 resp.Revision = op.InputRevision
563 resp.Outcome = op.Outcome577 resp.Outcome = op.Outcome
578+ // A non-terminal operation is still running; a terminal one reports
579+ // its persisted outcome as the stable status.
580+ resp.Status = dto.AppStatusRunning
581+ if op.Terminal() {
582+ resp.Status = op.Outcome
583+ }
564 resp.Services = map[string]dto.AppServiceResultDTO{}584 resp.Services = map[string]dto.AppServiceResultDTO{}
565 for _, step := range op.Steps {585 for _, step := range op.Steps {
566 service, ok := strings.CutPrefix(step.ID, "service.")586 service, ok := strings.CutPrefix(step.ID, "service.")
M internal/adapters/in/http/admin/handler_apps_test.go +219 -0
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "net/http"8 "net/http"
9 "net/http/httptest"9 "net/http/httptest"
10+ "sort"
10 "testing"11 "testing"
11 "time"12 "time"
12 13
@@ -52,6 +53,32 @@ func appsRequest(t *testing.T, handler *Handler, method, target string, body any
52 return rec53 return rec
53 }54 }
54 55
56+// appsServerDo sends a request through a test server backed by the apps
57+// handler and returns the real HTTP status code and body. The scopes are
58+// injected by newScopedTestServer because the server, not the client, owns
59+// the request context.
60+func appsServerDo(t *testing.T, srv *httptest.Server, method, target string, body any) (int, []byte) {
61+ t.Helper()
62+ var reader io.Reader
63+ if body != nil {
64+ raw, err := json.Marshal(body)
65+ require.NoError(t, err)
66+ reader = bytes.NewReader(raw)
67+ }
68+ req, err := http.NewRequest(method, srv.URL+target, reader)
69+ require.NoError(t, err)
70+ if method != http.MethodGet {
71+ req.Header.Set("Idempotency-Key", "test-operation-key")
72+ }
73+ resp, err := srv.Client().Do(req)
74+ require.NoError(t, err)
75+ defer func() { require.NoError(t, resp.Body.Close()) }()
76+ assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
77+ raw, err := io.ReadAll(resp.Body)
78+ require.NoError(t, err)
79+ return resp.StatusCode, raw
80+}
81+
55 const validAppManifest = `82 const validAppManifest = `
56 name = "blog"83 name = "blog"
57 [[service]]84 [[service]]
@@ -159,6 +186,198 @@ func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
159 assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail)186 assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail)
160 }187 }
161 188
189+// TestHandler_AppDeploy_RunningReturns202 proves a newly owned deploy whose
190+// effects are still executing answers 202 Accepted with the durable
191+// operation identity and a stable running status, so the client can recover
192+// completion through GET operations/by-key. The claim and its journal are
193+// already persisted when the handler answers.
194+func TestHandler_AppDeploy_RunningReturns202(t *testing.T) {
195+ appSvc := inmocks.NewMockAppService(t)
196+ handler := appsTestHandler(t, appSvc)
197+
198+ running := &domain.AppOperation{
199+ Op: "op-run", Kind: "deploy", App: "blog", InputRevision: "rev-1",
200+ Steps: []domain.AppOperationStep{
201+ {ID: "preflight", State: domain.AppStepSucceeded},
202+ {ID: "service.web.replace", State: domain.AppStepPending},
203+ },
204+ }
205+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, nil).Once()
206+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
207+
208+ srv := newScopedTestServer(t, handler, "admin:apps:write")
209+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
210+ require.Equal(t, http.StatusAccepted, status)
211+
212+ var resp dto.AppDeployResponse
213+ require.NoError(t, json.Unmarshal(body, &resp))
214+ assert.Equal(t, "op-run", resp.Op)
215+ assert.Equal(t, "running", resp.Status)
216+ assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
217+ require.Contains(t, resp.Services, "web")
218+ assert.Equal(t, "pending", resp.Services["web"].Result)
219+}
220+
221+// TestHandler_AppDeploy_TerminalReplayReturnsOK proves a duplicate idempotency
222+// key whose stored operation already reached a successful terminal outcome
223+// replays with 200 and the stored status instead of 202.
224+func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) {
225+ appSvc := inmocks.NewMockAppService(t)
226+ handler := appsTestHandler(t, appSvc)
227+
228+ terminal := &domain.AppOperation{
229+ Op: "op-done", Kind: "deploy", App: "blog", InputRevision: "rev-1",
230+ Outcome: domain.AppOutcomeSuccess,
231+ Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
232+ }
233+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(terminal, nil).Once()
234+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
235+
236+ srv := newScopedTestServer(t, handler, "admin:apps:write")
237+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
238+ require.Equal(t, http.StatusOK, status)
239+
240+ var resp dto.AppDeployResponse
241+ require.NoError(t, json.Unmarshal(body, &resp))
242+ assert.Equal(t, "op-done", resp.Op)
243+ assert.Equal(t, "success", resp.Status)
244+ assert.Equal(t, "success", resp.Outcome)
245+}
246+
247+// TestHandler_AppDeploy_TerminalFailureReplayIsVisible proves a replay of a
248+// failed operation keeps its conflict mapping while still surfacing the
249+// stored failure and per-service result, never a 202.
250+func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) {
251+ appSvc := inmocks.NewMockAppService(t)
252+ handler := appsTestHandler(t, appSvc)
253+
254+ failed := &domain.AppOperation{
255+ Op: "op-fail", Kind: "deploy", App: "blog", InputRevision: "rev-1",
256+ Outcome: domain.AppOutcomeFailed,
257+ Steps: []domain.AppOperationStep{{
258+ ID: "service.web.replace", State: domain.AppStepFailed, Error: "boom",
259+ }},
260+ }
261+ replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)
262+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(failed, replayErr).Once()
263+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
264+
265+ srv := newScopedTestServer(t, handler, "admin:apps:write")
266+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
267+ require.Equal(t, http.StatusConflict, status)
268+
269+ var resp dto.AppDeployResponse
270+ require.NoError(t, json.Unmarshal(body, &resp))
271+ assert.Equal(t, "op-fail", resp.Op)
272+ assert.Equal(t, "failed", resp.Status)
273+ assert.Equal(t, "failed", resp.Outcome)
274+ require.Contains(t, resp.Services, "web")
275+ assert.Equal(t, "failed", resp.Services["web"].Result)
276+ assert.Equal(t, "boom", resp.Services["web"].Error)
277+}
278+
279+// assertRunningReplayJournal pins the replay contract shared by a running
280+// replay before and after any service step has started: 409, the operation
281+// journal DTO with a running status and no terminal outcome, and never the
282+// mapped preflight error envelope. The top-level key set is asserted so both
283+// cases are proven to share one wire shape.
284+func assertRunningReplayJournal(t *testing.T, status int, body []byte, opID, lastStep string) {
285+ t.Helper()
286+ require.Equal(t, http.StatusConflict, status)
287+
288+ var shape map[string]json.RawMessage
289+ require.NoError(t, json.Unmarshal(body, &shape))
290+ _, hasError := shape["error"]
291+ assert.False(t, hasError, "a running replay must not degrade to the preflight error envelope")
292+ keys := make([]string, 0, len(shape))
293+ for key := range shape {
294+ keys = append(keys, key)
295+ }
296+ sort.Strings(keys)
297+ assert.Equal(t,
298+ []string{"app", "effective", "op", "outcome", "retained", "revision", "services", "status", "steps"},
299+ keys, "a running replay always returns the operation journal shape")
300+
301+ var resp dto.AppDeployResponse
302+ require.NoError(t, json.Unmarshal(body, &resp))
303+ assert.Equal(t, opID, resp.Op)
304+ assert.Equal(t, "running", resp.Status)
305+ assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
306+ found := false
307+ for _, step := range resp.Steps {
308+ if step.ID == lastStep {
309+ found = true
310+ assert.Equal(t, domain.AppStepPending, step.State)
311+ }
312+ }
313+ assert.True(t, found, "the journal must expose step %q", lastStep)
314+}
315+
316+// TestHandler_AppDeploy_RunningReplayBeforeServiceSteps proves a same-key
317+// replay of an operation still in flight answers 409 with the stored journal
318+// even before any service step has started. A non-terminal claim is a running
319+// journal, not a preflight failure, so the mapped error envelope never
320+// replaces it.
321+func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) {
322+ appSvc := inmocks.NewMockAppService(t)
323+ handler := appsTestHandler(t, appSvc)
324+
325+ running := &domain.AppOperation{
326+ Op: "op-run-pre", Kind: "deploy", App: "blog", InputRevision: "rev-1",
327+ Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
328+ }
329+ replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
330+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once()
331+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
332+
333+ srv := newScopedTestServer(t, handler, "admin:apps:write")
334+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
335+
336+ assertRunningReplayJournal(t, status, body, "op-run-pre", "preflight")
337+}
338+
339+// TestHandler_AppDeploy_RunningReplayAfterServiceSteps is the after-steps
340+// twin: the same running replay, now with a pending service step, must answer
341+// the identical 409 journal shape. Only the journal contents may differ from
342+// the before-steps case, never the envelope.
343+func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
344+ appSvc := inmocks.NewMockAppService(t)
345+ handler := appsTestHandler(t, appSvc)
346+
347+ running := &domain.AppOperation{
348+ Op: "op-run-post", Kind: "deploy", App: "blog", InputRevision: "rev-1",
349+ Steps: []domain.AppOperationStep{
350+ {ID: "preflight", State: domain.AppStepSucceeded},
351+ {ID: "service.web.replace", State: domain.AppStepPending},
352+ },
353+ }
354+ replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
355+ appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once()
356+ appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
357+
358+ srv := newScopedTestServer(t, handler, "admin:apps:write")
359+ status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
360+
361+ assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")
362+}
363+
364+// TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request
365+// validation contract: a deploy without a valid key never reaches the
366+// service.
367+func TestHandler_AppDeploy_RejectsMissingIdempotencyKey(t *testing.T) {
368+ appSvc := inmocks.NewMockAppService(t)
369+ handler := appsTestHandler(t, appSvc)
370+
371+ srv := newScopedTestServer(t, handler, "admin:apps:write")
372+ req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/apps/blog/deploy", nil)
373+ require.NoError(t, err)
374+ resp, err := srv.Client().Do(req)
375+ require.NoError(t, err)
376+ defer func() { require.NoError(t, resp.Body.Close()) }()
377+
378+ require.Equal(t, http.StatusBadRequest, resp.StatusCode)
379+}
380+
162 func TestHandler_AppList_RequiresScope(t *testing.T) {381 func TestHandler_AppList_RequiresScope(t *testing.T) {
163 appSvc := inmocks.NewMockAppService(t)382 appSvc := inmocks.NewMockAppService(t)
164 handler := appsTestHandler(t, appSvc)383 handler := appsTestHandler(t, appSvc)
A internal/app/app_deploy_handler_lifecycle_test.go +135 -0
@@ -0,0 +1,135 @@
1+package app
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/adapters/dto"
18+ "github.com/bnema/gordon/internal/adapters/in/http/admin"
19+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
20+ "github.com/bnema/gordon/internal/domain"
21+ "github.com/bnema/gordon/internal/usecase/apps"
22+ "github.com/bnema/gordon/internal/usecase/deployment"
23+)
24+
25+// blockingHandlerDeployEngine implements the deployment-engine port the
26+// daemon-owned app service executes through. ExecuteDeploy reports the
27+// context it received and blocks until released, so request-cancellation
28+// independence is observable at the HTTP handler boundary.
29+type blockingHandlerDeployEngine struct {
30+ started chan context.Context
31+ release chan struct{}
32+}
33+
34+func (e *blockingHandlerDeployEngine) StartDeploy(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
35+ op := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}
36+ return &deployment.StartDeployResult{
37+ Claim: deployment.DeployClaim{App: "blog", Op: "op-1", Service: "web", Revision: "rev-1", Journal: op},
38+ Owned: true,
39+ }, nil
40+}
41+
42+func (e *blockingHandlerDeployEngine) ExecuteDeploy(ctx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) {
43+ e.started <- ctx
44+ <-e.release
45+ return &deployment.DeployResult{Op: "op-1", App: "blog"}, nil
46+}
47+
48+func (e *blockingHandlerDeployEngine) AbandonDeploy(context.Context, deployment.DeployClaim) error {
49+ return nil
50+}
51+
52+func (e *blockingHandlerDeployEngine) Stop(context.Context, string, string) (*deployment.LifecycleResult, error) {
53+ return nil, nil
54+}
55+
56+func (e *blockingHandlerDeployEngine) Start(context.Context, string, string) (*deployment.LifecycleResult, error) {
57+ return nil, nil
58+}
59+
60+func (e *blockingHandlerDeployEngine) Restart(context.Context, string, string, string) (*deployment.LifecycleResult, error) {
61+ return nil, nil
62+}
63+
64+func (e *blockingHandlerDeployEngine) Remove(context.Context, string, string) (*deployment.LifecycleResult, error) {
65+ return nil, nil
66+}
67+
68+// TestAppDeployHandler_RequestCancellationDoesNotAbortExecution drives the
69+// admin deploy handler over the real daemon-owned AppServiceImpl and proves
70+// the request boundary is inert: the handler answers 202 with the persisted
71+// running journal promptly, and cancelling the request context afterwards
72+// never reaches the in-flight replacement. The handler owns no goroutine; the
73+// execution derives from the daemon context.
74+func TestAppDeployHandler_RequestCancellationDoesNotAbortExecution(t *testing.T) {
75+ store := outmocks.NewMockAppState(t)
76+ running := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}
77+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once()
78+ // Show resolves no app: the response carries the journal alone.
79+ store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
80+
81+ release := make(chan struct{})
82+ var releaseOnce sync.Once
83+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
84+
85+ engine := &blockingHandlerDeployEngine{started: make(chan context.Context, 1), release: release}
86+ svc := apps.NewAppServiceImpl(store, engine, nil, zerowrap.Default()).WithDaemonContext(context.Background())
87+ t.Cleanup(func() {
88+ unblock()
89+ _ = svc.Shutdown(context.Background())
90+ })
91+
92+ handler := admin.NewHandler(admin.HandlerDeps{Log: zerowrap.Default(), AppSvc: svc})
93+
94+ reqCtx, cancelRequest := context.WithCancel(
95+ context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:apps:write"}))
96+ defer cancelRequest()
97+ req := httptest.NewRequest(http.MethodPost, "/admin/apps/blog/deploy", nil)
98+ req.Header.Set("Idempotency-Key", "key-1")
99+ req = req.WithContext(reqCtx)
100+ rec := httptest.NewRecorder()
101+
102+ serveDone := make(chan struct{})
103+ go func() {
104+ defer close(serveDone)
105+ handler.ServeHTTP(rec, req)
106+ }()
107+
108+ var execCtx context.Context
109+ select {
110+ case execCtx = <-engine.started:
111+ case <-time.After(3 * time.Second):
112+ t.Fatal("background deploy execution never started")
113+ }
114+ select {
115+ case <-serveDone:
116+ case <-time.After(3 * time.Second):
117+ t.Fatal("handler did not answer promptly")
118+ }
119+ require.Equal(t, http.StatusAccepted, rec.Code)
120+ // The persisted journal is what the handler answers with: the client can
121+ // poll the same op through operations/by-key.
122+ var resp dto.AppDeployResponse
123+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
124+ assert.Equal(t, "op-1", resp.Op)
125+ assert.Equal(t, dto.AppStatusRunning, resp.Status)
126+
127+ // The request is over and its context is cancelled: the daemon-owned
128+ // execution must be unaffected.
129+ cancelRequest()
130+ select {
131+ case <-execCtx.Done():
132+ t.Fatal("request cancellation aborted the daemon-owned execution")
133+ case <-time.After(50 * time.Millisecond):
134+ }
135+}
M internal/app/kernel.go +33 -5
@@ -29,7 +29,12 @@ type Kernel struct {
29 volumeSvc in.VolumeService29 volumeSvc in.VolumeService
30 publicTLSSvc in.PublicTLSService30 publicTLSSvc in.PublicTLSService
31 appSvc in.AppService31 appSvc in.AppService
32- cleanup func()32+ // appAdmin is the daemon-owned app lifecycle (when the full wiring is
33+ // available). Close cancels and joins its in-flight background deploy
34+ // executions before any other kernel resource is torn down.
35+ appAdmin appAdministration
36+ log zerowrap.Logger
37+ cleanup func()
33 }38 }
34 39
35 // NewKernel initializes local services without starting server listeners.40 // NewKernel initializes local services without starting server listeners.
@@ -79,7 +84,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
79 cleanup()84 cleanup()
80 }85 }
81 86
82- return &Kernel{87+ kernel := &Kernel{
83 authEnabled: cfg.Auth.Enabled,88 authEnabled: cfg.Auth.Enabled,
84 configSvc: svc.configSvc,89 configSvc: svc.configSvc,
85 secretSvc: svc.secretSvc,90 secretSvc: svc.secretSvc,
@@ -92,8 +97,15 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
92 volumeSvc: svc.volumeSvc,97 volumeSvc: svc.volumeSvc,
93 publicTLSSvc: svc.publicTLSSvc,98 publicTLSSvc: svc.publicTLSSvc,
94 appSvc: svc.appSvc,99 appSvc: svc.appSvc,
100+ log: log,
95 cleanup: wrappedCleanup,101 cleanup: wrappedCleanup,
96- }, nil102+ }
103+ // A nil *AppServiceImpl must not be stored in the interface: the
104+ // interface would be non-nil and Close would call it.
105+ if svc.appSvcImpl != nil {
106+ kernel.appAdmin = svc.appSvcImpl
107+ }
108+ return kernel, nil
97 } else {109 } else {
98 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")110 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
99 }111 }
@@ -116,6 +128,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
116 authEnabled: cfg.Auth.Enabled,128 authEnabled: cfg.Auth.Enabled,
117 configSvc: configSvc,129 configSvc: configSvc,
118 secretSvc: secretSvc,130 secretSvc: secretSvc,
131+ log: log,
119 cleanup: cleanup,132 cleanup: cleanup,
120 }, nil133 }, nil
121 }134 }
@@ -124,11 +137,26 @@ func quietInitLogger(Config) (zerowrap.Logger, func(), error) {
124 return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil137 return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil
125 }138 }
126 139
140+// Close tears the kernel down. It first cancels and joins daemon-owned app
141+// administration on a bounded context, so a background deploy execution is
142+// never torn down mid-flight alongside the state and runtime it uses. When
143+// that quiescence times out the remaining cleanup is skipped and the error is
144+// returned: an unfinished execution may still be writing to state.
127 func (k *Kernel) Close() error {145 func (k *Kernel) Close() error {
128- if k == nil || k.cleanup == nil {146+ if k == nil {
129 return nil147 return nil
130 }148 }
131- k.cleanup()149+ if k.appAdmin != nil {
150+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
151+ err := quiesceAppAdministration(ctx, k.appAdmin, k.log)
152+ cancel()
153+ if err != nil {
154+ return err
155+ }
156+ }
157+ if k.cleanup != nil {
158+ k.cleanup()
159+ }
132 return nil160 return nil
133 }161 }
134 162
M internal/app/kernel_test.go +56 -0
@@ -1,11 +1,14 @@
1 package app1 package app
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "testing"8 "testing"
8 9
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
10 )13 )
11 14
@@ -36,3 +39,56 @@ secrets_backend = "unsafe"
36 require.NotNil(t, kernel.Config())39 require.NotNil(t, kernel.Config())
37 require.NotNil(t, kernel.Secrets())40 require.NotNil(t, kernel.Secrets())
38 }41 }
42+
43+// TestKernelClose_QuiescenceTimeoutSkipsCleanup proves the fail-closed kernel
44+// Close ordering: daemon-owned app administration is cancelled and joined on
45+// a bounded context before any remaining kernel resource is torn down, and
46+// when that quiescence times out the dependency teardown is skipped and the
47+// error is returned, so an unfinished deploy is never dropped onto a closed
48+// state or runtime.
49+func TestKernelClose_QuiescenceTimeoutSkipsCleanup(t *testing.T) {
50+ t.Parallel()
51+
52+ order := []string{}
53+ admin := &orderRecordingAdmin{order: &order, err: errors.New("in-flight deploy did not unwind")}
54+ cleanedUp := false
55+ kernel := &Kernel{
56+ appAdmin: admin,
57+ log: zerowrap.Default(),
58+ cleanup: func() { cleanedUp = true; order = append(order, "cleanup") },
59+ }
60+
61+ require.Error(t, kernel.Close())
62+ require.Equal(t, []string{"app-shutdown"}, order, "teardown must stop when quiescence times out")
63+ assert.False(t, cleanedUp)
64+ assert.True(t, admin.deadline, "app administration shutdown must run on a bounded context")
65+}
66+
67+// TestKernelClose_CleansUpAfterQuiescence keeps the normal path: once app
68+// administration joined cleanly, the kernel still tears its resources down.
69+func TestKernelClose_CleansUpAfterQuiescence(t *testing.T) {
70+ t.Parallel()
71+
72+ order := []string{}
73+ admin := &orderRecordingAdmin{order: &order}
74+ kernel := &Kernel{
75+ appAdmin: admin,
76+ log: zerowrap.Default(),
77+ cleanup: func() { order = append(order, "cleanup") },
78+ }
79+
80+ require.NoError(t, kernel.Close())
81+ require.Equal(t, []string{"app-shutdown", "cleanup"}, order)
82+}
83+
84+// TestKernelClose_WithoutAppAdministrationStillCleansUp keeps the minimal
85+// kernel path (no full service wiring) closing without a nil-interface trap.
86+func TestKernelClose_WithoutAppAdministrationStillCleansUp(t *testing.T) {
87+ t.Parallel()
88+
89+ cleaned := false
90+ kernel := &Kernel{log: zerowrap.Default(), cleanup: func() { cleaned = true }}
91+
92+ require.NoError(t, kernel.Close())
93+ assert.True(t, cleaned)
94+}
M internal/app/run.go +79 -31
@@ -839,6 +839,28 @@ func containerResourceLimits(cfg Config) (deployment.ResourceLimits, error) {
839 return limits, nil839 return limits, nil
840 }840 }
841 841
842+// appDeployEngine is the deployment-engine subset the daemon-owned app
843+// service executes through. It mirrors the usecase's own port so the
844+// composition root stays decoupled from engine internals.
845+type appDeployEngine interface {
846+ StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error)
847+ AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error
848+ ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error)
849+ Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
850+ Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
851+ Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error)
852+ Remove(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
853+}
854+
855+// newAppDaemonService builds the daemon-owned app administration service.
856+// ctx is the daemon/supervisor lifecycle context: background deploy
857+// executions derive from it and are cancelled by Shutdown during graceful
858+// teardown. It is deliberately never an HTTP request context, so request
859+// cancellation cannot abort an in-flight replacement.
860+func newAppDaemonService(ctx context.Context, store out.AppState, deploy appDeployEngine, secrets out.SecretWriter, log zerowrap.Logger) *apps.AppServiceImpl {
861+ return apps.NewAppServiceImpl(store, deploy, secrets, log).WithDaemonContext(ctx)
862+}
863+
842 // initApps wires the single v2.50 app engine: bbolt state, image digests,864 // initApps wires the single v2.50 app engine: bbolt state, image digests,
843 // pass-backed secrets, deployment/lifecycle execution, and traffic865 // pass-backed secrets, deployment/lifecycle execution, and traffic
844 // activation. The daemon is the sole app-state writer; CLI reaches the866 // activation. The daemon is the sole app-state writer; CLI reaches the
@@ -909,7 +931,7 @@ func (si *serviceInit) initApps() error {
909 return err931 return err
910 }932 }
911 si.svc.appDeploySvc.SetBindPolicies(mountPolicies)933 si.svc.appDeploySvc.SetBindPolicies(mountPolicies)
912- appSvcImpl := apps.NewAppServiceImpl(store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).934+ appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
913 WithEntrypoints(appEntrypointListeners(si.cfg)).935 WithEntrypoints(appEntrypointListeners(si.cfg)).
914 WithGCBarrier(si.svc.gcBarrier).936 WithGCBarrier(si.svc.gcBarrier).
915 WithImagePolicy(imagePolicy).937 WithImagePolicy(imagePolicy).
@@ -3085,8 +3107,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
3085 // so no new mutation can begin during shutdown.3107 // so no new mutation can begin during shutdown.
3086 localAdmin.Close()3108 localAdmin.Close()
3087 registrySrvs := []*http.Server{registrySrv, internalRegistrySrv}3109 registrySrvs := []*http.Server{registrySrv, internalRegistrySrv}
3088- gracefulShutdown(registrySrvs, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, svc.appMonitor, svc.appState, log)3110+ return gracefulShutdown(registrySrvs, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, svc.appMonitor, svc.appSvcImpl, svc.appState, log)
3089- return nil
3090 }3111 }
3091 3112
3092 // reconcileAppsAtBoot reconciles declarative apps intended to run,3113 // reconcileAppsAtBoot reconciles declarative apps intended to run,
@@ -3401,9 +3422,35 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan <-cha
3401 }3422 }
3402 }3423 }
3403 3424
3425+// appAdministration is the daemon-owned application lifecycle torn down
3426+// during graceful shutdown: it refuses new background deploys, cancels
3427+// in-flight executions on the daemon context, and joins them.
3428+type appAdministration interface {
3429+ Shutdown(context.Context) error
3430+}
3431+
3432+// quiesceAppAdministration cancels and joins daemon-owned app work on the
3433+// bounded shutdown context. A non-nil error means the bound expired while an
3434+// execution was still unwinding: the caller must stop tearing down the state
3435+// and runtime that execution still uses.
3436+func quiesceAppAdministration(ctx context.Context, appAdmin appAdministration, log zerowrap.Logger) error {
3437+ if appAdmin == nil {
3438+ return nil
3439+ }
3440+ if err := appAdmin.Shutdown(ctx); err != nil {
3441+ log.Error().Err(err).Msg("app administration did not quiesce before the shutdown deadline")
3442+ return err
3443+ }
3444+ return nil
3445+}
3446+
3404 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down3447 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
3405-// the container service and cleans up runtime files.3448+// the container service and cleans up runtime files. It returns an error when
3406-func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, monitor *appMonitor, appState out.AppState, log zerowrap.Logger) {3449+// app administration could not quiesce: in that case the remaining teardown
3450+// is skipped and the error is propagated to the process entry point, which
3451+// exits non-zero instead of closing state or runtime under an in-flight
3452+// ExecuteDeploy.
3453+func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, monitor *appMonitor, appAdmin appAdministration, appState out.AppState, log zerowrap.Logger) error {
3407 log.Info().Msg("shutting down Gordon...")3454 log.Info().Msg("shutting down Gordon...")
3408 3455
3409 // Phase 0: stop periodic app recovery first and wait for any in-flight3456 // Phase 0: stop periodic app recovery first and wait for any in-flight
@@ -3414,16 +3461,20 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server
3414 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)3461 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
3415 defer shutdownCancel()3462 defer shutdownCancel()
3416 3463
3417- // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted3464+ // Phase 0.5: quiesce daemon-owned app administration. New background
3418- for _, srv := range []*http.Server{tlsSrv, proxySrv} {3465+ // deploys are refused and in-flight executions are cancelled on the
3419- if srv == nil {3466+ // daemon context and joined here — before traffic, runtime, or the app
3420- continue3467+ // state store is torn down, so no execution can write to closed state.
3421- }3468+ // A timeout is fail-closed: the unfinished execution still owns the
3422- if err := srv.Shutdown(shutdownCtx); err != nil {3469+ // state and runtime it uses, so teardown stops here and the error makes
3423- log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error")3470+ // the process exit non-zero rather than close resources underneath it.
3424- }3471+ if err := quiesceAppAdministration(shutdownCtx, appAdmin, log); err != nil {
3472+ return fmt.Errorf("app administration quiescence: %w", err)
3425 }3473 }
3426 3474
3475+ // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
3476+ shutdownHTTPServers(shutdownCtx, log, tlsSrv, proxySrv)
3477+
3427 if trafficManager != nil {3478 if trafficManager != nil {
3428 if err := trafficManager.Shutdown(shutdownCtx); err != nil {3479 if err := trafficManager.Shutdown(shutdownCtx); err != nil {
3429 log.Warn().Err(err).Msg("traffic manager shutdown error")3480 log.Warn().Err(err).Msg("traffic manager shutdown error")
@@ -3451,14 +3502,7 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server
3451 }3502 }
3452 3503
3453 // Phase 3: Stop the registry backends.3504 // Phase 3: Stop the registry backends.
3454- for _, registrySrv := range registrySrvs {3505+ shutdownHTTPServers(shutdownCtx, log, registrySrvs...)
3455- if registrySrv == nil {
3456- continue
3457- }
3458- if err := registrySrv.Shutdown(shutdownCtx); err != nil {
3459- log.Warn().Err(err).Str("addr", registrySrv.Addr).Msg("server shutdown error")
3460- }
3461- }
3462 3506
3463 if err := containerSvc.Shutdown(shutdownCtx); err != nil {3507 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
3464 log.Warn().Err(err).Msg("error during container shutdown")3508 log.Warn().Err(err).Msg("error during container shutdown")
@@ -3467,6 +3511,20 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server
3467 3511
3468 cleanupInternalCredentials()3512 cleanupInternalCredentials()
3469 log.Info().Msg("Gordon stopped")3513 log.Info().Msg("Gordon stopped")
3514+ return nil
3515+}
3516+
3517+// shutdownHTTPServers gracefully stops each non-nil server on the shutdown
3518+// context, logging per-server failures so one listener cannot mask another.
3519+func shutdownHTTPServers(ctx context.Context, log zerowrap.Logger, servers ...*http.Server) {
3520+ for _, srv := range servers {
3521+ if srv == nil {
3522+ continue
3523+ }
3524+ if err := srv.Shutdown(ctx); err != nil {
3525+ log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error")
3526+ }
3527+ }
3470 }3528 }
3471 3529
3472 func closeAppState(state out.AppState, log zerowrap.Logger) {3530 func closeAppState(state out.AppState, log zerowrap.Logger) {
@@ -4096,7 +4154,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
4096 v.SetDefault("volumes.auto_create", true)4154 v.SetDefault("volumes.auto_create", true)
4097 v.SetDefault("volumes.prefix", "gordon")4155 v.SetDefault("volumes.prefix", "gordon")
4098 v.SetDefault("volumes.preserve", true)4156 v.SetDefault("volumes.preserve", true)
4099- v.SetDefault("deploy.pull_policy", "if-tag-changed")
4100 v.SetDefault("backups.databases.enabled", false)4157 v.SetDefault("backups.databases.enabled", false)
4101 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))4158 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
4102 v.SetDefault("backups.databases.storage_dir", "")4159 v.SetDefault("backups.databases.storage_dir", "")
@@ -4136,15 +4193,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
4136 v.SetDefault("server.registry_allowed_ips", []string{})4193 v.SetDefault("server.registry_allowed_ips", []string{})
4137 v.SetDefault("server.proxy_allowed_ips", []string{})4194 v.SetDefault("server.proxy_allowed_ips", []string{})
4138 v.SetDefault("server.registry_listen_address", "")4195 v.SetDefault("server.registry_listen_address", "")
4139- v.SetDefault("deploy.readiness_delay", "5s")
4140- v.SetDefault("deploy.readiness_mode", "auto")
4141- v.SetDefault("deploy.health_timeout", "90s")
4142- v.SetDefault("deploy.stabilization_delay", "2s")
4143- v.SetDefault("deploy.tcp_probe_timeout", "30s")
4144- v.SetDefault("deploy.http_probe_timeout", "60s")
4145- v.SetDefault("deploy.attachment_readiness_timeout", "30s")
4146- v.SetDefault("deploy.drain_mode", "auto")
4147- v.SetDefault("deploy.drain_timeout", "30s")
4148 4196
4149 ConfigureViper(v, configPath)4197 ConfigureViper(v, configPath)
4150 4198
A internal/app/run_app_lifecycle_test.go +172 -0
@@ -0,0 +1,172 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/container"
17+ "github.com/bnema/gordon/internal/usecase/deployment"
18+)
19+
20+// daemonCtxKey tags the daemon/supervisor lifecycle context so a test can
21+// prove the execution context descends from it and not from a request.
22+type daemonCtxKey struct{}
23+
24+// fakeAppDeployEngine implements the deployment-engine port used by
25+// newAppDaemonService. ExecuteDeploy reports the context it received and
26+// blocks until that context ends, so cancellation is observable.
27+type fakeAppDeployEngine struct {
28+ executeStarted chan context.Context
29+}
30+
31+func (f *fakeAppDeployEngine) StartDeploy(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
32+ return &deployment.StartDeployResult{
33+ Owned: true,
34+ Claim: deployment.DeployClaim{
35+ App: "blog", Op: "op-1", Service: "web", Revision: "rev-1",
36+ Journal: domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"},
37+ },
38+ }, nil
39+}
40+
41+func (f *fakeAppDeployEngine) ExecuteDeploy(ctx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) {
42+ f.executeStarted <- ctx
43+ <-ctx.Done()
44+ return nil, ctx.Err()
45+}
46+
47+func (f *fakeAppDeployEngine) AbandonDeploy(context.Context, deployment.DeployClaim) error {
48+ return nil
49+}
50+
51+func (f *fakeAppDeployEngine) Stop(context.Context, string, string) (*deployment.LifecycleResult, error) {
52+ return nil, nil
53+}
54+
55+func (f *fakeAppDeployEngine) Start(context.Context, string, string) (*deployment.LifecycleResult, error) {
56+ return nil, nil
57+}
58+
59+func (f *fakeAppDeployEngine) Restart(context.Context, string, string, string) (*deployment.LifecycleResult, error) {
60+ return nil, nil
61+}
62+
63+func (f *fakeAppDeployEngine) Remove(context.Context, string, string) (*deployment.LifecycleResult, error) {
64+ return nil, nil
65+}
66+
67+// TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy proves the
68+// bootstrap wiring used by initApps: the app service derives background
69+// executions from the daemon/supervisor context, that context — not the
70+// request context — owns the in-flight replacement, and daemon cancellation
71+// reaches it.
72+func TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy(t *testing.T) {
73+ store := outmocks.NewMockAppState(t)
74+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").
75+ Return(domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}, nil).Maybe()
76+
77+ engine := &fakeAppDeployEngine{executeStarted: make(chan context.Context, 1)}
78+ daemonCtx, cancelDaemon := context.WithCancel(context.WithValue(context.Background(), daemonCtxKey{}, "daemon"))
79+ svc := newAppDaemonService(daemonCtx, store, engine, nil, zerowrap.Default())
80+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
81+
82+ requestCtx, cancelRequest := context.WithCancel(context.Background())
83+ defer cancelRequest()
84+
85+ deployDone := make(chan struct{})
86+ go func() {
87+ defer close(deployDone)
88+ _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", "key-1")
89+ }()
90+
91+ var execCtx context.Context
92+ select {
93+ case execCtx = <-engine.executeStarted:
94+ case <-time.After(3 * time.Second):
95+ t.Fatal("background deploy execution never started")
96+ }
97+ assert.Equal(t, "daemon", execCtx.Value(daemonCtxKey{}), "execution must run on the daemon context")
98+
99+ // The request ending is unrelated to the daemon-owned execution.
100+ cancelRequest()
101+ select {
102+ case <-execCtx.Done():
103+ t.Fatal("request cancellation aborted the daemon-owned execution")
104+ case <-time.After(50 * time.Millisecond):
105+ }
106+
107+ cancelDaemon()
108+ select {
109+ case <-execCtx.Done():
110+ case <-time.After(3 * time.Second):
111+ t.Fatal("daemon context cancellation did not reach the in-flight execution")
112+ }
113+ <-deployDone
114+}
115+
116+// orderRecordingAdmin records when app administration quiescence runs and
117+// on which context, so shutdown ordering is observable.
118+type orderRecordingAdmin struct {
119+ order *[]string
120+ deadline bool
121+ err error
122+}
123+
124+func (a *orderRecordingAdmin) Shutdown(ctx context.Context) error {
125+ *a.order = append(*a.order, "app-shutdown")
126+ _, a.deadline = ctx.Deadline()
127+ return a.err
128+}
129+
130+// TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce proves
131+// the fail-closed shutdown ordering: when app administration cannot unwind on
132+// the bounded shutdown context, the remaining state and runtime teardown is
133+// skipped and the error is returned for the process to exit non-zero, so an
134+// in-flight execution can never write to a closed store.
135+func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *testing.T) {
136+ // Keep internal-credential cleanup inside the test temp dir.
137+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
138+
139+ order := []string{}
140+ admin := &orderRecordingAdmin{order: &order, err: errors.New("in-flight execution did not unwind")}
141+
142+ store := outmocks.NewMockAppState(t)
143+
144+ containerSvc := container.NewService(nil, nil, nil, nil, container.Config{})
145+ err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())
146+
147+ require.Error(t, err)
148+ assert.Contains(t, err.Error(), "quiescence")
149+ require.Equal(t, []string{"app-shutdown"}, order, "state teardown must not run under an unfinished execution")
150+ store.AssertNotCalled(t, "Close")
151+ assert.True(t, admin.deadline, "app administration shutdown must run on the bounded shutdown context")
152+}
153+
154+// TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState keeps the
155+// normal ordering: app administration is cancelled and joined on the bounded
156+// shutdown context before the app state store closes.
157+func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing.T) {
158+ // Keep internal-credential cleanup inside the test temp dir.
159+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
160+
161+ order := []string{}
162+ admin := &orderRecordingAdmin{order: &order}
163+
164+ store := outmocks.NewMockAppState(t)
165+ store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil)
166+
167+ containerSvc := container.NewService(nil, nil, nil, nil, container.Config{})
168+ require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()))
169+
170+ require.Equal(t, []string{"app-shutdown", "state-close"}, order)
171+ assert.True(t, admin.deadline, "app administration shutdown must run on the bounded shutdown context")
172+}
M internal/usecase/apps/app_service.go +156 -12
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "sort"6 "sort"
7+ "sync"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
9 10
@@ -24,11 +25,28 @@ type AppServiceImpl struct {
24 // core is the single configured apps service. It is built once and25 // core is the single configured apps service. It is built once and
25 // reconfigured in place, never reconstructed per request.26 // reconfigured in place, never reconstructed per request.
26 core *Service27 core *Service
28+
29+ // lifecycleMu guards the daemon lifecycle. daemonCtx is the
30+ // daemon-owned parent for background executions: it is never a request
31+ // context, so request cancellation cannot abort an in-flight deploy.
32+ // stopped gates new executions once Shutdown runs, handoffs tracks the
33+ // Deploy request hand-offs (claim through scheduling or settling) so
34+ // Shutdown never closes state under a claim it did not wait for, and
35+ // executions tracks the in-flight background executions so Shutdown can
36+ // wait for them.
37+ lifecycleMu sync.Mutex
38+ daemonCtx context.Context
39+ daemonStop context.CancelFunc
40+ stopped bool
41+ handoffs sync.WaitGroup
42+ executions sync.WaitGroup
27 }43 }
28 44
29 // deployEngine is the subset of the deployment engine the app service needs.45 // deployEngine is the subset of the deployment engine the app service needs.
30 type deployEngine interface {46 type deployEngine interface {
31- Deploy(ctx context.Context, input deployment.DeployInput) (*deployment.DeployResult, error)47+ StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error)
48+ AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error
49+ ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error)
32 Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)50 Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
33 Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)51 Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
34 Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error)52 Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error)
@@ -37,13 +55,104 @@ type deployEngine interface {
37 55
38 // NewAppServiceImpl wires the driving-port implementation.56 // NewAppServiceImpl wires the driving-port implementation.
39 func NewAppServiceImpl(store out.AppState, deploy deployEngine, secrets out.SecretWriter, log zerowrap.Logger) *AppServiceImpl {57 func NewAppServiceImpl(store out.AppState, deploy deployEngine, secrets out.SecretWriter, log zerowrap.Logger) *AppServiceImpl {
58+ daemonCtx, daemonStop := context.WithCancel(context.Background())
40 return &AppServiceImpl{59 return &AppServiceImpl{
41- store: store,60+ store: store,
42- deploy: deploy,61+ deploy: deploy,
43- secrets: secrets,62+ secrets: secrets,
44- log: log,63+ log: log,
45- core: NewService(store, log),64+ core: NewService(store, log),
65+ daemonCtx: daemonCtx,
66+ daemonStop: daemonStop,
67+ }
68+}
69+
70+// WithDaemonContext sets the parent context that owns background deploy
71+// executions. It must be called during wiring, before the first Deploy. The
72+// derived context is cancelled by Shutdown; request contexts are never used
73+// as its parent.
74+func (s *AppServiceImpl) WithDaemonContext(parent context.Context) *AppServiceImpl {
75+ if parent == nil {
76+ parent = context.Background()
77+ }
78+ s.lifecycleMu.Lock()
79+ defer s.lifecycleMu.Unlock()
80+ if s.stopped {
81+ return s
82+ }
83+ ctx, cancel := context.WithCancel(parent)
84+ if s.daemonStop != nil {
85+ s.daemonStop()
86+ }
87+ s.daemonCtx, s.daemonStop = ctx, cancel
88+ return s
89+}
90+
91+// Shutdown stops scheduling background deploy executions, cancels the daemon
92+// context so in-flight executions abort with a recoverable non-terminal
93+// journal, and waits for the in-flight hand-offs and executions to unwind or
94+// for ctx to expire. Waiting for the hand-offs first means a Deploy that
95+// claimed an operation just before Shutdown always schedules or settles it
96+// before Shutdown returns.
97+func (s *AppServiceImpl) Shutdown(ctx context.Context) error {
98+ s.lifecycleMu.Lock()
99+ if !s.stopped {
100+ s.stopped = true
101+ s.daemonStop()
102+ }
103+ s.lifecycleMu.Unlock()
104+
105+ done := make(chan struct{})
106+ go func() {
107+ s.handoffs.Wait()
108+ s.executions.Wait()
109+ close(done)
110+ }()
111+ select {
112+ case <-done:
113+ return nil
114+ case <-ctx.Done():
115+ return ctx.Err()
116+ }
117+}
118+
119+// scheduleExecution runs the claimed operation's effects on the daemon
120+// context. Only the owner of a freshly claimed key reaches it, so a duplicate
121+// key can never spawn a second execution. The journal claimed by StartDeploy
122+// is already durable when the goroutine starts. When Shutdown won the race
123+// between the claim and scheduling, the claim is settled without starting:
124+// the live marker is released and the journal converged as interrupted, so it
125+// can neither execute nor stay marked live forever.
126+func (s *AppServiceImpl) scheduleExecution(claim deployment.DeployClaim) error {
127+ opFields := map[string]any{
128+ zerowrap.FieldLayer: "usecase",
129+ zerowrap.FieldUseCase: "Deploy",
130+ "app": claim.App,
131+ "op": claim.Op,
132+ }
133+ s.lifecycleMu.Lock()
134+ if s.stopped {
135+ settleCtx := zerowrap.CtxWithFields(context.WithoutCancel(s.daemonCtx), opFields)
136+ s.lifecycleMu.Unlock()
137+ settleLog := zerowrap.FromCtx(settleCtx)
138+ settleLog.Warn().Msg("apps: deploy claimed during shutdown; settling the claim without executing")
139+ if err := s.deploy.AbandonDeploy(settleCtx, claim); err != nil {
140+ settleLog.Warn().Err(err).Msg("apps: failed to settle a deploy claimed during shutdown")
141+ }
142+ return fmt.Errorf("apps: deploy claimed during shutdown: %w", domain.ErrAppStateConflict)
46 }143 }
144+ ctx := zerowrap.CtxWithFields(s.daemonCtx, opFields)
145+ s.executions.Add(1)
146+ s.lifecycleMu.Unlock()
147+
148+ go func() {
149+ defer s.executions.Done()
150+ execLog := zerowrap.FromCtx(ctx)
151+ if _, err := s.deploy.ExecuteDeploy(ctx, claim); err != nil {
152+ execLog.Warn().Err(err).Msg("apps: background deploy execution failed")
153+ }
154+ }()
155+ return nil
47 }156 }
48 157
49 // WithEntrypoints supplies the installation entrypoint listeners used to158 // WithEntrypoints supplies the installation entrypoint listeners used to
@@ -275,15 +384,50 @@ func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff,
275 return domain.DiffAppSpec(desired.Spec, activeSpec(active)), nil384 return domain.DiffAppSpec(desired.Spec, activeSpec(active)), nil
276 }385 }
277 386
278-// Deploy implements in.AppService. A repeated idempotency key replays its387+// Deploy implements in.AppService. It enrolls the request hand-off with the
279-// stored journal: the engine claims the key atomically before any effect,388+// lifecycle first, so a deploy arriving after shutdown began is refused before
280-// so a duplicate request never deploys twice.389+// any claim, and one already in flight is always scheduled or settled before
390+// Shutdown returns. StartDeploy then claims and persists the operation under
391+// the app lock, and the effects run in the background on the daemon context,
392+// so Deploy returns the still-running journal promptly and request
393+// cancellation cannot abort an in-flight replacement. A repeated idempotency
394+// key is never owned twice: its stored journal replays instead of spawning a
395+// second execution, and only the owner schedules any work.
281 func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error) {396 func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error) {
282- result, err := s.deploy.Deploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey})397+ done, err := s.beginDeploy()
283- if result == nil {398+ if err != nil {
284 return nil, err399 return nil, err
285 }400 }
286- return s.operationResult(ctx, app, result.Op, err)401+ defer done()
402+
403+ started, err := s.deploy.StartDeploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey})
404+ if err != nil {
405+ return nil, err
406+ }
407+ if !started.Owned {
408+ // The key already answered this request: its stored journal is the
409+ // result and no workload is touched again.
410+ return s.operationResult(ctx, app, started.Claim.Op, started.ReplayError())
411+ }
412+ if err := s.scheduleExecution(started.Claim); err != nil {
413+ return s.operationResult(ctx, app, started.Claim.Op, err)
414+ }
415+ return s.operationResult(ctx, app, started.Claim.Op, nil)
416+}
417+
418+// beginDeploy enrolls one Deploy hand-off before the engine claims anything.
419+// It refuses the request once Shutdown began, and otherwise guarantees Shutdown
420+// waits for the hand-off (claim, schedule, or settle) to finish before state is
421+// torn down, so no claim is made or settled against closed state. The returned
422+// done func must be called exactly once.
423+func (s *AppServiceImpl) beginDeploy() (func(), error) {
424+ s.lifecycleMu.Lock()
425+ defer s.lifecycleMu.Unlock()
426+ if s.stopped {
427+ return nil, fmt.Errorf("apps: deploy refused: daemon is shutting down: %w", domain.ErrAppStateConflict)
428+ }
429+ s.handoffs.Add(1)
430+ return s.handoffs.Done, nil
287 }431 }
288 432
289 // Stop implements in.AppService.433 // Stop implements in.AppService.
A internal/usecase/apps/deploy_lifecycle_test.go +387 -0
@@ -0,0 +1,387 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/internal/usecase/apps"
18+ "github.com/bnema/gordon/internal/usecase/deployment"
19+)
20+
21+type deployCall struct {
22+ op *domain.AppOperation
23+ err error
24+}
25+
26+// callDeploy runs Deploy off the test goroutine so a blocking execution can
27+// never hang the test: only a prompt return is awaited.
28+func callDeploy(t *testing.T, svc *apps.AppServiceImpl, ctx context.Context, key string) <-chan deployCall {
29+ t.Helper()
30+ out := make(chan deployCall, 1)
31+ go func() {
32+ op, err := svc.Deploy(ctx, "blog", "rev-1", "web", key)
33+ out <- deployCall{op: op, err: err}
34+ }()
35+ return out
36+}
37+
38+func awaitDeploy(t *testing.T, ch <-chan deployCall) deployCall {
39+ t.Helper()
40+ select {
41+ case res := <-ch:
42+ return res
43+ case <-time.After(3 * time.Second):
44+ t.Fatal("Deploy did not return promptly")
45+ return deployCall{}
46+ }
47+}
48+
49+// gate returns a release channel and an idempotent closer, so a blocking test
50+// execution is always unblocked before Shutdown waits on it.
51+func gate() (chan struct{}, func()) {
52+ ch := make(chan struct{})
53+ var once sync.Once
54+ return ch, func() { once.Do(func() { close(ch) }) }
55+}
56+
57+func runningDeployOp() domain.AppOperation {
58+ return domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1", StartedAt: time.Now().UTC()}
59+}
60+
61+func ownedStart(op domain.AppOperation) *deployment.StartDeployResult {
62+ return &deployment.StartDeployResult{
63+ Claim: deployment.DeployClaim{App: op.App, Op: op.Op, Service: "web", Revision: "rev-1", Journal: op},
64+ Owned: true,
65+ }
66+}
67+
68+func replayStart(op domain.AppOperation) *deployment.StartDeployResult {
69+ return &deployment.StartDeployResult{
70+ Claim: deployment.DeployClaim{App: op.App, Op: op.Op, Service: "web", Revision: "rev-1", Journal: op},
71+ Owned: false,
72+ }
73+}
74+
75+// TestAppServiceImpl_Deploy_ReturnsRunningJournalAfterPersistence proves the
76+// request path is two-phase: StartDeploy has already journaled the operation
77+// when the background execution starts, and Deploy returns that non-terminal
78+// journal without waiting for the replacement.
79+func TestAppServiceImpl_Deploy_ReturnsRunningJournalAfterPersistence(t *testing.T) {
80+ ctx := context.Background()
81+ store := newMockAppState(t)
82+ deploy := newMockDeployEngine(t)
83+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
84+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
85+
86+ op := runningDeployOp()
87+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil)
88+
89+ startInputs := make(chan deployment.DeployInput, 1)
90+ deploy.startDeployFn = func(_ context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) {
91+ startInputs <- input
92+ return ownedStart(op), nil
93+ }
94+
95+ persistedBeforeExecution := make(chan error, 1)
96+ release, releaseExecution := gate()
97+ t.Cleanup(releaseExecution)
98+ deploy.executeDeployFn = func(execCtx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) {
99+ // The claimed journal must be durable and queryable before any effect.
100+ stored, err := store.LoadOperation(execCtx, claim.App, claim.Op)
101+ if err == nil && (stored.Op != claim.Op || stored.Terminal()) {
102+ err = errors.New("claimed journal is not the persisted running operation")
103+ }
104+ persistedBeforeExecution <- err
105+ <-release
106+ return &deployment.DeployResult{Op: claim.Op, App: claim.App}, nil
107+ }
108+
109+ res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
110+ require.NoError(t, res.err)
111+ require.NotNil(t, res.op)
112+ assert.Equal(t, "op-1", res.op.Op)
113+ assert.False(t, res.op.Terminal(), "Deploy returns the still-running journal")
114+ assert.Equal(t, deployment.DeployInput{App: "blog", Revision: "rev-1", Service: "web", Op: "key-1"}, <-startInputs)
115+
116+ select {
117+ case err := <-persistedBeforeExecution:
118+ require.NoError(t, err, "execution must start only after the journal is persisted")
119+ case <-time.After(3 * time.Second):
120+ t.Fatal("background execution never started")
121+ }
122+ releaseExecution()
123+}
124+
125+// TestAppServiceImpl_Deploy_RequestCancellationDoesNotCancelExecution proves
126+// the execution runs on the daemon context, never on the request context.
127+func TestAppServiceImpl_Deploy_RequestCancellationDoesNotCancelExecution(t *testing.T) {
128+ requestCtx, cancelRequest := context.WithCancel(context.Background())
129+ defer cancelRequest()
130+ store := newMockAppState(t)
131+ deploy := newMockDeployEngine(t)
132+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
133+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
134+
135+ op := runningDeployOp()
136+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil)
137+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
138+ return ownedStart(op), nil
139+ }
140+
141+ execCtxCh := make(chan context.Context, 1)
142+ release, releaseExecution := gate()
143+ t.Cleanup(releaseExecution)
144+ deploy.executeDeployFn = func(execCtx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) {
145+ execCtxCh <- execCtx
146+ <-release
147+ return nil, nil
148+ }
149+
150+ res := awaitDeploy(t, callDeploy(t, svc, requestCtx, "key-1"))
151+ require.NoError(t, res.err)
152+ require.NotNil(t, res.op)
153+
154+ execCtx := <-execCtxCh
155+ cancelRequest()
156+ select {
157+ case <-requestCtx.Done():
158+ default:
159+ t.Fatal("request context was not cancelled")
160+ }
161+ require.NotEqual(t, requestCtx, execCtx, "execution must not run on the request context")
162+ select {
163+ case <-execCtx.Done():
164+ t.Fatal("request cancellation cancelled the in-flight execution")
165+ case <-time.After(50 * time.Millisecond):
166+ }
167+ releaseExecution()
168+}
169+
170+// TestAppServiceImpl_Deploy_DuplicateKeyNeverSpawnsSecondExecution proves the
171+// claimed key is the only execution: a replay returns the stored journal, an
172+// in-flight replay reports a conflict, and ExecuteDeploy runs exactly once.
173+func TestAppServiceImpl_Deploy_DuplicateKeyNeverSpawnsSecondExecution(t *testing.T) {
174+ ctx := context.Background()
175+ store := newMockAppState(t)
176+ deploy := newMockDeployEngine(t)
177+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
178+ t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) })
179+
180+ op := runningDeployOp()
181+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil)
182+
183+ var startCalls atomic.Int32
184+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
185+ if startCalls.Add(1) == 1 {
186+ return ownedStart(op), nil
187+ }
188+ return replayStart(op), nil
189+ }
190+ var execCalls atomic.Int32
191+ execStarted := make(chan struct{})
192+ release, releaseExecution := gate()
193+ t.Cleanup(releaseExecution)
194+ deploy.executeDeployFn = func(context.Context, deployment.DeployClaim) (*deployment.DeployResult, error) {
195+ execCalls.Add(1)
196+ close(execStarted)
197+ <-release
198+ return &deployment.DeployResult{Op: "op-1"}, nil
199+ }
200+
201+ first := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
202+ require.NoError(t, first.err)
203+ require.NotNil(t, first.op)
204+ select {
205+ case <-execStarted:
206+ case <-time.After(3 * time.Second):
207+ t.Fatal("first execution never started")
208+ }
209+
210+ second := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
211+ require.ErrorIs(t, second.err, domain.ErrAppStateConflict, "an in-flight replay is never a second execution")
212+ require.NotNil(t, second.op)
213+ assert.Equal(t, "op-1", second.op.Op)
214+ assert.Equal(t, int32(1), execCalls.Load(), "a duplicate key never spawns a second execution")
215+ releaseExecution()
216+}
217+
218+// TestAppServiceImpl_Deploy_TerminalFailureRemainsQueryable proves a failed
219+// execution is recorded once, stays readable by key, and its replay reports
220+// the stored failure without executing again.
221+func TestAppServiceImpl_Deploy_TerminalFailureRemainsQueryable(t *testing.T) {
222+ ctx := context.Background()
223+ store := newMockAppState(t)
224+ deploy := newMockDeployEngine(t)
225+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
226+
227+ failed := runningDeployOp()
228+ failed.Outcome = domain.AppOutcomeFailed
229+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(failed, nil)
230+
231+ var startCalls atomic.Int32
232+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
233+ if startCalls.Add(1) == 1 {
234+ return ownedStart(runningDeployOp()), nil
235+ }
236+ return replayStart(failed), nil
237+ }
238+ var execCalls atomic.Int32
239+ executed := make(chan struct{}, 1)
240+ deploy.executeDeployFn = func(context.Context, deployment.DeployClaim) (*deployment.DeployResult, error) {
241+ execCalls.Add(1)
242+ executed <- struct{}{}
243+ return nil, errors.New("replacement failed")
244+ }
245+
246+ res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
247+ require.NoError(t, res.err, "a failed execution is reported through the journal, not the start call")
248+ require.NotNil(t, res.op)
249+ assert.True(t, res.op.Terminal())
250+ // Join the background execution before asserting the journal, so the
251+ // count is deterministic rather than a race with the goroutine.
252+ <-executed
253+ assert.Equal(t, int32(1), execCalls.Load())
254+
255+ // A terminal replay stays queryable and never executes again, even
256+ // though it is answered as a conflict.
257+ retry := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
258+ require.ErrorIs(t, retry.err, domain.ErrAppStateConflict)
259+ require.NotNil(t, retry.op)
260+ assert.Equal(t, domain.AppOutcomeFailed, retry.op.Outcome)
261+ assert.Equal(t, int32(1), execCalls.Load(), "a terminal replay never executes again")
262+
263+ require.NoError(t, svc.Shutdown(context.Background()))
264+
265+ got, err := svc.OperationByKey(ctx, "blog", "op-1")
266+ require.NoError(t, err)
267+ assert.Equal(t, domain.AppOutcomeFailed, got.Outcome, "terminal failure remains queryable by key")
268+}
269+
270+// TestAppServiceImpl_Deploy_ShutdownLeavesRecoverableNonTerminalJournal proves
271+// graceful shutdown cancels the execution and leaves the journal persisted
272+// non-terminal for reconciliation, instead of losing or finalizing it.
273+func TestAppServiceImpl_Deploy_ShutdownLeavesRecoverableNonTerminalJournal(t *testing.T) {
274+ ctx := context.Background()
275+ store := newMockAppState(t)
276+ deploy := newMockDeployEngine(t)
277+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()).
278+ WithDaemonContext(context.Background())
279+
280+ op := runningDeployOp()
281+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil)
282+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
283+ return ownedStart(op), nil
284+ }
285+
286+ execCtxCh := make(chan context.Context, 1)
287+ deploy.executeDeployFn = func(execCtx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) {
288+ execCtxCh <- execCtx
289+ <-execCtx.Done()
290+ return nil, execCtx.Err()
291+ }
292+
293+ res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
294+ require.NoError(t, res.err)
295+ require.NotNil(t, res.op)
296+
297+ execCtx := <-execCtxCh
298+ require.NoError(t, svc.Shutdown(context.Background()), "Shutdown waits for the execution to unwind")
299+ require.ErrorIs(t, execCtx.Err(), context.Canceled, "shutdown cancels the in-flight execution")
300+
301+ got, err := svc.OperationByKey(ctx, "blog", "op-1")
302+ require.NoError(t, err)
303+ assert.False(t, got.Terminal(), "shutdown must leave the non-terminal journal for reconciliation")
304+ assert.Equal(t, "op-1", got.Op)
305+}
306+
307+// TestAppServiceImpl_Deploy_RefusesAfterShutdownBeforeClaim proves a deploy
308+// that arrives after shutdown began is refused with a state conflict before
309+// the engine can claim it, so neither a durable 202-running journal nor a
310+// workload effect can be produced. The engine mock has no expectations, so
311+// any StartDeploy or ExecuteDeploy call fails the test.
312+func TestAppServiceImpl_Deploy_RefusesAfterShutdownBeforeClaim(t *testing.T) {
313+ ctx := context.Background()
314+ store := newMockAppState(t)
315+ deploy := newMockDeployEngine(t)
316+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
317+
318+ require.NoError(t, svc.Shutdown(context.Background()))
319+
320+ res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1"))
321+ require.ErrorIs(t, res.err, domain.ErrAppStateConflict, "a deploy after shutdown is refused with a state conflict")
322+ require.Nil(t, res.op, "a refused deploy never produces a journal to answer 202")
323+}
324+
325+// TestAppServiceImpl_Deploy_ShutdownRacingClaimSettlesWithoutExecuting proves
326+// the claim/schedule hand-off is closed against shutdown: once Shutdown starts
327+// after StartDeploy claimed the operation but before it was scheduled, the
328+// owned claim is settled without starting, Shutdown waits for that settle, and
329+// the request answers a conflict instead of a false 202-running orphan.
330+func TestAppServiceImpl_Deploy_ShutdownRacingClaimSettlesWithoutExecuting(t *testing.T) {
331+ ctx := context.Background()
332+ store := newMockAppState(t)
333+ deploy := newMockDeployEngine(t)
334+ svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default())
335+
336+ op := runningDeployOp()
337+ store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil)
338+
339+ claimed := make(chan struct{})
340+ releaseClaim := make(chan struct{})
341+ deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) {
342+ close(claimed)
343+ <-releaseClaim
344+ return ownedStart(op), nil
345+ }
346+ var execCalls atomic.Int32
347+ deploy.executeDeployFn = func(context.Context, deployment.DeployClaim) (*deployment.DeployResult, error) {
348+ execCalls.Add(1)
349+ return nil, nil
350+ }
351+ abandoned := make(chan deployment.DeployClaim, 1)
352+ deploy.abandonDeployFn = func(_ context.Context, claim deployment.DeployClaim) error {
353+ abandoned <- claim
354+ return nil
355+ }
356+
357+ resCh := callDeploy(t, svc, ctx, "key-1")
358+ <-claimed
359+
360+ shutdownDone := make(chan error, 1)
361+ go func() { shutdownDone <- svc.Shutdown(context.Background()) }()
362+
363+ // Shutdown must wait for the hand-off rather than return while the claim
364+ // is still being made.
365+ select {
366+ case <-shutdownDone:
367+ t.Fatal("Shutdown returned before the in-flight claim hand-off settled")
368+ case <-time.After(50 * time.Millisecond):
369+ }
370+
371+ // The claim completes now; shutdown is already set, so it must be settled
372+ // rather than executed.
373+ close(releaseClaim)
374+
375+ select {
376+ case claim := <-abandoned:
377+ assert.Equal(t, "op-1", claim.Op)
378+ case <-time.After(3 * time.Second):
379+ t.Fatal("the raced claim was not settled")
380+ }
381+ require.NoError(t, <-shutdownDone, "Shutdown waits for the settle and returns cleanly")
382+
383+ res := awaitDeploy(t, resCh)
384+ require.ErrorIs(t, res.err, domain.ErrAppStateConflict, "a settled claim answers a conflict, never 202-running")
385+ require.NotNil(t, res.op)
386+ assert.Equal(t, int32(0), execCalls.Load(), "a claim settled during shutdown never executes")
387+}
M internal/usecase/apps/mocks_test.go +30 -1
@@ -16,6 +16,13 @@ import (
16 // so this hand-written mock follows the same EXPECT()/Return() pattern.16 // so this hand-written mock follows the same EXPECT()/Return() pattern.
17 type mockDeployEngine struct {17 type mockDeployEngine struct {
18 mock.Mock18 mock.Mock
19+
20+ // startDeployFn, abandonDeployFn and executeDeployFn override the testify
21+ // expectations when set, for tests that script per-call results or block
22+ // execution.
23+ startDeployFn func(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error)
24+ abandonDeployFn func(ctx context.Context, claim deployment.DeployClaim) error
25+ executeDeployFn func(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error)
19 }26 }
20 27
21 func newMockDeployEngine(t *testing.T) *mockDeployEngine {28 func newMockDeployEngine(t *testing.T) *mockDeployEngine {
@@ -25,11 +32,33 @@ func newMockDeployEngine(t *testing.T) *mockDeployEngine {
25 return m32 return m
26 }33 }
27 34
28-func (m *mockDeployEngine) Deploy(ctx context.Context, input deployment.DeployInput) (*deployment.DeployResult, error) {35+func (m *mockDeployEngine) StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) {
36+ if m.startDeployFn != nil {
37+ return m.startDeployFn(ctx, input)
38+ }
29 args := m.Called(ctx, input)39 args := m.Called(ctx, input)
30 if args.Get(0) == nil {40 if args.Get(0) == nil {
31 return nil, args.Error(1)41 return nil, args.Error(1)
32 }42 }
43+ return args.Get(0).(*deployment.StartDeployResult), args.Error(1)
44+}
45+
46+func (m *mockDeployEngine) AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error {
47+ if m.abandonDeployFn != nil {
48+ return m.abandonDeployFn(ctx, claim)
49+ }
50+ args := m.Called(ctx, claim)
51+ return args.Error(0)
52+}
53+
54+func (m *mockDeployEngine) ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) {
55+ if m.executeDeployFn != nil {
56+ return m.executeDeployFn(ctx, claim)
57+ }
58+ args := m.Called(ctx, claim)
59+ if args.Get(0) == nil {
60+ return nil, args.Error(1)
61+ }
33 return args.Get(0).(*deployment.DeployResult), args.Error(1)62 return args.Get(0).(*deployment.DeployResult), args.Error(1)
34 }63 }
35 64
M internal/usecase/deployment/bind_policy_internal_test.go +64 -29
@@ -129,7 +129,7 @@ func TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation(t *testing.T) {
129 },129 },
130 }130 }
131 131
132- _, _, _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1")132+ _, _, _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil)
133 133
134 require.ErrorIs(t, err, domain.ErrBindPolicy)134 require.ErrorIs(t, err, domain.ErrBindPolicy)
135 assert.Contains(t, err.Error(), "config")135 assert.Contains(t, err.Error(), "config")
@@ -187,7 +187,12 @@ func TestCheckImageVolumes_IncludesBindDestinations(t *testing.T) {
187 })187 })
188 }188 }
189 189
190-func TestHTTPEligibleAndSingleWriterGating(t *testing.T) {190+// TestSingleWriterRequiredAndImplicitHTTPReadiness proves the two
191+// independent service rules: singleWriterRequired marks the stateful
192+// services whose superseded generation must stay recovery-inhibited, and
193+// implicitHTTPProbe decides which undeclared readiness checks keep the
194+// implicit HTTP probe.
195+func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) {
191 httpSpec := func() domain.AppService {196 httpSpec := func() domain.AppService {
192 return domain.AppService{197 return domain.AppService{
193 Name: "web",198 Name: "web",
@@ -195,52 +200,78 @@ func TestHTTPEligibleAndSingleWriterGating(t *testing.T) {
195 }200 }
196 }201 }
197 202
198- t.Run("http-only without binds is parallel eligible", func(t *testing.T) {203+ t.Run("stateless public HTTP service keeps the implicit HTTP probe", func(t *testing.T) {
199- assert.True(t, httpEligible(httpSpec()))204+ assert.True(t, implicitHTTPProbe(httpSpec()))
205+ assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(httpSpec()).Readiness.Type)
200 assert.False(t, singleWriterRequired(httpSpec()))206 assert.False(t, singleWriterRequired(httpSpec()))
201 })207 })
202 208
203- t.Run("internal-only http is not overlap eligible", func(t *testing.T) {209+ t.Run("undeclared probe is not guessed for internal, mixed, or L4 services", func(t *testing.T) {
204- spec := domain.AppService{210+ internal := domain.AppService{
205 Name: "api",211 Name: "api",
206 HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},212 HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
207 }213 }
208- assert.False(t, httpEligible(spec),214+ assert.False(t, implicitHTTPProbe(internal),
209- "an internal-only service has no public generation to cut over and must use interrupted replacement")215+ "an internal-only interface is not reachable through a loopback publish, so no HTTP probe is guessed")
210- assert.False(t, singleWriterRequired(spec))216+ assert.Empty(t, deploymentReadiness(internal).Readiness.Type)
217+ assert.False(t, singleWriterRequired(internal))
218+
219+ mixed := httpSpec()
220+ mixed.HTTP = append(mixed.HTTP, domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal})
221+ assert.False(t, implicitHTTPProbe(mixed))
222+ assert.Empty(t, deploymentReadiness(mixed).Readiness.Type)
223+ assert.False(t, singleWriterRequired(mixed))
224+
225+ l4Only := domain.AppService{
226+ Name: "db",
227+ TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
228+ }
229+ assert.False(t, implicitHTTPProbe(l4Only), "an HTTP GET proves nothing about an L4 workload")
230+ assert.Empty(t, deploymentReadiness(l4Only).Readiness.Type)
231+ })
232+
233+ t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) {
234+ for _, declared := range []string{domain.AppReadinessHTTP, domain.AppReadinessTCP, domain.AppReadinessLog} {
235+ spec := httpSpec()
236+ spec.Readiness = domain.AppReadiness{Type: declared}
237+ assert.False(t, implicitHTTPProbe(spec), "%s is an explicit declaration", declared)
238+ assert.Equal(t, declared, deploymentReadiness(spec).Readiness.Type)
239+ }
211 })240 })
212 241
213- t.Run("mixed public and internal http must interrupt", func(t *testing.T) {242+ t.Run("an explicit none on a probeable service keeps the implicit HTTP probe", func(t *testing.T) {
243+ spec := httpSpec()
244+ spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessNone}
245+ assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type)
246+ })
247+
248+ t.Run("an explicit none on an L4 service stays unchecked", func(t *testing.T) {
214 spec := domain.AppService{249 spec := domain.AppService{
215- Name: "api",250+ Name: "db",
216- HTTP: []domain.AppHTTPInterface{251+ TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
217- {Host: "app.example.com", Port: 8080, TLS: domain.AppTLSAuto},252+ Readiness: domain.AppReadiness{Type: domain.AppReadinessNone},
218- {Port: 9090, Visibility: domain.AppVisibilityInternal},
219- },
220 }253 }
221- assert.False(t, httpEligible(spec),254+ assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type)
222- "the candidate is resolvable by service alias over the private network before readiness, so any internal interface must use interrupted replacement")
223- assert.False(t, singleWriterRequired(spec))
224 })255 })
225 256
226 t.Run("writable bind is single-writer", func(t *testing.T) {257 t.Run("writable bind is single-writer", func(t *testing.T) {
227 spec := httpSpec()258 spec := httpSpec()
228 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}259 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
229- assert.False(t, httpEligible(spec), "a bind must disable parallel HTTP replacement")260+ assert.False(t, implicitHTTPProbe(spec), "a stateful service must declare its own readiness")
230 assert.True(t, singleWriterRequired(spec))261 assert.True(t, singleWriterRequired(spec))
231 })262 })
232 263
233 t.Run("read-only bind is also single-writer", func(t *testing.T) {264 t.Run("read-only bind is also single-writer", func(t *testing.T) {
234 spec := httpSpec()265 spec := httpSpec()
235 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}}266 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}}
236- assert.False(t, httpEligible(spec))267+ assert.False(t, implicitHTTPProbe(spec))
237 assert.True(t, singleWriterRequired(spec))268 assert.True(t, singleWriterRequired(spec))
238 })269 })
239 270
240 t.Run("volume is single-writer", func(t *testing.T) {271 t.Run("volume is single-writer", func(t *testing.T) {
241 spec := httpSpec()272 spec := httpSpec()
242 spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}273 spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
243- assert.False(t, httpEligible(spec))274+ assert.False(t, implicitHTTPProbe(spec))
244 assert.True(t, singleWriterRequired(spec))275 assert.True(t, singleWriterRequired(spec))
245 })276 })
246 }277 }
@@ -259,10 +290,13 @@ func TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation(t *testing.T)
259 },290 },
260 }291 }
261 292
262- step, result := svc.restartOneService(context.Background(), "blog", "web", eff)293+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
294+ ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container,
295+ }}}
296+ result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0)
263 297
264- assert.Equal(t, domain.AppStepFailed, step.State)298+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
265- assert.Contains(t, step.Error, "config")299+ assert.Contains(t, op.Steps[0].Error, "config")
266 assert.Equal(t, "failed", result.Result)300 assert.Equal(t, "failed", result.Result)
267 runtime.AssertNotCalled(t, "RestartContainer")301 runtime.AssertNotCalled(t, "RestartContainer")
268 runtime.AssertExpectations(t)302 runtime.AssertExpectations(t)
@@ -281,14 +315,15 @@ func TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation(t *testing.
281 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},315 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
282 },316 },
283 }317 }
284- step := &domain.AppOperationStep{}318+ op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{
319+ ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container,
320+ }}}
285 result := &LifecycleResult{Services: map[string]ServiceResult{}}321 result := &LifecycleResult{Services: map[string]ServiceResult{}}
286 322
287- cont := svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, step, result)323+ svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result)
288 324
289- assert.True(t, cont)325+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
290- assert.Equal(t, domain.AppStepFailed, step.State)326+ assert.Contains(t, op.Steps[0].Error, "config")
291- assert.Contains(t, step.Error, "config")
292 runtime.AssertNotCalled(t, "StartContainer")327 runtime.AssertNotCalled(t, "StartContainer")
293 runtime.AssertExpectations(t)328 runtime.AssertExpectations(t)
294 }329 }
A internal/usecase/deployment/crash_recovery_test.go +664 -0
@@ -0,0 +1,664 @@
1+package deployment_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/out/appstate"
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/deployment"
17+)
18+
19+// seedRevision materializes one revision of an app, so a later recovery can
20+// rebuild that generation from its pinned digest.
21+func seedRevision(t *testing.T, ctx context.Context, store *appstate.Store, intentID, supersedes string, rev domain.AppDesiredRevision) {
22+ t.Helper()
23+ require.NoError(t, store.StageApply(ctx, domain.AppApplyIntent{
24+ Intent: intentID, App: rev.App, Revision: rev.Revision, Supersedes: supersedes,
25+ SourceSHA256: "0f1e2d", Spec: rev.Spec, CreatedAt: time.Now().UTC(),
26+ }))
27+ require.NoError(t, store.CommitApply(ctx, rev.App, intentID))
28+ require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID))
29+}
30+
31+// seedReplaceableApp seeds one app whose ACTIVE generation (rev-0) has a
32+// materialized superseding revision (rev-1). The published container c-old is
33+// gone from the runtime: the caller decides what the runtime reports for it.
34+func seedReplaceableApp(t *testing.T, ctx context.Context, store *appstate.Store) (domain.AppDesiredRevision, domain.AppDesiredRevision) {
35+ t.Helper()
36+ spec := webService()
37+ spec.Secrets = map[string]string{}
38+ spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: time.Second}
39+ activeRev := testRevision("blog", spec)
40+ activeRev.Revision = "rev-0"
41+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
42+ require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
43+ "web": {
44+ Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image,
45+ Digest: restartTestDigest, Spec: spec, BackendBinds: map[int]int{8080: 18080},
46+ },
47+ }}))
48+ require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"}))
49+ seedRevision(t, ctx, store, "intent-0", "", activeRev)
50+ desired := testRevision("blog", spec)
51+ seedRevision(t, ctx, store, "intent-1", "rev-0", desired)
52+ return activeRev, desired
53+}
54+
55+// TestDeploy_CandidateIsJournaledBeforeItStarts proves the crash window is
56+// closed as far as a container ID allows: the candidate ID reaches the
57+// durable journal before the container is started, and the journal is
58+// finalized as a success only after publication.
59+func TestDeploy_CandidateIsJournaledBeforeItStarts(t *testing.T) {
60+ ctx := context.Background()
61+ store := newTestStore(t)
62+ runtime := outmocks.NewMockContainerRuntime(t)
63+ images := outmocks.NewMockImageResolver(t)
64+ secrets := outmocks.NewMockSecretProvider(t)
65+ seedReplaceableApp(t, ctx, store)
66+
67+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
68+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
69+ expectNetworkProvision(runtime, "app-blog", 1)
70+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
71+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
72+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
73+
74+ journaledBeforeStart := false
75+ runtime.EXPECT().StartContainer(mock.Anything, "c-new").RunAndReturn(func(context.Context, string) error {
76+ op, ok, err := store.LoadLatestOperation(context.Background(), "blog")
77+ require.NoError(t, err)
78+ require.True(t, ok, "the replacement is journaled before it starts")
79+ require.False(t, op.Terminal(), "the journal is still in flight while the candidate starts")
80+ step, found := opStep(op, "service.web.replace")
81+ journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending
82+ return nil
83+ }).Once()
84+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
85+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
86+
87+ svc := deployment.NewService(deployment.Deps{
88+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
89+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
90+ func(context.Context, string, string) (int, error) { return 200, nil },
91+ func(context.Context, string) error { return nil },
92+ ))
93+
94+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-journal"})
95+ require.NoError(t, err)
96+ require.NotNil(t, result)
97+ assert.Equal(t, "deployed", result.Services["web"].Result)
98+ assert.True(t, journaledBeforeStart,
99+ "the candidate ID is durable before the container is started")
100+
101+ op, err := store.LoadOperation(context.Background(), "blog", "op-journal")
102+ require.NoError(t, err)
103+ assert.Equal(t, domain.AppOutcomeSuccess, op.Outcome)
104+ step, found := opStep(op, "service.web.replace")
105+ require.True(t, found)
106+ assert.Equal(t, domain.AppStepSucceeded, step.State)
107+ assert.Equal(t, "c-new", step.After)
108+}
109+
110+// crashDuringReplacement runs one replacement deploy and interrupts it
111+// between creating the candidate and publishing it. The candidate is left
112+// running, ACTIVE still names the superseded generation, and the operation
113+// journal stays in flight: the state a process crash or a failed cleanup
114+// leaves behind.
115+func crashDuringReplacement(t *testing.T, store *appstate.Store, opKey string) {
116+ t.Helper()
117+ ctx, cancel := context.WithCancel(context.Background())
118+ defer cancel()
119+ runtime := outmocks.NewMockContainerRuntime(t)
120+ images := outmocks.NewMockImageResolver(t)
121+ secrets := outmocks.NewMockSecretProvider(t)
122+
123+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
124+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
125+ expectNetworkProvision(runtime, "app-blog", 1)
126+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
127+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
128+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-orphan", Name: "web"}, nil).Once()
129+ runtime.EXPECT().StartContainer(mock.Anything, "c-orphan").Return(nil).Once()
130+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-orphan", mock.Anything).Return(
131+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
132+ // The canceled context also refuses the candidate cleanup, so the
133+ // candidate outlives the operation exactly as a crash would leave it.
134+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).RunAndReturn(
135+ func(removeCtx context.Context, _ string, _ bool) error {
136+ return removeCtx.Err()
137+ }).Once()
138+
139+ probeStarted := make(chan struct{})
140+ svc := deployment.NewService(deployment.Deps{
141+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
142+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
143+ func(probeCtx context.Context, _, _ string) (int, error) {
144+ select {
145+ case <-probeStarted:
146+ default:
147+ close(probeStarted)
148+ }
149+ <-probeCtx.Done()
150+ return 0, probeCtx.Err()
151+ },
152+ func(context.Context, string) error { return nil },
153+ ))
154+ done := make(chan error, 1)
155+ go func() {
156+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: opKey})
157+ done <- err
158+ }()
159+ select {
160+ case <-probeStarted:
161+ case <-time.After(5 * time.Second):
162+ t.Fatal("timed out waiting for the readiness probe")
163+ }
164+ cancel()
165+ require.Error(t, <-done)
166+}
167+
168+// TestBootRecovery_RemovesUnpublishedCandidateBeforeRebuildingPublished
169+// proves the crash window between creating a replacement and publishing it is
170+// recoverable: an interruption leaves the candidate journaled but unpublished,
171+// and boot recovery removes that candidate before it rebuilds the generation
172+// recorded in ACTIVE, so two generations of one service never run together.
173+func TestBootRecovery_RemovesUnpublishedCandidateBeforeRebuildingPublished(t *testing.T) {
174+ ctx := context.Background()
175+ store := newTestStore(t)
176+ images := outmocks.NewMockImageResolver(t)
177+ secrets := outmocks.NewMockSecretProvider(t)
178+ seedReplaceableApp(t, ctx, store)
179+ crashDuringReplacement(t, store, "op-crash")
180+
181+ // The crash left the candidate durable but unpublished, and ACTIVE still
182+ // names the superseded generation.
183+ interrupted, err := store.LoadOperation(context.Background(), "blog", "op-crash")
184+ require.NoError(t, err)
185+ assert.False(t, interrupted.Terminal(), "an interrupted operation stays in flight until recovery finalizes it")
186+ crashStep, found := opStep(interrupted, "service.web.replace")
187+ require.True(t, found)
188+ assert.Equal(t, domain.AppStepPending, crashStep.State)
189+ assert.Equal(t, "c-orphan", crashStep.After, "the created candidate is durably recorded")
190+ active, ok, err := store.LoadActive(context.Background(), "blog")
191+ require.NoError(t, err)
192+ require.True(t, ok)
193+ assert.Equal(t, "c-old", active.Services["web"].Container)
194+
195+ // Boot recovery: the orphan is removed before the published generation is
196+ // rebuilt from its pinned revision.
197+ var order []string
198+ runtime2 := outmocks.NewMockContainerRuntime(t)
199+ runtime2.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).RunAndReturn(
200+ func(context.Context, string, bool) error {
201+ order = append(order, "remove-orphan")
202+ return nil
203+ }).Once()
204+ runtime2.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once()
205+ runtime2.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once()
206+ runtime2.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
207+ runtime2.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
208+ expectNetworkProvision(runtime2, "app-blog", 1)
209+ runtime2.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(
210+ func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
211+ order = append(order, "rebuild-published-generation")
212+ return &domain.Container{ID: "c-rebuilt", Name: "web"}, nil
213+ }).Once()
214+ runtime2.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
215+ runtime2.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
216+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
217+
218+ rebooted := deployment.NewService(deployment.Deps{
219+ State: store, Runtime: runtime2, Images: images, Secrets: secrets,
220+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime2,
221+ func(context.Context, string, string) (int, error) { return 200, nil },
222+ func(context.Context, string) error { return nil },
223+ ))
224+ // Boot recovery runs on a fresh process context: the crashed deploy's
225+ // context is gone with it.
226+ require.NoError(t, rebooted.ReconcileBoot(ctx))
227+
228+ assert.Equal(t, []string{"remove-orphan", "rebuild-published-generation"}, order,
229+ "the unpublished candidate is gone before the published generation is rebuilt")
230+ runtime2.AssertNotCalled(t, "StartContainer", mock.Anything, "c-orphan")
231+ runtime2.AssertNotCalled(t, "RestartContainer", mock.Anything, "c-orphan", mock.Anything)
232+
233+ // The interrupted operation is finalized as failed, so a repeat of its key
234+ // replays an explicit failure instead of a permanent in-flight claim.
235+ finalized, err := store.LoadOperation(context.Background(), "blog", "op-crash")
236+ require.NoError(t, err)
237+ require.True(t, finalized.Terminal(), "boot recovery finalizes the interrupted operation")
238+ assert.Equal(t, domain.AppOutcomeFailed, finalized.Outcome)
239+ finalStep, found := opStep(finalized, "service.web.replace")
240+ require.True(t, found)
241+ assert.Equal(t, domain.AppStepFailed, finalStep.State)
242+ assert.Contains(t, finalStep.Error, "unpublished candidate was removed")
243+
244+ // ACTIVE now names the rebuilt generation: recovery converged the app
245+ // instead of leaving it pointing at a container that no longer exists.
246+ converged, ok, err := store.LoadActive(context.Background(), "blog")
247+ require.NoError(t, err)
248+ require.True(t, ok)
249+ assert.Equal(t, "c-rebuilt", converged.Services["web"].Container)
250+ assert.Equal(t, "rev-0", converged.Services["web"].EffectiveRevision)
251+}
252+
253+// TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement
254+// proves the live case: when a cleanup failed and Gordon kept running, the
255+// next mutation of the app removes the leftover candidate before it creates a
256+// new generation, so two generations of one service never run together even
257+// without a restart. It also finalizes the interrupted journal.
258+func TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement(t *testing.T) {
259+ ctx := context.Background()
260+ store := newTestStore(t)
261+ images := outmocks.NewMockImageResolver(t)
262+ secrets := outmocks.NewMockSecretProvider(t)
263+ seedReplaceableApp(t, ctx, store)
264+ crashDuringReplacement(t, store, "op-crash")
265+
266+ var order []string
267+ runtime := outmocks.NewMockContainerRuntime(t)
268+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).RunAndReturn(
269+ func(context.Context, string, bool) error {
270+ order = append(order, "remove-leftover-candidate")
271+ return nil
272+ }).Once()
273+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
274+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
275+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
276+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
277+ expectNetworkProvision(runtime, "app-blog", 1)
278+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(
279+ func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
280+ order = append(order, "create-new-generation")
281+ return &domain.Container{ID: "c-next", Name: "web"}, nil
282+ }).Once()
283+ runtime.EXPECT().StartContainer(mock.Anything, "c-next").Return(nil).Once()
284+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-next", mock.Anything).Return(
285+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
286+
287+ svc := deployment.NewService(deployment.Deps{
288+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
289+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
290+ func(context.Context, string, string) (int, error) { return 200, nil },
291+ func(context.Context, string) error { return nil },
292+ ))
293+
294+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next"})
295+ require.NoError(t, err)
296+ require.NotNil(t, result)
297+ assert.Equal(t, "deployed", result.Services["web"].Result)
298+ assert.Equal(t, []string{"remove-leftover-candidate", "create-new-generation"}, order)
299+
300+ active, ok, err := store.LoadActive(ctx, "blog")
301+ require.NoError(t, err)
302+ require.True(t, ok)
303+ assert.Equal(t, "c-next", active.Services["web"].Container)
304+
305+ interrupted, err := store.LoadOperation(ctx, "blog", "op-crash")
306+ require.NoError(t, err)
307+ require.True(t, interrupted.Terminal(), "the interrupted operation is finalized")
308+ assert.Equal(t, domain.AppOutcomeFailed, interrupted.Outcome)
309+ step, found := opStep(interrupted, "service.web.replace")
310+ require.True(t, found)
311+ assert.Equal(t, domain.AppStepFailed, step.State)
312+}
313+
314+// TestBootRecovery_RemovesCandidateOfAnInterruptedFirstDeployment proves the
315+// same recovery works when the interrupted operation is the app's FIRST
316+// deployment: there is no ACTIVE generation to converge, and the leftover
317+// candidate is still removed and the journal finalized.
318+func TestBootRecovery_RemovesCandidateOfAnInterruptedFirstDeployment(t *testing.T) {
319+ ctx := context.Background()
320+ store := newTestStore(t)
321+ images := outmocks.NewMockImageResolver(t)
322+ secrets := outmocks.NewMockSecretProvider(t)
323+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
324+ require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
325+ Op: "op-seeded", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(),
326+ Steps: []domain.AppOperationStep{
327+ {ID: "preflight", State: domain.AppStepSucceeded},
328+ {ID: "service.web.replace", State: domain.AppStepPending, Service: "web", Before: "c-old", After: "c-orphan"},
329+ },
330+ }))
331+
332+ runtime := outmocks.NewMockContainerRuntime(t)
333+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once()
334+
335+ svc := deployment.NewService(deployment.Deps{
336+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
337+ }, zerowrap.Default())
338+ require.NoError(t, svc.ReconcileBoot(ctx))
339+
340+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
341+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, "c-orphan")
342+
343+ finalized, err := store.LoadOperation(ctx, "blog", "op-seeded")
344+ require.NoError(t, err)
345+ require.True(t, finalized.Terminal())
346+ assert.Equal(t, domain.AppOutcomeFailed, finalized.Outcome)
347+ step, found := opStep(finalized, "service.web.replace")
348+ require.True(t, found)
349+ assert.Equal(t, domain.AppStepFailed, step.State)
350+ assert.Contains(t, step.Error, "unpublished candidate was removed")
351+}
352+
353+// TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer proves a
354+// restart that falls back to a rebuild does not leave a durable inhibition for
355+// a container that is already gone: nothing could ever revive it, and the
356+// marker would refuse every later start, recovery pass, and restart.
357+func TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer(t *testing.T) {
358+ ctx := context.Background()
359+ store := newTestStore(t)
360+ images := outmocks.NewMockImageResolver(t)
361+ secrets := outmocks.NewMockSecretProvider(t)
362+ runtime := outmocks.NewMockContainerRuntime(t)
363+
364+ spec := webService()
365+ spec.Secrets = map[string]string{}
366+ spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
367+ spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 50 * time.Millisecond}
368+ rev := testRevision("blog", spec)
369+ rev.Revision = "rev-0"
370+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
371+ require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
372+ "web": {
373+ Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image,
374+ Digest: restartTestDigest, Spec: spec, BackendBinds: map[int]int{8080: 18080},
375+ },
376+ }}))
377+ require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"}))
378+ seedRevision(t, ctx, store, "intent-0", "", rev)
379+
380+ runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
381+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
382+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
383+ expectNetworkProvision(runtime, "app-blog", 1)
384+ runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
385+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuild", Name: "web"}, nil).Once()
386+ runtime.EXPECT().StartContainer(mock.Anything, "c-rebuild").Return(nil).Once()
387+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuild", mock.Anything).Return(
388+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
389+ runtime.EXPECT().GetContainerLogs(mock.Anything, "c-rebuild", false).Return(nil, assert.AnError).Once()
390+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-rebuild", true).Return(nil).Once()
391+
392+ svc := deployment.NewService(deployment.Deps{
393+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
394+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
395+ func(context.Context, string, string) (int, error) { return 503, nil },
396+ func(context.Context, string) error { return assert.AnError },
397+ ))
398+
399+ result, err := svc.Restart(ctx, "blog", "web", "op-restart-failed")
400+ require.Error(t, err)
401+ require.NotNil(t, result)
402+ assert.Equal(t, "failed", result.Services["web"].Result)
403+
404+ inhibitions, loadErr := store.LoadRecoveryInhibitions(ctx, "blog")
405+ require.NoError(t, loadErr)
406+ assert.Empty(t, inhibitions,
407+ "the inhibition of a container proven gone must not survive a failed rebuild")
408+}
409+
410+// TestBootRecovery_RetriesLeftoverOfATerminalFailedOperation proves a
411+// leftover candidate recorded by an operation that already reached a terminal
412+// failure is still converged: the journal keeps the candidate ID, and the next
413+// recovery pass removes it.
414+func TestBootRecovery_RetriesLeftoverOfATerminalFailedOperation(t *testing.T) {
415+ ctx := context.Background()
416+ store := newTestStore(t)
417+ images := outmocks.NewMockImageResolver(t)
418+ secrets := outmocks.NewMockSecretProvider(t)
419+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog", Stopped: true}))
420+ require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
421+ "web": {Container: "c-old", EffectiveRevision: "rev-0", Spec: headlessSpec()},
422+ }}))
423+ require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
424+ Op: "op-failed", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(),
425+ Outcome: domain.AppOutcomeFailed,
426+ Steps: []domain.AppOperationStep{
427+ {ID: "preflight", State: domain.AppStepSucceeded},
428+ {ID: "service.web.replace", State: domain.AppStepFailed, Service: "web", Before: "c-old", After: "c-orphan", Error: "readiness"},
429+ },
430+ }))
431+
432+ runtime := outmocks.NewMockContainerRuntime(t)
433+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once()
434+ runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running"}, nil).Once()
435+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
436+
437+ svc := deployment.NewService(deployment.Deps{
438+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
439+ }, zerowrap.Default())
440+ require.NoError(t, svc.ReconcileBoot(ctx))
441+
442+ runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-orphan", true)
443+
444+ // The removal is recorded: the step no longer retries the candidate on a
445+ // later pass, and the removed container id stays visible for audit.
446+ convergedOp, err := store.LoadOperation(ctx, "blog", "op-failed")
447+ require.NoError(t, err)
448+ step, found := opStep(convergedOp, "service.web.replace")
449+ require.True(t, found)
450+ assert.Empty(t, step.After, "a converged candidate must not be retried on the next pass")
451+ assert.Contains(t, step.Detail, "c-orphan", "the removed candidate stays in the audit trail")
452+}
453+
454+// TestBootRecovery_InterruptionAfterTheLastStepIsASuccess proves an operation
455+// whose effects all ran but whose final outcome write was interrupted is
456+// classified as the success it was, and the published generation is kept.
457+func TestBootRecovery_InterruptionAfterTheLastStepIsASuccess(t *testing.T) {
458+ ctx := context.Background()
459+ store := newTestStore(t)
460+ images := outmocks.NewMockImageResolver(t)
461+ secrets := outmocks.NewMockSecretProvider(t)
462+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog", Stopped: true}))
463+ require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
464+ "web": {Container: "c-published", EffectiveRevision: "rev-0", Spec: headlessSpec()},
465+ }}))
466+ require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
467+ Op: "op-unfinished", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(),
468+ Steps: []domain.AppOperationStep{
469+ {ID: "preflight", State: domain.AppStepSucceeded},
470+ {ID: "service.web.replace", State: domain.AppStepSucceeded, Service: "web", Before: "c-old", After: "c-published"},
471+ },
472+ }))
473+
474+ runtime := outmocks.NewMockContainerRuntime(t)
475+ runtime.EXPECT().InspectContainer(mock.Anything, "c-published").Return(&domain.Container{ID: "c-published", Status: "exited"}, nil).Once()
476+
477+ svc := deployment.NewService(deployment.Deps{
478+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
479+ }, zerowrap.Default())
480+ require.NoError(t, svc.ReconcileBoot(ctx))
481+
482+ reconciled, err := store.LoadOperation(ctx, "blog", "op-unfinished")
483+ require.NoError(t, err)
484+ require.True(t, reconciled.Terminal())
485+ assert.Equal(t, domain.AppOutcomeSuccess, reconciled.Outcome)
486+ step, found := opStep(reconciled, "service.web.replace")
487+ require.True(t, found)
488+ assert.Equal(t, domain.AppStepSucceeded, step.State, "a step that already succeeded is left as recorded")
489+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-published", mock.Anything)
490+}
491+
492+// TestBootRecovery_FailsClosedWhenCandidateCannotBeRemoved proves
493+// reconciliation never proceeds while an orphan candidate may still run: the
494+// removal failure aborts the mutation, the interrupted journal is not
495+// finalized, and no newer operation is created to mask the leftover.
496+func TestBootRecovery_FailsClosedWhenCandidateCannotBeRemoved(t *testing.T) {
497+ ctx := context.Background()
498+ store := newTestStore(t)
499+ images := outmocks.NewMockImageResolver(t)
500+ secrets := outmocks.NewMockSecretProvider(t)
501+ seedReplaceableApp(t, ctx, store)
502+ require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
503+ Op: "op-crash", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(),
504+ Steps: []domain.AppOperationStep{
505+ {ID: "service.web.replace", State: domain.AppStepPending, Service: "web", Before: "c-old", After: "c-orphan"},
506+ },
507+ }))
508+
509+ runtime := outmocks.NewMockContainerRuntime(t)
510+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(assert.AnError)
511+
512+ svc := deployment.NewService(deployment.Deps{
513+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
514+ }, zerowrap.Default())
515+
516+ err := svc.ReconcileBoot(ctx)
517+ require.Error(t, err)
518+ assert.Contains(t, err.Error(), "could not be removed")
519+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
520+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
521+
522+ // The leftover stays journaled and non-terminal, so it is still the latest
523+ // operation.
524+ leftover, err := store.LoadOperation(ctx, "blog", "op-crash")
525+ require.NoError(t, err)
526+ assert.False(t, leftover.Terminal(), "a leftover that could not be removed is never finalized")
527+
528+ // A later mutation fails closed on the same leftover and opens no journal
529+ // that could mask it.
530+ _, err = svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next"})
531+ require.Error(t, err)
532+ assert.Contains(t, err.Error(), "could not be removed")
533+ _, err = store.LoadOperation(ctx, "blog", "op-next")
534+ require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
535+}
536+
537+// TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive proves
538+// the crash window is fully recoverable for a volume-owning service: an
539+// interruption after the superseded container was retired leaves the
540+// replacement-pending inhibition behind, boot recovery removes the
541+// never-published candidate, clears that stale inhibition, and rebuilds the
542+// generation ACTIVE records from its pinned revision.
543+func TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive(t *testing.T) {
544+ ctx := context.Background()
545+ store := newTestStore(t)
546+ images := outmocks.NewMockImageResolver(t)
547+ secrets := outmocks.NewMockSecretProvider(t)
548+
549+ spec := webService()
550+ spec.Secrets = map[string]string{}
551+ spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
552+ spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: time.Second}
553+ rev := testRevision("blog", spec)
554+ rev.Revision = "rev-0"
555+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
556+ require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
557+ "web": {
558+ Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image,
559+ Digest: restartTestDigest, Spec: spec, BackendBinds: map[int]int{8080: 18080},
560+ },
561+ }}))
562+ require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"}))
563+ seedRevision(t, ctx, store, "intent-0", "", rev)
564+ // A replacement interrupted after it retired c-old and created its
565+ // candidate: ACTIVE still names c-old, the candidate is journaled but
566+ // unpublished, and c-old carries the replacement-pending inhibition.
567+ require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
568+ Op: "op-crash", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(),
569+ Steps: []domain.AppOperationStep{
570+ {ID: "preflight", State: domain.AppStepSucceeded},
571+ {ID: "service.web.replace", State: domain.AppStepPending, Service: "web", Before: "c-old", After: "c-orphan"},
572+ },
573+ }))
574+ require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{
575+ App: "blog", Service: "web", ContainerID: "c-old",
576+ Reason: domain.AppInhibitReplacementPending, Operation: "op-crash",
577+ }))
578+
579+ runtime := outmocks.NewMockContainerRuntime(t)
580+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once()
581+ runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once()
582+ runtime.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once()
583+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
584+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
585+ expectNetworkProvision(runtime, "app-blog", 1)
586+ runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
587+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()
588+ runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
589+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
590+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
591+
592+ svc := deployment.NewService(deployment.Deps{
593+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
594+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
595+ func(context.Context, string, string) (int, error) { return 200, nil },
596+ func(context.Context, string) error { return nil },
597+ ))
598+ require.NoError(t, svc.ReconcileBoot(ctx))
599+
600+ inhibitions, err := store.LoadRecoveryInhibitions(ctx, "blog")
601+ require.NoError(t, err)
602+ assert.Empty(t, inhibitions, "the stale replacement inhibition must not survive recovery")
603+
604+ active, ok, err := store.LoadActive(ctx, "blog")
605+ require.NoError(t, err)
606+ require.True(t, ok)
607+ assert.Equal(t, "c-rebuilt", active.Services["web"].Container)
608+ assert.Equal(t, "rev-0", active.Services["web"].EffectiveRevision)
609+
610+ finalized, err := store.LoadOperation(ctx, "blog", "op-crash")
611+ require.NoError(t, err)
612+ require.True(t, finalized.Terminal())
613+ assert.Equal(t, domain.AppOutcomeFailed, finalized.Outcome)
614+}
615+
616+// TestRestart_RebuildsMissingActiveContainer proves restart is not a dead end
617+// when the recorded container is gone: the service is rebuilt from the pinned
618+// ACTIVE digest and published again.
619+func TestRestart_RebuildsMissingActiveContainer(t *testing.T) {
620+ ctx := context.Background()
621+ store := newTestStore(t)
622+ runtime := outmocks.NewMockContainerRuntime(t)
623+ images := outmocks.NewMockImageResolver(t)
624+ secrets := outmocks.NewMockSecretProvider(t)
625+ seedReplaceableApp(t, ctx, store)
626+
627+ runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
628+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once()
629+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once()
630+ expectNetworkProvision(runtime, "app-blog", 1)
631+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once()
632+ runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once()
633+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return(
634+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
635+
636+ svc := deployment.NewService(deployment.Deps{
637+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
638+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
639+ func(context.Context, string, string) (int, error) { return 200, nil },
640+ func(context.Context, string) error { return nil },
641+ ))
642+
643+ result, err := svc.Restart(ctx, "blog", "web", "op-restart-missing")
644+ require.NoError(t, err)
645+ require.NotNil(t, result)
646+ assert.Equal(t, "deployed", result.Services["web"].Result)
647+ assert.Equal(t, "c-rebuilt", result.Services["web"].After,
648+ "the missing generation is rebuilt from the pinned ACTIVE digest")
649+
650+ active, ok, err := store.LoadActive(context.Background(), "blog")
651+ require.NoError(t, err)
652+ require.True(t, ok)
653+ assert.Equal(t, "c-rebuilt", active.Services["web"].Container)
654+ assert.Equal(t, "rev-0", active.Services["web"].EffectiveRevision,
655+ "the rebuild reuses the pinned revision")
656+
657+ op, err := store.LoadOperation(context.Background(), "blog", "op-restart-missing")
658+ require.NoError(t, err)
659+ assert.Equal(t, domain.AppOutcomeSuccess, op.Outcome)
660+ step, found := opStep(op, "service.web.restart")
661+ require.True(t, found)
662+ assert.Equal(t, domain.AppStepSucceeded, step.State)
663+ assert.Equal(t, "c-rebuilt", step.After)
664+}
M internal/usecase/deployment/deploy_mock_test.go +105 -33
@@ -20,12 +20,51 @@ import (
20 20
21 func mockDeps(t *testing.T) (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) {21 func mockDeps(t *testing.T) (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) {
22 t.Helper()22 t.Helper()
23- return outmocks.NewMockAppState(t),23+ state := outmocks.NewMockAppState(t)
24+ // Every app mutation reconciles an interrupted predecessor first. These
25+ // tests exercise the mutation itself, so they see no interrupted
26+ // operation; tests that seed one use their own expectation or a real
27+ // store.
28+ state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything).
29+ Return(domain.AppOperation{}, false, nil).Maybe()
30+ // ExecuteDeploy always reloads the claimed operation from the store by
31+ // app/op identity. Mock-backed execution sees the freshly claimed
32+ // non-terminal journal shape (a single pending preflight step) unless a
33+ // test overrides it with expectStoredOperation.
34+ state.EXPECT().LoadOperation(mock.Anything, mock.Anything, mock.Anything).
35+ RunAndReturn(func(_ context.Context, app, opID string) (domain.AppOperation, error) {
36+ return domain.AppOperation{
37+ Kind: "deploy", App: app, Op: opID,
38+ Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
39+ }, nil
40+ }).Maybe()
41+ return state,
24 outmocks.NewMockContainerRuntime(t),42 outmocks.NewMockContainerRuntime(t),
25 outmocks.NewMockImageResolver(t),43 outmocks.NewMockImageResolver(t),
26 outmocks.NewMockSecretProvider(t)44 outmocks.NewMockSecretProvider(t)
27 }45 }
28 46
47+// expectStoredOperation makes one store record authoritative for the next
48+// ExecuteDeploy reload. The operation identity is echoed from the request so
49+// an unkeyed claim matches too. It replaces the catch-all default installed
50+// by mockDeps, letting a test prove that a terminal or mismatched store
51+// record overrides a stale in-process journal.
52+func expectStoredOperation(state *outmocks.MockAppState, op domain.AppOperation) {
53+ kept := state.ExpectedCalls[:0]
54+ for _, call := range state.ExpectedCalls {
55+ if call.Method != "LoadOperation" {
56+ kept = append(kept, call)
57+ }
58+ }
59+ state.ExpectedCalls = kept
60+ state.EXPECT().LoadOperation(mock.Anything, op.App, mock.Anything).
61+ RunAndReturn(func(_ context.Context, app, opID string) (domain.AppOperation, error) {
62+ op.App = app
63+ op.Op = opID
64+ return op, nil
65+ })
66+}
67+
29 func mockRevision() domain.AppDesiredRevision {68 func mockRevision() domain.AppDesiredRevision {
30 svc := webService()69 svc := webService()
31 svc.Image = "docker.io/example/web:1.4.2"70 svc.Image = "docker.io/example/web:1.4.2"
@@ -74,6 +113,11 @@ func TestDeploy_PreflightFailureReturnsJournaledOperation(t *testing.T) {
74 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Twice()113 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Twice()
75 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once()114 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once()
76 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError).Once()115 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError).Once()
116+ // The store record carries the revision the claim was started for.
117+ expectStoredOperation(state, domain.AppOperation{
118+ Kind: "deploy", App: "blog", InputRevision: rev.Revision,
119+ Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
120+ })
77 121
78 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default())122 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default())
79 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})123 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
@@ -143,7 +187,7 @@ func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) {
143 },187 },
144 zerowrap.Default(),188 zerowrap.Default(),
145 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,189 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
146- func(context.Context, string) (int, error) { return 200, nil },190+ func(context.Context, string, string) (int, error) { return 200, nil },
147 func(context.Context, string) error { return nil },191 func(context.Context, string) error { return nil },
148 ))192 ))
149 193
@@ -189,7 +233,7 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
189 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},233 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
190 zerowrap.Default(),234 zerowrap.Default(),
191 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,235 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
192- func(_ context.Context, url string) (int, error) { probedURL = url; return 200, nil },236+ func(_ context.Context, url, _ string) (int, error) { probedURL = url; return 200, nil },
193 func(context.Context, string) error { return nil },237 func(context.Context, string) error { return nil },
194 ))238 ))
195 239
@@ -202,11 +246,12 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
202 runtime.AssertNotCalled(t, "GetContainerNetworkInfo", mock.Anything, mock.Anything)246 runtime.AssertNotCalled(t, "GetContainerNetworkInfo", mock.Anything, mock.Anything)
203 }247 }
204 248
205-// TestDeploy_Mockery_HTTPRetiresOldAfterPublish proves retire-after-publish249+// TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish proves the
206-// ordering: the replaced container is stopped+removed only after the new250+// sequential ordering: the superseded container is stopped and removed
207-// effective state is published (proxy already switched), and a retire251+// before the replacement is created, and ACTIVE names the replacement only
208-// failure records a cleanup warning without flipping the outcome.252+// after it exists. A public HTTP service without volumes is replaced exactly
209-func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {253+// like every other service.
254+func TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish(t *testing.T) {
210 ctx := context.Background()255 ctx := context.Background()
211 state, runtime, images, secrets := mockDeps(t)256 state, runtime, images, secrets := mockDeps(t)
212 rev := mockRevision()257 rev := mockRevision()
@@ -221,7 +266,20 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
221 }, true, nil)266 }, true, nil)
222 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)267 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
223 268
224- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()269+ var replacedAt, createdAt, publishedAt int
270+ var step int
271+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error {
272+ step++
273+ replacedAt = step
274+ return nil
275+ }).Once()
276+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
277+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
278+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
279+ step++
280+ createdAt = step
281+ return &domain.Container{ID: "c-new", Name: "web"}, nil
282+ }).Once()
225 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()283 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
226 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()284 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
227 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {285 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
@@ -233,20 +291,11 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
233 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},291 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
234 },292 },
235 }, true, nil)293 }, true, nil)
236- var publishedAt, retiredAt int
237- var step int
238 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {294 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {
239 step++295 step++
240 publishedAt = step296 publishedAt = step
241 return nil297 return nil
242 }).Once()298 }).Once()
243- runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error {
244- step++
245- retiredAt = step
246- return nil
247- }).Once()
248- runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
249- state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
250 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)299 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
251 // The superseded generation stops being recovery-inhibited only300 // The superseded generation stops being recovery-inhibited only
252 // once the replacement is published.301 // once the replacement is published.
@@ -256,7 +305,7 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
256 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},305 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
257 zerowrap.Default(),306 zerowrap.Default(),
258 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,307 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
259- func(context.Context, string) (int, error) { return 200, nil },308+ func(context.Context, string, string) (int, error) { return 200, nil },
260 func(context.Context, string) error { return nil },309 func(context.Context, string) error { return nil },
261 ))310 ))
262 311
@@ -264,12 +313,16 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
264 require.NoError(t, err)313 require.NoError(t, err)
265 require.NotNil(t, result)314 require.NotNil(t, result)
266 assert.Equal(t, "deployed", result.Services["web"].Result)315 assert.Equal(t, "deployed", result.Services["web"].Result)
267- assert.Equal(t, "c-old", result.Services["web"].Retire)316+ assert.Less(t, replacedAt, createdAt, "the superseded generation is gone before the replacement is created")
268- assert.Greater(t, retiredAt, publishedAt, "retire runs after publication")317+ assert.Greater(t, publishedAt, createdAt, "ACTIVE is published after the replacement exists")
269 assert.Empty(t, result.CleanupWarnings)318 assert.Empty(t, result.CleanupWarnings)
270 }319 }
271 320
272-func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) {321+// TestDeploy_Mockery_HTTPReadinessFailureLeavesNoOldGeneration proves a
322+// failed replacement never falls back to the generation it already retired:
323+// the superseded container stays gone, the unready candidate is removed, and
324+// nothing is published in its place.
325+func TestDeploy_Mockery_HTTPReadinessFailureLeavesNoOldGeneration(t *testing.T) {
273 ctx := context.Background()326 ctx := context.Background()
274 state, runtime, images, secrets := mockDeps(t)327 state, runtime, images, secrets := mockDeps(t)
275 rev := mockRevision()328 rev := mockRevision()
@@ -284,7 +337,11 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) {
284 }, true, nil)337 }, true, nil)
285 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)338 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
286 339
287- // Replacement created + started; readiness fails fast.340+ // The superseded generation is retired before the replacement is created,
341+ // then the replacement fails readiness fast.
342+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
343+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
344+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
288 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()345 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
289 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()346 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
290 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()347 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
@@ -301,7 +358,7 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) {
301 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},358 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
302 zerowrap.Default(),359 zerowrap.Default(),
303 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,360 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
304- func(context.Context, string) (int, error) { return 500, nil },361+ func(context.Context, string, string) (int, error) { return 500, nil },
305 func(context.Context, string) error { return errors.New("refused") },362 func(context.Context, string) error { return errors.New("refused") },
306 ))363 ))
307 364
@@ -312,12 +369,13 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) {
312 assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error")369 assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error")
313 require.Len(t, result.Services["web"].Diagnostics, 1, "failure diagnostics are kept separately")370 require.Len(t, result.Services["web"].Diagnostics, 1, "failure diagnostics are kept separately")
314 assert.Equal(t, "boom", result.Services["web"].Diagnostics[0])371 assert.Equal(t, "boom", result.Services["web"].Diagnostics[0])
315- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)372+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything)
316- runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", false)
317 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)373 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
374+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
375+ state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
318 }376 }
319 377
320-func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {378+func TestDeploy_Mockery_VolumeReplacementMarksUnsafe(t *testing.T) {
321 ctx := context.Background()379 ctx := context.Background()
322 state, runtime, images, secrets := mockDeps(t)380 state, runtime, images, secrets := mockDeps(t)
323 381
@@ -361,8 +419,15 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
361 }, true, nil)419 }, true, nil)
362 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)420 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
363 421
364- runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()422+ var order []string
365- runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()423+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error {
424+ order = append(order, "stop-old")
425+ return nil
426+ }).Once()
427+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).RunAndReturn(func(context.Context, string, bool) error {
428+ order = append(order, "remove-old")
429+ return nil
430+ }).Once()
366 // The superseded writer's confirmed disappearance releases its claims.431 // The superseded writer's confirmed disappearance releases its claims.
367 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()432 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
368 expectNetworkProvision(runtime, "app-uuid-blog", 1)433 expectNetworkProvision(runtime, "app-uuid-blog", 1)
@@ -371,7 +436,10 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
371 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool {436 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool {
372 return inhibition.App == "blog" && inhibition.Service == "web" &&437 return inhibition.App == "blog" && inhibition.Service == "web" &&
373 inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitReplacementPending438 inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitReplacementPending
374- })).Return(nil).Once()439+ })).RunAndReturn(func(context.Context, domain.AppRecoveryInhibition) error {
440+ order = append(order, "inhibit")
441+ return nil
442+ }).Once()
375 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()443 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
376 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-blog--web--vol--d", mock.MatchedBy(func(labels map[string]string) bool {444 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-blog--web--vol--d", mock.MatchedBy(func(labels map[string]string) bool {
377 return labels[domain.LabelApp] == "blog" &&445 return labels[domain.LabelApp] == "blog" &&
@@ -386,7 +454,10 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
386 require.GreaterOrEqual(t, ownershipSaves, 1,454 require.GreaterOrEqual(t, ownershipSaves, 1,
387 "ownership must be reserved before the runtime volume exists")455 "ownership must be reserved before the runtime volume exists")
388 }).Return(nil).Once()456 }).Return(nil).Once()
389- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()457+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
458+ order = append(order, "create-new")
459+ return &domain.Container{ID: "c-new", Name: "web"}, nil
460+ }).Once()
390 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()461 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
391 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()462 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
392 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {463 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
@@ -411,7 +482,8 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
411 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})482 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
412 require.NoError(t, err)483 require.NoError(t, err)
413 require.NotNil(t, result)484 require.NotNil(t, result)
414- assert.Equal(t, []string{"web"}, result.Interrupted)485+ assert.Equal(t, []string{"inhibit", "stop-old", "remove-old", "create-new"}, order,
486+ "the durable inhibition is written before the superseded writer can be stopped, and the writer is gone before the replacement can write")
415 assert.True(t, result.Services["web"].RestartUnsafe)487 assert.True(t, result.Services["web"].RestartUnsafe)
416 assert.True(t, restartUnsafeSaved, "the terminal ownership write must record the restart-unsafe service")488 assert.True(t, restartUnsafeSaved, "the terminal ownership write must record the restart-unsafe service")
417 assert.GreaterOrEqual(t, ownershipSaves, 2, "the volume reservation and the terminal ownership write must both be durable")489 assert.GreaterOrEqual(t, ownershipSaves, 2, "the volume reservation and the terminal ownership write must both be durable")
@@ -639,7 +711,7 @@ func TestStart_RunningContainerRefreshesShiftedBinds(t *testing.T) {
639 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},711 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
640 zerowrap.Default(),712 zerowrap.Default(),
641 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,713 ).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
642- func(context.Context, string) (int, error) { return 200, nil },714+ func(context.Context, string, string) (int, error) { return 200, nil },
643 func(context.Context, string) error { return nil },715 func(context.Context, string) error { return nil },
644 ))716 ))
645 result, err := svc.Start(ctx, "blog", "")717 result, err := svc.Start(ctx, "blog", "")
M internal/usecase/deployment/execute.go +648 -203
@@ -21,49 +21,225 @@ const failLogTailLines = 50
21 const failLogTailTimeout = 10 * time.Second21 const failLogTailTimeout = 10 * time.Second
22 22
23 // Deploy executes a preflighted revision: fail-fast across services in23 // Deploy executes a preflighted revision: fail-fast across services in
24-// sorted name order. HTTP-only services whose interfaces are all public,24+// sorted name order. Every service is replaced sequentially — withdraw
25-// with no volumes and no binds, keep the old container serving until the25+// traffic, retire the superseded container, start the replacement and wait
26-// replacement passes readiness, then switch with a bounded drain and26+// for its readiness probe — so a deployment may briefly interrupt the
27-// retire the old container by exact ID. TCP/UDP, any internal HTTP27+// service, and two Gordon-managed generations of one service never run at
28-// interface, and bind- or volume-owning services use replacement with28+// the same time. Volumes are never deleted: no RemoveVolume call, no
29-// interruption. Volumes are never deleted: no RemoveVolume call, no
30 // volume-deletion flags on container removal.29 // volume-deletion flags on container removal.
30+//
31+// It is the synchronous composition of the two engine phases: StartDeploy
32+// claims the key and journals the operation, then ExecuteDeploy runs the
33+// effects for the owner only.
31 func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) {34 func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) {
32- release, err := s.acquireAppContext(ctx, input.App)35+ started, err := s.StartDeploy(ctx, input)
36+ if err != nil {
37+ return nil, err
38+ }
39+ if !started.Owned {
40+ // The key already answered this request: its stored journal is the
41+ // result and no workload is touched again.
42+ return journaledOrNil(input, &started.Claim.Journal, started.ReplayError())
43+ }
44+ return s.ExecuteDeploy(ctx, started.Claim)
45+}
46+
47+// DeployClaim is the durable identity of one started deploy operation. It is
48+// the hand-off between the two phases: StartDeploy returns it and
49+// ExecuteDeploy consumes it. App and Op are the durable identity, so a
50+// caller that reconstructs the claim from them alone (for example after a
51+// restart, with no in-process state) leaves Journal and Resolved empty and
52+// ExecuteDeploy loads and resolves both from the store.
53+type DeployClaim struct {
54+ App string
55+ Op string
56+ Service string
57+ Revision string
58+ // Resolved is the revision StartDeploy resolved for this claim. Empty
59+ // when the claim was reconstructed from identity alone.
60+ Resolved domain.AppDesiredRevision
61+ // Journal is the claimed operation held in process. Its Op is empty when
62+ // the claim was reconstructed from identity alone.
63+ Journal domain.AppOperation
64+}
65+
66+// StartDeployResult reports whether this caller owns the claimed operation.
67+// Owned is true only for the single caller that claimed Op. Every idempotent
68+// replay (the same key already answered) reports Owned false, and the stored
69+// journal in Claim.Journal is the result. Only an owner may pass the claim to
70+// ExecuteDeploy.
71+type StartDeployResult struct {
72+ Claim DeployClaim
73+ Owned bool
74+}
75+
76+// ReplayError is the explicit disposition of a claim this caller does not
77+// own: nil for a terminal success, and a conflict for an in-flight,
78+// interrupted, or failed operation, so a replay is never mistaken for a
79+// second execution. It is nil for an owned operation.
80+func (r StartDeployResult) ReplayError() error {
81+ if r.Owned {
82+ return nil
83+ }
84+ return replayError(r.Claim.Journal)
85+}
86+
87+// StartDeploy performs the first phase of a deploy. Under the app lock it
88+// converges any interrupted predecessor, resolves the request to a revision
89+// and runs the targeted-deploy convergence checks, atomically claims the
90+// request key, and persists the non-terminal journal. It performs no image
91+// pull, pinning, or workload mutation, so a crash right after it leaves a
92+// durable claim that reconciliation can converge.
93+//
94+// The returned claim distinguishes the newly owned operation (Owned true)
95+// from an idempotent replay (Owned false): the same key is never handed to
96+// two owners, and a replay never executes effects again.
97+func (s *Service) StartDeploy(ctx context.Context, input DeployInput) (*StartDeployResult, error) {
98+ // Planning and claiming acquire no runtime resource, so StartDeploy takes
99+ // only the per-app coordinator. ExecuteDeploy takes the GC shared lease
100+ // across resource acquisition and publication, where it is required.
101+ release, err := s.coord.acquire(ctx, input.App)
33 if err != nil {102 if err != nil {
34 return nil, err103 return nil, err
35 }104 }
36 defer release()105 defer release()
37- return s.deployLocked(ctx, input)106+ return s.startDeployLocked(ctx, input)
38 }107 }
39 108
40-func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployResult, error) {109+func (s *Service) startDeployLocked(ctx context.Context, input DeployInput) (*StartDeployResult, error) {
41 ctx = zerowrap.CtxWithFields(ctx, map[string]any{110 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42 zerowrap.FieldLayer: "usecase",111 zerowrap.FieldLayer: "usecase",
43- zerowrap.FieldUseCase: "Deploy",112+ zerowrap.FieldUseCase: "StartDeploy",
44 "app": input.App,113 "app": input.App,
45 })114 })
46 log := zerowrap.FromCtx(ctx)115 log := zerowrap.FromCtx(ctx)
47 116
48- pinned, op, replayed, err := s.preflightLocked(ctx, input)117+ if err := s.deps.State.Recover(ctx); err != nil {
49- if replayed || err != nil {118+ return nil, fmt.Errorf("deployment: recover before deploy: %w", err)
50- // The key already answered this request: its stored journal is119+ }
51- // the result and no workload is touched again. A failed preflight120+ // A mutation of this app never starts on top of an interrupted one: its
52- // returns the journal together with its error.121+ // never-published candidates must be gone before this mutation creates a
53- return journaledOrNil(input, op, err)122+ // generation of the same service. ACTIVE is materialized first, so the
123+ // published generation cannot be mistaken for a leftover.
124+ if err := s.reconcileInterruptedDeploy(ctx, input.App); err != nil {
125+ return nil, err
126+ }
127+ rev, op, owned, err := s.claimDeploymentLocked(ctx, input)
128+ if err != nil {
129+ return nil, err
130+ }
131+ if owned {
132+ // The claimed operation is now live in this process. It is
133+ // registered before the caller releases the coordinator, so a
134+ // foreground mutation that takes the lock in the gap before
135+ // ExecuteDeploy cannot reconcile the claim away.
136+ s.markOperationLive(input.App, op.Op)
137+ } else {
138+ log.Info().Str("op", op.Op).Msg("deployment: replayed an already claimed operation")
139+ }
140+ return &StartDeployResult{
141+ Claim: DeployClaim{
142+ App: input.App, Op: op.Op, Service: input.Service, Revision: input.Revision,
143+ Resolved: rev, Journal: op,
144+ },
145+ Owned: owned,
146+ }, nil
147+}
148+
149+// ExecuteDeploy performs the second phase of a deploy: it reacquires the app
150+// lock, loads and verifies the claimed journal by app/op identity, runs the
151+// image pull/pin and the sequential replacement, and records the terminal
152+// outcome. A claim that is already terminal, or that does not match its
153+// identity, is never executed: it replays its stored outcome instead. An
154+// operation that stops mid-execution (shutdown or cancellation) leaves its
155+// non-terminal journal behind for reconciliation, so it can never run twice.
156+//
157+// The operation stays live until this returns: the store record, not the
158+// in-process claim, is what executes, and the live marker is cleared only
159+// after the outcome was recorded or a recoverable non-terminal claim was
160+// left behind, while the app lock is still held.
161+func (s *Service) ExecuteDeploy(ctx context.Context, claim DeployClaim) (*DeployResult, error) {
162+ release, err := s.acquireAppContext(ctx, claim.App)
163+ if err != nil {
164+ // The claim is left as persisted: recoverable, but no longer owned
165+ // here, so a later reconciliation can converge it.
166+ s.clearOperationLive(claim.App, claim.Op)
167+ return nil, err
168+ }
169+ defer release()
170+ // Registered after release, so it runs first: the marker is dropped
171+ // before the app lock is released, and never while a live goroutine may
172+ // still persist state.
173+ defer s.clearOperationLive(claim.App, claim.Op)
174+ return s.executeLocked(ctx, claim)
175+}
176+
177+// AbandonDeploy settles a claim this process owns but will never execute, such
178+// as when daemon shutdown races the hand-off between StartDeploy and
179+// ExecuteDeploy. It drops the in-process live marker and converges the still
180+// non-terminal journal as an interrupted operation, so no durable claim stays
181+// marked live forever and a later reconciliation reads a terminal outcome
182+// instead of a permanently in-flight key. It takes the app lock but performs
183+// no workload effect.
184+func (s *Service) AbandonDeploy(ctx context.Context, claim DeployClaim) error {
185+ if claim.App == "" || claim.Op == "" {
186+ return fmt.Errorf("deployment: abandon requires an app and operation identity: %w", domain.ErrAppStateConflict)
187+ }
188+ // The marker is dropped before any fallible step: even if convergence
189+ // fails, the claim is no longer owned here, so a later reconciliation
190+ // (foreground or boot) can converge it instead of skipping it as live.
191+ s.clearOperationLive(claim.App, claim.Op)
192+ release, err := s.coord.acquire(ctx, claim.App)
193+ if err != nil {
194+ return err
195+ }
196+ defer release()
197+ return s.reconcileInterruptedDeploy(ctx, claim.App)
198+}
199+
200+// executeLocked runs the claimed, non-terminal operation. The caller holds
201+// the app lock. Every failure that happens before a terminal outcome is
202+// recorded (load active, revision, service step, traffic) leaves the journal
203+// with its step state, never a silent success.
204+func (s *Service) executeLocked(ctx context.Context, claim DeployClaim) (*DeployResult, error) {
205+ input := DeployInput{App: claim.App, Revision: claim.Revision, Service: claim.Service}
206+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
207+ zerowrap.FieldLayer: "usecase",
208+ zerowrap.FieldUseCase: "ExecuteDeploy",
209+ "app": claim.App,
210+ "op": claim.Op,
211+ })
212+ log := zerowrap.FromCtx(ctx)
213+
214+ op, err := s.loadClaim(ctx, claim)
215+ if err != nil {
216+ return nil, err
217+ }
218+ if op.Terminal() {
219+ // The claim was finalized between the phases: never execute a settled
220+ // key, replay its stored outcome instead.
221+ return journaledDeployResult(input, &op), replayError(op)
222+ }
223+ // The claimed revision is authoritative for the whole execution: it was
224+ // resolved when the key was claimed and is what the journal records, so a
225+ // desired-state change between the two phases must never redirect this
226+ // operation to a different revision than the one it pinned.
227+ rev, err := s.claimRevision(ctx, claim, op)
228+ if err != nil {
229+ s.failOperation(ctx, &op, err, "error")
230+ return journaledDeployResult(input, &op), err
231+ }
232+ pinned, err := s.pinPreflightLocked(ctx, input.App, input.Service, rev, &op)
233+ if err != nil {
234+ return journaledDeployResult(input, &op), err
54 }235 }
55 sort.Slice(pinned, func(i, j int) bool { return pinned[i].name < pinned[j].name })236 sort.Slice(pinned, func(i, j int) bool { return pinned[i].name < pinned[j].name })
56 237
57 active, _, err := s.deps.State.LoadActive(ctx, input.App)238 active, _, err := s.deps.State.LoadActive(ctx, input.App)
58 if err != nil {239 if err != nil {
59 loadErr := fmt.Errorf("deployment: load active: %w", err)240 loadErr := fmt.Errorf("deployment: load active: %w", err)
60- s.failOperation(ctx, op, loadErr, "error")241+ s.failOperation(ctx, &op, loadErr, "error")
61- return journaledDeployResult(input, op), loadErr242+ return journaledDeployResult(input, &op), loadErr
62- }
63- rev, err := s.resolveRevision(ctx, input)
64- if err != nil {
65- s.failOperation(ctx, op, err, "error")
66- return journaledDeployResult(input, op), err
67 }243 }
68 244
69 result := &DeployResult{245 result := &DeployResult{
@@ -75,29 +251,61 @@ func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployR
75 // A targeted deploy is intentionally a partial plan: services omitted251 // A targeted deploy is intentionally a partial plan: services omitted
76 // from pinned remain active. Full deploys reconcile actual removals.252 // from pinned remain active. Full deploys reconcile actual removals.
77 if input.Service == "" {253 if input.Service == "" {
78- if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, op, result, log); err != nil {254+ if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, &op, result, log); err != nil {
79 return result, err255 return result, err
80 }256 }
81 }257 }
82 for i, p := range pinned {258 for i, p := range pinned {
83- if err := s.runServiceStep(ctx, input.App, rev.Revision, i, p, op, active, result, log); err != nil {259+ if err := s.runServiceStep(ctx, input.App, rev.Revision, i, p, &op, active, result, log); err != nil {
84 return result, err260 return result, err
85 }261 }
86 }262 }
87 op.Outcome = ComputeOutcome(result.Services)263 op.Outcome = ComputeOutcome(result.Services)
88 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))264 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))
89 result.CleanupWarnings = collectCleanupWarnings(result.Services)265 result.CleanupWarnings = collectCleanupWarnings(result.Services)
90- if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {266+ if saveErr := s.deps.State.SaveOperation(ctx, op); saveErr != nil {
91 log.Warn().Err(saveErr).Msg("deployment: failed to record deploy outcome")267 log.Warn().Err(saveErr).Msg("deployment: failed to record deploy outcome")
92 }268 }
93 return result, nil269 return result, nil
94 }270 }
95 271
96-// runServiceStep executes one pinned service in the journal: replace,272+// loadClaim verifies the claimed journal by app/op identity. The store is
97-// publish ACTIVE, then cutover traffic and retire the replaced container.273+// authoritative: the operation is always reloaded by app/op ID, so a stale
98-// The step is recorded as succeeded only after the runtime effect, the274+// in-process journal can never be executed (for example when another actor
99-// ACTIVE publication, and the traffic apply all succeeded, so a journaled275+// finalized the key between the claim and execution). A journal that does
100-// success is never ahead of what is routed.276+// not match the identity, or is not a deploy, is refused.
277+func (s *Service) loadClaim(ctx context.Context, claim DeployClaim) (domain.AppOperation, error) {
278+ if claim.App == "" || claim.Op == "" {
279+ return domain.AppOperation{}, fmt.Errorf("deployment: execute requires an app and operation identity: %w", domain.ErrAppStateConflict)
280+ }
281+ op, err := s.deps.State.LoadOperation(ctx, claim.App, claim.Op)
282+ if err != nil {
283+ return domain.AppOperation{}, fmt.Errorf("deployment: load claimed operation %q: %w", claim.Op, err)
284+ }
285+ if op.App != claim.App || op.Op != claim.Op {
286+ return domain.AppOperation{}, fmt.Errorf("deployment: claim does not identify app %q operation %q: %w", claim.App, claim.Op, domain.ErrAppStateConflict)
287+ }
288+ if op.Kind != "deploy" {
289+ return domain.AppOperation{}, fmt.Errorf("deployment: operation %q is %q, not a deploy: %w", claim.Op, op.Kind, domain.ErrAppStateConflict)
290+ }
291+ return op, nil
292+}
293+
294+// claimRevision returns the revision StartDeploy captured. A claim
295+// reconstructed from identity alone resolves the revision its journal
296+// recorded, so a resumed execution pins the revision it was started for.
297+func (s *Service) claimRevision(ctx context.Context, claim DeployClaim, op domain.AppOperation) (domain.AppDesiredRevision, error) {
298+ if claim.Resolved.Revision != "" {
299+ return claim.Resolved, nil
300+ }
301+ return s.resolveRevision(ctx, DeployInput{App: claim.App, Revision: op.InputRevision, Service: claim.Service})
302+}
303+
304+// runServiceStep executes one pinned service in the journal: replace it,
305+// publish its ACTIVE entry, then publish traffic for the new container. The
306+// step is recorded as succeeded only after the runtime effect, the ACTIVE
307+// publication, and the traffic apply all succeeded, so a journaled success
308+// is never ahead of what is routed.
101 func (s *Service) runServiceStep(309 func (s *Service) runServiceStep(
102 ctx context.Context,310 ctx context.Context,
103 app, revision string,311 app, revision string,
@@ -112,22 +320,29 @@ func (s *Service) runServiceStep(
112 if eff, ok := active.Services[p.name]; ok {320 if eff, ok := active.Services[p.name]; ok {
113 before = eff.Container321 before = eff.Container
114 }322 }
115- svcResult, interrupted := s.deployService(ctx, app, revision, p, op.Op, before, activeStopGrace(active, p.name))
116- result.Services[p.name] = svcResult
117- if interrupted {
118- result.Interrupted = append(result.Interrupted, p.name)
119- }
120 stepID := "service." + p.name + ".replace"323 stepID := "service." + p.name + ".replace"
121- step := domain.AppOperationStep{324+ // The step is journaled before the replacement runs and carries the
325+ // candidate's container ID as soon as it exists, so an interrupted deploy
326+ // leaves a trace boot recovery can clean up instead of an untracked
327+ // generation.
328+ step := &op.Steps[index+1]
329+ *step = domain.AppOperationStep{
122 ID: stepID, Service: p.name,330 ID: stepID, Service: p.name,
123 Digest: p.digest, Image: p.runtimeImage,331 Digest: p.digest, Image: p.runtimeImage,
124- Before: before, After: svcResult.After,332+ Before: before, State: domain.AppStepPending,
333+ }
334+ svcResult := s.deployService(ctx, app, revision, p, op.Op, before, activeStopGrace(active, p.name), s.journalCandidate(op, index+1))
335+ result.Services[p.name] = svcResult
336+ // A recorded candidate is authoritative while the step is unresolved: a
337+ // replacement that failed after creating its container must stay traceable
338+ // for reconciliation.
339+ if svcResult.After != "" {
340+ step.After = svcResult.After
125 }341 }
126 fail := func(err error) error {342 fail := func(err error) error {
127 step.State = domain.AppStepFailed343 step.State = domain.AppStepFailed
128 step.Error = err.Error()344 step.Error = err.Error()
129 step.Diagnostics = svcResult.Diagnostics345 step.Diagnostics = svcResult.Diagnostics
130- op.Steps[index+1] = step
131 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))346 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))
132 op.Outcome = ComputeOutcome(result.Services)347 op.Outcome = ComputeOutcome(result.Services)
133 if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {348 if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {
@@ -140,17 +355,18 @@ func (s *Service) runServiceStep(
140 return fail(fmt.Errorf("deployment: service %q failed at %s: %s: %w",355 return fail(fmt.Errorf("deployment: service %q failed at %s: %s: %w",
141 p.name, stepID, svcResult.Error, domain.ErrAppStateConflict))356 p.name, stepID, svcResult.Error, domain.ErrAppStateConflict))
142 }357 }
143- // Publish per-service effective state first: the proxy switches to the358+ // Publish the per-service effective state: the new binds become the
144- // new binds at publication. Retire the replaced container only AFTER359+ // service's recorded backend before the proxy is repointed at them.
145- // publication (no outage on crash between the two); a retire failure
146- // is a cleanup warning, not an outcome flip.
147 if err := s.publishService(ctx, app, revision, p, svcResult, op.Op); err != nil {360 if err := s.publishService(ctx, app, revision, p, svcResult, op.Op); err != nil {
148 svcResult.Result = "failed"361 svcResult.Result = "failed"
149 svcResult.Error = err.Error()362 svcResult.Error = err.Error()
150 result.Services[p.name] = svcResult363 result.Services[p.name] = svcResult
151 return fail(err)364 return fail(err)
152 }365 }
153- if err := s.cutoverService(ctx, app, p, &svcResult, result); err != nil {366+ // Rebuild and publish the traffic graph. ACTIVE already names the new
367+ // container, but the operation is a failure until routing accepted it,
368+ // so a rejected graph must never be journaled as success.
369+ if err := s.refreshTraffic(ctx, app); err != nil {
154 svcResult.Result = "failed"370 svcResult.Result = "failed"
155 svcResult.Error = err.Error()371 svcResult.Error = err.Error()
156 result.Services[p.name] = svcResult372 result.Services[p.name] = svcResult
@@ -158,7 +374,7 @@ func (s *Service) runServiceStep(
158 }374 }
159 result.Services[p.name] = svcResult375 result.Services[p.name] = svcResult
160 step.State = domain.AppStepSucceeded376 step.State = domain.AppStepSucceeded
161- op.Steps[index+1] = step377+ step.Error = ""
162 if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {378 if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {
163 log.Warn().Err(saveErr).Msg("deployment: failed to checkpoint service progress")379 log.Warn().Err(saveErr).Msg("deployment: failed to checkpoint service progress")
164 }380 }
@@ -310,175 +526,332 @@ func (s *Service) retireRemovedService(ctx context.Context, app, name string, ef
310 return step, nil, nil526 return step, nil, nil
311 }527 }
312 528
313-// cutoverService applies traffic after publication, then retires the529+// candidateJournal durably records a freshly created replacement container
314-// replaced container. A traffic failure is returned to the caller, which530+// before it can be started or join a network. An interrupted deployment then
315-// records the failed step: ACTIVE is published but the new service is not531+// leaves a trace boot recovery removes, instead of an untracked running
316-// routable, so the operation must not report success. The old container532+// generation that could overlap the one recovery rebuilds.
317-// is retained when traffic activation fails.533+//
318-func (s *Service) cutoverService(ctx context.Context, app string, p pinnedService, svcResult *ServiceResult, result *DeployResult) error {534+// The window between the runtime creating a container and this write is the
319- if trafficErr := s.refreshTraffic(ctx, app); trafficErr != nil {535+// irreducible one: a container ID cannot be recorded before it exists.
320- return trafficErr536+// sync/atomic is not needed here: the journal write is synchronous.
321- }537+type candidateJournal func(ctx context.Context, containerID string) error
322- if svcResult.Retire != "" {538+
323- drainWithDeadline(ctx, p.spec.StopGrace)539+// journalCandidate records one created candidate in the operation journal.
324- // The replaced generation keeps its inhibition: only a published540+// The step is addressed by index and read at write time, so a later append to
325- // replacement may clear it.541+// the operation's steps can never leave the recorder writing to a stale
326- retired := s.retireContainer(ctx, app, retireOptions{542+// element. A nil operation (an internal path with no journal) records nothing.
327- Service: p.name, Grace: svcResult.RetireGrace,543+func (s *Service) journalCandidate(op *domain.AppOperation, index int) candidateJournal {
328- }, svcResult.Retire)544+ if op == nil || index < 0 || index >= len(op.Steps) {
329- svcResult.CleanupWarnings = append(svcResult.CleanupWarnings, retired.Warnings...)545+ return nil
546+ }
547+ return func(ctx context.Context, containerID string) error {
548+ op.Steps[index].After = containerID
549+ if err := s.deps.State.SaveOperation(ctx, *op); err != nil {
550+ return fmt.Errorf("deployment: record candidate %s: %w", containerID, err)
551+ }
552+ return nil
330 }553 }
331- return nil
332 }554 }
333 555
334-// deployService replaces one service. beforeGrace is the effective stop556+// reconcileInterruptedDeploy converges the journal of a deployment that a
335-// grace of the container being replaced. It returns the terminal result557+// crash, shutdown, or failed cleanup interrupted. A candidate that was
336-// and whether the service saw interruption.558+// created but never published is removed and its loopback claims released, so
337-func (s *Service) deployService(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {559+// recovery can rebuild the published generation without ever running two
338- if httpEligible(p.spec) {560+// generations of one service. The interrupted operation is then finalized as
339- return s.deployHTTP(ctx, app, revision, p, opID, before, beforeGrace)561+// failed: a repeat of its key replays an explicit failure instead of a
562+// permanent in-flight claim.
563+//
564+// It also retries the candidates of a failed operation whose cleanup did not
565+// complete, so a leftover stays visible and is eventually removed. The
566+// published generation recorded in ACTIVE is never touched.
567+//
568+// It runs at boot and before every mutation of the app, always under the app
569+// lock, so a non-terminal journal is definitively interrupted, and a mutation
570+// that cannot reconcile a predecessor does not proceed. It reads the
571+// operation journal first and only then ACTIVE, so the common case of a
572+// successful operation costs one state read.
573+//
574+// Reconciliation fails closed on a candidate that cannot be removed: an
575+// orphan that may still run aborts the caller instead of being journaled as a
576+// recoverable leftover. Because the journal is never finalized while such a
577+// leftover exists, it stays the latest operation and a newer operation can
578+// never be created on top of it and mask it.
579+func (s *Service) reconcileInterruptedDeploy(ctx context.Context, app string) error {
580+ op, ok, err := s.deps.State.LoadLatestOperation(ctx, app)
581+ if err != nil {
582+ return fmt.Errorf("deployment: load interrupted operation for %q: %w", app, err)
583+ }
584+ if !ok {
585+ return nil
586+ }
587+ if !op.Terminal() && s.operationLive(app, op.Op) {
588+ // The operation is owned by a live goroutine in this process that
589+ // may still be persisting its outcome. Reconcile it only after that
590+ // owner has exited; until then the store record is authoritative and
591+ // must not be finalized here.
592+ return nil
593+ }
594+ terminal := op.Terminal()
595+ needsActive := false
596+ for _, step := range op.Steps {
597+ if reconcileNeedsContainer(step, terminal) {
598+ needsActive = true
599+ break
600+ }
601+ }
602+ var active domain.AppActive
603+ if needsActive {
604+ active, _, err = s.deps.State.LoadActive(ctx, app)
605+ if err != nil {
606+ return fmt.Errorf("deployment: load active for interrupted operation: %w", err)
607+ }
608+ }
609+ log := zerowrap.FromCtx(ctx)
610+ // write is true when the journal itself changes: an interrupted operation
611+ // is always finalized, a terminal one only when a leftover must be
612+ // reported.
613+ changed, err := s.convergeInterruptedSteps(ctx, app, &op, active, terminal, log)
614+ if err != nil {
615+ return err
616+ }
617+ write := !terminal || changed
618+ if !write {
619+ return nil
340 }620 }
341- return s.deployInterrupted(ctx, app, revision, p, opID, before, beforeGrace)621+ if !terminal {
622+ // The effects ran and only the outcome write was interrupted: that is
623+ // a success, not a failure.
624+ op.Outcome = interruptedOutcome(op)
625+ }
626+ if err := s.deps.State.SaveOperation(ctx, op); err != nil {
627+ return fmt.Errorf("deployment: finalize interrupted operation for %q: %w", app, err)
628+ }
629+ log.Warn().Str("app", app).Str("op", op.Op).Msg("deployment: reconciled an unfinished operation")
630+ return nil
342 }631 }
343 632
344-// httpEligible reports HTTP services whose generations may overlap while633+// reconcileNeedsContainer reports whether one step still needs container
345-// the candidate proves readiness. Every declared HTTP interface must be634+// work: a step that recorded a candidate. A terminal operation only retries
346-// effective-public: the proxy repoints each public host to the candidate635+// the step that recorded a failed candidate; a pending step exists only in an
347-// only after readiness, so overlap buys availability. Any internal HTTP636+// interrupted operation.
348-// interface is instead resolved over the app private network by service637+func reconcileNeedsContainer(step domain.AppOperationStep, terminal bool) bool {
349-// alias, and the candidate joins that network with the same alias as soon638+ if step.After == "" {
350-// as it starts — before readiness — so an internal consumer could reach an
351-// unready generation with no cutover gate to stop it. Such a service takes
352-// interrupted replacement like L4 services. Volumes, binds, and L4 ports
353-// remain interrupted because two generations must not share their state or
354-// publications.
355-func httpEligible(spec domain.AppService) bool {
356- if len(spec.HTTP) == 0 || len(spec.TCP) > 0 || len(spec.UDP) > 0 || len(spec.Volumes) > 0 || len(spec.Binds) > 0 {
357 return false639 return false
358 }640 }
359- for _, h := range spec.HTTP {641+ if terminal {
360- if !h.IsPublic() {642+ return step.State == domain.AppStepFailed
361- return false
362- }
363 }643 }
364 return true644 return true
365 }645 }
366 646
367-// singleWriterRequired reports services that must never have two generations647+// convergeInterruptedSteps retries the recorded candidate of every step that
368-// running concurrently. Persistent volumes and any bind (especially a648+// needs container work and accumulates the bounded leftovers in the journal.
369-// writable one) may be written by both, so they are treated identically.649+// It reports whether the journal changed and fails closed on the first
370-func singleWriterRequired(spec domain.AppService) bool {650+// candidate that cannot be removed, before any later step or mutation runs.
371- return len(spec.Volumes) > 0 || len(spec.Binds) > 0651+func (s *Service) convergeInterruptedSteps(ctx context.Context, app string, op *domain.AppOperation, active domain.AppActive, terminal bool, log zerowrap.Logger) (bool, error) {
652+ changed := false
653+ for i := range op.Steps {
654+ step := &op.Steps[i]
655+ if !reconcileNeedsContainer(*step, terminal) {
656+ continue
657+ }
658+ warnings, convErr := s.convergeCandidate(ctx, app, active, step, terminal, log)
659+ if step.After == "" {
660+ // Converging the candidate clears its recorded After, so the
661+ // journal changed even when nothing was left over to report: the
662+ // converged step must be persisted, or every later pass reconciles
663+ // it again.
664+ changed = true
665+ }
666+ if len(warnings) > 0 {
667+ // A candidate that will not go away is operator-visible instead of
668+ // staying untracked.
669+ op.Warnings = append(op.Warnings, journalWarnings(warnings)...)
670+ changed = true
671+ }
672+ if convErr != nil {
673+ // Fail closed: the orphan may still be running, so no later
674+ // mutation may proceed. The journal keeps the leftover (and the
675+ // failed step) for the next pass; a terminal operation stays the
676+ // latest record because no newer claim is opened.
677+ if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {
678+ log.Warn().Err(saveErr).Msg("deployment: failed to record reconciliation failure")
679+ }
680+ return changed, fmt.Errorf("deployment: reconcile incomplete operation for %q: %w", app, convErr)
681+ }
682+ }
683+ return changed, nil
372 }684 }
373 685
374-// deployHTTP keeps the old container serving until the replacement passes686+// convergeCandidate converges the container one step recorded. The published
375-// readiness AND the new effective state is published: the proxy switches687+// generation is kept; a never-published candidate is removed so recovery can
376-// to the new loopback binds at publication, the old container drains,688+// rebuild the generation recorded in ACTIVE without two generations running
377-// and only then is the old container retired by exact ID. Retire never689+// at once. An interrupted operation's step is marked failed.
378-// precedes publication (no outage window on crash between the two).690+//
379-func (s *Service) deployHTTP(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {691+// A candidate that cannot be removed is returned as an error, not a
380- created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID)692+// recoverable warning: the orphan may still run, so every later mutation must
381- if err != nil {693+// fail closed instead of proceeding. The returned warnings are the leftovers
382- return s.failResult(revision, before, "", err), false694+// of that removal.
695+//
696+// Success clears the step's recorded After and keeps the removed (or kept)
697+// container id in Detail, so the audit trail survives while the converged step
698+// is no longer retried on every later pass. Only the failure paths leave After
699+// in place, keeping a failed convergence eligible for retry.
700+//
701+// Once the candidate is converged, the inhibition the replacement wrote for
702+// the superseded container is cleared. A step carrying both Before and After
703+// records that the superseded container was confirmed gone before the
704+// candidate was created (deployService retires it first), so the marker
705+// protects nothing and would otherwise refuse the boot/start/restart rebuild
706+// from ACTIVE.
707+func (s *Service) convergeCandidate(ctx context.Context, app string, active domain.AppActive, step *domain.AppOperationStep, terminal bool, log zerowrap.Logger) ([]CleanupWarning, error) {
708+ candidate := step.After
709+ if publishedContainer(active, candidate) {
710+ // The published generation: keep it. A step that already succeeded is
711+ // left as recorded; a routing apply that never ran is republished by
712+ // the periodic recovery pass.
713+ if !terminal && step.State != domain.AppStepSucceeded {
714+ step.State = domain.AppStepFailed
715+ step.Error = "interrupted before traffic publication; the published container is kept and routing is republished"
716+ }
717+ step.Detail = "converged: published container " + candidate + " kept"
718+ } else {
719+ retired := s.retireContainer(ctx, app, retireOptions{Service: step.Service, Force: true}, candidate)
720+ if !retired.Gone {
721+ if !terminal {
722+ step.State = domain.AppStepFailed
723+ step.Error = "interrupted before publication; the unpublished candidate could not be removed: " + cleanupDetail(retired)
724+ log.Warn().Str("app", app).Str("container", candidate).Msg("deployment: unpublished candidate could not be removed")
725+ }
726+ return retired.Warnings, fmt.Errorf("deployment: candidate %s of service %q could not be removed: %s", candidate, step.Service, cleanupDetail(retired))
727+ }
728+ if !terminal {
729+ step.State = domain.AppStepFailed
730+ step.Error = "interrupted before publication; the unpublished candidate was removed"
731+ }
732+ step.Detail = "converged: removed unpublished candidate " + candidate
733+ }
734+ // The candidate is gone (or published): the replacement-pending
735+ // inhibition of the superseded container is stale. Dropping it lets
736+ // boot/start/restart rebuild that generation from ACTIVE; the single
737+ // writer is preserved because the superseded container was proven gone
738+ // before the candidate existed.
739+ if step.Service != "" && step.Before != "" && step.Before != candidate {
740+ if err := s.clearRecoveryInhibition(ctx, app, step.Service, step.Before); err != nil {
741+ log.Warn().Err(err).Str("app", app).Str("container", step.Before).Msg("deployment: clear stale replacement inhibition")
742+ return []CleanupWarning{{Service: step.Service, Leftover: step.Before, Detail: "clear recovery inhibition: " + err.Error()}}, nil
743+ }
383 }744 }
384- if err := s.waitServiceReady(ctx, app, created.ID, httpReadiness(p.spec), binds); err != nil {745+ // Only reconciliation removes a recorded candidate: clearing After here is
385- // Old version keeps serving. Capture redacted diagnostics while746+ // what marks the step converged.
386- // the failed replacement still exists, then remove only that747+ step.After = ""
387- // candidate.748+ return nil, nil
388- tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))749+}
389- cleanup := s.retireCandidate(ctx, app, p.name, created.ID)750+
390- return ServiceResult{751+// interruptedOutcome classifies an interrupted operation: a failure, unless
391- Result: "failed",752+// every step had already succeeded.
392- EffectiveRevision: revision,753+func interruptedOutcome(op domain.AppOperation) string {
393- Before: before,754+ if operationSucceeded(op) {
394- After: created.ID,755+ return domain.AppOutcomeSuccess
395- Error: err.Error(),
396- Diagnostics: tail,
397- CleanupWarnings: cleanup,
398- }, false
399 }756 }
400- return ServiceResult{757+ return domain.AppOutcomeFailed
401- Result: "deployed",
402- EffectiveRevision: revision,
403- Before: before,
404- After: created.ID,
405- BackendBinds: binds,
406- UDPBackendBinds: udpBinds,
407- Retire: retireContainerID(before, created.ID),
408- RetireGrace: beforeGrace,
409- }, false
410 }758 }
411 759
412-// refreshTraffic rebuilds the proxy host index and applies the full760+// operationSucceeded reports whether every step of an interrupted operation
413-// HTTP/L4 graph after activation. A failure is fatal to the caller:761+// reached success: its effects ran and only the final outcome write was
414-// ACTIVE is published but the new service is not routable, so the762+// interrupted.
415-// operation must not report success. Any service of this app whose763+func operationSucceeded(op domain.AppOperation) bool {
416-// fail-closed withdrawal was never applied is withdrawn again first, so764+ if len(op.Steps) == 0 {
417-// a later publication never runs on top of unproven forwarding.765+ return false
418-func (s *Service) refreshTraffic(ctx context.Context, app string) error {
419- if s.deps.Traffic == nil {
420- return nil
421 }766 }
422- for _, service := range s.publication.pending(app) {767+ for _, step := range op.Steps {
423- if err := s.withdrawForRecovery(ctx, app, service); err != nil {768+ if step.State != domain.AppStepSucceeded {
424- return err769+ return false
425 }770 }
426 }771 }
427- if err := s.deps.Traffic.RebuildTraffic(ctx); err != nil {772+ return true
428- return fmt.Errorf("deployment: rebuild traffic for %q: %w", app, err)
429- }
430- return nil
431 }773 }
432 774
433-// retireContainerID returns the exact container ID to retire after the775+// publishedContainer reports whether any service of the ACTIVE record names
434-func retireContainerID(before, after string) string {776+// the container. A container that is the published generation is never a
435- if before == "" || before == after {777+// leftover, whichever service recorded it.
436- return ""778+func publishedContainer(active domain.AppActive, containerID string) bool {
779+ if containerID == "" {
780+ return false
781+ }
782+ for _, service := range active.Services {
783+ if service.Container == containerID {
784+ return true
785+ }
437 }786 }
438- return before787+ return false
439 }788 }
440 789
441-// deployInterrupted stops the old instance after preflight, then creates790+// deployService replaces one service sequentially and returns its terminal
442-// the replacement. Volume-owning failures never restart the old image:791+// result. before is the ACTIVE container being superseded (empty on a first
443-// the replacement may already have written data.792+// deployment); beforeGrace is its effective stop grace.
444-func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {793+//
445- // Revalidate the current authorization and source before inhibiting or794+// The order is fixed: withdraw from traffic and confirm it, durably inhibit
795+// a single-writer generation, retire the superseded container and confirm
796+// it is gone, then create and start the replacement and wait for its
797+// readiness probe. Nothing is created before the previous generation is
798+// confirmed gone, so two generations never overlap; a withdrawal or
799+// retirement that cannot be confirmed aborts without creating a candidate.
800+func (s *Service) deployService(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration, journal candidateJournal) ServiceResult {
801+ // Revalidate the current authorization and source before withdrawing or
446 // retiring the serving generation. A reload between preflight and this802 // retiring the serving generation. A reload between preflight and this
447 // service step must fail without mutating the existing workload.803 // service step must fail without mutating the existing workload.
448 if _, err := s.resolveServiceBinds(app, p.spec); err != nil {804 if _, err := s.resolveServiceBinds(app, p.spec); err != nil {
449- return s.failResult(revision, before, "", err), true805+ return s.failResult(revision, before, "", err)
450- }806+ }
451- // A volume-owning replacement may write while the old generation807+ // Withdraw the service from traffic first and confirm it: a withdrawal
452- // still exists and could be revived by native restart policy.808+ // that cannot be applied must block every container mutation, so no
453- // Inhibit that generation durably BEFORE the write can happen, so809+ // unverified generation stays routable. A first deployment has no
454- // boot or periodic recovery can never restart the old writer on810+ // published generation to withdraw.
455- // top of the new one. Cleared once the safe generation is811+ if before != "" {
456- // published (publishService) or the operator removes the app.812+ if err := s.withdrawForRecovery(ctx, app, p.name); err != nil {
457- inhibited := before != "" && singleWriterRequired(p.spec)813+ return s.failResult(revision, before, "", err)
458- if inhibited {814+ }
815+ }
816+ // A volume- or bind-owning replacement may write while the old
817+ // generation still exists and could be revived by native restart policy.
818+ // Inhibit that generation durably BEFORE the write can happen, so boot or
819+ // periodic recovery can never restart the old writer on top of the new
820+ // one. Cleared once the safe generation is published (publishService) or
821+ // the operator removes the app.
822+ if before != "" && singleWriterRequired(p.spec) {
459 if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil {823 if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil {
460- return s.failResult(revision, before, "", err), true824+ return s.failResult(revision, before, "", err)
461 }825 }
462 }826 }
463 if before != "" {827 if before != "" {
464- // The superseded writer must be confirmed gone before a new828+ // The superseded generation must be confirmed gone before a new
465- // writer can touch the same data: a failed retirement aborts the829+ // generation starts: a failed retirement aborts the replacement
466- // replacement instead of allowing overlapping writers, and the830+ // instead of allowing overlapping generations, and the generation
467- // generation keeps its inhibition and its claims.831+ // keeps its inhibition and its claims.
468 retired := s.retireContainer(ctx, app, retireOptions{832 retired := s.retireContainer(ctx, app, retireOptions{
469 Service: p.name, Grace: beforeGrace,833 Service: p.name, Grace: beforeGrace,
470 }, before)834 }, before)
471 if !retired.Gone {835 if !retired.Gone {
472- return s.failResult(revision, before, "", fmt.Errorf("deployment: retire superseded container %s: %s", before, cleanupDetail(retired))), true836+ return s.failResult(revision, before, "", fmt.Errorf("deployment: retire superseded container %s: %s", before, cleanupDetail(retired)))
473 }837 }
474 }838 }
475- created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID)839+ created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID, journal)
476 if err != nil {840 if err != nil {
477- return s.failResult(revision, before, "", err), true841+ result := s.failResult(revision, before, "", err)
478- }842+ if created != nil {
479- if err := s.waitServiceReady(ctx, app, created.ID, p.spec, binds); err != nil {843+ // The candidate exists: keep its ID in the terminal result so the
480- // Capture redacted diagnostics while the candidate still exists,844+ // journal and the operator can still trace it.
481- // then remove only that candidate.845+ result.After = created.ID
846+ }
847+ return result
848+ }
849+ if err := s.waitServiceReady(ctx, app, created.ID, deploymentReadiness(p.spec), binds); err != nil {
850+ // Capture redacted diagnostics while the failed replacement still
851+ // exists, then remove only that candidate. The superseded container
852+ // is already gone and is never recreated. Cleanup is best effort: a
853+ // canceled context can leave the candidate behind, which is then
854+ // reported as a leftover warning instead of a false success.
482 tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))855 tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))
483 cleanup := s.retireCandidate(ctx, app, p.name, created.ID)856 cleanup := s.retireCandidate(ctx, app, p.name, created.ID)
484 return ServiceResult{857 return ServiceResult{
@@ -490,7 +863,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
490 Error: err.Error(),863 Error: err.Error(),
491 Diagnostics: tail,864 Diagnostics: tail,
492 CleanupWarnings: cleanup,865 CleanupWarnings: cleanup,
493- }, true866+ }
494 }867 }
495 return ServiceResult{868 return ServiceResult{
496 Result: "deployed",869 Result: "deployed",
@@ -500,7 +873,35 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
500 RestartUnsafe: singleWriterRequired(p.spec),873 RestartUnsafe: singleWriterRequired(p.spec),
501 BackendBinds: binds,874 BackendBinds: binds,
502 UDPBackendBinds: udpBinds,875 UDPBackendBinds: udpBinds,
503- }, true876+ }
877+}
878+
879+// singleWriterRequired reports services that must never have two generations
880+// running concurrently. Persistent volumes and any bind (especially a
881+// writable one) may be written by both, so they are treated identically.
882+func singleWriterRequired(spec domain.AppService) bool {
883+ return len(spec.Volumes) > 0 || len(spec.Binds) > 0
884+}
885+
886+// refreshTraffic rebuilds the proxy host index and applies the full
887+// HTTP/L4 graph after activation. A failure is fatal to the caller:
888+// ACTIVE is published but the new service is not routable, so the
889+// operation must not report success. Any service of this app whose
890+// fail-closed withdrawal was never applied is withdrawn again first, so
891+// a later publication never runs on top of unproven forwarding.
892+func (s *Service) refreshTraffic(ctx context.Context, app string) error {
893+ if s.deps.Traffic == nil {
894+ return nil
895+ }
896+ for _, service := range s.publication.pending(app) {
897+ if err := s.withdrawForRecovery(ctx, app, service); err != nil {
898+ return err
899+ }
900+ }
901+ if err := s.deps.Traffic.RebuildTraffic(ctx); err != nil {
902+ return fmt.Errorf("deployment: rebuild traffic for %q: %w", app, err)
903+ }
904+ return nil
504 }905 }
505 906
506 // reserveVolumeOwnership durably records one app-owned volume as907 // reserveVolumeOwnership durably records one app-owned volume as
@@ -568,7 +969,7 @@ func volumeProvenanceLabels(app, appID, service, revision string) map[string]str
568 // never container IPs. The runtime keeps native restarts; Gordon969 // never container IPs. The runtime keeps native restarts; Gordon
569 // reconciles intent at boot and in the monitor (accepted decision:970 // reconciles intent at boot and in the monitor (accepted decision:
570 // native runtime restarts plus daemon reconciliation).971 // native runtime restarts plus daemon reconciliation).
571-func (s *Service) createAndStart(ctx context.Context, app, revision string, p pinnedService, opID string) (*domain.Container, map[int]int, map[int]int, error) {972+func (s *Service) createAndStart(ctx context.Context, app, revision string, p pinnedService, opID string, journal candidateJournal) (*domain.Container, map[int]int, map[int]int, error) {
572 // Re-resolve binds from the current policy immediately before any973 // Re-resolve binds from the current policy immediately before any
573 // runtime mutation: a bind revoked since preflight must fail here,974 // runtime mutation: a bind revoked since preflight must fail here,
574 // before volume ownership or container creation.975 // before volume ownership or container creation.
@@ -580,13 +981,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
580 if err != nil {981 if err != nil {
581 return nil, nil, nil, err982 return nil, nil, nil, err
582 }983 }
583- image := p.runtimeImage984+ image := runtimeImageRef(p)
584- if image == "" {
585- image = p.spec.Image
586- if p.digest != "" {
587- image = stripImageTag(p.spec.Image) + "@" + p.digest
588- }
589- }
590 // The incarnation network is verified or created before any985 // The incarnation network is verified or created before any
591 // container exists, so a workload is only ever started on a986 // container exists, so a workload is only ever started on a
592 // Gordon-owned network that no other app shares.987 // Gordon-owned network that no other app shares.
@@ -646,22 +1041,58 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
646 if err != nil {1041 if err != nil {
647 return nil, nil, nil, err1042 return nil, nil, nil, err
648 }1043 }
1044+ // The candidate ID is durably journaled before the container is started
1045+ // or joins a shared network, so an interrupted deployment can be cleaned
1046+ // up instead of leaving an untracked running generation.
1047+ if err := s.recordCandidate(ctx, app, p.spec.Name, created.ID, journal); err != nil {
1048+ return nil, nil, nil, err
1049+ }
649 if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil {1050 if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil {
650 s.retireCandidate(ctx, app, p.spec.Name, created.ID)1051 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
651- return nil, nil, nil, err1052+ return created, nil, nil, err
652 }1053 }
653 if err := s.deps.Runtime.StartContainer(ctx, created.ID); err != nil {1054 if err := s.deps.Runtime.StartContainer(ctx, created.ID); err != nil {
654 s.retireCandidate(ctx, app, p.spec.Name, created.ID)1055 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
655- return nil, nil, nil, fmt.Errorf("deployment: start container: %w", err)1056+ return created, nil, nil, fmt.Errorf("deployment: start container: %w", err)
656 }1057 }
657 binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec))1058 binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec))
658 if err != nil {1059 if err != nil {
659 s.retireCandidate(ctx, app, p.spec.Name, created.ID)1060 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
660- return nil, nil, nil, err1061+ return created, nil, nil, err
661 }1062 }
662 return created, binds, udpBinds, nil1063 return created, binds, udpBinds, nil
663 }1064 }
664 1065
1066+// runtimeImageRef resolves the exact runtime image reference of one pinned
1067+// service: the image preflight resolved, else the manifest reference pinned to
1068+// its digest.
1069+func runtimeImageRef(p pinnedService) string {
1070+ if p.runtimeImage != "" {
1071+ return p.runtimeImage
1072+ }
1073+ if p.digest == "" {
1074+ return p.spec.Image
1075+ }
1076+ return stripImageTag(p.spec.Image) + "@" + p.digest
1077+}
1078+
1079+// recordCandidate durably journals a freshly created candidate before it is
1080+// started. A candidate whose ID cannot be recorded must not keep running:
1081+// recovery could never find it, so it is removed and the failure surfaced.
1082+func (s *Service) recordCandidate(ctx context.Context, app, service, containerID string, journal candidateJournal) error {
1083+ if journal == nil {
1084+ return nil
1085+ }
1086+ if err := journal(ctx, containerID); err != nil {
1087+ retired := s.retireContainer(ctx, app, retireOptions{Service: service, Force: true}, containerID)
1088+ if !retired.Gone {
1089+ return fmt.Errorf("%w (candidate cleanup: %s)", err, cleanupDetail(retired))
1090+ }
1091+ return err
1092+ }
1093+ return nil
1094+}
1095+
665 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {1096 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
666 created, err := s.deps.Runtime.CreateContainer(ctx, config)1097 created, err := s.deps.Runtime.CreateContainer(ctx, config)
667 if err == nil {1098 if err == nil {
@@ -927,7 +1358,8 @@ func sortedEnv(envMap map[string]string) []string {
927 return env1358 return env
928 }1359 }
929 1360
930-// publishService writes the per-service effective record after cutover.1361+// publishService writes the per-service effective record once the
1362+// replacement passed readiness.
931 func (s *Service) publishService(ctx context.Context, app, revision string, p pinnedService, result ServiceResult, opID string) error {1363 func (s *Service) publishService(ctx context.Context, app, revision string, p pinnedService, result ServiceResult, opID string) error {
932 if app == "" {1364 if app == "" {
933 return fmt.Errorf("deployment: publish active: empty app: %w", domain.ErrAppStateConflict)1365 return fmt.Errorf("deployment: publish active: empty app: %w", domain.ErrAppStateConflict)
@@ -982,7 +1414,7 @@ func (s *Service) publishService(ctx context.Context, app, revision string, p pi
982 return nil1414 return nil
983 }1415 }
984 1416
985-// recordOwnership stamps volume/secret/network ownership after cutover.1417+// recordOwnership stamps volume/secret/network ownership after publication.
986 func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedService) error {1418 func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedService) error {
987 ownership, err := s.deps.State.LoadOwnership(ctx, app)1419 ownership, err := s.deps.State.LoadOwnership(ctx, app)
988 if err != nil {1420 if err != nil {
@@ -1145,26 +1577,39 @@ func tailLines(r io.Reader, n int) ([]string, error) {
1145 return lines, nil1577 return lines, nil
1146 }1578 }
1147 1579
1148-// httpReadiness forces the HTTP path for eligible cutover checks.1580+// deploymentReadiness resolves the readiness check a deploy or restart
1149-func httpReadiness(spec domain.AppService) domain.AppService {1581+// runs. Readiness never depends on how a service is replaced: a declared
1150- if spec.Readiness.Type == "" || spec.Readiness.Type == domain.AppReadinessNone {1582+// http, tcp, or log check is used as declared, and an undeclared check
1151- spec.Readiness.Type = domain.AppReadinessHTTP1583+// ("none" or unset) keeps the implicit HTTP probe on a service Gordon can
1152- }1584+// probe over the published loopback bind — at least one effective-public
1585+// HTTP interface, no L4 interface, no volume, and no bind. An undeclared
1586+// probe on a stateful or L4 workload stays unchecked instead: an HTTP GET on
1587+// the published port proves nothing there, so Gordon never guesses one.
1588+func deploymentReadiness(spec domain.AppService) domain.AppService {
1589+ if !implicitHTTPProbe(spec) {
1590+ return spec
1591+ }
1592+ spec.Readiness.Type = domain.AppReadinessHTTP
1153 return spec1593 return spec
1154 }1594 }
1155 1595
1156-// drainWithDeadline pauses briefly so in-flight HTTP requests finish.1596+// implicitHTTPProbe reports whether a service that declares no readiness
1157-func drainWithDeadline(ctx context.Context, grace time.Duration) {1597+// check receives the implicit HTTP probe when a deploy or restart starts it.
1158- if grace <= 0 {1598+// Recovery verification of an already running generation keeps the declared
1159- grace = domain.AppDefaultStopGrace1599+// readiness unchanged: it re-checks a generation it did not replace.
1600+func implicitHTTPProbe(spec domain.AppService) bool {
1601+ if spec.Readiness.Type != "" && spec.Readiness.Type != domain.AppReadinessNone {
1602+ return false
1160 }1603 }
1161- if grace > time.Second {1604+ if len(spec.HTTP) == 0 || len(spec.TCP) > 0 || len(spec.UDP) > 0 || len(spec.Volumes) > 0 || len(spec.Binds) > 0 {
1162- grace = time.Second1605+ return false
1163 }1606 }
1164- select {1607+ for _, h := range spec.HTTP {
1165- case <-time.After(grace):1608+ if !h.IsPublic() {
1166- case <-ctx.Done():1609+ return false
1610+ }
1167 }1611 }
1612+ return true
1168 }1613 }
1169 1614
1170 // appLabels stamps engine ownership on every created container.1615 // appLabels stamps engine ownership on every created container.
D internal/usecase/deployment/gcbarrier_test.go +0 -51
@@ -1,51 +0,0 @@
1-package deployment_test
2-
3-import (
4- "context"
5- "testing"
6-
7- "github.com/bnema/zerowrap"
8- "github.com/stretchr/testify/mock"
9- "github.com/stretchr/testify/require"
10-
11- outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12- "github.com/bnema/gordon/internal/domain"
13- "github.com/bnema/gordon/internal/usecase/deployment"
14-)
15-
16-// TestPreflight_HoldsSharedGCBarrierAcrossResourceSelection proves the
17-// shared lease is taken before resource selection and released only
18-// when the mutation ends. Prune can therefore never observe a resource
19-// that was selected but whose protection is not yet durable.
20-func TestPreflight_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
21- ctx := context.Background()
22- state, runtime, images, secrets := mockDeps(t)
23- barrier := outmocks.NewMockGCBarrier(t)
24- lease := outmocks.NewMockGCLease(t)
25-
26- held := false
27- barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
28- held = true
29- }).Return(lease, nil).Once()
30- lease.EXPECT().Release().Run(func() {
31- held = false
32- }).Once()
33-
34- rev := testRevision("blog", webService())
35- // Image resolution is the resource-selection step: it must happen
36- // inside the shared lease.
37- expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "s3cr3t", nil)
38- images.ExpectedCalls = nil
39- images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Run(func(context.Context, string) {
40- require.True(t, held, "image resolution must run inside the shared GC lease")
41- }).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
42-
43- svc := deployment.NewService(deployment.Deps{
44- State: state, Runtime: runtime, Images: images, Secrets: secrets,
45- ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
46- }, zerowrap.Default()).WithGCBarrier(barrier)
47-
48- _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
49- require.NoError(t, err)
50- require.False(t, held, "the shared lease must be released when the operation ends")
51-}
D internal/usecase/deployment/http_cutover_behavior_test.go +0 -143
@@ -1,143 +0,0 @@
1-package deployment_test
2-
3-import (
4- "context"
5- "io"
6- "net/http"
7- "net/http/httptest"
8- "sync"
9- "testing"
10- "time"
11-
12- "github.com/bnema/zerowrap"
13- "github.com/stretchr/testify/assert"
14- "github.com/stretchr/testify/mock"
15- "github.com/stretchr/testify/require"
16-
17- "github.com/bnema/gordon/internal/domain"
18- "github.com/bnema/gordon/internal/usecase/deployment"
19-)
20-
21-type switchingTraffic struct {
22- mu sync.RWMutex
23- backend string
24- ready <-chan struct{}
25-}
26-
27-func (s *switchingTraffic) RebuildTraffic(context.Context) error {
28- <-s.ready
29- s.mu.Lock()
30- s.backend = "new"
31- s.mu.Unlock()
32- return nil
33-}
34-
35-func (s *switchingTraffic) WithdrawService(context.Context, string, string) error {
36- s.mu.Lock()
37- s.backend = "withdrawn"
38- s.mu.Unlock()
39- return nil
40-}
41-
42-func (s *switchingTraffic) WithdrawServiceState(context.Context, string, string) error {
43- s.mu.Lock()
44- s.backend = "withdrawn"
45- s.mu.Unlock()
46- return nil
47-}
48-
49-func (s *switchingTraffic) current() string {
50- s.mu.RLock()
51- defer s.mu.RUnlock()
52- return s.backend
53-}
54-
55-// TestDeploy_HTTPZeroDowntimeBehavior exercises the observable cutover contract:
56-// requests keep reaching the old backend while candidate readiness is delayed,
57-// traffic changes only after readiness, and retirement follows the switch.
58-func TestDeploy_HTTPZeroDowntimeBehavior(t *testing.T) {
59- ctx := context.Background()
60- state, runtime, images, secrets := mockDeps(t)
61- rev := mockRevision()
62- rev.Spec.Services[0].StopGrace = time.Millisecond
63- expectDeployPreflight(state, runtime, images, secrets, rev)
64- expectNetworkProvision(runtime, "app-blog", 1)
65- state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
66- "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
67- }}, true, nil)
68- state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
69- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
70- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
71- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
72- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
73- state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
74- "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
75- }}, true, nil)
76- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
77- state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once()
78- state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
79- state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
80-
81- ready := make(chan struct{})
82- traffic := &switchingTraffic{backend: "old", ready: ready}
83- retired := make(chan struct{})
84- runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error {
85- assert.Equal(t, "new", traffic.current(), "old backend retired before traffic switched")
86- close(retired)
87- return nil
88- }).Once()
89- runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
90-
91- front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
92- _, _ = io.WriteString(w, traffic.current())
93- }))
94- defer front.Close()
95-
96- probeStarted := make(chan struct{})
97- svc := deployment.NewService(deployment.Deps{
98- State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,
99- }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
100- func(context.Context, string) (int, error) {
101- select {
102- case <-probeStarted:
103- default:
104- close(probeStarted)
105- }
106- <-ready
107- return http.StatusOK, nil
108- },
109- func(context.Context, string) error { return nil },
110- ))
111-
112- done := make(chan error, 1)
113- go func() {
114- _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
115- done <- err
116- }()
117- select {
118- case <-probeStarted:
119- case err := <-done:
120- require.NoError(t, err)
121- t.Fatal("deploy completed before readiness probe started")
122- case <-time.After(5 * time.Second):
123- t.Fatal("timed out waiting for readiness probe")
124- }
125- for range 20 {
126- resp, err := http.Get(front.URL) //nolint:noctx // bounded local test server
127- require.NoError(t, err)
128- body, readErr := io.ReadAll(resp.Body)
129- resp.Body.Close()
130- require.NoError(t, readErr)
131- assert.Equal(t, http.StatusOK, resp.StatusCode)
132- assert.Equal(t, "old", string(body))
133- }
134- close(ready)
135- require.NoError(t, <-done)
136- <-retired
137- resp, err := http.Get(front.URL) //nolint:noctx // bounded local test server
138- require.NoError(t, err)
139- defer resp.Body.Close()
140- body, err := io.ReadAll(resp.Body)
141- require.NoError(t, err)
142- assert.Equal(t, "new", string(body))
143-}
M internal/usecase/deployment/internal_readiness_test.go +2 -2
@@ -327,7 +327,7 @@ func TestWaitServiceReady_InternalUsesNetworkPath(t *testing.T) {
327 assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network)327 assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network)
328 return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil328 return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
329 },329 },
330- httpGet: func(context.Context, string) (int, error) {330+ httpGet: func(context.Context, string, string) (int, error) {
331 loopbackCalls++331 loopbackCalls++
332 return 200, nil332 return 200, nil
333 },333 },
@@ -346,7 +346,7 @@ func TestWaitServiceReady_PublicKeepsLoopback(t *testing.T) {
346 loopbackCalls := 0346 loopbackCalls := 0
347 service := NewService(Deps{}, zerowrap.Default()).347 service := NewService(Deps{}, zerowrap.Default()).
348 WithProbeDeps(ProbeDeps{348 WithProbeDeps(ProbeDeps{
349- httpGet: func(context.Context, string) (int, error) {349+ httpGet: func(context.Context, string, string) (int, error) {
350 loopbackCalls++350 loopbackCalls++
351 return 200, nil351 return 200, nil
352 },352 },
M internal/usecase/deployment/lifecycle.go +123 -209
@@ -44,6 +44,9 @@ func (s *Service) stopLocked(ctx context.Context, app, opID string) (*LifecycleR
44 if err := s.deps.State.Recover(ctx); err != nil {44 if err := s.deps.State.Recover(ctx); err != nil {
45 return nil, fmt.Errorf("deployment: recover before stop: %w", err)45 return nil, fmt.Errorf("deployment: recover before stop: %w", err)
46 }46 }
47+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
48+ return nil, err
49+ }
47 op := domain.AppOperation{50 op := domain.AppOperation{
48 Kind: "stop", App: app,51 Kind: "stop", App: app,
49 StartedAt: time.Now().UTC(),52 StartedAt: time.Now().UTC(),
@@ -147,6 +150,9 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
147 if err := s.deps.State.Recover(ctx); err != nil {150 if err := s.deps.State.Recover(ctx); err != nil {
148 return nil, fmt.Errorf("deployment: recover before start: %w", err)151 return nil, fmt.Errorf("deployment: recover before start: %w", err)
149 }152 }
153+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
154+ return nil, err
155+ }
150 op := domain.AppOperation{156 op := domain.AppOperation{
151 Kind: "start", App: app,157 Kind: "start", App: app,
152 StartedAt: time.Now().UTC(),158 StartedAt: time.Now().UTC(),
@@ -176,12 +182,12 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
176 result := &LifecycleResult{Op: op.Op, App: app, Verb: "start", Services: map[string]ServiceResult{}}182 result := &LifecycleResult{Op: op.Op, App: app, Verb: "start", Services: map[string]ServiceResult{}}
177 for _, name := range sortedServiceNames(active) {183 for _, name := range sortedServiceNames(active) {
178 eff := active.Services[name]184 eff := active.Services[name]
179- step := domain.AppOperationStep{ID: "service." + name + ".start", State: domain.AppStepPending, Before: eff.Container}185+ // The step is journaled before the runtime work, so a candidate
180- if s.ensureServiceRunning(ctx, app, opID, name, eff, &step, result) {186+ // created by a redeploy is durably recorded as soon as it exists.
181- op.Steps = append(op.Steps, step)187+ op.Steps = append(op.Steps, domain.AppOperationStep{
182- continue188+ ID: "service." + name + ".start", State: domain.AppStepPending, Before: eff.Container, Service: name,
183- }189+ })
184- op.Steps = append(op.Steps, step)190+ s.ensureServiceRunning(ctx, app, opID, name, eff, &op, len(op.Steps)-1, result)
185 }191 }
186 op.Outcome = ComputeOutcome(result.Services)192 op.Outcome = ComputeOutcome(result.Services)
187 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))193 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))
@@ -196,10 +202,10 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
196 }202 }
197 203
198 // Restart restarts from pinned digests without re-resolution. Empty204 // Restart restarts from pinned digests without re-resolution. Empty
199-// service means all services in sorted order. HTTP-only, volume-less205+// service means all services in sorted order. Every service restarts in
200-// services restart with zero downtime: the old container keeps serving206+// place: traffic is withdrawn, the same pinned container is restarted, its
201-// until the candidate passes readiness and traffic has moved to it.207+// readiness probe is checked, and traffic is republished. No second
202-// HTTP-with-volume, TCP/UDP, and mixed services restart in place.208+// container is created and the pinned digest never changes.
203 func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) {209 func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) {
204 release, err := s.acquireAppContext(ctx, app)210 release, err := s.acquireAppContext(ctx, app)
205 if err != nil {211 if err != nil {
@@ -214,6 +220,9 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
214 if err := s.deps.State.Recover(ctx); err != nil {220 if err := s.deps.State.Recover(ctx); err != nil {
215 return nil, fmt.Errorf("deployment: recover before restart: %w", err)221 return nil, fmt.Errorf("deployment: recover before restart: %w", err)
216 }222 }
223+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
224+ return nil, err
225+ }
217 active, ok, err := s.deps.State.LoadActive(ctx, app)226 active, ok, err := s.deps.State.LoadActive(ctx, app)
218 if err != nil {227 if err != nil {
219 return nil, fmt.Errorf("deployment: load active: %w", err)228 return nil, fmt.Errorf("deployment: load active: %w", err)
@@ -244,14 +253,12 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
244 var failures []string253 var failures []string
245 for _, name := range names {254 for _, name := range names {
246 eff := active.Services[name]255 eff := active.Services[name]
247- var step domain.AppOperationStep256+ // The step is journaled before the runtime work, so a candidate
248- var svcResult ServiceResult257+ // created by a rebuild of a missing generation is durably recorded.
249- if httpEligible(eff.Spec) {258+ op.Steps = append(op.Steps, domain.AppOperationStep{
250- step, svcResult = s.restartOneHTTPService(ctx, app, name, eff, op.Op)259+ ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container, Service: name,
251- } else {260+ })
252- step, svcResult = s.restartOneService(ctx, app, name, eff)261+ svcResult := s.restartOneService(ctx, app, op.Op, name, eff, &op, len(op.Steps)-1)
253- }
254- op.Steps = append(op.Steps, step)
255 result.Services[name] = svcResult262 result.Services[name] = svcResult
256 if svcResult.Result != "deployed" {263 if svcResult.Result != "deployed" {
257 failures = append(failures, name)264 failures = append(failures, name)
@@ -272,141 +279,21 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
272 return result, nil279 return result, nil
273 }280 }
274 281
275-// restartOneHTTPService restarts one HTTP-only, volume-less service with
276-// zero downtime, gated by the same httpEligible rule as deploy. The old
277-// container keeps ACTIVE and serving while a candidate is created and
278-// started from the pinned ACTIVE digest and revision, and readiness is
279-// awaited against its fresh loopback binds. Only then is ACTIVE narrowly
280-// repointed at the candidate (carrying the restart operation identity and
281-// the unchanged revision, so convergence metadata stays intact), traffic
282-// applied, and the old generation drained and retired.
283-//
284-// Every failure before the repoint leaves the old generation ACTIVE and
285-// serving and removes only the candidate: the restart never trades a
286-// working generation for an unverified one.
287-func (s *Service) restartOneHTTPService(ctx context.Context, app, name string, eff domain.AppEffectiveService, opID string) (domain.AppOperationStep, ServiceResult) {
288- step := domain.AppOperationStep{ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container}
289- result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container}
290- fail := func(msg string) (domain.AppOperationStep, ServiceResult) {
291- step.State = domain.AppStepFailed
292- step.Error = msg
293- result.Result = "failed"
294- result.Error = msg
295- return step, result
296- }
297- if eff.Container == "" {
298- result.After = ""
299- return fail("no active container")
300- }
301- // A durably inhibited generation is never revived, not even as the
302- // candidate's predecessor: its replacement may already own its data.
303- if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {
304- return fail(err.Error())
305- }
306- rev, err := s.deps.State.LoadRevision(ctx, app, eff.EffectiveRevision)
307- if err != nil {
308- return fail(err.Error())
309- }
310- runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest)
311- if err != nil {
312- return fail(err.Error())
313- }
314- if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
315- return fail(err.Error())
316- }
317- pinned := pinnedService{
318- name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage,
319- appEnv: maps.Clone(rev.Spec.Env),
320- appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
321- sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),
322- }
323- created, binds, udpBinds, err := s.createAndStart(ctx, app, eff.EffectiveRevision, pinned, opID)
324- if err != nil {
325- return fail(err.Error())
326- }
327- if err := s.waitServiceReady(ctx, app, created.ID, httpReadiness(eff.Spec), binds); err != nil {
328- // The old generation is still ACTIVE and serving: capture redacted
329- // diagnostics while the failed candidate exists, then remove only
330- // that candidate.
331- tail := s.redactDiagnostics(ctx, app, pinned, s.logTail(ctx, created.ID))
332- step.State = domain.AppStepFailed
333- step.Error = err.Error()
334- step.Diagnostics = tail
335- result.Error = err.Error()
336- result.Diagnostics = tail
337- result.CleanupWarnings = s.retireCandidate(ctx, app, name, created.ID)
338- return step, result
339- }
340- result = ServiceResult{
341- Result: "deployed",
342- EffectiveRevision: eff.EffectiveRevision,
343- Before: eff.Container,
344- After: created.ID,
345- BackendBinds: binds,
346- UDPBackendBinds: udpBinds,
347- }
348- // Narrow ACTIVE repoint: only this service's effective entry moves.
349- // ActivatedBy is the restart operation, ActivatedAt is refreshed, and
350- // the pinned revision is unchanged, so ConvergedRevision/Converged
351- // stay truthful for the app.
352- if err := s.publishService(ctx, app, eff.EffectiveRevision, pinned, result, opID); err != nil {
353- // Publication can fail before ACTIVE is written (the candidate was
354- // never published, so remove it and keep the old generation
355- // serving) or after it is written (a post-save failure such as
356- // ownership persistence: ACTIVE now names the candidate, which must
357- // keep running). An unprovable publication state keeps the
358- // candidate: an unconfirmed leftover is safer than retiring the
359- // live generation.
360- if s.candidateUnpublished(ctx, app, name, created.ID) {
361- result.CleanupWarnings = append(result.CleanupWarnings, s.retireCandidate(ctx, app, name, created.ID)...)
362- }
363- return fail(err.Error())
364- }
365- step.After = created.ID
366- // Traffic is applied before the old generation drains: the proxy only
367- // switches to the candidate's verified loopback binds at the rebuild,
368- // and only a routable candidate may cost the old generation its drain
369- // window. A traffic failure retains the old container for the next
370- // convergence pass.
371- if err := s.refreshTraffic(ctx, app); err != nil {
372- if inhibitErr := s.inhibitRecovery(ctx, app, name, eff.Container, domain.AppInhibitRetirementPending, opID); inhibitErr != nil {
373- return fail(errors.Join(err, inhibitErr).Error())
374- }
375- return fail(err.Error())
376- }
377- drainWithDeadline(ctx, eff.Spec.StopGrace)
378- retired := s.retireContainer(ctx, app, retireOptions{Service: name, Grace: serviceStopGrace(eff)}, eff.Container)
379- result.CleanupWarnings = append(result.CleanupWarnings, retired.Warnings...)
380- step.State = domain.AppStepSucceeded
381- step.After = created.ID
382- return step, result
383-}
384-
385-// candidateUnpublished reports whether a candidate that failed
386-// publication is provably not the app's current generation. It is only
387-// true when ACTIVE loads and designates some other container for the
388-// service: a candidate whose state cannot be read is treated as possibly
389-// published and retained, never removed.
390-func (s *Service) candidateUnpublished(ctx context.Context, app, service, containerID string) bool {
391- active, ok, err := s.deps.State.LoadActive(ctx, app)
392- if err != nil || !ok {
393- return false
394- }
395- return active.Services[service].Container != containerID
396-}
397-
398 // restartOneService withdraws one service, restarts its exact container,282 // restartOneService withdraws one service, restarts its exact container,
399 // re-inspects its binds, and verifies readiness before it may be published283 // re-inspects its binds, and verifies readiness before it may be published
400 // again. Every failure leaves the service withdrawn with its recorded binds284 // again. Every failure leaves the service withdrawn with its recorded binds
401-// cleared, so a failed restart is never served.285+// cleared, so a failed restart is never served. A generation whose recorded
402-func (s *Service) restartOneService(ctx context.Context, app, name string, eff domain.AppEffectiveService) (domain.AppOperationStep, ServiceResult) {286+// container no longer exists is rebuilt from the pinned ACTIVE digest, so a
403- step := domain.AppOperationStep{ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container}287+// failed replacement cannot leave restart as a permanent dead end.
288+func (s *Service) restartOneService(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, op *domain.AppOperation, index int) ServiceResult {
289+ step := &op.Steps[index]
404 result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container}290 result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container}
405- fail := func(msg string) (domain.AppOperationStep, ServiceResult) {291+ fail := func(msg string) ServiceResult {
406 step.State = domain.AppStepFailed292 step.State = domain.AppStepFailed
407 step.Error = msg293 step.Error = msg
294+ result.Result = "failed"
408 result.Error = msg295 result.Error = msg
409- return step, result296+ return result
410 }297 }
411 if eff.Container == "" {298 if eff.Container == "" {
412 result.After = ""299 result.After = ""
@@ -427,6 +314,30 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
427 return fail(err.Error())314 return fail(err.Error())
428 }315 }
429 if err := s.deps.Runtime.RestartContainer(ctx, eff.Container, serviceStopGrace(eff)); err != nil {316 if err := s.deps.Runtime.RestartContainer(ctx, eff.Container, serviceStopGrace(eff)); err != nil {
317+ if errors.Is(err, domain.ErrContainerNotFound) {
318+ // The recorded generation is gone: rebuild and publish it from the
319+ // pinned ACTIVE digest instead of leaving the service withdrawn
320+ // until a deploy.
321+ svcResult, rebuildErr := s.redeployPinned(ctx, app, opID, name, eff, s.journalCandidate(op, index))
322+ if rebuildErr != nil {
323+ svcResult.Error = rebuildErr.Error()
324+ step.State = domain.AppStepFailed
325+ step.Error = rebuildErr.Error()
326+ step.Diagnostics = svcResult.Diagnostics
327+ // The recorded container is proven gone: the inhibition the
328+ // rebuild wrote for it protects nothing, and keeping it would
329+ // refuse every later start, recovery pass, and restart.
330+ if clearErr := s.clearRecoveryInhibition(ctx, app, name, eff.Container); clearErr != nil {
331+ svcResult.CleanupWarnings = append(svcResult.CleanupWarnings, CleanupWarning{
332+ Service: name, Leftover: eff.Container, Detail: "clear recovery inhibition: " + clearErr.Error(),
333+ })
334+ }
335+ return svcResult
336+ }
337+ step.State = domain.AppStepSucceeded
338+ step.After = svcResult.After
339+ return svcResult
340+ }
430 s.clearServiceBinds(ctx, app, name)341 s.clearServiceBinds(ctx, app, name)
431 return fail(err.Error())342 return fail(err.Error())
432 }343 }
@@ -438,13 +349,13 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
438 s.clearServiceBinds(ctx, app, name)349 s.clearServiceBinds(ctx, app, name)
439 return fail(err.Error())350 return fail(err.Error())
440 }351 }
441- if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {352+ if err := s.waitServiceReady(ctx, app, eff.Container, deploymentReadiness(eff.Spec), binds); err != nil {
442 s.clearServiceBinds(ctx, app, name)353 s.clearServiceBinds(ctx, app, name)
443 return fail(err.Error())354 return fail(err.Error())
444 }355 }
445 step.State = domain.AppStepSucceeded356 step.State = domain.AppStepSucceeded
446 step.After = eff.Container357 step.After = eff.Container
447- return step, ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}358+ return ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}
448 }359 }
449 360
450 // Remove withdraws workloads by exact container ID; volumes, secrets, and361 // Remove withdraws workloads by exact container ID; volumes, secrets, and
@@ -468,6 +379,9 @@ func (s *Service) removeLocked(ctx context.Context, app, opID string) (*Lifecycl
468 if err := s.deps.State.Recover(ctx); err != nil {379 if err := s.deps.State.Recover(ctx); err != nil {
469 return nil, fmt.Errorf("deployment: recover before remove: %w", err)380 return nil, fmt.Errorf("deployment: recover before remove: %w", err)
470 }381 }
382+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
383+ return nil, err
384+ }
471 op := domain.AppOperation{385 op := domain.AppOperation{
472 Kind: "remove", App: app,386 Kind: "remove", App: app,
473 StartedAt: time.Now().UTC(),387 StartedAt: time.Now().UTC(),
@@ -643,9 +557,18 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
643 return fmt.Errorf("deployment: load active: %w", err)557 return fmt.Errorf("deployment: load active: %w", err)
644 }558 }
645 if !ok || len(active.Services) == 0 {559 if !ok || len(active.Services) == 0 {
646- return nil560+ // An interrupted operation is reconciled even when ACTIVE carries no
561+ // service: a first deployment can be interrupted between creating its
562+ // candidate and publishing it. No start follows here, so this is the
563+ // only reconciliation of the boot pass.
564+ return s.reconcileInterruptedDeploy(ctx, app)
647 }565 }
648 if intent.Stopped {566 if intent.Stopped {
567+ // A stopped app does not start, so its unfinished operation is
568+ // reconciled here instead.
569+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
570+ return err
571+ }
649 for _, name := range sortedServiceNames(active) {572 for _, name := range sortedServiceNames(active) {
650 if err := s.convergeStoppedService(ctx, app, name, active.Services[name]); err != nil {573 if err := s.convergeStoppedService(ctx, app, name, active.Services[name]); err != nil {
651 return fmt.Errorf("deployment: boot stopped convergence %q/%q: %w", app, name, err)574 return fmt.Errorf("deployment: boot stopped convergence %q/%q: %w", app, name, err)
@@ -653,6 +576,8 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
653 }576 }
654 return nil577 return nil
655 }578 }
579+ // startLocked reconciles the app's unfinished operation before it starts
580+ // anything, so the boot pass reconciles exactly once.
656 result, err := s.startLocked(ctx, app, "")581 result, err := s.startLocked(ctx, app, "")
657 if err != nil {582 if err != nil {
658 return fmt.Errorf("deployment: boot start %q: %w", app, err)583 return fmt.Errorf("deployment: boot start %q: %w", app, err)
@@ -670,16 +595,16 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
670 // may shift across any runtime restart (stop/start, daemon absence,595 // may shift across any runtime restart (stop/start, daemon absence,
671 // host reboot), so the recorded binds are re-inspected and persisted596 // host reboot), so the recorded binds are re-inspected and persisted
672 // before the proxy can dial them. Verification failure fails the step;597 // before the proxy can dial them. Verification failure fails the step;
673-// a stale recorded bind is never served. It reports whether598+// a stale recorded bind is never served. A recorded container that no
674-// the caller should continue with the next service (true) or finish the599+// longer exists is rebuilt from the revision pinned in ACTIVE.
675-// step inline (false, reserved for future fallible publishes).600+func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, op *domain.AppOperation, index int, result *LifecycleResult) {
676-func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool {601+ step := &op.Steps[index]
677 if eff.Container != "" {602 if eff.Container != "" {
678 // Fail closed before any runtime mutation if a bind's policy was603 // Fail closed before any runtime mutation if a bind's policy was
679 // revoked or became invalid since ACTIVE was published.604 // revoked or became invalid since ACTIVE was published.
680 if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {605 if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
681 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())606 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
682- return true607+ return
683 }608 }
684 // Boot recovery refuses a generation whose recovery is durably609 // Boot recovery refuses a generation whose recovery is durably
685 // inhibited: a replacement may already have written to its610 // inhibited: a replacement may already have written to its
@@ -687,106 +612,96 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri
687 inhibited, err := s.recoveryInhibited(ctx, app, name, eff.Container)612 inhibited, err := s.recoveryInhibited(ctx, app, name, eff.Container)
688 if err != nil {613 if err != nil {
689 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())614 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
690- return true615+ return
691 }616 }
692 if inhibited {617 if inhibited {
693 s.failServiceStep(ctx, app, name, eff, step, result,618 s.failServiceStep(ctx, app, name, eff, step, result,
694 fmt.Sprintf("recovery inhibited for container %s", eff.Container))619 fmt.Sprintf("recovery inhibited for container %s", eff.Container))
695- return true620+ return
696 }621 }
697 if ok, err := s.deps.Runtime.IsContainerRunning(ctx, eff.Container); err == nil && ok {622 if ok, err := s.deps.Runtime.IsContainerRunning(ctx, eff.Container); err == nil && ok {
698- return s.verifyRunningService(ctx, app, name, eff, step, result)623+ s.verifyRunningService(ctx, app, name, eff, step, result)
624+ return
699 }625 }
700 // Restart by exact container ID when the runtime still has it.626 // Restart by exact container ID when the runtime still has it.
701 if err := s.deps.Runtime.StartContainer(ctx, eff.Container); err == nil {627 if err := s.deps.Runtime.StartContainer(ctx, eff.Container); err == nil {
702- return s.verifyRunningService(ctx, app, name, eff, step, result)628+ s.verifyRunningService(ctx, app, name, eff, step, result)
629+ return
703 }630 }
704 }631 }
705- // Otherwise redeploy from the pinned active digest.632+ // Otherwise rebuild from the revision pinned in ACTIVE.
706- rev, err := s.deps.State.LoadRevision(ctx, app, eff.EffectiveRevision)633+ svcResult, err := s.redeployPinned(ctx, app, opID, name, eff, s.journalCandidate(op, index))
707 if err != nil {634 if err != nil {
708 step.State = domain.AppStepFailed635 step.State = domain.AppStepFailed
709 step.Error = err.Error()636 step.Error = err.Error()
710- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}637+ step.Diagnostics = svcResult.Diagnostics
711- return true638+ result.Services[name] = svcResult
639+ return
640+ }
641+ step.State = domain.AppStepSucceeded
642+ step.After = svcResult.After
643+ result.Services[name] = svcResult
644+}
645+
646+// redeployPinned rebuilds one service from the revision pinned in ACTIVE and
647+// publishes it. It is the shared recovery path for a recorded container that
648+// no longer exists: boot/start recovery, and a restart of a missing
649+// generation. The returned result is always populated, so a caller can
650+// journal the attempt even when err is non-nil.
651+func (s *Service) redeployPinned(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, journal candidateJournal) (ServiceResult, error) {
652+ rev, err := s.deps.State.LoadRevision(ctx, app, eff.EffectiveRevision)
653+ if err != nil {
654+ return failedServiceResult(eff.Container, err), err
712 }655 }
713 runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest)656 runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest)
714 if err != nil {657 if err != nil {
715- step.State = domain.AppStepFailed658+ return failedServiceResult(eff.Container, err), err
716- step.Error = err.Error()
717- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
718- return true
719 }659 }
720 if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {660 if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
721- step.State = domain.AppStepFailed661+ return failedServiceResult(eff.Container, err), err
722- step.Error = err.Error()
723- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
724- return true
725 }662 }
726 pinned := pinnedService{663 pinned := pinnedService{
727 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),664 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),
728 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),665 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
729 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),666 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),
730 }667 }
731- svcResult, _ := s.deployService(ctx, app, rev.Revision, pinned, opID, eff.Container, serviceStopGrace(eff))668+ svcResult := s.deployService(ctx, app, rev.Revision, pinned, opID, eff.Container, serviceStopGrace(eff), journal)
732 if svcResult.Result == "failed" {669 if svcResult.Result == "failed" {
733- step.State = domain.AppStepFailed670+ return svcResult, errors.New(svcResult.Error)
734- step.Error = svcResult.Error
735- step.Diagnostics = svcResult.Diagnostics
736- result.Services[name] = svcResult
737- return true
738 }671 }
739- result.Services[name] = svcResult672+ // The step may only be recorded as succeeded once the effective state is
740- // The step is recorded as succeeded only after the effective state is673+ // published: a journaled success is never ahead of what the proxy reaches.
741- // published: a journaled success is never ahead of what the proxy can
742- // reach.
743 if err := s.publishService(ctx, app, rev.Revision, pinned, svcResult, opID); err != nil {674 if err := s.publishService(ctx, app, rev.Revision, pinned, svcResult, opID); err != nil {
744- step.State = domain.AppStepFailed675+ svcResult.Result = "failed"
745- step.Error = err.Error()676+ svcResult.Error = err.Error()
746- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}677+ return svcResult, err
747- return true
748- }
749- // The replaced container is only retired once the replacement is
750- // published: it must not be removed while a crash could leave the
751- // service with no routable generation at all.
752- if svcResult.Retire != "" {
753- s.retireAfterPublish(ctx, app, pinned, &svcResult, result)
754 }678 }
755- step.State = domain.AppStepSucceeded679+ return svcResult, nil
756- step.After = svcResult.After
757- return true
758 }680 }
759 681
760-// retireAfterPublish retires the container a published replacement682+// failedServiceResult builds the terminal result of a service step that
761-// superseded. The replaced generation keeps its inhibition: the new683+// failed before any container existed.
762-// generation's publication already cleared it, and a still-unpublished684+func failedServiceResult(before string, err error) ServiceResult {
763-// replacement never reaches here.685+ return ServiceResult{Result: "failed", Before: before, Error: err.Error()}
764-func (s *Service) retireAfterPublish(ctx context.Context, app string, p pinnedService, svcResult *ServiceResult, result *LifecycleResult) {
765- retired := s.retireContainer(ctx, app, retireOptions{
766- Service: p.name, Grace: svcResult.RetireGrace,
767- }, svcResult.Retire)
768- service := result.Services[p.name]
769- service.CleanupWarnings = append(service.CleanupWarnings, retired.Warnings...)
770- result.Services[p.name] = service
771 }686 }
772 687
773 // verifyRunningService withdraws a running or freshly started generation,688 // verifyRunningService withdraws a running or freshly started generation,
774 // re-inspects its loopback binds, and verifies readiness before the service689 // re-inspects its loopback binds, and verifies readiness before the service
775 // may be published again. Any failure leaves the service withdrawn with its690 // may be published again. Any failure leaves the service withdrawn with its
776 // recorded binds cleared, so a not-ready backend is never projected.691 // recorded binds cleared, so a not-ready backend is never projected.
777-func (s *Service) verifyRunningService(ctx context.Context, app, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool {692+func (s *Service) verifyRunningService(ctx context.Context, app, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) {
778 if err := s.withdrawForRecovery(ctx, app, name); err != nil {693 if err := s.withdrawForRecovery(ctx, app, name); err != nil {
779 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())694 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
780- return true695+ return
781 }696 }
782 binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)697 binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
783 if err != nil {698 if err != nil {
784 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())699 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
785- return true700+ return
786 }701 }
787 if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {702 if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {
788 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())703 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
789- return true704+ return
790 }705 }
791 // The service is running and its re-inspected loopback binds are706 // The service is running and its re-inspected loopback binds are
792 // persisted, so the step may succeed: the graph apply for this app707 // persisted, so the step may succeed: the graph apply for this app
@@ -794,7 +709,6 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
794 step.State = domain.AppStepSucceeded709 step.State = domain.AppStepSucceeded
795 step.After = eff.Container710 step.After = eff.Container
796 result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}711 result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}
797- return true
798 }712 }
799 713
800 // refreshBackendBinds re-inspects the loopback publishes of a restarted714 // refreshBackendBinds re-inspects the loopback publishes of a restarted
M internal/usecase/deployment/networks_internal_test.go +5 -5
@@ -54,7 +54,7 @@ func TestCreateAndStart_UsesIncarnationPrivateNetworkAndLimits(t *testing.T) {
54 ResourceLimits{MemoryBytes: 1 << 30, NanoCPUs: 2_000_000_000, PidsLimit: 256},54 ResourceLimits{MemoryBytes: 1 << 30, NanoCPUs: 2_000_000_000, PidsLimit: 256},
55 )55 )
56 created, binds, udp, err := svc.createAndStart(ctx, "blog", "rev-1",56 created, binds, udp, err := svc.createAndStart(ctx, "blog", "rev-1",
57- pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1")57+ pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil)
58 require.NoError(t, err)58 require.NoError(t, err)
59 require.NotNil(t, created)59 require.NotNil(t, created)
60 assert.Empty(t, binds)60 assert.Empty(t, binds)
@@ -80,7 +80,7 @@ func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) {
80 80
81 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})81 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
82 _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1",82 _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1",
83- pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1")83+ pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil)
84 require.NoError(t, err)84 require.NoError(t, err)
85 }85 }
86 86
@@ -100,7 +100,7 @@ func TestCreateAndStart_RefusesForeignNetwork(t *testing.T) {
100 100
101 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})101 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
102 _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1",102 _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1",
103- pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1")103+ pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil)
104 require.ErrorIs(t, err, domain.ErrAppStateConflict)104 require.ErrorIs(t, err, domain.ErrAppStateConflict)
105 }105 }
106 106
@@ -130,7 +130,7 @@ func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) {
130 name: "web",130 name: "web",
131 spec: domain.AppService{Name: "web"},131 spec: domain.AppService{Name: "web"},
132 sharedNetworks: []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}}},132 sharedNetworks: []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}}},
133- }, "op-1")133+ }, "op-1", nil)
134 require.NoError(t, err)134 require.NoError(t, err)
135 }135 }
136 136
@@ -163,7 +163,7 @@ func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T
163 Name: "web",163 Name: "web",
164 Volumes: []domain.AppVolume{{Name: "config", Path: "/config", ReadOnly: true}},164 Volumes: []domain.AppVolume{{Name: "config", Path: "/config", ReadOnly: true}},
165 },165 },
166- }, "op-1")166+ }, "op-1", nil)
167 require.NoError(t, err)167 require.NoError(t, err)
168 }168 }
169 169
A internal/usecase/deployment/preflight_gate_test.go +289 -0
@@ -0,0 +1,289 @@
1+package deployment_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/internal/usecase/deployment"
16+)
17+
18+// recordSavedOperations captures every journal write so a test can assert the
19+// terminal preflight outcome and that no effect ran before the gate failed.
20+func recordSavedOperations(state *outmocks.MockAppState) *[]domain.AppOperation {
21+ saved := &[]domain.AppOperation{}
22+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
23+ func(_ context.Context, op domain.AppOperation) error {
24+ *saved = append(*saved, op)
25+ return nil
26+ })
27+ return saved
28+}
29+
30+// assertNoDeployMutation proves a gate that failed before execution never
31+// touched the workload: no container is created, started, stopped, or
32+// removed, no volume is created, and ACTIVE is never republished.
33+func assertNoDeployMutation(t *testing.T, runtime *outmocks.MockContainerRuntime, state *outmocks.MockAppState) {
34+ t.Helper()
35+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
36+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
37+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
38+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
39+ runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything, mock.Anything)
40+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
41+}
42+
43+// assertPreflightGateJournal proves a failed preflight gate left a terminal
44+// failed journal whose preflight step carries the failure.
45+func assertPreflightGateJournal(t *testing.T, saved []domain.AppOperation) {
46+ t.Helper()
47+ require.NotEmpty(t, saved, "a failed gate must journal the claim and its failure")
48+ last := saved[len(saved)-1]
49+ require.True(t, last.Terminal(), "a failed preflight gate must persist a terminal journal")
50+ assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
51+ step, ok := opStep(last, "preflight")
52+ require.True(t, ok)
53+ assert.Equal(t, domain.AppStepFailed, step.State)
54+}
55+
56+// TestDeploy_MissingRequiredSecretFailsClosed proves a deploy whose required
57+// secret cannot be resolved fails closed with ErrAppSecretMissing before any
58+// workload mutation, and journals the terminal failure.
59+func TestDeploy_MissingRequiredSecretFailsClosed(t *testing.T) {
60+ ctx := context.Background()
61+ state, runtime, images, secrets := mockDeps(t)
62+ rev := mockRevision()
63+
64+ state.EXPECT().Recover(mock.Anything).Return(nil)
65+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
66+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
67+ images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
68+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("", assert.AnError).Once()
69+ saved := recordSavedOperations(state)
70+
71+ svc := keyedService(t, state, runtime, images, secrets)
72+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
73+
74+ require.ErrorIs(t, err, domain.ErrAppSecretMissing)
75+ assertNoDeployMutation(t, runtime, state)
76+ assertPreflightGateJournal(t, *saved)
77+}
78+
79+// TestDeploy_EnvSecretCollisionFailsClosed proves a stored revision whose
80+// public env collides with one of the service's secret keys is refused
81+// before any image resolution or workload mutation.
82+func TestDeploy_EnvSecretCollisionFailsClosed(t *testing.T) {
83+ ctx := context.Background()
84+ state, runtime, images, secrets := mockDeps(t)
85+ rev := mockRevision()
86+ rev.Spec.Env = map[string]string{"DATABASE_URL": "public-value"}
87+
88+ state.EXPECT().Recover(mock.Anything).Return(nil)
89+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
90+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
91+ saved := recordSavedOperations(state)
92+
93+ svc := keyedService(t, state, runtime, images, secrets)
94+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
95+
96+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
97+ images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
98+ secrets.AssertNotCalled(t, "GetSecret", mock.Anything, mock.Anything)
99+ assertNoDeployMutation(t, runtime, state)
100+ assertPreflightGateJournal(t, *saved)
101+}
102+
103+// TestDeploy_UnmanagedImageVolumeRejected proves an image that declares a
104+// VOLUME with no operator mapping is refused before any workload mutation,
105+// and journals the terminal failure.
106+func TestDeploy_UnmanagedImageVolumeRejected(t *testing.T) {
107+ ctx := context.Background()
108+ state, runtime, images, secrets := mockDeps(t)
109+ rev := mockRevision()
110+
111+ state.EXPECT().Recover(mock.Anything).Return(nil)
112+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
113+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
114+ images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once()
115+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once()
116+ runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return([]string{"/data"}, nil).Once()
117+ saved := recordSavedOperations(state)
118+
119+ svc := keyedService(t, state, runtime, images, secrets)
120+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
121+
122+ require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume)
123+ assertNoDeployMutation(t, runtime, state)
124+ assertPreflightGateJournal(t, *saved)
125+}
126+
127+// TestDeploy_RefusesUnownedExistingVolume proves R2: a generated runtime
128+// volume that already exists but is NOT recorded in the app's ownership is
129+// refused fail-closed — the deploy must never implicitly adopt (and modify)
130+// foreign data under a reused name.
131+func TestDeploy_RefusesUnownedExistingVolume(t *testing.T) {
132+ ctx := context.Background()
133+ state, runtime, images, secrets := mockDeps(t)
134+ svcSpec := webService()
135+ svcSpec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
136+ rev := testRevision("blog", svcSpec)
137+ runtimeName := domain.RuntimeVolumeName("blog", "web", "data")
138+
139+ state.EXPECT().Recover(mock.Anything).Return(nil)
140+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
141+ // Ownership records no volume, so the runtime one is foreign.
142+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
143+ images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil).Once()
144+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once()
145+ runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil).Once()
146+ state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
147+ runtime.EXPECT().VolumeExists(mock.Anything, runtimeName).Return(true, nil).Once()
148+ saved := recordSavedOperations(state)
149+
150+ svc := keyedService(t, state, runtime, images, secrets)
151+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
152+
153+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
154+ require.Contains(t, err.Error(), "not owned by app")
155+ assertNoDeployMutation(t, runtime, state)
156+ assertPreflightGateJournal(t, *saved)
157+}
158+
159+// TestStartDeploy_TargetedDiffPolicy proves the targeted-deploy convergence
160+// rules live in the claim phase: a requested-service image change is allowed
161+// and claims the operation, while app-wide or other-service divergence is
162+// refused with ErrAppStateConflict before any claim is written and without
163+// resolving an image or touching the runtime.
164+func TestStartDeploy_TargetedDiffPolicy(t *testing.T) {
165+ baseService := webService()
166+ worker := webService()
167+ worker.Name = "worker"
168+ worker.HTTP = []domain.AppHTTPInterface{{Host: "worker.example.com", Port: 8080, TLS: domain.AppTLSAuto}}
169+ base := domain.AppActive{
170+ App: "blog", ConvergedRevision: "rev-1", Converged: true,
171+ Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}},
172+ Services: map[string]domain.AppEffectiveService{
173+ "web": {Spec: baseService},
174+ "worker": {Spec: worker},
175+ },
176+ }
177+
178+ tests := []struct {
179+ name string
180+ mutate func(*domain.AppSpec)
181+ wantErr bool
182+ }{
183+ {name: "requested service image", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }},
184+ {name: "other service", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }},
185+ {name: "environment", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }},
186+ {name: "network", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }},
187+ {name: "service added", wantErr: true, mutate: func(spec *domain.AppSpec) {
188+ spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
189+ }},
190+ }
191+ for _, tc := range tests {
192+ t.Run(tc.name, func(t *testing.T) {
193+ ctx := context.Background()
194+ state, runtime, images, secrets := mockDeps(t)
195+ spec := domain.AppSpec{
196+ Name: "blog",
197+ Networks: append([]domain.AppSharedNetwork(nil), base.Networks...),
198+ Services: []domain.AppService{baseService, worker},
199+ }
200+ spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...)
201+ tc.mutate(&spec)
202+ rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec}
203+
204+ state.EXPECT().Recover(mock.Anything).Return(nil)
205+ state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
206+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once()
207+ if !tc.wantErr {
208+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once()
209+ }
210+
211+ svc := keyedService(t, state, runtime, images, secrets)
212+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"})
213+
214+ if tc.wantErr {
215+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
216+ state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything)
217+ } else {
218+ require.NoError(t, err)
219+ require.True(t, started.Owned)
220+ }
221+ images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
222+ assertNoDeployMutation(t, runtime, state)
223+ })
224+ }
225+}
226+
227+// TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection proves the shared
228+// GC lease is taken by the execution phase before resource selection and
229+// released only when the mutation ends. Prune can therefore never observe a
230+// resource that was selected but whose protection is not yet durable.
231+func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) {
232+ ctx := context.Background()
233+ state, runtime, images, secrets := mockDeps(t)
234+ svcSpec := webService()
235+ svcSpec.HTTP = nil
236+ svcSpec.Readiness = domain.AppReadiness{}
237+ rev := testRevision("blog", svcSpec)
238+
239+ barrier := outmocks.NewMockGCBarrier(t)
240+ lease := outmocks.NewMockGCLease(t)
241+ held := false
242+ barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
243+ held = true
244+ }).Return(lease, nil).Once()
245+ lease.EXPECT().Release().Run(func() {
246+ held = false
247+ }).Once()
248+
249+ state.EXPECT().Recover(mock.Anything).Return(nil)
250+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
251+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
252+ // Image resolution and image-volume inspection are the resource-selection
253+ // steps: they must happen inside the shared lease.
254+ images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
255+ require.True(t, held, "image resolution must run inside the shared GC lease")
256+ }).Return(restartTestDigest, nil).Once()
257+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
258+ runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Run(func(context.Context, string) {
259+ require.True(t, held, "image-volume inspection must run inside the shared GC lease")
260+ }).Return(nil, nil).Once()
261+ state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
262+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
263+ App: "blog", Services: map[string]domain.AppEffectiveService{},
264+ }, true, nil)
265+ expectNetworkProvision(runtime, "app-blog", 1)
266+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
267+ runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
268+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
269+ state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
270+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
271+
272+ svc := deployment.NewService(deployment.Deps{
273+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
274+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
275+ func(context.Context, string, string) (int, error) { return 200, nil },
276+ func(context.Context, string) error { return nil },
277+ )).WithGCBarrier(barrier)
278+
279+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
280+ require.NoError(t, err)
281+ require.True(t, started.Owned)
282+ require.False(t, held, "the claim phase takes no shared GC lease")
283+
284+ result, err := svc.ExecuteDeploy(ctx, started.Claim)
285+ require.NoError(t, err)
286+ require.NotNil(t, result)
287+ assert.Equal(t, "deployed", result.Services["web"].Result)
288+ assert.False(t, held, "the shared lease must be released when the operation ends")
289+}
M internal/usecase/deployment/probes.go +9 -3
@@ -22,12 +22,18 @@ var probeHTTPClient = &http.Client{
22 Timeout: 5 * time.Second,22 Timeout: 5 * time.Second,
23 }23 }
24 24
25-// httpGetOnce performs one HTTP GET without following redirects.25+// httpGetOnce performs one HTTP GET without following redirects. It dials
26-func httpGetOnce(ctx context.Context, url string) (int, error) {26+// probeURL's authority (the loopback published bind) while sending
27- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)27+// hostAuthority as the Host header, so a workload that validates the Host
28+// port of its declared container port accepts the probe.
29+func httpGetOnce(ctx context.Context, probeURL, hostAuthority string) (int, error) {
30+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
28 if err != nil {31 if err != nil {
29 return 0, err32 return 0, err
30 }33 }
34+ if hostAuthority != "" {
35+ req.Host = hostAuthority
36+ }
31 resp, err := probeHTTPClient.Do(req)37 resp, err := probeHTTPClient.Do(req)
32 if err != nil {38 if err != nil {
33 return 0, err39 return 0, err
M internal/usecase/deployment/publication_internal_test.go +4 -4
@@ -46,13 +46,13 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
46 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()46 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
47 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).47 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
48 WithProbeDeps(NewTestProbeDeps(runtime,48 WithProbeDeps(NewTestProbeDeps(runtime,
49- func(context.Context, string) (int, error) { return 500, nil },49+ func(context.Context, string, string) (int, error) { return 500, nil },
50 func(context.Context, string) error { return nil },50 func(context.Context, string) error { return nil },
51 ))51 ))
52 52
53 step := domain.AppOperationStep{ID: "service.web.start"}53 step := domain.AppOperationStep{ID: "service.web.start"}
54 result := &LifecycleResult{Services: map[string]ServiceResult{}}54 result := &LifecycleResult{Services: map[string]ServiceResult{}}
55- require.True(t, svc.verifyRunningService(ctx, "blog", "web", eff, &step, result))55+ svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)
56 56
57 assert.Equal(t, domain.AppStepFailed, step.State)57 assert.Equal(t, domain.AppStepFailed, step.State)
58 assert.Equal(t, "failed", result.Services["web"].Result)58 assert.Equal(t, "failed", result.Services["web"].Result)
@@ -81,13 +81,13 @@ func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) {
81 81
82 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).82 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
83 WithProbeDeps(NewTestProbeDeps(runtime,83 WithProbeDeps(NewTestProbeDeps(runtime,
84- func(context.Context, string) (int, error) { return 200, nil },84+ func(context.Context, string, string) (int, error) { return 200, nil },
85 func(context.Context, string) error { return nil },85 func(context.Context, string) error { return nil },
86 ))86 ))
87 87
88 step := domain.AppOperationStep{ID: "service.web.start"}88 step := domain.AppOperationStep{ID: "service.web.start"}
89 result := &LifecycleResult{Services: map[string]ServiceResult{}}89 result := &LifecycleResult{Services: map[string]ServiceResult{}}
90- require.True(t, svc.verifyRunningService(ctx, "blog", "web", eff, &step, result))90+ svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)
91 91
92 assert.Equal(t, domain.AppStepSucceeded, step.State)92 assert.Equal(t, domain.AppStepSucceeded, step.State)
93 assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds)93 assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds)
M internal/usecase/deployment/readiness.go +9 -3
@@ -20,7 +20,7 @@ type ProbeDeps struct {
20 networkInfo func(ctx context.Context, containerID string) (string, int, error)20 networkInfo func(ctx context.Context, containerID string) (string, int, error)
21 containerStart func(ctx context.Context, containerID string) (time.Time, error)21 containerStart func(ctx context.Context, containerID string) (time.Time, error)
22 logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error)22 logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error)
23- httpGet func(ctx context.Context, url string) (int, error)23+ httpGet func(ctx context.Context, url, hostAuthority string) (int, error)
24 tcpDial func(ctx context.Context, addr string) error24 tcpDial func(ctx context.Context, addr string) error
25 // networkProbe runs one bounded session against an internal port that25 // networkProbe runs one bounded session against an internal port that
26 // has no host publication. Nil disables the internal path: it must26 // has no host publication. Nil disables the internal path: it must
@@ -67,7 +67,7 @@ func NewProbeDeps(runtime ProbeRuntime) ProbeDeps {
67 // NewTestProbeDeps builds ProbeDeps with injectable HTTP/TCP probers.67 // NewTestProbeDeps builds ProbeDeps with injectable HTTP/TCP probers.
68 // Tests control L4 outcomes without dialing; logStream still reads the68 // Tests control L4 outcomes without dialing; logStream still reads the
69 // runtime mock.69 // runtime mock.
70-func NewTestProbeDeps(runtime ProbeRuntime, httpGet func(ctx context.Context, url string) (int, error), tcpDial func(ctx context.Context, addr string) error) ProbeDeps {70+func NewTestProbeDeps(runtime ProbeRuntime, httpGet func(ctx context.Context, url, hostAuthority string) (int, error), tcpDial func(ctx context.Context, addr string) error) ProbeDeps {
71 deps := NewProbeDeps(runtime)71 deps := NewProbeDeps(runtime)
72 deps.httpGet = httpGet72 deps.httpGet = httpGet
73 deps.tcpDial = tcpDial73 deps.tcpDial = tcpDial
@@ -287,6 +287,7 @@ func waitServiceReadyWithDeps(ctx context.Context, deps ProbeDeps, containerID s
287 // waitHTTPReadyWithDeps polls GET path until 2xx/3xx or timeout.287 // waitHTTPReadyWithDeps polls GET path until 2xx/3xx or timeout.
288 func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppService, binds map[int]int) error {288 func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppService, binds map[int]int) error {
289 timeout := readinessTimeout(spec)289 timeout := readinessTimeout(spec)
290+ containerPort := readinessContainerPort(spec)
290 port, err := dialPort(spec, binds)291 port, err := dialPort(spec, binds)
291 if err != nil {292 if err != nil {
292 return err293 return err
@@ -302,6 +303,11 @@ func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppS
302 Path: pathPart,303 Path: pathPart,
303 RawQuery: rawQuery,304 RawQuery: rawQuery,
304 }).String()305 }).String()
306+ // The probe dials the random loopback publication but must address the
307+ // workload at its declared container port: a workload that validates the
308+ // Host port (e.g. qBittorrent) rejects a published-port authority with
309+ // 401 "Invalid Host header, port mismatch".
310+ hostAuthority := net.JoinHostPort("127.0.0.1", itoa(containerPort))
305 deadline := time.Now().Add(timeout)311 deadline := time.Now().Add(timeout)
306 var lastAttempt string312 var lastAttempt string
307 for {313 for {
@@ -309,7 +315,7 @@ func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppS
309 return err315 return err
310 }316 }
311 attemptCtx, cancel := context.WithTimeout(ctx, min(2*time.Second, time.Until(deadline)))317 attemptCtx, cancel := context.WithTimeout(ctx, min(2*time.Second, time.Until(deadline)))
312- status, err := deps.httpGet(attemptCtx, probeURL)318+ status, err := deps.httpGet(attemptCtx, probeURL, hostAuthority)
313 cancel()319 cancel()
314 if err == nil && status >= 200 && status < 400 {320 if err == nil && status >= 200 && status < 400 {
315 return nil321 return nil
M internal/usecase/deployment/readiness_internal_test.go +36 -6
@@ -2,6 +2,9 @@ package deployment
2 2
3 import (3 import (
4 "context"4 "context"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
5 "testing"8 "testing"
6 "time"9 "time"
7 10
@@ -13,11 +16,12 @@ import (
13 16
14 // TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority proves the probe URL is17 // TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority proves the probe URL is
15 // built from an immutable loopback host and an escaped path, so no declared18 // built from an immutable loopback host and an escaped path, so no declared
16-// path can redirect the request to a different authority.19+// path can redirect the request to a different authority. The Host header
20+// must name the declared container port, not the random published port.
17 func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) {21 func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) {
18- var got string22+ var gotURL, gotHost string
19- deps := ProbeDeps{httpGet: func(_ context.Context, url string) (int, error) {23+ deps := ProbeDeps{httpGet: func(_ context.Context, url, hostAuthority string) (int, error) {
20- got = url24+ gotURL, gotHost = url, hostAuthority
21 return 200, nil25 return 200, nil
22 }}26 }}
23 spec := domain.AppService{27 spec := domain.AppService{
@@ -26,13 +30,39 @@ func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) {
26 Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz?probe=1", Timeout: time.Second},30 Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz?probe=1", Timeout: time.Second},
27 }31 }
28 require.NoError(t, waitHTTPReadyWithDeps(context.Background(), deps, spec, map[int]int{8080: 18080}))32 require.NoError(t, waitHTTPReadyWithDeps(context.Background(), deps, spec, map[int]int{8080: 18080}))
29- assert.Equal(t, "http://127.0.0.1:18080/healthz?probe=1", got)33+ assert.Equal(t, "http://127.0.0.1:18080/healthz?probe=1", gotURL)
34+ assert.Equal(t, "127.0.0.1:8080", gotHost, "the Host header names the declared container port, not the published port")
35+}
36+
37+// TestWaitHTTPReady_SendsContainerPortAsHostAuthority is the qBittorrent
38+// regression: readiness dials the random loopback publication, but the
39+// workload rejects a Host whose port is not its declared container port
40+// with 401 "Invalid Host header, port mismatch". The probe must dial the
41+// published port while sending the container port as the Host authority.
42+func TestWaitHTTPReady_SendsContainerPortAsHostAuthority(t *testing.T) {
43+ const containerPort = 8080
44+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+ if r.Host != "127.0.0.1:8080" {
46+ http.Error(w, "Invalid Host header, port mismatch", http.StatusUnauthorized)
47+ return
48+ }
49+ w.WriteHeader(http.StatusOK)
50+ }))
51+ defer server.Close()
52+ publishedPort := server.Listener.Addr().(*net.TCPAddr).Port
53+
54+ spec := domain.AppService{
55+ Name: "qbittorrent",
56+ HTTP: []domain.AppHTTPInterface{{Host: "qbit.example.com", Port: containerPort}},
57+ Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/api/v2/app/version", Timeout: 2 * time.Second},
58+ }
59+ require.NoError(t, waitHTTPReadyWithDeps(context.Background(), ProbeDeps{httpGet: httpGetOnce}, spec, map[int]int{containerPort: publishedPort}))
30 }60 }
31 61
32 // TestWaitHTTPReady_HangingProbeRespectsDeadline proves a workload that62 // TestWaitHTTPReady_HangingProbeRespectsDeadline proves a workload that
33 // never answers cannot hold the readiness operation open past its bound.63 // never answers cannot hold the readiness operation open past its bound.
34 func TestWaitHTTPReady_HangingProbeRespectsDeadline(t *testing.T) {64 func TestWaitHTTPReady_HangingProbeRespectsDeadline(t *testing.T) {
35- deps := ProbeDeps{httpGet: func(ctx context.Context, _ string) (int, error) {65+ deps := ProbeDeps{httpGet: func(ctx context.Context, _, _ string) (int, error) {
36 <-ctx.Done()66 <-ctx.Done()
37 return 0, ctx.Err()67 return 0, ctx.Err()
38 }}68 }}
M internal/usecase/deployment/reconcile_mock_test.go +4 -4
@@ -86,7 +86,7 @@ func recoveringService(
86 state *outmocks.MockAppState,86 state *outmocks.MockAppState,
87 runtime *outmocks.MockContainerRuntime,87 runtime *outmocks.MockContainerRuntime,
88 traffic *recordingTraffic,88 traffic *recordingTraffic,
89- httpGet func(context.Context, string) (int, error),89+ httpGet func(context.Context, string, string) (int, error),
90 ) *deployment.Service {90 ) *deployment.Service {
91 t.Helper()91 t.Helper()
92 return deployment.NewService(deployment.Deps{92 return deployment.NewService(deployment.Deps{
@@ -195,7 +195,7 @@ func TestReconcileRunning_StartsExitedExactIDThenVerifiesAndPublishes(t *testing
195 })).Return(nil).Once()195 })).Return(nil).Once()
196 196
197 var probedURL string197 var probedURL string
198- svc := recoveringService(t, state, runtime, traffic, func(_ context.Context, url string) (int, error) {198+ svc := recoveringService(t, state, runtime, traffic, func(_ context.Context, url, _ string) (int, error) {
199 probedURL = url199 probedURL = url
200 return 200, nil200 return 200, nil
201 })201 })
@@ -599,7 +599,7 @@ func TestReconcileRunning_NoHealthcheckNeverRestartsOnReadiness(t *testing.T) {
599 // An execution unseen by this daemon is verified immediately because it599 // An execution unseen by this daemon is verified immediately because it
600 // may have restarted while Gordon was absent. Readiness failure keeps it600 // may have restarted while Gordon was absent. Readiness failure keeps it
601 // withdrawn, but never restarts an otherwise running workload.601 // withdrawn, but never restarts an otherwise running workload.
602- svc := recoveringService(t, state, runtime, traffic, func(context.Context, string) (int, error) {602+ svc := recoveringService(t, state, runtime, traffic, func(context.Context, string, string) (int, error) {
603 return 500, nil603 return 500, nil
604 })604 })
605 require.Error(t, svc.ReconcileRunning(ctx), "the unseen execution fails readiness and stays withdrawn")605 require.Error(t, svc.ReconcileRunning(ctx), "the unseen execution fails readiness and stays withdrawn")
@@ -796,7 +796,7 @@ func TestReconcileRunning_FailedReadinessPersistsNoBinds(t *testing.T) {
796 Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()796 Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
797 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()797 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
798 798
799- svc := recoveringService(t, state, runtime, traffic, func(context.Context, string) (int, error) {799+ svc := recoveringService(t, state, runtime, traffic, func(context.Context, string, string) (int, error) {
800 return 500, nil800 return 500, nil
801 })801 })
802 require.Error(t, svc.ReconcileRunning(ctx))802 require.Error(t, svc.ReconcileRunning(ctx))
M internal/usecase/deployment/recovery_integration_test.go +3 -3
@@ -96,11 +96,11 @@ func seedDesired(t *testing.T, ctx context.Context, store *appstate.Store, rev d
96 require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID))96 require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID))
97 }97 }
98 98
99-// TestInterruptedVolumeFailureLeavesOldGenerationInhibited proves the99+// TestVolumeReplacementFailureLeavesOldGenerationInhibited proves the
100 // marker is written BEFORE a volume-owning replacement can write and is100 // marker is written BEFORE a volume-owning replacement can write and is
101 // not cleared when that replacement fails: the old generation must never101 // not cleared when that replacement fails: the old generation must never
102 // be restarted on top of possibly-newer data.102 // be restarted on top of possibly-newer data.
103-func TestInterruptedVolumeFailureLeavesOldGenerationInhibited(t *testing.T) {103+func TestVolumeReplacementFailureLeavesOldGenerationInhibited(t *testing.T) {
104 ctx := context.Background()104 ctx := context.Background()
105 store := newTestStore(t)105 store := newTestStore(t)
106 runtime := outmocks.NewMockContainerRuntime(t)106 runtime := outmocks.NewMockContainerRuntime(t)
@@ -144,7 +144,7 @@ func TestInterruptedVolumeFailureLeavesOldGenerationInhibited(t *testing.T) {
144 State: store, Runtime: runtime, Images: images, Secrets: secrets,144 State: store, Runtime: runtime, Images: images, Secrets: secrets,
145 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},145 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
146 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,146 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
147- func(context.Context, string) (int, error) { return 500, nil },147+ func(context.Context, string, string) (int, error) { return 500, nil },
148 func(context.Context, string) error { return assert.AnError },148 func(context.Context, string) error { return assert.AnError },
149 ))149 ))
150 150
D internal/usecase/deployment/restart_http_test.go +0 -314
@@ -1,314 +0,0 @@
1-package deployment_test
2-
3-import (
4- "context"
5- "errors"
6- "sync"
7- "testing"
8- "time"
9-
10- "github.com/bnema/zerowrap"
11- "github.com/stretchr/testify/assert"
12- "github.com/stretchr/testify/mock"
13- "github.com/stretchr/testify/require"
14-
15- "github.com/bnema/gordon/internal/domain"
16- "github.com/bnema/gordon/internal/usecase/deployment"
17-)
18-
19-type restartTrafficRecorder struct {
20- mu sync.Mutex
21- events *[]string
22- failRebuild bool
23-}
24-
25-func (r *restartTrafficRecorder) record(event string) {
26- r.mu.Lock()
27- defer r.mu.Unlock()
28- if r.events != nil {
29- *r.events = append(*r.events, event)
30- }
31-}
32-func (r *restartTrafficRecorder) RebuildTraffic(context.Context) error {
33- r.record("traffic")
34- if r.failRebuild {
35- return errors.New("traffic rebuild failed")
36- }
37- return nil
38-}
39-func (r *restartTrafficRecorder) WithdrawService(context.Context, string, string) error {
40- r.record("withdraw")
41- return nil
42-}
43-func (r *restartTrafficRecorder) WithdrawServiceState(context.Context, string, string) error {
44- r.record("withdraw-state")
45- return nil
46-}
47-
48-const restartTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
49-
50-func restartActive(t *testing.T, spec domain.AppService) domain.AppActive {
51- t.Helper()
52- return domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true, Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", ActivatedBy: "op-before", ActivatedAt: time.Now().Add(-time.Hour).UTC(), Image: spec.Image, Digest: restartTestDigest, Container: "c-old", Spec: spec, BackendBinds: map[int]int{8080: 32770}}}}
53-}
54-
55-func restartRevision(spec domain.AppService) domain.AppDesiredRevision {
56- return domain.AppDesiredRevision{Revision: "rev-1", App: "blog", Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{spec}}}
57-}
58-
59-// cloneActive returns a copy of an ACTIVE record with its own service map,
60-// so a mocked LoadActive never hands the same mutable map to the code
61-// under test twice.
62-func cloneActive(a domain.AppActive) domain.AppActive {
63- cloned := a
64- cloned.Services = make(map[string]domain.AppEffectiveService, len(a.Services))
65- for name, svc := range a.Services {
66- cloned.Services[name] = svc
67- }
68- return cloned
69-}
70-
71-func TestRestart_HTTPZeroDowntimeRepointsTrafficBeforeRetiringOld(t *testing.T) {
72- ctx := context.Background()
73- state, runtime, images, secrets := mockDeps(t)
74- spec := webService()
75- spec.Secrets = map[string]string{}
76- spec.StopGrace = 10 * time.Millisecond
77- spec.Readiness.Timeout = time.Second
78- active := restartActive(t, spec)
79- rev := restartRevision(spec)
80- oldActivatedAt := active.Services["web"].ActivatedAt
81- var order []string
82- state.EXPECT().Recover(mock.Anything).Return(nil)
83- state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
84- state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
85- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once()
86- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
87- expectNetworkProvision(runtime, "app-blog", 1)
88- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
89- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
90- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
91- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
92- var saved domain.AppActive
93- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Run(func(_ context.Context, a domain.AppActive) { saved = a; order = append(order, "active") }).Return(nil).Once()
94- state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once()
95- state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
96- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
97- runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Run(func(context.Context, string, time.Duration) { order = append(order, "stop-old") }).Return(nil).Once()
98- runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
99- state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
100- svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &order}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil }))
101- result, err := svc.Restart(ctx, "blog", "web", "")
102- require.NoError(t, err)
103- assert.Equal(t, []string{"active", "traffic", "stop-old", "traffic"}, order)
104- assert.Equal(t, "c-new", saved.Services["web"].Container)
105- assert.Equal(t, result.Op, saved.Services["web"].ActivatedBy)
106- assert.True(t, saved.Services["web"].ActivatedAt.After(oldActivatedAt))
107- assert.Equal(t, "rev-1", saved.ConvergedRevision)
108- assert.True(t, saved.Converged)
109-}
110-
111-func TestRestart_HTTPReadinessFailureKeepsOldServing(t *testing.T) {
112- ctx := context.Background()
113- state, runtime, images, secrets := mockDeps(t)
114- spec := webService()
115- spec.Secrets = map[string]string{}
116- spec.Readiness.Timeout = 50 * time.Millisecond
117- active := restartActive(t, spec)
118- rev := restartRevision(spec)
119- state.EXPECT().Recover(mock.Anything).Return(nil)
120- state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil).Once()
121- state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
122- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once()
123- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
124- expectNetworkProvision(runtime, "app-blog", 1)
125- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new"}, nil).Once()
126- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
127- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
128- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
129- runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()
130- runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once()
131- state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once()
132- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
133- var events []string
134- svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 503, nil }, func(context.Context, string) error { return nil }))
135- _, err := svc.Restart(ctx, "blog", "web", "")
136- require.ErrorIs(t, err, domain.ErrAppStateConflict)
137- state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
138- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)
139- assert.NotContains(t, events, "withdraw")
140-}
141-
142-func TestRestart_HTTPWithVolumeStaysInPlace(t *testing.T) {
143- ctx := context.Background()
144- state, runtime, images, secrets := mockDeps(t)
145- spec := webService()
146- spec.Secrets = map[string]string{}
147- spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
148- active := restartActive(t, spec)
149- state.EXPECT().Recover(mock.Anything).Return(nil)
150- state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil).Once()
151- state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
152- runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
153- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32770, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
154- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
155- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
156- svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil }))
157- result, err := svc.Restart(ctx, "blog", "web", "")
158- require.NoError(t, err)
159- assert.Equal(t, "c-old", result.Services["web"].After)
160- runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
161-}
162-
163-func TestRestart_HTTPPublicationFailureReportsFailedAndRetiresCandidate(t *testing.T) {
164- ctx := context.Background()
165- state, runtime, images, secrets := mockDeps(t)
166- spec := webService()
167- spec.Secrets = map[string]string{}
168- spec.StopGrace = 10 * time.Millisecond
169- spec.Readiness.Timeout = time.Second
170- active := restartActive(t, spec)
171- rev := restartRevision(spec)
172- publishErr := errors.New("publish failed")
173-
174- state.EXPECT().Recover(mock.Anything).Return(nil)
175- state.EXPECT().LoadActive(mock.Anything, "blog").RunAndReturn(func(context.Context, string) (domain.AppActive, bool, error) {
176- return cloneActive(active), true, nil
177- })
178- state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
179- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once()
180- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
181- expectNetworkProvision(runtime, "app-blog", 1)
182- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
183- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
184- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
185- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
186- // ACTIVE is never written: the candidate was not published.
187- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(publishErr).Once()
188- runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once()
189- state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once()
190- var ops []domain.AppOperation
191- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
192- ops = append(ops, op)
193- return nil
194- })
195- var events []string
196- svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil }))
197-
198- result, err := svc.Restart(ctx, "blog", "web", "")
199- require.ErrorIs(t, err, domain.ErrAppStateConflict)
200- require.NotNil(t, result)
201- assert.Equal(t, "failed", result.Services["web"].Result, "a failed publication is not a deployed restart")
202- require.NotEmpty(t, ops)
203- last := ops[len(ops)-1]
204- assert.Equal(t, domain.AppOutcomeFailed, last.Outcome, "a failed publication must not be journaled as success")
205- step, ok := opStep(last, "service.web.restart")
206- require.True(t, ok)
207- assert.Equal(t, domain.AppStepFailed, step.State, "the restart step must not claim success before publication")
208- // The old generation keeps serving and a never-published candidate is
209- // removed without a grace period; it is never stopped or served.
210- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)
211- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything)
212- assert.NotContains(t, events, "withdraw")
213-}
214-
215-func TestRestart_HTTPPostSavePublicationFailureKeepsPublishedCandidate(t *testing.T) {
216- ctx := context.Background()
217- state, runtime, images, secrets := mockDeps(t)
218- spec := webService()
219- spec.Secrets = map[string]string{}
220- spec.StopGrace = 10 * time.Millisecond
221- spec.Readiness.Timeout = time.Second
222- active := restartActive(t, spec)
223- rev := restartRevision(spec)
224- published := active
225- published.Services = map[string]domain.AppEffectiveService{
226- "web": {EffectiveRevision: "rev-1", Container: "c-new", Spec: spec},
227- }
228- loads := 0
229-
230- state.EXPECT().Recover(mock.Anything).Return(nil)
231- state.EXPECT().LoadActive(mock.Anything, "blog").RunAndReturn(func(context.Context, string) (domain.AppActive, bool, error) {
232- loads++
233- if loads == 1 {
234- return cloneActive(active), true, nil
235- }
236- return cloneActive(published), true, nil
237- })
238- state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
239- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once()
240- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
241- expectNetworkProvision(runtime, "app-blog", 1)
242- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
243- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
244- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
245- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
246- // ACTIVE is written, then ownership persistence fails: the candidate is
247- // the published generation and must keep running.
248- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
249- state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(errors.New("ownership failed")).Once()
250- var ops []domain.AppOperation
251- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
252- ops = append(ops, op)
253- return nil
254- })
255- var events []string
256- svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil }))
257-
258- result, err := svc.Restart(ctx, "blog", "web", "")
259- require.ErrorIs(t, err, domain.ErrAppStateConflict)
260- require.NotNil(t, result)
261- assert.Equal(t, "failed", result.Services["web"].Result, "a post-save publication failure is not a deployed restart")
262- require.NotEmpty(t, ops)
263- assert.Equal(t, domain.AppOutcomeFailed, ops[len(ops)-1].Outcome)
264- runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything)
265- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)
266-}
267-
268-func TestRestart_HTTPTrafficFailureReportsFailedAndKeepsPublishedCandidate(t *testing.T) {
269- ctx := context.Background()
270- state, runtime, images, secrets := mockDeps(t)
271- spec := webService()
272- spec.Secrets = map[string]string{}
273- spec.StopGrace = 10 * time.Millisecond
274- spec.Readiness.Timeout = time.Second
275- active := restartActive(t, spec)
276- rev := restartRevision(spec)
277-
278- state.EXPECT().Recover(mock.Anything).Return(nil)
279- state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
280- state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
281- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once()
282- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
283- expectNetworkProvision(runtime, "app-blog", 1)
284- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
285- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
286- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
287- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
288- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
289- state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once()
290- state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
291- state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool {
292- return inhibition.Service == "web" && inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitRetirementPending
293- })).Return(nil).Once()
294- var ops []domain.AppOperation
295- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
296- ops = append(ops, op)
297- return nil
298- })
299- var events []string
300- svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events, failRebuild: true}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil }))
301-
302- result, err := svc.Restart(ctx, "blog", "web", "")
303- require.ErrorContains(t, err, "traffic rebuild failed")
304- require.NotNil(t, result)
305- assert.Equal(t, "failed", result.Services["web"].Result, "a published but unroutable candidate is not a deployed restart")
306- require.NotEmpty(t, ops)
307- assert.Equal(t, domain.AppOutcomeFailed, ops[len(ops)-1].Outcome, "an unroutable publication must not be journaled as success")
308- assert.Equal(t, []string{"traffic", "traffic"}, events, "the graph apply is retried once per restart phase and neither retry may claim success")
309- // ACTIVE names the candidate: neither generation may be retired while
310- // routing is unproven.
311- runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything)
312- runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", mock.Anything)
313- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)
314-}
A internal/usecase/deployment/restart_inplace_test.go +163 -0
@@ -0,0 +1,163 @@
1+package deployment_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+ "github.com/bnema/gordon/internal/usecase/deployment"
17+)
18+
19+// restartTrafficRecorder records the traffic boundary calls of a restart.
20+type restartTrafficRecorder struct {
21+ mu sync.Mutex
22+ events *[]string
23+ failRebuild bool
24+}
25+
26+func (r *restartTrafficRecorder) record(event string) {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ if r.events != nil {
30+ *r.events = append(*r.events, event)
31+ }
32+}
33+func (r *restartTrafficRecorder) RebuildTraffic(context.Context) error {
34+ r.record("traffic")
35+ if r.failRebuild {
36+ return errors.New("traffic rebuild failed")
37+ }
38+ return nil
39+}
40+func (r *restartTrafficRecorder) WithdrawService(context.Context, string, string) error {
41+ r.record("withdraw")
42+ return nil
43+}
44+func (r *restartTrafficRecorder) WithdrawServiceState(context.Context, string, string) error {
45+ r.record("withdraw-state")
46+ return nil
47+}
48+
49+const restartTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
50+
51+func restartActive(t *testing.T, spec domain.AppService) domain.AppActive {
52+ t.Helper()
53+ return domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true, Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", ActivatedBy: "op-before", ActivatedAt: time.Now().Add(-time.Hour).UTC(), Image: spec.Image, Digest: restartTestDigest, Container: "c-old", Spec: spec, BackendBinds: map[int]int{8080: 32770}}}}
54+}
55+
56+// TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes proves the
57+// restart contract: the service is withdrawn from traffic, the SAME pinned
58+// container is restarted, readiness is verified against its refreshed
59+// loopback bind, and traffic is republished. No second container is created
60+// and no container is stopped or removed.
61+func TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes(t *testing.T) {
62+ ctx := context.Background()
63+ state, runtime, images, secrets := mockDeps(t)
64+ spec := webService()
65+ spec.Secrets = map[string]string{}
66+ spec.StopGrace = 10 * time.Millisecond
67+ // No declared readiness probe: the implicit HTTP check still applies to a
68+ // stateless public HTTP service on restart, exactly as it does on deploy.
69+ spec.Readiness = domain.AppReadiness{Timeout: time.Second}
70+ active := restartActive(t, spec)
71+
72+ var order []string
73+ state.EXPECT().Recover(mock.Anything).Return(nil)
74+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
75+ state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
76+ runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(
77+ func(context.Context, string, time.Duration) error {
78+ recordEvent(&order, "restart")
79+ return nil
80+ }).Once()
81+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
82+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
83+ state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
84+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
85+ var saved domain.AppActive
86+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(
87+ func(_ context.Context, refreshed domain.AppActive) error {
88+ saved = refreshed
89+ recordEvent(&order, "persist-binds")
90+ return nil
91+ }).Once()
92+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
93+
94+ var probedURL string
95+ svc := deployment.NewService(deployment.Deps{
96+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
97+ Traffic: &restartTrafficRecorder{events: &order},
98+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
99+ func(_ context.Context, url, _ string) (int, error) {
100+ probedURL = url
101+ recordEvent(&order, "ready")
102+ return 200, nil
103+ },
104+ func(context.Context, string) error { return nil },
105+ ))
106+
107+ result, err := svc.Restart(ctx, "blog", "web", "")
108+ require.NoError(t, err)
109+ require.NotNil(t, result)
110+ assert.Equal(t, "deployed", result.Services["web"].Result)
111+ assert.Equal(t, "c-old", result.Services["web"].After, "the same pinned container is restarted in place")
112+ assert.Contains(t, probedURL, "127.0.0.1:32771", "readiness dials the refreshed loopback bind")
113+ assert.Equal(t, []string{"withdraw", "restart", "persist-binds", "ready", "traffic"}, order)
114+ assert.Equal(t, map[int]int{8080: 32771}, saved.Services["web"].BackendBinds)
115+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
116+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
117+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
118+}
119+
120+// TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn proves a
121+// restart is never served unverified: the failure leaves the same container
122+// in place but withdraws its recorded binds, and the graph is republished
123+// fail-closed. Nothing is created, stopped, or removed.
124+func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing.T) {
125+ ctx := context.Background()
126+ state, runtime, images, secrets := mockDeps(t)
127+ spec := webService()
128+ spec.Secrets = map[string]string{}
129+ spec.StopGrace = 10 * time.Millisecond
130+ spec.Readiness = domain.AppReadiness{Timeout: 50 * time.Millisecond}
131+ active := restartActive(t, spec)
132+
133+ var order []string
134+ state.EXPECT().Recover(mock.Anything).Return(nil)
135+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
136+ state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
137+ runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
138+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
139+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
140+ state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
141+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
142+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
143+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
144+
145+ svc := deployment.NewService(deployment.Deps{
146+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
147+ Traffic: &restartTrafficRecorder{events: &order},
148+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
149+ func(context.Context, string, string) (int, error) { return 503, nil },
150+ func(context.Context, string) error { return assert.AnError },
151+ ))
152+
153+ result, err := svc.Restart(ctx, "blog", "web", "")
154+ require.Error(t, err)
155+ require.NotNil(t, result)
156+ assert.Equal(t, "failed", result.Services["web"].Result)
157+ assert.Contains(t, result.Services["web"].Error, "readiness")
158+ assert.Contains(t, order, "withdraw-state", "the unverified generation is withdrawn from state")
159+ assert.Contains(t, order, "traffic", "the graph is republished fail-closed")
160+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
161+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
162+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
163+}
M internal/usecase/deployment/retirement_test.go +26 -27
@@ -133,14 +133,14 @@ func TestRemove_FailedRetirementKeepsClaimsAndActiveState(t *testing.T) {
133 state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything)133 state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything)
134 }134 }
135 135
136-// TestDeploy_RetirementFailureIsSurfacedAsAWarning proves a leftover of a136+// TestDeploy_RetirementFailureBlocksReplacement proves the superseded
137-// successful deploy is journaled as a bounded warning instead of being137+// generation must be confirmed gone before a replacement starts: a failed
138-// dropped, and that the deploy itself still reports success.138+// retirement aborts the service without creating a candidate, and the
139-func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {139+// failure is journaled instead of being downgraded to a warning.
140+func TestDeploy_RetirementFailureBlocksReplacement(t *testing.T) {
140 ctx := context.Background()141 ctx := context.Background()
141 state, runtime, images, secrets := mockDeps(t)142 state, runtime, images, secrets := mockDeps(t)
142 rev := mockRevision()143 rev := mockRevision()
143- rev.Spec.Services[0].StopGrace = time.Millisecond
144 144
145 oldActive := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{145 oldActive := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
146 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old", Spec: graceSpec(time.Second)},146 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old", Spec: graceSpec(time.Second)},
@@ -155,18 +155,9 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
155 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()155 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
156 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)156 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
157 state.EXPECT().LoadActive(mock.Anything, "blog").Return(oldActive, true, nil)157 state.EXPECT().LoadActive(mock.Anything, "blog").Return(oldActive, true, nil)
158- state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)158+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
159- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)159+ // The superseded container is retired with its own effective grace, and
160- expectNetworkProvision(runtime, "app-blog", 1)160+ // its removal fails: the replacement must not start.
161- runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
162- runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
163- runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
164- []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
165- state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
166- state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
167- // The replaced container is retired with its own effective grace, and
168- // its removal fails: the deploy is still successful, the leftover is
169- // a warning.
170 runtime.EXPECT().StopContainer(mock.Anything, "c-old", time.Second).Return(nil).Once()161 runtime.EXPECT().StopContainer(mock.Anything, "c-old", time.Second).Return(nil).Once()
171 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(assert.AnError).Once()162 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(assert.AnError).Once()
172 163
@@ -180,17 +171,27 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
180 svc := deployment.NewService(deployment.Deps{171 svc := deployment.NewService(deployment.Deps{
181 State: state, Runtime: runtime, Images: images, Secrets: secrets,172 State: state, Runtime: runtime, Images: images, Secrets: secrets,
182 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,173 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
183- func(context.Context, string) (int, error) { return 200, nil },174+ func(context.Context, string, string) (int, error) { return 200, nil },
184 func(context.Context, string) error { return nil },175 func(context.Context, string) error { return nil },
185 ))176 ))
186 177
187 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})178 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
188- require.NoError(t, err)179+ require.Error(t, err)
189 require.NotNil(t, result)180 require.NotNil(t, result)
190- assert.Equal(t, "deployed", result.Services["web"].Result)181+ assert.Equal(t, "failed", result.Services["web"].Result)
191- require.Len(t, result.CleanupWarnings, 1)182+ assert.Contains(t, result.Services["web"].Error, "retire superseded container",
192- assert.Equal(t, "c-old", result.CleanupWarnings[0].Leftover)183+ "the unconfirmed retirement is the reported failure")
193- assert.NotContains(t, result.CleanupWarnings[0].Detail, "unexpected", "warnings carry stable, log-free detail")184+
185+ // No workload is created, started, or published while the superseded
186+ // generation is not confirmed gone, and no claim or inhibition of that
187+ // generation is released.
188+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
189+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
190+ runtime.AssertNotCalled(t, "ListNetworks", mock.Anything)
191+ runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
192+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
193+ state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, mock.Anything, mock.Anything)
194+ state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
194 195
195 require.NotEmpty(t, saved)196 require.NotEmpty(t, saved)
196 last := saved[len(saved)-1]197 last := saved[len(saved)-1]
@@ -198,8 +199,6 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
198 assert.Equal(t, "preflight", last.Steps[0].ID)199 assert.Equal(t, "preflight", last.Steps[0].ID)
199 assert.Equal(t, domain.AppStepSucceeded, last.Steps[0].State)200 assert.Equal(t, domain.AppStepSucceeded, last.Steps[0].State)
200 assert.Equal(t, "service.web.replace", last.Steps[1].ID)201 assert.Equal(t, "service.web.replace", last.Steps[1].ID)
201- assert.Equal(t, domain.AppStepSucceeded, last.Steps[1].State)202+ assert.Equal(t, domain.AppStepFailed, last.Steps[1].State)
202- require.Len(t, last.Warnings, 1)203+ assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
203- assert.Equal(t, "web", last.Warnings[0].Service)
204- assert.Equal(t, "c-old", last.Warnings[0].Leftover)
205 }204 }
A internal/usecase/deployment/sequential_replacement_test.go +745 -0
@@ -0,0 +1,745 @@
1+package deployment_test
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
16+ "github.com/bnema/gordon/internal/domain"
17+ "github.com/bnema/gordon/internal/usecase/deployment"
18+)
19+
20+// orderedTraffic records traffic boundary calls into a shared event log and
21+// can fail one withdrawal or one graph apply.
22+type orderedTraffic struct {
23+ mu sync.Mutex
24+ events *[]string
25+ failWithdraw bool
26+ failRebuild bool
27+}
28+
29+func (t *orderedTraffic) record(event string) {
30+ t.mu.Lock()
31+ defer t.mu.Unlock()
32+ if t.events != nil {
33+ *t.events = append(*t.events, event)
34+ }
35+}
36+
37+func (t *orderedTraffic) RebuildTraffic(context.Context) error {
38+ t.record("traffic")
39+ if t.failRebuild {
40+ return assert.AnError
41+ }
42+ return nil
43+}
44+
45+func (t *orderedTraffic) WithdrawService(context.Context, string, string) error {
46+ t.record("withdraw")
47+ if t.failWithdraw {
48+ return assert.AnError
49+ }
50+ return nil
51+}
52+
53+func (t *orderedTraffic) WithdrawServiceState(context.Context, string, string) error {
54+ t.record("withdraw-state")
55+ if t.failWithdraw {
56+ return assert.AnError
57+ }
58+ return nil
59+}
60+
61+// replaceableActive is one ACTIVE record whose web service is superseded.
62+func replaceableActive() domain.AppActive {
63+ return domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
64+ "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
65+ }}
66+}
67+
68+// recordEvent appends one step to a shared event log. Test doubles and mock
69+// hooks share the log, so the sequence a deployment performs is observable.
70+func recordEvent(events *[]string, event string) {
71+ if events != nil {
72+ *events = append(*events, event)
73+ }
74+}
75+
76+// expectReplacementPreflight wires the preflight calls of one replacement
77+// without pre-registering an operation-journal write, so a test can observe
78+// the terminal journal write itself.
79+func expectReplacementPreflight(
80+ state *outmocks.MockAppState,
81+ runtime *outmocks.MockContainerRuntime,
82+ images *outmocks.MockImageResolver,
83+ secrets *outmocks.MockSecretProvider,
84+ rev domain.AppDesiredRevision,
85+) {
86+ state.EXPECT().Recover(mock.Anything).Return(nil)
87+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
88+ images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(
89+ "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
90+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
91+ runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil)
92+ state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
93+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
94+}
95+
96+// expectSequentialCreate wires one replacement's candidate creation and loopback
97+// publication. It records "create-new" and "start-new" in order.
98+func expectSequentialCreate(
99+ state *outmocks.MockAppState,
100+ runtime *outmocks.MockContainerRuntime,
101+ events *[]string,
102+ containerID string,
103+ hostPort int,
104+) {
105+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(
106+ func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
107+ recordEvent(events, "create-new")
108+ return &domain.Container{ID: containerID, Name: "web"}, nil
109+ }).Once()
110+ runtime.EXPECT().StartContainer(mock.Anything, containerID).RunAndReturn(
111+ func(context.Context, string) error {
112+ recordEvent(events, "start-new")
113+ return nil
114+ }).Once()
115+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, containerID, mock.Anything).Return(
116+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: hostPort, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
117+ state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
118+}
119+
120+// expectRetireOld wires the confirmed retirement of the superseded container.
121+func expectRetireOld(
122+ state *outmocks.MockAppState,
123+ runtime *outmocks.MockContainerRuntime,
124+ events *[]string,
125+ containerID string,
126+) {
127+ runtime.EXPECT().StopContainer(mock.Anything, containerID, mock.Anything).RunAndReturn(
128+ func(context.Context, string, time.Duration) error {
129+ recordEvent(events, "stop-old")
130+ return nil
131+ }).Once()
132+ runtime.EXPECT().RemoveContainer(mock.Anything, containerID, false).RunAndReturn(
133+ func(context.Context, string, bool) error {
134+ recordEvent(events, "remove-old")
135+ return nil
136+ }).Once()
137+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", containerID).Return(nil).Once()
138+}
139+
140+// TestDeploy_SequentialReplacementNeverOverlapsGenerations proves the
141+// replacement order for a public HTTP service without volumes: withdrawal is
142+// confirmed first, the superseded container is stopped and removed before the
143+// replacement is created, readiness runs before ACTIVE is published, and the
144+// graph is applied last.
145+func TestDeploy_SequentialReplacementNeverOverlapsGenerations(t *testing.T) {
146+ ctx := context.Background()
147+ state, runtime, images, secrets := mockDeps(t)
148+ rev := mockRevision()
149+
150+ expectDeployPreflight(state, runtime, images, secrets, rev)
151+ expectNetworkProvision(runtime, "app-blog", 1)
152+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil)
153+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
154+
155+ var order []string
156+ expectRetireOld(state, runtime, &order, "c-old")
157+ expectSequentialCreate(state, runtime, &order, "c-new", 18080)
158+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil)
159+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(
160+ func(context.Context, domain.AppActive) error {
161+ recordEvent(&order, "publish-active")
162+ return nil
163+ }).Once()
164+ state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
165+ state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
166+
167+ var probedURL string
168+ traffic := &orderedTraffic{events: &order}
169+ svc := deployment.NewService(deployment.Deps{
170+ State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,
171+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
172+ func(_ context.Context, url, _ string) (int, error) {
173+ probedURL = url
174+ recordEvent(&order, "ready")
175+ return 200, nil
176+ },
177+ func(context.Context, string) error { return nil },
178+ ))
179+
180+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
181+ require.NoError(t, err)
182+ require.NotNil(t, result)
183+ assert.Equal(t, "deployed", result.Services["web"].Result)
184+ assert.Contains(t, probedURL, "127.0.0.1:18080", "readiness dials the replacement's fresh loopback bind")
185+ assert.Equal(t,
186+ []string{"withdraw", "stop-old", "remove-old", "create-new", "start-new", "ready", "publish-active", "traffic"},
187+ order)
188+}
189+
190+// TestDeploy_PreflightFailureLeavesTheWorkloadUntouched proves preflight runs
191+// to completion before anything is disrupted: an unresolvable image fails the
192+// deploy without withdrawing traffic or stopping, removing, creating, or
193+// starting any container.
194+func TestDeploy_PreflightFailureLeavesTheWorkloadUntouched(t *testing.T) {
195+ ctx := context.Background()
196+ state, runtime, images, secrets := mockDeps(t)
197+ rev := mockRevision()
198+
199+ state.EXPECT().Recover(mock.Anything).Return(nil).Once()
200+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
201+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
202+ images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("", assert.AnError).Once()
203+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
204+
205+ var events []string
206+ svc := deployment.NewService(deployment.Deps{
207+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
208+ Traffic: &orderedTraffic{events: &events},
209+ }, zerowrap.Default())
210+
211+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
212+ require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
213+ assert.Empty(t, events, "preflight must not touch traffic before it succeeds")
214+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
215+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
216+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
217+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
218+ runtime.AssertNotCalled(t, "ListNetworks", mock.Anything)
219+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
220+}
221+
222+// TestDeploy_WithdrawalFailureBlocksMutation proves a traffic withdrawal that
223+// cannot be applied leaves the running generation untouched: no container is
224+// stopped, removed, created, or started, and no ACTIVE state is published.
225+func TestDeploy_WithdrawalFailureBlocksMutation(t *testing.T) {
226+ ctx := context.Background()
227+ state, runtime, images, secrets := mockDeps(t)
228+ rev := mockRevision()
229+
230+ expectDeployPreflight(state, runtime, images, secrets, rev)
231+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil)
232+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
233+
234+ svc := deployment.NewService(deployment.Deps{
235+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
236+ Traffic: &orderedTraffic{failWithdraw: true},
237+ }, zerowrap.Default())
238+
239+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
240+ require.Error(t, err)
241+ require.NotNil(t, result)
242+ assert.Equal(t, "failed", result.Services["web"].Result)
243+ assert.Contains(t, result.Services["web"].Error, "withdraw")
244+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
245+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
246+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
247+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
248+ runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything, mock.Anything)
249+ runtime.AssertNotCalled(t, "ListNetworks", mock.Anything)
250+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
251+ state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, mock.Anything, mock.Anything)
252+}
253+
254+// TestDeploy_FirstDeploymentRetiresNothing proves a service with no previous
255+// ACTIVE generation is created, verified, and published without any
256+// withdrawal or retirement step.
257+func TestDeploy_FirstDeploymentRetiresNothing(t *testing.T) {
258+ ctx := context.Background()
259+ state, runtime, images, secrets := mockDeps(t)
260+ rev := mockRevision()
261+
262+ expectDeployPreflight(state, runtime, images, secrets, rev)
263+ expectNetworkProvision(runtime, "app-blog", 1)
264+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
265+ App: "blog", Services: map[string]domain.AppEffectiveService{},
266+ }, true, nil)
267+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
268+
269+ var order []string
270+ expectSequentialCreate(state, runtime, &order, "c-new", 18080)
271+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
272+ App: "blog", Services: map[string]domain.AppEffectiveService{},
273+ }, true, nil)
274+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(
275+ func(context.Context, domain.AppActive) error {
276+ recordEvent(&order, "publish-active")
277+ return nil
278+ }).Once()
279+ state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
280+
281+ traffic := &orderedTraffic{events: &order}
282+ svc := deployment.NewService(deployment.Deps{
283+ State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,
284+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
285+ func(context.Context, string, string) (int, error) {
286+ recordEvent(&order, "ready")
287+ return 200, nil
288+ },
289+ func(context.Context, string) error { return nil },
290+ ))
291+
292+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
293+ require.NoError(t, err)
294+ require.NotNil(t, result)
295+ assert.Equal(t, "deployed", result.Services["web"].Result)
296+ assert.Equal(t,
297+ []string{"create-new", "start-new", "ready", "publish-active", "traffic"},
298+ order,
299+ "a first deployment withdraws nothing and retires nothing")
300+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
301+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
302+}
303+
304+// TestDeploy_ReadinessFailureJournalsFailureAndRemovesCandidate proves a
305+// replacement that is not ready is journaled as failed, that ACTIVE is never
306+// published for it, and that the unready candidate is removed. The superseded
307+// container is already gone and is never recreated.
308+func TestDeploy_ReadinessFailureJournalsFailureAndRemovesCandidate(t *testing.T) {
309+ ctx := context.Background()
310+ state, runtime, images, secrets := mockDeps(t)
311+ rev := mockRevision()
312+ rev.Spec.Services[0].Readiness.Timeout = 50 * time.Millisecond
313+
314+ expectReplacementPreflight(state, runtime, images, secrets, rev)
315+ expectNetworkProvision(runtime, "app-blog", 1)
316+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil)
317+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
318+
319+ var order []string
320+ expectRetireOld(state, runtime, &order, "c-old")
321+ expectSequentialCreate(state, runtime, &order, "c-new", 18080)
322+ runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()
323+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).RunAndReturn(
324+ func(context.Context, string, bool) error {
325+ recordEvent(&order, "remove-candidate")
326+ return nil
327+ }).Once()
328+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once()
329+
330+ var saved []domain.AppOperation
331+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
332+ func(_ context.Context, op domain.AppOperation) error {
333+ saved = append(saved, op)
334+ return nil
335+ })
336+
337+ svc := deployment.NewService(deployment.Deps{
338+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
339+ Traffic: &orderedTraffic{events: &order},
340+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
341+ func(context.Context, string, string) (int, error) { return 503, nil },
342+ func(context.Context, string) error { return assert.AnError },
343+ ))
344+
345+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
346+ require.Error(t, err)
347+ require.NotNil(t, result)
348+ assert.Equal(t, "failed", result.Services["web"].Result)
349+ assert.Contains(t, result.Services["web"].Error, "readiness", "the failure names the readiness probe")
350+ assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error")
351+ assert.Equal(t, "c-new", result.Services["web"].After)
352+
353+ // ACTIVE was never repointed and the replaced generation's inhibition was
354+ // never cleared: nothing claims the old workload is available again.
355+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
356+ state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
357+
358+ require.NotEmpty(t, saved)
359+ last := saved[len(saved)-1]
360+ assert.Equal(t, domain.AppOutcomeFailed, last.Outcome, "a failed replacement must not be journaled as success")
361+ step, ok := opStep(last, "service.web.replace")
362+ require.True(t, ok)
363+ assert.Equal(t, domain.AppStepFailed, step.State)
364+
365+ assert.Equal(t, "remove-candidate", order[len(order)-1], "the unready candidate is the last thing removed")
366+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything)
367+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", true)
368+}
369+
370+// TestDeploy_CandidateCleanupFailureStaysVisible proves a candidate that
371+// cannot be removed does not turn a failed replacement into a success: the
372+// leftover is reported as a cleanup warning on the failed service.
373+func TestDeploy_CandidateCleanupFailureStaysVisible(t *testing.T) {
374+ ctx := context.Background()
375+ state, runtime, images, secrets := mockDeps(t)
376+ rev := mockRevision()
377+ rev.Spec.Services[0].Readiness.Timeout = 50 * time.Millisecond
378+
379+ expectDeployPreflight(state, runtime, images, secrets, rev)
380+ expectNetworkProvision(runtime, "app-blog", 1)
381+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil)
382+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
383+
384+ expectRetireOld(state, runtime, nil, "c-old")
385+ expectSequentialCreate(state, runtime, nil, "c-new", 18080)
386+ runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()
387+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(assert.AnError).Once()
388+
389+ svc := deployment.NewService(deployment.Deps{
390+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
391+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
392+ func(context.Context, string, string) (int, error) { return 503, nil },
393+ func(context.Context, string) error { return assert.AnError },
394+ ))
395+
396+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
397+ require.Error(t, err)
398+ require.NotNil(t, result)
399+ svcResult := result.Services["web"]
400+ assert.Equal(t, "failed", svcResult.Result)
401+ require.Len(t, svcResult.CleanupWarnings, 1, "an unremovable candidate is reported, never hidden")
402+ assert.Equal(t, "web", svcResult.CleanupWarnings[0].Service)
403+ assert.Equal(t, "c-new", svcResult.CleanupWarnings[0].Leftover)
404+ assert.Contains(t, svcResult.CleanupWarnings[0].Detail, "remove")
405+ state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, "blog", "c-new")
406+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
407+}
408+
409+// TestDeploy_CancellationDuringReadinessIsNotReportedAsSuccess proves a
410+// cancellation during the readiness wait never becomes a success: ACTIVE is
411+// not published, no terminal outcome is written, and the candidate cleanup
412+// that the canceled context could not complete is reported as a leftover.
413+// The durable store and the runtime refuse work on a canceled context, so
414+// this test models both.
415+func TestDeploy_CancellationDuringReadinessIsNotReportedAsSuccess(t *testing.T) {
416+ ctx, cancel := context.WithCancel(context.Background())
417+ defer cancel()
418+ state, runtime, images, secrets := mockDeps(t)
419+ rev := mockRevision()
420+ rev.Spec.Services[0].Readiness.Timeout = 30 * time.Second
421+
422+ expectReplacementPreflight(state, runtime, images, secrets, rev)
423+ expectNetworkProvision(runtime, "app-blog", 1)
424+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil)
425+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
426+
427+ expectRetireOld(state, runtime, nil, "c-old")
428+ expectSequentialCreate(state, runtime, nil, "c-new", 18080)
429+ // The runtime refuses to remove the candidate with a canceled context.
430+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(context.Canceled).Once()
431+
432+ var saved []domain.AppOperation
433+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
434+ func(opCtx context.Context, op domain.AppOperation) error {
435+ if opCtx.Err() != nil {
436+ // The durable store refuses writes on a canceled context, so
437+ // the operation stays in flight instead of becoming terminal.
438+ return opCtx.Err()
439+ }
440+ saved = append(saved, op)
441+ return nil
442+ })
443+
444+ probeStarted := make(chan struct{})
445+ svc := deployment.NewService(deployment.Deps{
446+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
447+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
448+ func(probeCtx context.Context, _, _ string) (int, error) {
449+ select {
450+ case <-probeStarted:
451+ default:
452+ close(probeStarted)
453+ }
454+ <-probeCtx.Done()
455+ return 0, probeCtx.Err()
456+ },
457+ func(context.Context, string) error { return nil },
458+ ))
459+
460+ done := make(chan error, 1)
461+ var deployResult *deployment.DeployResult
462+ go func() {
463+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
464+ deployResult = result
465+ done <- err
466+ }()
467+ select {
468+ case <-probeStarted:
469+ case <-time.After(5 * time.Second):
470+ t.Fatal("timed out waiting for the readiness probe")
471+ }
472+ cancel()
473+ err := <-done
474+ require.Error(t, err)
475+ assert.Contains(t, err.Error(), context.Canceled.Error(), "the canceled probe is reported")
476+
477+ // No success is claimed anywhere: ACTIVE is untouched, and no journaled
478+ // outcome records the replacement as succeeded. The durable store refuses
479+ // the terminal write on a canceled context, so the operation stays in
480+ // flight rather than becoming a false success.
481+ state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
482+ require.NotEmpty(t, saved)
483+ for _, op := range saved {
484+ assert.NotContains(t, []string{domain.AppOutcomeSuccess, domain.AppOutcomePartial}, op.Outcome,
485+ "a canceled replacement is never journaled as a success")
486+ if step, ok := opStep(op, "service.web.replace"); ok {
487+ assert.NotEqual(t, domain.AppStepSucceeded, step.State,
488+ "a canceled replacement step is never recorded as succeeded")
489+ }
490+ }
491+
492+ // The canceled cleanup is best effort, and what it could not finish is
493+ // reported instead of being hidden.
494+ require.NotNil(t, deployResult)
495+ require.Len(t, deployResult.Services["web"].CleanupWarnings, 1,
496+ "a candidate the canceled cleanup could not remove is reported")
497+ assert.Equal(t, "c-new", deployResult.Services["web"].CleanupWarnings[0].Leftover)
498+ runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-new", true)
499+ state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, "blog", "c-new")
500+}
501+
502+// TestDeploy_MultiServiceStopsAtFirstFailure proves services are deployed in
503+// sorted order and the first failure stops the run: the failing service is
504+// cleaned up, later services are untouched, and the services already deployed
505+// are kept without any rollback.
506+func TestDeploy_MultiServiceStopsAtFirstFailure(t *testing.T) {
507+ ctx := context.Background()
508+ state, runtime, images, secrets := mockDeps(t)
509+
510+ httpService := func(name, host, image string, timeout time.Duration) domain.AppService {
511+ return domain.AppService{
512+ Name: name, Image: image,
513+ Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: timeout},
514+ HTTP: []domain.AppHTTPInterface{{Host: host, Port: 8080, TLS: domain.AppTLSAuto}},
515+ }
516+ }
517+ rev := testRevision("blog",
518+ httpService("alpha", "alpha.example.com", "docker.io/example/alpha:1.4.2", time.Second),
519+ httpService("beta", "beta.example.com", "docker.io/example/beta:1.4.2", 50*time.Millisecond),
520+ httpService("gamma", "gamma.example.com", "docker.io/example/gamma:1.4.2", time.Second),
521+ )
522+
523+ state.EXPECT().Recover(mock.Anything).Return(nil)
524+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
525+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/alpha:1.4.2").Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
526+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/beta:1.4.2").Return("sha256:"+strings.Repeat("b", 64), nil).Once()
527+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/gamma:1.4.2").Return("sha256:"+strings.Repeat("c", 64), nil).Once()
528+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/alpha:1.4.2").Return(nil, nil).Once()
529+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/beta:1.4.2").Return(nil, nil).Once()
530+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/gamma:1.4.2").Return(nil, nil).Once()
531+ state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
532+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
533+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
534+ App: "blog", Services: map[string]domain.AppEffectiveService{},
535+ }, true, nil)
536+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
537+ expectNetworkProvision(runtime, "app-blog", 2)
538+
539+ expectSequentialCreate(state, runtime, nil, "c-alpha", 18080)
540+ state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(active domain.AppActive) bool {
541+ return active.Services["alpha"].Container == "c-alpha"
542+ })).Return(nil).Once()
543+ state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
544+
545+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-beta", mock.Anything).Return(
546+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
547+ state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
548+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-beta", Name: "beta"}, nil).Once()
549+ runtime.EXPECT().StartContainer(mock.Anything, "c-beta").Return(nil).Once()
550+ runtime.EXPECT().GetContainerLogs(mock.Anything, "c-beta", false).Return(nil, assert.AnError).Once()
551+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-beta", true).Return(nil).Once()
552+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-beta").Return(nil).Once()
553+
554+ var saved []domain.AppOperation
555+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
556+ func(_ context.Context, op domain.AppOperation) error {
557+ saved = append(saved, op)
558+ return nil
559+ })
560+
561+ traffic := &orderedTraffic{}
562+ svc := deployment.NewService(deployment.Deps{
563+ State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,
564+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
565+ func(_ context.Context, url, _ string) (int, error) {
566+ if strings.Contains(url, "18081") {
567+ return 503, nil
568+ }
569+ return 200, nil
570+ },
571+ func(context.Context, string) error { return nil },
572+ ))
573+
574+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
575+ require.Error(t, err)
576+ require.NotNil(t, result)
577+ assert.Equal(t, "deployed", result.Services["alpha"].Result, "an already deployed service is kept")
578+ assert.Equal(t, "failed", result.Services["beta"].Result)
579+ assert.NotContains(t, result.Services, "gamma", "a later service is never attempted after a failure")
580+
581+ runtime.AssertNumberOfCalls(t, "CreateContainer", 2)
582+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
583+ return cfg.Name == "gamma"
584+ }))
585+ // No rollback of the already deployed service.
586+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
587+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-alpha", mock.Anything)
588+ state.AssertNumberOfCalls(t, "SaveActive", 1)
589+
590+ require.NotEmpty(t, saved)
591+ last := saved[len(saved)-1]
592+ assert.Equal(t, domain.AppOutcomePartial, last.Outcome,
593+ "the run stops at the first failure; the earlier success is kept, not rolled back")
594+}
595+
596+// TestDeploy_VolumeReplacementPreventsOverlappingWriters proves a stateful
597+// replacement durably inhibits the superseded generation before it is
598+// retired, stops and removes it before the candidate starts, and keeps the
599+// inhibition while the candidate's removal cannot be confirmed. Exclusion
600+// holds because the previous process is gone before the replacement can write.
601+func TestDeploy_VolumeReplacementPreventsOverlappingWriters(t *testing.T) {
602+ ctx := context.Background()
603+ store := newTestStore(t)
604+ runtime := outmocks.NewMockContainerRuntime(t)
605+ images := outmocks.NewMockImageResolver(t)
606+ secrets := outmocks.NewMockSecretProvider(t)
607+
608+ image := "registry.example.com/blog/web:1.4.2"
609+ spec := domain.AppService{
610+ Name: "web",
611+ Image: image,
612+ Secrets: map[string]string{},
613+ Volumes: []domain.AppVolume{{Name: "data", Path: "/data"}},
614+ HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: domain.AppTLSAuto}},
615+ Readiness: domain.AppReadiness{
616+ Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 50 * time.Millisecond,
617+ },
618+ }
619+ rev := testRevision("blog", spec)
620+
621+ require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"}))
622+ require.NoError(t, store.SaveActive(ctx, domain.AppActive{
623+ App: "blog",
624+ Services: map[string]domain.AppEffectiveService{
625+ "web": {
626+ Container: "c-old", EffectiveRevision: "rev-0", Spec: spec,
627+ BackendBinds: map[int]int{8080: 18080},
628+ },
629+ },
630+ }))
631+ seedDesired(t, ctx, store, rev)
632+ // The app keeps one incarnation: the private network every service joins
633+ // is derived from it.
634+ require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"}))
635+ expectNetworkProvision(runtime, "app-blog", 1)
636+
637+ images.EXPECT().ResolveDigest(mock.Anything, image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
638+ runtime.EXPECT().InspectImageVolumes(mock.Anything, image).Return(nil, nil).Once()
639+ runtime.EXPECT().VolumeExists(mock.Anything, mock.Anything).Return(false, nil).Once()
640+
641+ var order []string
642+ inhibitedBeforeStop := false
643+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(
644+ func(context.Context, string, time.Duration) error {
645+ recordEvent(&order, "stop-old")
646+ // The durable marker must already exist before the superseded
647+ // writer is stopped: recovery must never race the destructive step.
648+ inhibitions, loadErr := store.LoadRecoveryInhibitions(context.Background(), "blog")
649+ require.NoError(t, loadErr)
650+ inhibitedBeforeStop = len(inhibitions) == 1 &&
651+ inhibitions[0].ContainerID == "c-old" &&
652+ inhibitions[0].Reason == domain.AppInhibitReplacementPending
653+ return nil
654+ }).Once()
655+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).RunAndReturn(
656+ func(context.Context, string, bool) error {
657+ recordEvent(&order, "remove-old")
658+ return nil
659+ }).Once()
660+ runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
661+ func(context.Context, string, map[string]string) error {
662+ recordEvent(&order, "create-volume")
663+ return nil
664+ }).Once()
665+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(
666+ func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
667+ recordEvent(&order, "create-new")
668+ return &domain.Container{ID: "c-new", Name: "web"}, nil
669+ }).Once()
670+ runtime.EXPECT().StartContainer(mock.Anything, "c-new").RunAndReturn(
671+ func(context.Context, string) error {
672+ recordEvent(&order, "start-new")
673+ return nil
674+ }).Once()
675+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
676+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
677+ runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once()
678+ // The failed replacement removes its candidate once. Boot reconciliation
679+ // retries it because a terminal failed operation cannot know the earlier
680+ // removal succeeded without re-checking the runtime; that retry fails, so
681+ // recovery fails closed and never revives the superseded writer whose
682+ // volume the orphan may still touch.
683+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once()
684+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(assert.AnError).Once()
685+
686+ svc := deployment.NewService(deployment.Deps{
687+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
688+ ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
689+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
690+ func(context.Context, string, string) (int, error) { return 503, nil },
691+ func(context.Context, string) error { return assert.AnError },
692+ ))
693+
694+ _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
695+ require.Error(t, err)
696+
697+ assert.Equal(t, []string{"stop-old", "remove-old", "create-volume", "create-new", "start-new"}, order,
698+ "the superseded writer is gone before the replacement can write")
699+ assert.True(t, inhibitedBeforeStop,
700+ "the durable recovery inhibition exists before the superseded writer is stopped")
701+
702+ // The failed replacement never cleared the durable inhibition: a later
703+ // boot must not revive the old writer on top of data the candidate may
704+ // already have touched.
705+ inhibitions, loadErr := store.LoadRecoveryInhibitions(ctx, "blog")
706+ require.NoError(t, loadErr)
707+ require.Len(t, inhibitions, 1)
708+ assert.Equal(t, "c-old", inhibitions[0].ContainerID)
709+ assert.Equal(t, domain.AppInhibitReplacementPending, inhibitions[0].Reason)
710+
711+ // ACTIVE still names the superseded generation: the failure is explicit
712+ // and no availability was restored on its behalf.
713+ active, ok, loadErr := store.LoadActive(ctx, "blog")
714+ require.NoError(t, loadErr)
715+ require.True(t, ok)
716+ assert.Equal(t, "c-old", active.Services["web"].Container)
717+
718+ // The failed replacement keeps its candidate in the journal: a leftover
719+ // must stay traceable so reconciliation can converge it.
720+ failedOp, hasOp, loadErr := store.LoadLatestOperation(ctx, "blog")
721+ require.NoError(t, loadErr)
722+ require.True(t, hasOp)
723+ failedStep, found := opStep(failedOp, "service.web.replace")
724+ require.True(t, found)
725+ assert.Equal(t, domain.AppStepFailed, failedStep.State)
726+ assert.Equal(t, "c-new", failedStep.After, "the created candidate stays recorded")
727+
728+ rebooted := deployment.NewService(deployment.Deps{
729+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
730+ }, zerowrap.Default())
731+ bootErr := rebooted.ReconcileBoot(ctx)
732+ require.Error(t, bootErr)
733+ assert.Contains(t, bootErr.Error(), "could not be removed")
734+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, "c-old")
735+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, "c-old", mock.Anything)
736+
737+ // The orphan that could not be removed keeps the inhibition: the
738+ // superseded writer is never revived on top of data the candidate may
739+ // already have touched.
740+ keptInhibitions, keptErr := store.LoadRecoveryInhibitions(ctx, "blog")
741+ require.NoError(t, keptErr)
742+ require.Len(t, keptInhibitions, 1)
743+ assert.Equal(t, "c-old", keptInhibitions[0].ContainerID)
744+ assert.Equal(t, domain.AppInhibitReplacementPending, keptInhibitions[0].Reason)
745+}
M internal/usecase/deployment/service.go +121 -47
@@ -103,6 +103,14 @@ type Service struct {
103 // the lock.103 // the lock.
104 bindMu sync.RWMutex104 bindMu sync.RWMutex
105 bindPolicies map[string]domain.AppBindPolicy105 bindPolicies map[string]domain.AppBindPolicy
106+ // liveMu guards liveOps. liveOps holds the operations this process
107+ // owns between the StartDeploy claim and the end of ExecuteDeploy. It
108+ // is in-memory only: a fresh Service (boot) starts empty and may
109+ // reconcile every persisted non-terminal operation, while a
110+ // foreground mutation must never finalize an operation a live
111+ // goroutine still owns.
112+ liveMu sync.Mutex
113+ liveOps map[string]struct{}
106 }114 }
107 115
108 // NewService creates the deployment engine. All deps are required;116 // NewService creates the deployment engine. All deps are required;
@@ -119,6 +127,43 @@ func NewService(deps Deps, log zerowrap.Logger) *Service {
119 }127 }
120 }128 }
121 129
130+// markOperationLive records that this process owns one operation from the
131+// StartDeploy claim until ExecuteDeploy finishes. A live operation is the
132+// single owner's in-flight work: a foreground reconciliation must not
133+// finalize it, or the claim would be settled before the owner can execute it.
134+func (s *Service) markOperationLive(app, op string) {
135+ if app == "" || op == "" {
136+ return
137+ }
138+ s.liveMu.Lock()
139+ defer s.liveMu.Unlock()
140+ if s.liveOps == nil {
141+ s.liveOps = make(map[string]struct{})
142+ }
143+ s.liveOps[liveOperationKey(app, op)] = struct{}{}
144+}
145+
146+// clearOperationLive drops the live marker. It is called once ExecuteDeploy
147+// has recorded its outcome or left the claim recoverable, so a later
148+// reconciliation can converge the operation instead of leaving it in flight.
149+func (s *Service) clearOperationLive(app, op string) {
150+ s.liveMu.Lock()
151+ defer s.liveMu.Unlock()
152+ delete(s.liveOps, liveOperationKey(app, op))
153+}
154+
155+// operationLive reports whether this process still owns the operation.
156+func (s *Service) operationLive(app, op string) bool {
157+ s.liveMu.Lock()
158+ defer s.liveMu.Unlock()
159+ _, ok := s.liveOps[liveOperationKey(app, op)]
160+ return ok
161+}
162+
163+func liveOperationKey(app, op string) string {
164+ return app + "\x00" + op
165+}
166+
122 // WithGCBarrier wires the process-wide GC barrier. Every workload167 // WithGCBarrier wires the process-wide GC barrier. Every workload
123 // mutation holds a shared lease from resource selection through the168 // mutation holds a shared lease from resource selection through the
124 // durable publication of the resource's protection, so prune can never169 // durable publication of the resource's protection, so prune can never
@@ -258,7 +303,6 @@ type DeployResult struct {
258 Outcome string303 Outcome string
259 Services map[string]ServiceResult304 Services map[string]ServiceResult
260 CleanupWarnings []CleanupWarning305 CleanupWarnings []CleanupWarning
261- Interrupted []string
262 // Removed names services retired because the new revision no longer306 // Removed names services retired because the new revision no longer
263 // declares them.307 // declares them.
264 Removed []string308 Removed []string
@@ -279,12 +323,6 @@ type ServiceResult struct {
279 // publishes for the active record. Nil when the service declares323 // publishes for the active record. Nil when the service declares
280 // no UDP interface.324 // no UDP interface.
281 UDPBackendBinds map[int]int325 UDPBackendBinds map[int]int
282- // Retire is the exact container ID to stop+remove AFTER the new
283- // effective state is published. Empty when nothing retires.
284- Retire string
285- // RetireGrace is the effective stop grace of the container named by
286- // Retire: the generation being stopped, never its replacement.
287- RetireGrace time.Duration
288 // CleanupWarnings are bounded leftovers of this service's terminal326 // CleanupWarnings are bounded leftovers of this service's terminal
289 // path: a candidate that could not be removed, or a backend claim327 // path: a candidate that could not be removed, or a backend claim
290 // that could not be released.328 // that could not be released.
@@ -330,38 +368,21 @@ func newOpID() string {
330 return "op-" + string(buf[:])368 return "op-" + string(buf[:])
331 }369 }
332 370
333-// Preflight resolves a captured revision without any workload mutation:371+// claimDeploymentLocked is the first phase of a deploy: it resolves the
334-// image digests, secret presence, image-volume mapping, reservation372+// requested revision, enforces the targeted-deploy convergence rules, and
335-// recheck, and resource preconditions. It records the pinned digest373+// claims the request key, persisting the non-terminal journal. It performs
336-// table into the journal BEFORE any effect.374+// no image pull and no workload mutation, so a crash after it leaves a
337-func (s *Service) Preflight(ctx context.Context, input DeployInput) ([]pinnedService, *domain.AppOperation, error) {375+// durable claim that reconciliation can converge. owned is true only for the
338- release, err := s.acquireAppContext(ctx, input.App)376+// single claimer of the key; a replay returns the stored journal instead.
339- if err != nil {377+// The caller holds the app lock and has already recovered the store.
340- return nil, nil, err378+func (s *Service) claimDeploymentLocked(ctx context.Context, input DeployInput) (domain.AppDesiredRevision, domain.AppOperation, bool, error) {
341- }
342- defer release()
343- pinned, op, _, err := s.preflightLocked(ctx, input)
344- return pinned, op, err
345-}
346-
347-func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pinnedService, *domain.AppOperation, bool, error) {
348- ctx = zerowrap.CtxWithFields(ctx, map[string]any{
349- zerowrap.FieldLayer: "usecase",
350- zerowrap.FieldUseCase: "Preflight",
351- "app": input.App,
352- })
353- log := zerowrap.FromCtx(ctx)
354-
355- if err := s.deps.State.Recover(ctx); err != nil {
356- return nil, nil, false, fmt.Errorf("deployment: recover before preflight: %w", err)
357- }
358 rev, err := s.resolveRevision(ctx, input)379 rev, err := s.resolveRevision(ctx, input)
359 if err != nil {380 if err != nil {
360- return nil, nil, false, err381+ return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err
361 }382 }
362 if input.Service != "" {383 if input.Service != "" {
363 if err := s.checkConverged(ctx, input.App, input.Service, rev); err != nil {384 if err := s.checkConverged(ctx, input.App, input.Service, rev); err != nil {
364- return nil, nil, false, err385+ return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err
365 }386 }
366 }387 }
367 388
@@ -374,26 +395,42 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
374 }395 }
375 op, owned, err := s.claimOperation(ctx, input.Op, op, []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}})396 op, owned, err := s.claimOperation(ctx, input.Op, op, []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}})
376 if err != nil {397 if err != nil {
377- return nil, nil, false, err398+ return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err
378- }
379- if !owned {
380- // The key already answered this request: its journal is the
381- // result, and no effect may run again.
382- return nil, &op, true, replayError(op)
383 }399 }
400+ return rev, op, owned, nil
401+}
384 402
385- pinned, err := s.preflightServices(ctx, input.App, rev, input.Service)403+// pinPreflightLocked is the second preflight phase: it runs the image
404+// pull/pin and resource gates of an already claimed operation and records the
405+// pinned table (or the terminal preflight failure) in the journal. The
406+// caller holds the app lock and owns the claimed, non-terminal operation.
407+func (s *Service) pinPreflightLocked(ctx context.Context, app, onlyService string, rev domain.AppDesiredRevision, op *domain.AppOperation) ([]pinnedService, error) {
408+ log := zerowrap.FromCtx(ctx)
409+
410+ pinned, err := s.preflightServices(ctx, app, rev, onlyService)
386 if err != nil {411 if err != nil {
387 op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepFailed, Error: err.Error()}412 op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepFailed, Error: err.Error()}
388 op.Outcome = domain.AppOutcomeFailed413 op.Outcome = domain.AppOutcomeFailed
389- if saveErr := s.deps.State.SaveOperation(ctx, op); saveErr != nil {414+ if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {
390 log.Warn().Err(saveErr).Msg("deployment: failed to record preflight failure")415 log.Warn().Err(saveErr).Msg("deployment: failed to record preflight failure")
391 }416 }
392- return nil, &op, false, err417+ return nil, err
393 }418 }
394 op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepSucceeded}419 op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepSucceeded}
420+ // A resumed claim can carry the service-step plan of an earlier attempt,
421+ // including candidates it recorded. Converge those candidates before
422+ // replacing the plan: an unreconciled leftover may still be running, and
423+ // the replacement this execution creates must never overlap it.
424+ if err := s.convergeResumedCandidates(ctx, app, op, log); err != nil {
425+ return nil, err
426+ }
427+ // Replace the persisted plan instead of appending: the preflight step plus
428+ // one step per pinned service, so runServiceStep's index contract matches a
429+ // normalized layout and no earlier step is overwritten or left pending.
430+ plan := make([]domain.AppOperationStep, 1, len(pinned)+1)
431+ plan[0] = op.Steps[0]
395 for _, p := range pinned {432 for _, p := range pinned {
396- op.Steps = append(op.Steps, domain.AppOperationStep{433+ plan = append(plan, domain.AppOperationStep{
397 ID: "service." + p.name + ".replace",434 ID: "service." + p.name + ".replace",
398 State: domain.AppStepPending,435 State: domain.AppStepPending,
399 Service: p.name,436 Service: p.name,
@@ -401,11 +438,37 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
401 Image: p.runtimeImage,438 Image: p.runtimeImage,
402 })439 })
403 }440 }
404- if err := s.deps.State.SaveOperation(ctx, op); err != nil {441+ op.Steps = plan
405- return nil, nil, false, fmt.Errorf("deployment: persist pinned table: %w", err)442+ if err := s.deps.State.SaveOperation(ctx, *op); err != nil {
443+ return nil, fmt.Errorf("deployment: persist pinned table: %w", err)
406 }444 }
407 log.Info().Str("op", op.Op).Str("revision", rev.Revision).Int("services", len(pinned)).Msg("deployment: preflight passed")445 log.Info().Str("op", op.Op).Str("revision", rev.Revision).Int("services", len(pinned)).Msg("deployment: preflight passed")
408- return pinned, &op, false, nil446+ return pinned, nil
447+}
448+
449+// convergeResumedCandidates converges any candidate an earlier attempt of a
450+// resumed claim recorded, so replacing its service-step plan cannot drop a
451+// leftover that may still be running. A fresh claim has only its preflight
452+// step and does no work here.
453+func (s *Service) convergeResumedCandidates(ctx context.Context, app string, op *domain.AppOperation, log zerowrap.Logger) error {
454+ needsActive := false
455+ for _, step := range op.Steps {
456+ if reconcileNeedsContainer(step, false) {
457+ needsActive = true
458+ break
459+ }
460+ }
461+ if !needsActive {
462+ return nil
463+ }
464+ active, _, err := s.deps.State.LoadActive(ctx, app)
465+ if err != nil {
466+ return fmt.Errorf("deployment: load active for resumed claim: %w", err)
467+ }
468+ if _, err := s.convergeInterruptedSteps(ctx, app, op, active, false, log); err != nil {
469+ return err
470+ }
471+ return nil
409 }472 }
410 473
411 // claimOperation is the single claim point before any mutation effect.474 // claimOperation is the single claim point before any mutation effect.
@@ -418,6 +481,17 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
418 // journal under a generated id.481 // journal under a generated id.
419 func (s *Service) claimOperation(ctx context.Context, key string, op domain.AppOperation, steps []domain.AppOperationStep) (domain.AppOperation, bool, error) {482 func (s *Service) claimOperation(ctx context.Context, key string, op domain.AppOperation, steps []domain.AppOperationStep) (domain.AppOperation, bool, error) {
420 op.Steps = steps483 op.Steps = steps
484+ // A journal is never opened while a different operation of this app is
485+ // still unfinished: that predecessor must be reconciled first, or this
486+ // claim would mask it and its leftover generation could stay untracked.
487+ latest, found, err := s.deps.State.LoadLatestOperation(ctx, op.App)
488+ if err != nil {
489+ return domain.AppOperation{}, false, fmt.Errorf("deployment: load latest operation: %w", err)
490+ }
491+ if found && !latest.Terminal() && latest.Op != key {
492+ return domain.AppOperation{}, false, fmt.Errorf(
493+ "deployment: app %q has unfinished operation %s: %w", op.App, latest.Op, domain.ErrAppStateConflict)
494+ }
421 if key == "" {495 if key == "" {
422 op.Op = newOpID()496 op.Op = newOpID()
423 if err := s.deps.State.SaveOperation(ctx, op); err != nil {497 if err := s.deps.State.SaveOperation(ctx, op); err != nil {
M internal/usecase/deployment/service_test.go +7 -341
@@ -2,7 +2,6 @@ package deployment_test
2 2
3 import (3 import (
4 "context"4 "context"
5- "strings"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -48,333 +47,19 @@ func preflightService(
48 secrets *outmocks.MockSecretProvider,47 secrets *outmocks.MockSecretProvider,
49 ) *deployment.Service {48 ) *deployment.Service {
50 t.Helper()49 t.Helper()
50+ // Every mutation claims a journal only when no other operation of the
51+ // app is unfinished. These tests start clean.
52+ state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything).
53+ Return(domain.AppOperation{}, false, nil).Maybe()
51 return deployment.NewService(deployment.Deps{54 return deployment.NewService(deployment.Deps{
52 State: state, Runtime: runtime, Images: images, Secrets: secrets,55 State: state, Runtime: runtime, Images: images, Secrets: secrets,
53 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},56 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
54 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,57 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
55- func(context.Context, string) (int, error) { return 200, nil },58+ func(context.Context, string, string) (int, error) { return 200, nil },
56 func(context.Context, string) error { return nil },59 func(context.Context, string) error { return nil },
57 ))60 ))
58 }61 }
59 62
60-// expectFullPreflight wires every preflight gate on mocks.
61-func expectFullPreflight(
62- state *outmocks.MockAppState,
63- runtime *outmocks.MockContainerRuntime,
64- images *outmocks.MockImageResolver,
65- secrets *outmocks.MockSecretProvider,
66- rev domain.AppDesiredRevision,
67- imageVolumes []string,
68- digest string,
69- secretValue string,
70- secretErr error,
71-) {
72- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
73- state.EXPECT().LoadDesired(mock.Anything, rev.App).Return(rev, true, nil).Once()
74- state.EXPECT().LoadOwnership(mock.Anything, rev.App).Return(domain.AppOwnership{App: rev.App}, nil)
75- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
76- images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(digest, nil).Once()
77- if secretErr != nil {
78- secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("", secretErr).Once()
79- return
80- }
81- secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/blog/web/database-url").Return(secretValue, nil).Once()
82- runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(imageVolumes, nil).Once()
83- if secretErr == nil && imageVolumes == nil {
84- state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
85- }
86-}
87-
88-func TestPreflight_PassesAndPins(t *testing.T) {
89- ctx := context.Background()
90- state := outmocks.NewMockAppState(t)
91- runtime := outmocks.NewMockContainerRuntime(t)
92- images := outmocks.NewMockImageResolver(t)
93- secrets := outmocks.NewMockSecretProvider(t)
94- rev := testRevision("blog", webService())
95- expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "s3cr3t", nil)
96- svc := preflightService(t, state, runtime, images, secrets)
97-
98- pinned, op, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
99- require.NoError(t, err)
100- require.NotNil(t, pinned)
101- require.NotNil(t, op)
102- assert.Equal(t, "rev-1", op.InputRevision)
103- require.Len(t, op.Steps, 2)
104- assert.Equal(t, "succeeded", op.Steps[0].State)
105- assert.Contains(t, op.Steps[1].Digest, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
106- assert.Equal(t, "web", op.Steps[1].Service)
107- runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
108-}
109-
110-func TestPreflight_ImageFailureActivatesNothing(t *testing.T) {
111- ctx := context.Background()
112- state := outmocks.NewMockAppState(t)
113- runtime := outmocks.NewMockContainerRuntime(t)
114- images := outmocks.NewMockImageResolver(t)
115- secrets := outmocks.NewMockSecretProvider(t)
116- rev := testRevision("blog", webService())
117-
118- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
119- state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
120- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil)
121- images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("", assert.AnError).Once()
122- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
123- svc := preflightService(t, state, runtime, images, secrets)
124-
125- _, op, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
126- require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
127- require.NotNil(t, op)
128- assert.Equal(t, "failed", op.Outcome)
129- runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
130-}
131-
132-func TestPreflight_SecretMissingFailsClosed(t *testing.T) {
133- ctx := context.Background()
134- state := outmocks.NewMockAppState(t)
135- runtime := outmocks.NewMockContainerRuntime(t)
136- images := outmocks.NewMockImageResolver(t)
137- secrets := outmocks.NewMockSecretProvider(t)
138- rev := testRevision("blog", webService())
139- expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", assert.AnError)
140- svc := preflightService(t, state, runtime, images, secrets)
141-
142- _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
143- require.ErrorIs(t, err, domain.ErrAppSecretMissing)
144-}
145-
146-func TestPreflight_UnmanagedImageVolumeRejected(t *testing.T) {
147- ctx := context.Background()
148- state := outmocks.NewMockAppState(t)
149- runtime := outmocks.NewMockContainerRuntime(t)
150- images := outmocks.NewMockImageResolver(t)
151- secrets := outmocks.NewMockSecretProvider(t)
152- rev := testRevision("blog", webService())
153- expectFullPreflight(state, runtime, images, secrets, rev, []string{"/data"}, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "x", nil)
154- svc := preflightService(t, state, runtime, images, secrets)
155-
156- _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
157- require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume)
158-}
159-
160-// TestPreflight_RefusesUnownedExistingVolume proves R2: a generated
161-// runtime volume that already exists but is NOT recorded in the app's
162-// ownership is refused fail-closed — the deploy must never implicitly
163-// adopt (and modify) foreign data under a reused name.
164-func TestPreflight_RefusesUnownedExistingVolume(t *testing.T) {
165- ctx := context.Background()
166- state := outmocks.NewMockAppState(t)
167- runtime := outmocks.NewMockContainerRuntime(t)
168- images := outmocks.NewMockImageResolver(t)
169- secrets := outmocks.NewMockSecretProvider(t)
170- svc := webService()
171- svc.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
172- rev := testRevision("blog", svc)
173- expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "x", nil)
174- // Ownership knows no volumes; the runtime one is foreign.
175- runtime.EXPECT().VolumeExists(mock.Anything, "gordon-blog--web--vol--data").Return(true, nil).Once()
176- svcSvc := preflightService(t, state, runtime, images, secrets)
177-
178- _, _, err := svcSvc.Preflight(ctx, deployment.DeployInput{App: "blog"})
179- require.ErrorIs(t, err, domain.ErrAppStateConflict)
180- require.Contains(t, err.Error(), "not owned by app")
181- runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
182-}
183-
184-// TestPreflight_AcceptsOwnedExistingVolume proves R2 does not break the
185-// legitimate reuse path: a volume created by this app's own earlier
186-// deploy (recorded in ownership) is accepted.
187-func TestPreflight_AcceptsOwnedExistingVolume(t *testing.T) {
188- ctx := context.Background()
189- state := outmocks.NewMockAppState(t)
190- runtime := outmocks.NewMockContainerRuntime(t)
191- images := outmocks.NewMockImageResolver(t)
192- secrets := outmocks.NewMockSecretProvider(t)
193- svc := webService()
194- svc.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
195- rev := testRevision("blog", svc)
196- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
197- state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
198- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{
199- App: "blog",
200- Volumes: []domain.AppOwnedVolume{{
201- Name: "data", Service: "web",
202- RuntimeName: "gordon-blog--web--vol--data",
203- State: domain.AppResourceAttached,
204- }},
205- }, nil)
206- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
207- images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
208- secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/blog/web/database-url").Return("x", nil).Once()
209- runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once()
210- state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
211- runtime.EXPECT().VolumeExists(mock.Anything, "gordon-blog--web--vol--data").Return(true, nil).Once()
212- svcSvc := preflightService(t, state, runtime, images, secrets)
213-
214- pinned, _, err := svcSvc.Preflight(ctx, deployment.DeployInput{App: "blog"})
215- require.NoError(t, err)
216- require.Len(t, pinned, 1)
217-}
218-
219-func TestPreflight_TargetedRefusesDivergence(t *testing.T) {
220- ctx := context.Background()
221- state := outmocks.NewMockAppState(t)
222- runtime := outmocks.NewMockContainerRuntime(t)
223- images := outmocks.NewMockImageResolver(t)
224- secrets := outmocks.NewMockSecretProvider(t)
225- rev := testRevision("blog", webService())
226- rev.Revision = "rev-2"
227-
228- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
229- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
230- state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true}, true, nil).Once()
231- svc := preflightService(t, state, runtime, images, secrets)
232-
233- _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"})
234- require.ErrorIs(t, err, domain.ErrAppStateConflict)
235-}
236-
237-func TestPreflight_TargetedDiffPolicy(t *testing.T) {
238- baseService := webService()
239- worker := webService()
240- worker.Name = "worker"
241- worker.HTTP[0].Host = "worker.example.com"
242- base := domain.AppActive{
243- App: "blog", ConvergedRevision: "rev-1", Converged: true,
244- Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}},
245- Services: map[string]domain.AppEffectiveService{
246- "web": {Spec: baseService},
247- "worker": {Spec: worker},
248- },
249- }
250-
251- tests := []struct {
252- name string
253- mutate func(*domain.AppSpec)
254- service string
255- wantErr bool
256- }{
257- {name: "requested service image", service: "web", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }},
258- {name: "other service", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }},
259- {name: "environment", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }},
260- {name: "network", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }},
261- {name: "service added", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) {
262- spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
263- }},
264- }
265- for _, tc := range tests {
266- t.Run(tc.name, func(t *testing.T) {
267- spec := domain.AppSpec{Name: "blog", Networks: append([]domain.AppSharedNetwork(nil), base.Networks...), Services: []domain.AppService{baseService, worker}}
268- spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...)
269- tc.mutate(&spec)
270- state := outmocks.NewMockAppState(t)
271- runtime := outmocks.NewMockContainerRuntime(t)
272- images := outmocks.NewMockImageResolver(t)
273- secrets := outmocks.NewMockSecretProvider(t)
274- rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec}
275- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
276- state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
277- state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once()
278- if !tc.wantErr {
279- images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:"+strings.Repeat("a", 64), nil).Once()
280- secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("x", nil).Once()
281- runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once()
282- state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
283- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil).Once()
284- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Times(2)
285- }
286- svc := preflightService(t, state, runtime, images, secrets)
287- _, _, err := svc.Preflight(context.Background(), deployment.DeployInput{App: "blog", Revision: "rev-2", Service: tc.service})
288- if tc.wantErr {
289- require.ErrorIs(t, err, domain.ErrAppStateConflict)
290- } else {
291- require.NoError(t, err)
292- }
293- })
294- }
295-}
296-
297-func TestPreflight_TargetedConvergedPasses(t *testing.T) {
298- ctx := context.Background()
299- state := outmocks.NewMockAppState(t)
300- runtime := outmocks.NewMockContainerRuntime(t)
301- images := outmocks.NewMockImageResolver(t)
302- secrets := outmocks.NewMockSecretProvider(t)
303- rev := testRevision("blog", webService())
304-
305- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
306- state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
307- state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true}, true, nil).Once()
308- images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
309- secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("x", nil).Once()
310- runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once()
311- state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
312- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil)
313- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Times(2)
314- svc := preflightService(t, state, runtime, images, secrets)
315-
316- _, op, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog", Service: "web"})
317- require.NoError(t, err)
318- require.NotNil(t, op)
319-}
320-
321-func TestPreflight_MutableTagReresolved(t *testing.T) {
322- ctx := context.Background()
323- newDeps := func() (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) {
324- return outmocks.NewMockAppState(t), outmocks.NewMockContainerRuntime(t), outmocks.NewMockImageResolver(t), outmocks.NewMockSecretProvider(t)
325- }
326- svcSpec := webService()
327- svcSpec.Image = "registry.example.com/blog/web:latest"
328- rev := testRevision("blog", svcSpec)
329-
330- firstDigest := "sha256:" + strings.Repeat("a", 64)
331- secondDigest := "sha256:" + strings.Repeat("b", 64)
332- state, runtime, images, secrets := newDeps()
333- expectFullPreflight(state, runtime, images, secrets, rev, nil, firstDigest, "x", nil)
334- svc := preflightService(t, state, runtime, images, secrets)
335- _, op1, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
336- require.NoError(t, err)
337- assert.Contains(t, op1.Steps[1].Digest, firstDigest)
338-
339- state2, runtime2, images2, secrets2 := newDeps()
340- expectFullPreflight(state2, runtime2, images2, secrets2, rev, nil, secondDigest, "x", nil)
341- svc2 := preflightService(t, state2, runtime2, images2, secrets2)
342- _, op2, err := svc2.Preflight(ctx, deployment.DeployInput{App: "blog"})
343- require.NoError(t, err)
344- assert.Contains(t, op2.Steps[1].Digest, secondDigest)
345- assert.NotEqual(t, op1.Op, op2.Op)
346-}
347-
348-// TestPreflight_RejectsCrossAppReservationConflict proves preflight
349-// re-validates the candidate against the live global table with the
350-// same wildcard-aware policy as apply.
351-func TestPreflight_RejectsCrossAppReservationConflict(t *testing.T) {
352- ctx := context.Background()
353- state := outmocks.NewMockAppState(t)
354- runtime := outmocks.NewMockContainerRuntime(t)
355- images := outmocks.NewMockImageResolver(t)
356- secrets := outmocks.NewMockSecretProvider(t)
357- svcSpec := webService()
358- svcSpec.HTTP = nil
359- svcSpec.TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "127.0.0.1:19090"}}
360- rev := testRevision("blog", svcSpec)
361- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
362- state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
363- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil)
364- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
365- images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
366- secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/blog/web/database-url").Return("x", nil).Once()
367- runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil).Once()
368- state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{
369- Reservations: []domain.AppListenerReservation{{Proto: "tcp", IP: "dual", Port: 19090, Service: "s", App: "other"}},
370- }, nil).Once()
371- svc := preflightService(t, state, runtime, images, secrets)
372-
373- _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"})
374- require.ErrorIs(t, err, domain.ErrAppReservationConflict)
375- runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
376-}
377-
378 func TestComputeOutcome_TerminalResults(t *testing.T) {63 func TestComputeOutcome_TerminalResults(t *testing.T) {
379 deployed := deployment.ServiceResult{Result: "deployed"}64 deployed := deployment.ServiceResult{Result: "deployed"}
380 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}65 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}
@@ -412,6 +97,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
412 // bad app: Start fails bind verification, binds withdrawn.97 // bad app: Start fails bind verification, binds withdrawn.
413 state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once()98 state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once()
414 state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil)99 state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil)
100+ state.EXPECT().LoadLatestOperation(mock.Anything, "bad").Return(domain.AppOperation{}, false, nil).Maybe()
415 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)101 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
416 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)102 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
417 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()103 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()
@@ -423,6 +109,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
423 state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once()109 state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once()
424 // Start load + bind-refresh reload.110 // Start load + bind-refresh reload.
425 state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil)111 state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil)
112+ state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()
426 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()113 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()
427 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()114 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()
428 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()115 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
@@ -434,24 +121,3 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
434 require.Error(t, err)121 require.Error(t, err)
435 assert.Contains(t, err.Error(), `"bad"`)122 assert.Contains(t, err.Error(), `"bad"`)
436 }123 }
437-
438-func TestPreflight_EnvCollisionFailsClosed(t *testing.T) {
439- ctx := context.Background()
440- state := outmocks.NewMockAppState(t)
441- runtime := outmocks.NewMockContainerRuntime(t)
442- images := outmocks.NewMockImageResolver(t)
443- secrets := outmocks.NewMockSecretProvider(t)
444- svc := webService()
445- rev := testRevision("blog", svc)
446- rev.Spec.Env = map[string]string{"DATABASE_URL": "public-value"}
447-
448- state.EXPECT().Recover(mock.Anything).Return(nil).Once()
449- state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
450- state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil).Once()
451- state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
452- svcSvc := preflightService(t, state, runtime, images, secrets)
453-
454- _, _, err := svcSvc.Preflight(ctx, deployment.DeployInput{App: "blog"})
455- require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
456- runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
457-}
M internal/usecase/deployment/traffic_failure_test.go +10 -4
@@ -50,6 +50,11 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
50 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)50 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
51 51
52 expectNetworkProvision(runtime, "app-blog", 1)52 expectNetworkProvision(runtime, "app-blog", 1)
53+ // The superseded generation is confirmed gone before the replacement is
54+ // created: a deploy never overlaps two generations of one service.
55+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
56+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
57+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
53 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()58 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
54 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()59 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
55 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(60 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
@@ -68,7 +73,7 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
68 svc := deployment.NewService(deployment.Deps{73 svc := deployment.NewService(deployment.Deps{
69 State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,74 State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,
70 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,75 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
71- func(context.Context, string) (int, error) { return 200, nil },76+ func(context.Context, string, string) (int, error) { return 200, nil },
72 func(context.Context, string) error { return nil },77 func(context.Context, string) error { return nil },
73 ))78 ))
74 79
@@ -83,9 +88,10 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
83 step, ok := opStep(last, "service.web.replace")88 step, ok := opStep(last, "service.web.replace")
84 require.True(t, ok)89 require.True(t, ok)
85 assert.Equal(t, domain.AppStepFailed, step.State, "the service step must not claim success before traffic applied")90 assert.Equal(t, domain.AppStepFailed, step.State, "the service step must not claim success before traffic applied")
86- // The replaced container is retained: ACTIVE is published but the graph91+ // The superseded container is gone: a rejected graph is reported as a
87- // never accepted the new routing.92+ // failure of the new generation, never as a return to the old one.
88- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)93+ runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-old", false)
94+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything)
89 }95 }
90 96
91 // TestStop_TrafficFailureRecordsTerminalFailure proves a rejected graph97 // TestStop_TrafficFailureRecordsTerminalFailure proves a rejected graph
A internal/usecase/deployment/two_phase_deploy_test.go +459 -0
@@ -0,0 +1,459 @@
1+package deployment_test
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/internal/usecase/deployment"
16+)
17+
18+// TestStartDeploy_PersistsClaimBeforeAnyExecution proves the first phase
19+// durably claims the key with a non-terminal journal and touches no runtime
20+// resource: no image resolution, no pull, no workload mutation. A crash right
21+// after StartDeploy therefore leaves a claim reconciliation can converge.
22+func TestStartDeploy_PersistsClaimBeforeAnyExecution(t *testing.T) {
23+ t.Run("keyed", func(t *testing.T) {
24+ ctx := context.Background()
25+ state, runtime, images, secrets := mockDeps(t)
26+ rev := mockRevision()
27+
28+ state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
29+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
30+ state.EXPECT().ClaimOperation(mock.Anything, mock.MatchedBy(func(op domain.AppOperation) bool {
31+ return op.Op == "k1" && !op.Terminal()
32+ })).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) {
33+ return op, true, nil
34+ }).Once()
35+
36+ svc := keyedService(t, state, runtime, images, secrets)
37+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
38+
39+ require.NoError(t, err)
40+ require.True(t, started.Owned)
41+ require.Equal(t, "k1", started.Claim.Op)
42+ require.False(t, started.Claim.Journal.Terminal(), "the claim is non-terminal before execution")
43+ require.Len(t, started.Claim.Journal.Steps, 1)
44+ assert.Equal(t, domain.AppStepPending, started.Claim.Journal.Steps[0].State)
45+
46+ state.AssertNotCalled(t, "LoadOwnership", mock.Anything, mock.Anything)
47+ images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
48+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
49+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
50+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
51+ })
52+
53+ t.Run("unkeyed", func(t *testing.T) {
54+ ctx := context.Background()
55+ state, runtime, images, secrets := mockDeps(t)
56+ rev := mockRevision()
57+
58+ var saved []domain.AppOperation
59+ state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
60+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
61+ saved = append(saved, op)
62+ return nil
63+ }).Maybe()
64+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
65+
66+ svc := keyedService(t, state, runtime, images, secrets)
67+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
68+
69+ require.NoError(t, err)
70+ require.True(t, started.Owned)
71+ require.Len(t, saved, 1, "the claim journal is persisted before any effect")
72+ require.False(t, saved[0].Terminal())
73+ assert.Equal(t, domain.AppStepPending, saved[0].Steps[0].State)
74+ images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything)
75+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
76+ })
77+}
78+
79+// TestStartDeploy_SameKeyHandsOneOwner proves the second phase is only ever
80+// handed to the single claimer: a repeated key reports Owned false, carries
81+// the stored journal, and replays with an explicit conflict instead of a
82+// second execution.
83+func TestStartDeploy_SameKeyHandsOneOwner(t *testing.T) {
84+ ctx := context.Background()
85+ state, runtime, images, secrets := mockDeps(t)
86+ rev := mockRevision()
87+
88+ stored := domain.AppOperation{
89+ Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
90+ Request: domain.AppOperationRequestFor("deploy", "blog", "", ""),
91+ Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
92+ }
93+ state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
94+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
95+ state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(domain.AppOperation{}, true, nil).Once()
96+ state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(stored, false, nil).Once()
97+
98+ svc := keyedService(t, state, runtime, images, secrets)
99+ first, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
100+ require.NoError(t, err)
101+ require.True(t, first.Owned)
102+
103+ second, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
104+ require.NoError(t, err)
105+ require.False(t, second.Owned, "the key is never handed to a second owner")
106+ require.Equal(t, "k1", second.Claim.Journal.Op)
107+ require.ErrorIs(t, second.ReplayError(), domain.ErrAppStateConflict)
108+
109+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
110+}
111+
112+// expectHeadlessDeploy wires one full sequential replacement of a headless
113+// service whose only effect is one container replacement, and records every
114+// journal write so the caller can assert claim-before-execution ordering.
115+func expectHeadlessDeploy(
116+ state *outmocks.MockAppState,
117+ runtime *outmocks.MockContainerRuntime,
118+ images *outmocks.MockImageResolver,
119+ secrets *outmocks.MockSecretProvider,
120+) (domain.AppDesiredRevision, *[]domain.AppOperation) {
121+ svcSpec := webService()
122+ svcSpec.HTTP = nil
123+ svcSpec.Readiness = domain.AppReadiness{}
124+ rev := domain.AppDesiredRevision{
125+ Revision: "rev-1", App: "blog",
126+ Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svcSpec}},
127+ }
128+
129+ saved := &[]domain.AppOperation{}
130+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
131+ *saved = append(*saved, op)
132+ return nil
133+ }).Maybe()
134+ state.EXPECT().Recover(mock.Anything).Return(nil)
135+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
136+ images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil)
137+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
138+ runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil)
139+ state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
140+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
141+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
142+ App: "blog", Services: map[string]domain.AppEffectiveService{},
143+ }, true, nil)
144+ expectNetworkProvision(runtime, "app-blog", 1)
145+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil)
146+ runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil)
147+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
148+ state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
149+ return rev, saved
150+}
151+
152+// TestExecuteDeploy_RecordsTerminalOutcome proves the second phase executes
153+// the already claimed operation and records its terminal success, while the
154+// claim itself stayed non-terminal up to execution.
155+func TestExecuteDeploy_RecordsTerminalOutcome(t *testing.T) {
156+ ctx := context.Background()
157+ state, runtime, images, secrets := mockDeps(t)
158+ _, saved := expectHeadlessDeploy(state, runtime, images, secrets)
159+
160+ svc := deployment.NewService(deployment.Deps{
161+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
162+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
163+ func(context.Context, string, string) (int, error) { return 200, nil },
164+ func(context.Context, string) error { return nil },
165+ ))
166+
167+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
168+ require.NoError(t, err)
169+ require.True(t, started.Owned)
170+ require.Len(t, *saved, 1)
171+ require.False(t, (*saved)[0].Terminal(), "the claim is non-terminal before execution")
172+
173+ result, err := svc.ExecuteDeploy(ctx, started.Claim)
174+
175+ require.NoError(t, err)
176+ require.NotNil(t, result)
177+ assert.Equal(t, "deployed", result.Services["web"].Result)
178+ last := (*saved)[len(*saved)-1]
179+ require.True(t, last.Terminal(), "the second phase records a terminal outcome")
180+ assert.Equal(t, domain.AppOutcomeSuccess, last.Outcome)
181+ runtime.AssertNumberOfCalls(t, "CreateContainer", 1)
182+}
183+
184+// TestExecuteDeploy_RecordsTerminalFailure proves a preflight that fails
185+// during the second phase records the terminal failure in the claimed
186+// journal and runs no workload.
187+func TestExecuteDeploy_RecordsTerminalFailure(t *testing.T) {
188+ ctx := context.Background()
189+ state, runtime, images, secrets := mockDeps(t)
190+ rev := mockRevision()
191+
192+ var saved []domain.AppOperation
193+ state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
194+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error {
195+ saved = append(saved, op)
196+ return nil
197+ }).Maybe()
198+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
199+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
200+ images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError)
201+
202+ svc := keyedService(t, state, runtime, images, secrets)
203+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"})
204+ require.NoError(t, err)
205+ require.True(t, started.Owned)
206+
207+ result, err := svc.ExecuteDeploy(ctx, started.Claim)
208+
209+ require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
210+ require.NotNil(t, result)
211+ require.NotEmpty(t, saved)
212+ last := saved[len(saved)-1]
213+ require.True(t, last.Terminal())
214+ assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
215+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
216+}
217+
218+// TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim proves the store is
219+// authoritative: a claim whose durable record was terminalized by another
220+// actor between the phases replays that outcome and never executes, even
221+// though the in-process claim still carries a non-terminal journal.
222+func TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim(t *testing.T) {
223+ ctx := context.Background()
224+ state, runtime, images, secrets := mockDeps(t)
225+ rev := mockRevision()
226+
227+ state.EXPECT().Recover(mock.Anything).Return(nil).Maybe()
228+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
229+ state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) {
230+ return op, true, nil
231+ }).Once()
232+
233+ svc := keyedService(t, state, runtime, images, secrets)
234+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"})
235+ require.NoError(t, err)
236+ require.True(t, started.Owned)
237+ require.False(t, started.Claim.Journal.Terminal(), "the in-process claim is still non-terminal")
238+
239+ // Another actor settled the key in the store: the stale in-memory copy
240+ // must not drive execution.
241+ expectStoredOperation(state, domain.AppOperation{
242+ Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
243+ Outcome: domain.AppOutcomeSuccess,
244+ Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepSucceeded}},
245+ })
246+
247+ result, err := svc.ExecuteDeploy(ctx, started.Claim)
248+
249+ require.NoError(t, err)
250+ require.NotNil(t, result)
251+ assert.Equal(t, domain.AppOutcomeSuccess, result.Outcome)
252+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
253+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
254+}
255+
256+// TestExecuteDeploy_CancellationLeavesRecoverableClaim proves cancellation or
257+// shutdown during the second phase leaves the durable claim non-terminal, and
258+// the existing reconciliation converges it on the next mutation instead of
259+// ever re-executing it.
260+func TestExecuteDeploy_CancellationLeavesRecoverableClaim(t *testing.T) {
261+ ctx := context.Background()
262+ store := newTestStore(t)
263+ _, desired := seedReplaceableApp(t, ctx, store)
264+ runtime := outmocks.NewMockContainerRuntime(t)
265+
266+ barrier := outmocks.NewMockGCBarrier(t)
267+ lease := outmocks.NewMockGCLease(t)
268+ barrier.EXPECT().AcquireShared(mock.Anything).Return(nil, context.Canceled).Once()
269+ barrier.EXPECT().AcquireShared(mock.Anything).Return(lease, nil).Once()
270+ lease.EXPECT().Release().Return().Once()
271+
272+ svc := deployment.NewService(deployment.Deps{
273+ State: store, Runtime: runtime,
274+ Images: outmocks.NewMockImageResolver(t), Secrets: outmocks.NewMockSecretProvider(t),
275+ }, zerowrap.Default()).WithGCBarrier(barrier)
276+
277+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-cancel", Revision: desired.Revision})
278+ require.NoError(t, err)
279+ require.True(t, started.Owned)
280+
281+ claimed, err := store.LoadOperation(ctx, "blog", "op-cancel")
282+ require.NoError(t, err)
283+ require.False(t, claimed.Terminal(), "the claim is durable and non-terminal before execution")
284+
285+ cancelled, cancel := context.WithCancel(ctx)
286+ cancel()
287+ _, err = svc.ExecuteDeploy(cancelled, started.Claim)
288+ require.ErrorIs(t, err, context.Canceled)
289+
290+ interrupted, err := store.LoadOperation(ctx, "blog", "op-cancel")
291+ require.NoError(t, err)
292+ require.False(t, interrupted.Terminal(), "cancellation leaves the claim non-terminal for recovery")
293+
294+ // The next mutation reconciles the interrupted claim before claiming its
295+ // own key: the operation is finalized and never executes twice.
296+ _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision})
297+ require.NoError(t, err)
298+
299+ reconciled, err := store.LoadOperation(ctx, "blog", "op-cancel")
300+ require.NoError(t, err)
301+ require.True(t, reconciled.Terminal())
302+ assert.Equal(t, domain.AppOutcomeFailed, reconciled.Outcome)
303+
304+ _, err = svc.ExecuteDeploy(ctx, deployment.DeployClaim{App: "blog", Op: "op-cancel"})
305+ require.ErrorIs(t, err, domain.ErrAppStateConflict, "a settled key never re-executes")
306+}
307+
308+// TestDeploy_IsStartDeployThenExecuteDeploy proves the synchronous entry point
309+// is still the composition of both phases: it journals a non-terminal claim
310+// before any effect and a terminal outcome after the replacement.
311+func TestDeploy_IsStartDeployThenExecuteDeploy(t *testing.T) {
312+ ctx := context.Background()
313+ state, runtime, images, secrets := mockDeps(t)
314+ _, saved := expectHeadlessDeploy(state, runtime, images, secrets)
315+
316+ svc := deployment.NewService(deployment.Deps{
317+ State: state, Runtime: runtime, Images: images, Secrets: secrets,
318+ }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
319+ func(context.Context, string, string) (int, error) { return 200, nil },
320+ func(context.Context, string) error { return nil },
321+ ))
322+
323+ result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
324+
325+ require.NoError(t, err)
326+ require.NotNil(t, result)
327+ assert.Equal(t, "deployed", result.Services["web"].Result)
328+ require.NotEmpty(t, *saved)
329+ require.False(t, (*saved)[0].Terminal(), "Deploy journals the claim before execution")
330+ require.True(t, (*saved)[len(*saved)-1].Terminal(), "Deploy records the terminal outcome")
331+}
332+
333+// TestStartDeploy_LiveClaimSurvivesForegroundReconciliation pins the
334+// StartDeploy/ExecuteDeploy hand-off window: while an owner is between the
335+// claim and the coordinator (blocked before its GC lease), a same-key and a
336+// different-key foreground mutation must not reconcile, terminalize, or
337+// re-claim the live operation. The single owner then executes exactly the
338+// revision it claimed.
339+func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) {
340+ ctx := context.Background()
341+ store := newTestStore(t)
342+ _, desired := seedReplaceableApp(t, ctx, store)
343+ runtime := outmocks.NewMockContainerRuntime(t)
344+ images := outmocks.NewMockImageResolver(t)
345+ secrets := outmocks.NewMockSecretProvider(t)
346+
347+ // One full headless replacement, the only effects the live owner may run.
348+ images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once()
349+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once()
350+ expectNetworkProvision(runtime, "app-blog", 1)
351+ runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
352+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
353+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
354+ runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
355+ runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
356+ []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
357+
358+ // Block ExecuteDeploy on its GC lease, before it takes the coordinator.
359+ entered := make(chan struct{})
360+ release := make(chan struct{})
361+ barrier := outmocks.NewMockGCBarrier(t)
362+ lease := outmocks.NewMockGCLease(t)
363+ barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
364+ close(entered)
365+ <-release
366+ }).Return(lease, nil).Once()
367+ lease.EXPECT().Release().Return().Once()
368+
369+ svc := deployment.NewService(deployment.Deps{
370+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
371+ }, zerowrap.Default()).WithGCBarrier(barrier).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
372+ func(context.Context, string, string) (int, error) { return 200, nil },
373+ func(context.Context, string) error { return nil },
374+ ))
375+
376+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision})
377+ require.NoError(t, err)
378+ require.True(t, started.Owned, "the first claim owns the operation")
379+
380+ done := make(chan error, 1)
381+ go func() {
382+ _, execErr := svc.ExecuteDeploy(ctx, started.Claim)
383+ done <- execErr
384+ }()
385+ select {
386+ case <-entered:
387+ case <-time.After(5 * time.Second):
388+ t.Fatal("timed out waiting for ExecuteDeploy to block before the coordinator")
389+ }
390+
391+ // Same key while the owner is live: replay, never a second owner.
392+ same, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision})
393+ require.NoError(t, err)
394+ require.False(t, same.Owned, "a same-key replay never owns the operation twice")
395+ require.ErrorIs(t, same.ReplayError(), domain.ErrAppStateConflict, "the live claim was not terminalized")
396+
397+ // Different key while the owner is live: the unfinished-operation guard
398+ // refuses, so no second claim is written.
399+ _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-other", Revision: "rev-0"})
400+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
401+
402+ live, err := store.LoadOperation(ctx, "blog", "op-live")
403+ require.NoError(t, err)
404+ require.False(t, live.Terminal(), "a live operation is never terminalized by a foreground mutation")
405+ _, err = store.LoadOperation(ctx, "blog", "op-other")
406+ require.ErrorIs(t, err, domain.ErrAppOperationNotFound, "no second claim was written")
407+
408+ close(release)
409+ require.NoError(t, <-done)
410+
411+ final, err := store.LoadOperation(ctx, "blog", "op-live")
412+ require.NoError(t, err)
413+ require.True(t, final.Terminal())
414+ assert.Equal(t, domain.AppOutcomeSuccess, final.Outcome)
415+ assert.Equal(t, desired.Revision, final.InputRevision, "the owner executed the revision it claimed, not the stale one")
416+ runtime.AssertNumberOfCalls(t, "CreateContainer", 1)
417+}
418+
419+// TestAbandonDeploy_SettlesClaimWithoutStarting proves a claimed but never
420+// scheduled operation can be settled safely: the journal becomes terminal, the
421+// in-process live marker is released, and a later claim succeeds instead of
422+// being refused as a permanently unfinished operation. No runtime effect is
423+// touched.
424+func TestAbandonDeploy_SettlesClaimWithoutStarting(t *testing.T) {
425+ ctx := context.Background()
426+ store := newTestStore(t)
427+ _, desired := seedReplaceableApp(t, ctx, store)
428+ runtime := outmocks.NewMockContainerRuntime(t)
429+ images := outmocks.NewMockImageResolver(t)
430+ secrets := outmocks.NewMockSecretProvider(t)
431+
432+ svc := deployment.NewService(deployment.Deps{
433+ State: store, Runtime: runtime, Images: images, Secrets: secrets,
434+ }, zerowrap.Default())
435+
436+ started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-abandon", Revision: desired.Revision})
437+ require.NoError(t, err)
438+ require.True(t, started.Owned)
439+
440+ // While the claim is live, a different key is refused as unfinished.
441+ _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-blocked", Revision: desired.Revision})
442+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
443+
444+ require.NoError(t, svc.AbandonDeploy(ctx, started.Claim))
445+
446+ settled, err := store.LoadOperation(ctx, "blog", "op-abandon")
447+ require.NoError(t, err)
448+ require.True(t, settled.Terminal(), "a settled claim must not remain in flight")
449+ assert.Equal(t, domain.AppOutcomeFailed, settled.Outcome)
450+
451+ // The live marker is gone and the journal terminal, so the next claim is
452+ // accepted cleanly instead of hanging on a false live operation.
453+ next, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision})
454+ require.NoError(t, err)
455+ require.True(t, next.Owned, "a settled claim never blocks the next owner")
456+ require.NoError(t, svc.AbandonDeploy(ctx, next.Claim))
457+
458+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
459+}
M wiki/tutorials/first-deploy.md +1 -1
@@ -155,7 +155,7 @@ docker tag my-first-app registry.mydomain.com/my-first-app:latest
155 docker push registry.mydomain.com/my-first-app:latest155 docker push registry.mydomain.com/my-first-app:latest
156 ```156 ```
157 157
158-Gordon automatically deploys the update with zero downtime.158+Gordon replaces the running container: it withdraws traffic, stops and removes the old container, starts the new one, waits for it to pass its readiness probe, and routes traffic to it. Expect a short interruption at that point.
159 159
160 ## Next Steps160 ## Next Steps
161 161

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close