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
157157 - Private Docker/Podman registry on your VPS
158158 - Declarative apps: one TOML file per app, explicit apply then deploy
159159 - 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)
161161 - Remote CLI management (daemon is the sole writer)
162162 - Declarative per-service volumes, retained across lifecycle operations
163163 - 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
5555 persistence succeeds; the two outcomes are reported separately because a
5656 deploy may fail after the apply succeeded.
5757
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+
5879 `--dry-run` and `--deploy` cannot be combined.
5980
6081 ---
@@ -102,6 +123,33 @@ reused for a different request is refused.
102123
103124 ---
104125
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+
105153 ## gordon apps diff
106154
107155 ```bash
@@ -205,6 +253,14 @@ Activates a revision (default: desired head). Fail-fast across services:
205253 the first failure stops the deploy, successful services are preserved,
206254 later services stay unchanged.
207255
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+
208264 ---
209265
210266 ## gordon apps restart
@@ -288,9 +344,12 @@ gordon apps apply --file blog.toml --remote prod
288344 # Write secret values for registered names
289345 gordon apps secrets set blog --service web DATABASE_URL=... --remote prod
290346
291-# Deploy the accepted revision
347+# Deploy the accepted revision (polls 202 operations to terminal)
292348 gordon apps deploy blog --remote prod
293349
350+# Resume watching an interrupted or externally started operation
351+gordon apps operations watch blog --key <operation-key> --remote prod
352+
294353 # Inspect
295354 gordon apps show blog --remote prod
296355 gordon apps status blog --remote prod
@@ -300,3 +359,4 @@ gordon apps status blog --remote prod
300359
301360 - [CLI Overview](./index.md)
302361 - [Push Command](./push.md)
362+- [Deployment](../deployment/index.md)
M docs/cli/index.md +1 -0
@@ -68,6 +68,7 @@ gordon apps stop blog
6868 gordon apps start blog
6969 gordon apps remove blog
7070 gordon apps operations show blog --key <operation-key>
71+gordon apps operations watch blog --key <operation-key>
7172 gordon apps secrets list blog
7273 gordon config validate --file /path/to/gordon.toml
7374
M docs/concepts.md +11 -8
@@ -69,18 +69,21 @@ DATABASE_URL = "database-url"
6969
7070 ## Updates
7171
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.
7373
74-```
75-Time ─────────────────────────────────────────────>
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.
7675
77-Old Container: [═══════════════════]
78- ↓ retire
79-New Container: [═════════════════════════>
80- ↑ start ↑ traffic routed
8176 ```
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.
8285
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.
8487
8588 ## Deletion and Cleanup Lifecycle
8689
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
6060 burst = 100 # Burst size
6161 trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For
6262
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-
7363 # Container runtime profile
7464 [containers]
7565 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
151141 | `[server]` | Core server settings | [Server](./server.md) |
152142 | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) |
153143 | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) |
154-| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
155144 | `[logging]` | Logging configuration | [Logging](./logging.md) |
156145 | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) |
157146 | `[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
180169 | `api.rate_limit.per_ip_rps` | `50` |
181170 | `api.rate_limit.burst` | `100` |
182171 | `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"` |
190172 | `containers.security_profile` | `"compat"` |
191173 | `logging.level` | `"info"` |
192174 | `logging.format` | `"console"` |
@@ -244,11 +226,6 @@ gordon reload
244226 | `server.max_blob_chunk_size` |
245227 | `server.max_blob_size` |
246228 | `auth.*` |
247-| `deploy.readiness_mode` |
248-| `deploy.readiness_delay` |
249-| `deploy.health_timeout` |
250-| `deploy.drain_mode` |
251-| `deploy.drain_timeout` |
252229
253230 ## Environment Variable Override
254231
M docs/config/reference.md +0 -19
@@ -116,18 +116,6 @@ trace_sample_rate = 1.0 # Fraction of traces to sample (0.0
116116 [env]
117117 dir = "" # Env files directory (default: {data_dir}/env)
118118
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-
131119 # =============================================================================
132120 # CONTAINERS
133121 # =============================================================================
@@ -287,13 +275,6 @@ keep_last = 3 # Keep N newest tags per repository
287275 | `telemetry.metrics` | `true` | Export metrics |
288276 | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs |
289277 | `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 |
297278 | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities |
298279 | `network_isolation.enabled` | `true` | Network isolation enabled |
299280 | `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
161161 - [Auth](./auth.md)
162162 - [Images](./images.md)
163163 - [Network Isolation](./network-isolation.md)
164-- [Deploy](./deploy.md)
165164 - [Volumes](./volumes.md)
166165 - [Reference](./reference.md)
M docs/deployment/index.md +6 -7
@@ -141,17 +141,16 @@ docker push gordon.example.com/myapp:$VERSION
141141
142142 ## Updates
143143
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.
145145
146146 ```
147-Timeline ─────────────────────────────────────────>
148-
149-Old Container: [═══════════════════]
150- ↓ retire
151-New Container: [═════════════════════════>
152- ↑ start ↑ traffic routed
147+Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic
153148 ```
154149
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+
155154 ## Related
156155
157156 - [GitHub Actions](./github-actions.md)
M docs/deployment/rollback.md +2 -2
@@ -1,6 +1,6 @@
11 # Rollback
22
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.
44
55 ## Roll Forward to a Previous Tag
66
@@ -12,7 +12,7 @@ gordon apps apply --file blog.toml --remote prod
1212 gordon apps deploy blog --remote prod
1313 ```
1414
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.
1616
1717 ## Re-push the Previous Image as latest
1818
M docs/getting-started.md +2 -2
@@ -176,7 +176,7 @@ What these commands do:
176176
177177 - `gordon push` builds, uploads, and stores the image. It never deploys.
178178 - `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.
180180
181181 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:
182182
@@ -198,7 +198,7 @@ gordon push myapp --build --remote prod
198198 gordon apps deploy blog --remote prod
199199 ```
200200
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.
202202
203203 ## Next Steps
204204
M docs/index.md +1 -1
@@ -10,7 +10,7 @@ Gordon runs on your VPS and provides:
1010 - **Declarative Apps** - One TOML file per app, explicit apply then deploy
1111 - **HTTP Reverse Proxy** - Routes app hosts to containers
1212 - **Push, Apply, Deploy** - Push stores images; deploy is always explicit
13-- **HTTP Zero-Downtime Updates** - Old containers serve until replacements pass readiness
13+- **Sequential Service Replacement** - One service generation at a time: withdraw traffic, replace, verify readiness, republish
1414 - **Single Binary** - ~15MB RAM footprint
1515
1616 ## How It Works
M gordon.toml.example +0 -9
@@ -55,15 +55,6 @@ per_ip_rps = 50
5555 burst = 100
5656 trusted_proxies = []
5757
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-
6758 [containers]
6859 # "compat" preserves broad image compatibility. "strict" enables a read-only
6960 # root filesystem and narrower Linux capabilities.
M internal/adapters/dto/admin_apps.go +6 -0
@@ -103,6 +103,11 @@ type AppSecretMetadataDTO struct {
103103 Presence string `json:"presence"`
104104 }
105105
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+
106111 // AppDeployResponse reports deploy outcome with terminal results.
107112 // Effective and Retained are filled from current app state when the app
108113 // still exists; they are omitted when it does not.
@@ -110,6 +115,7 @@ type AppDeployResponse struct {
110115 Op string `json:"op"`
111116 App string `json:"app"`
112117 Revision string `json:"revision"`
118+ Status string `json:"status"`
113119 Outcome string `json:"outcome"`
114120 Services map[string]AppServiceResultDTO `json:"services"`
115121 Steps []AppStepDTO `json:"steps"`
M internal/adapters/dto/admin_apps_test.go +2 -1
@@ -63,6 +63,7 @@ func TestAppDeployResponseKeys(t *testing.T) {
6363 Op: "op-abc",
6464 App: "blog",
6565 Revision: "rev-new",
66+ Status: AppStatusRunning,
6667 Outcome: "success",
6768 Services: map[string]AppServiceResultDTO{
6869 "web": {Result: "deployed", EffectiveRevision: "rev-new"},
@@ -75,7 +76,7 @@ func TestAppDeployResponseKeys(t *testing.T) {
7576 Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
7677 }
7778 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"} {
7980 assert.Contains(t, keys, key)
8081 }
8182 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
9595 } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil {
9696 return rerr, true
9797 }
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 {
98104 return fmt.Errorf(
99105 "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+
100106 "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), true
107+ op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, conflict)
102108 }
103109
104110 // newAppsCmd creates the `apps` parent command.
@@ -156,7 +162,7 @@ deploy may fail after the apply succeeded.`,
156162 }
157163 defer handle.close()
158164 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)
160166 },
161167 }
162168 cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)")
@@ -166,7 +172,7 @@ deploy may fail after the apply succeeded.`,
166172 return cmd
167173 }
168174
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 {
170176 if dryRun && chainDeploy {
171177 return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy")
172178 }
@@ -190,29 +196,86 @@ func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.W
190196 }
191197 return renderAppApply(out, resp, dryRun)
192198 }
193- if !jsonOut {
194- if err := renderAppApply(out, resp, dryRun); err != nil {
195- return err
196- }
199+ if jsonOut {
200+ return runAppsApplyDeployJSON(ctx, plane, resp, out, errOut)
201+ }
202+ if err := renderAppApply(out, resp, dryRun); err != nil {
203+ return err
197204 }
198- deployResp, key, deployErr := plane.DeployApp(ctx, resp.App, dto.AppDeployRequest{Revision: resp.ResultingRevision})
199- if deployErr != nil {
200- if conflictErr, ok := renderAppOpConflict(out, "deploy", resp.App, key, deployErr, jsonOut); ok {
201- return fmt.Errorf("apply of %s succeeded (%s); %w",
202- resp.App, resp.ResultingRevision, conflictErr)
205+ return runAppsApplyDeploy(ctx, plane, resp, out, errOut)
206+}
207+
208+// appApplyDeployDocument is the single machine-readable apply+deploy result.
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)
203226 }
204- return fmt.Errorf("apply of %s succeeded (%s); %w",
205- resp.App, resp.ResultingRevision, appMutationError("deploy", resp.App, key, deployErr))
227+ return applySucceededError(apply, appMutationError("deploy", apply.App, key, err))
206228 }
207- if jsonOut {
208- return writeJSON(out, struct {
209- Apply *dto.AppApplyResponse `json:"apply"`
210- Deploy *dto.AppDeployResponse `json:"deploy"`
211- }{Apply: resp, Deploy: deployResp})
229+ if deployResp.Status == dto.AppStatusRunning {
230+ if werr := watchOperation(ctx, plane, apply.App, key, deployResp, out, errOut, false); werr != nil {
231+ return applySucceededError(apply, werr)
232+ }
233+ return nil
212234 }
213235 return renderAppDeployResponse(out, deployResp)
214236 }
215237
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+
216279 func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error {
217280 if resp.Noop {
218281 return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision))))
@@ -719,7 +782,7 @@ func newAppDeployCmd() *cobra.Command {
719782 }
720783 defer handle.close()
721784 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)
723786 },
724787 }
725788 cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)")
@@ -728,7 +791,10 @@ func newAppDeployCmd() *cobra.Command {
728791 return cmd
729792 }
730793
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 {
732798 resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service})
733799 if err != nil {
734800 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
736802 }
737803 return appMutationError("deploy", app, key, err)
738804 }
805+ if resp.Status == dto.AppStatusRunning {
806+ return watchOperation(ctx, plane, app, key, resp, out, errOut, jsonOut)
807+ }
739808 if jsonOut {
740809 return writeJSON(out, resp)
741810 }
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) {
2424 Intent: "apply-7",
2525 }, nil).Once()
2626 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{},
2828 writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false))
2929 text := out.String()
3030 assert.Contains(t, text, "Validated (dry-run) blog")
@@ -38,7 +38,7 @@ func TestRunAppsApply_NoopOmitsEmptyRevision(t *testing.T) {
3838 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).
3939 Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once()
4040 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{},
4242 writeManifest(t, "x"), true, false, false))
4343 assert.Contains(t, out.String(), "No changes for blog")
4444 assert.NotContains(t, out.String(), "()")
M internal/adapters/in/cli/apps_operations.go +1 -1
@@ -10,7 +10,7 @@ import (
1010
1111 func newAppsOperationsCmd() *cobra.Command {
1212 cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"}
13- cmd.AddCommand(newAppsOperationsShowCmd())
13+ cmd.AddCommand(newAppsOperationsShowCmd(), newAppsOperationsWatchCmd())
1414 return cmd
1515 }
1616
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 {
4141
4242 func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
4343 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)
4545 require.Error(t, err)
4646 assert.Contains(t, err.Error(), "--dry-run")
4747 assert.Contains(t, err.Error(), "--deploy")
@@ -49,7 +49,7 @@ func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
4949
5050 func TestRunAppsApply_RequiresFile(t *testing.T) {
5151 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)
5353 require.Error(t, err)
5454 assert.Contains(t, err.Error(), "--file")
5555 }
@@ -63,7 +63,7 @@ func TestRunAppsApply_JSONParity(t *testing.T) {
6363 plane := appPlane(t)
6464 plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()
6565 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{},
6767 writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))
6868 var got dto.AppApplyResponse
6969 require.NoError(t, json.Unmarshal(out.Bytes(), &got))
@@ -77,7 +77,7 @@ func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) {
7777 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
7878 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
7979 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{},
8181 writeManifest(t, "x"), false, true, false))
8282 assert.Contains(t, out.String(), "rev-b")
8383 }
@@ -89,7 +89,7 @@ func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) {
8989 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
9090 &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
9191 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{},
9393 writeManifest(t, "x"), false, true, true))
9494 var got struct {
9595 Apply dto.AppApplyResponse `json:"apply"`
@@ -204,7 +204,7 @@ func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
204204 plane := appPlane(t)
205205 plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
206206 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)
208208 require.Error(t, err)
209209 assert.Contains(t, err.Error(), "outcome-unknown")
210210 assert.Contains(t, err.Error(), "key-abc")
@@ -223,7 +223,7 @@ func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
223223 },
224224 }).Once()
225225 var out bytes.Buffer
226- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, false)
226+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, false)
227227 require.Error(t, err, "conflict must retain failure semantics")
228228 text := out.String()
229229 assert.Contains(t, text, "web:")
@@ -246,7 +246,7 @@ func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
246246 },
247247 }).Once()
248248 var out bytes.Buffer
249- err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, true)
249+ err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, true)
250250 require.Error(t, err)
251251 var got dto.AppDeployResponse
252252 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 {
214214 return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome)
215215 }
216216
217-// parseDeployResponse decodes a deploy/lifecycle mutation response. A 409
218-// carries the journaled AppDeployResponse body (per-service results,
219-// steps, effective/observed state) instead of an error envelope; it is
220-// decoded and surfaced as AppOpConflictError so the CLI can render the
221-// journal while retaining failure semantics.
217+// parseDeployResponse decodes a deploy/lifecycle mutation response. A 202
218+// Accepted is a success shape: it carries the same running AppDeployResponse
219+// journal as a 200 and is polled via OperationByKey, so it decodes through
220+// the generic 2xx path. A 409 carries the journaled AppDeployResponse body
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.
222224 func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) {
223225 if resp.StatusCode == http.StatusConflict {
224226 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) {
105105 assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key")
106106 }
107107
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+
108132 func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) {
109133 journal := dto.AppDeployResponse{
110134 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
309309 return
310310 }
311311 status := http.StatusOK
312- if err != nil {
312+ switch {
313+ case err != nil:
313314 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
314321 }
315322 h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false))
316323 }
@@ -498,6 +505,13 @@ func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message,
498505 }
499506
500507 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+ }
501515 for _, step := range op.Steps {
502516 if strings.HasPrefix(step.ID, "service.") {
503517 return false
@@ -561,6 +575,12 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics
561575 resp.Op = op.Op
562576 resp.Revision = op.InputRevision
563577 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+ }
564584 resp.Services = map[string]dto.AppServiceResultDTO{}
565585 for _, step := range op.Steps {
566586 service, ok := strings.CutPrefix(step.ID, "service.")
M internal/adapters/in/http/admin/handler_apps_test.go +219 -0
@@ -7,6 +7,7 @@ import (
77 "io"
88 "net/http"
99 "net/http/httptest"
10+ "sort"
1011 "testing"
1112 "time"
1213
@@ -52,6 +53,32 @@ func appsRequest(t *testing.T, handler *Handler, method, target string, body any
5253 return rec
5354 }
5455
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+
5582 const validAppManifest = `
5683 name = "blog"
5784 [[service]]
@@ -159,6 +186,198 @@ func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
159186 assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail)
160187 }
161188
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+
162381 func TestHandler_AppList_RequiresScope(t *testing.T) {
163382 appSvc := inmocks.NewMockAppService(t)
164383 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 {
2929 volumeSvc in.VolumeService
3030 publicTLSSvc in.PublicTLSService
3131 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()
3338 }
3439
3540 // NewKernel initializes local services without starting server listeners.
@@ -79,7 +84,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
7984 cleanup()
8085 }
8186
82- return &Kernel{
87+ kernel := &Kernel{
8388 authEnabled: cfg.Auth.Enabled,
8489 configSvc: svc.configSvc,
8590 secretSvc: svc.secretSvc,
@@ -92,8 +97,15 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
9297 volumeSvc: svc.volumeSvc,
9398 publicTLSSvc: svc.publicTLSSvc,
9499 appSvc: svc.appSvc,
100+ log: log,
95101 cleanup: wrappedCleanup,
96- }, nil
102+ }
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
97109 } else {
98110 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
99111 }
@@ -116,6 +128,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
116128 authEnabled: cfg.Auth.Enabled,
117129 configSvc: configSvc,
118130 secretSvc: secretSvc,
131+ log: log,
119132 cleanup: cleanup,
120133 }, nil
121134 }
@@ -124,11 +137,26 @@ func quietInitLogger(Config) (zerowrap.Logger, func(), error) {
124137 return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil
125138 }
126139
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.
127145 func (k *Kernel) Close() error {
128- if k == nil || k.cleanup == nil {
146+ if k == nil {
129147 return nil
130148 }
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+ }
132160 return nil
133161 }
134162
M internal/app/kernel_test.go +56 -0
@@ -1,11 +1,14 @@
11 package app
22
33 import (
4+ "errors"
45 "fmt"
56 "os"
67 "path/filepath"
78 "testing"
89
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
912 "github.com/stretchr/testify/require"
1013 )
1114
@@ -36,3 +39,56 @@ secrets_backend = "unsafe"
3639 require.NotNil(t, kernel.Config())
3740 require.NotNil(t, kernel.Secrets())
3841 }
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) {
839839 return limits, nil
840840 }
841841
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+
842864 // initApps wires the single v2.50 app engine: bbolt state, image digests,
843865 // pass-backed secrets, deployment/lifecycle execution, and traffic
844866 // activation. The daemon is the sole app-state writer; CLI reaches the
@@ -909,7 +931,7 @@ func (si *serviceInit) initApps() error {
909931 return err
910932 }
911933 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).
913935 WithEntrypoints(appEntrypointListeners(si.cfg)).
914936 WithGCBarrier(si.svc.gcBarrier).
915937 WithImagePolicy(imagePolicy).
@@ -3085,8 +3107,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
30853107 // so no new mutation can begin during shutdown.
30863108 localAdmin.Close()
30873109 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)
3089- return nil
3110+ return gracefulShutdown(registrySrvs, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, svc.appMonitor, svc.appSvcImpl, svc.appState, log)
30903111 }
30913112
30923113 // reconcileAppsAtBoot reconciles declarative apps intended to run,
@@ -3401,9 +3422,35 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan <-cha
34013422 }
34023423 }
34033424
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+
34043447 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
3405-// the container service and cleans up runtime files.
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) {
3448+// the container service and cleans up runtime files. It returns an error when
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 {
34073454 log.Info().Msg("shutting down Gordon...")
34083455
34093456 // 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
34143461 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
34153462 defer shutdownCancel()
34163463
3417- // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
3418- for _, srv := range []*http.Server{tlsSrv, proxySrv} {
3419- if srv == nil {
3420- continue
3421- }
3422- if err := srv.Shutdown(shutdownCtx); err != nil {
3423- log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error")
3424- }
3464+ // Phase 0.5: quiesce daemon-owned app administration. New background
3465+ // deploys are refused and in-flight executions are cancelled on the
3466+ // daemon context and joined here — before traffic, runtime, or the app
3467+ // state store is torn down, so no execution can write to closed state.
3468+ // A timeout is fail-closed: the unfinished execution still owns the
3469+ // state and runtime it uses, so teardown stops here and the error makes
3470+ // the process exit non-zero rather than close resources underneath it.
3471+ if err := quiesceAppAdministration(shutdownCtx, appAdmin, log); err != nil {
3472+ return fmt.Errorf("app administration quiescence: %w", err)
34253473 }
34263474
3475+ // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
3476+ shutdownHTTPServers(shutdownCtx, log, tlsSrv, proxySrv)
3477+
34273478 if trafficManager != nil {
34283479 if err := trafficManager.Shutdown(shutdownCtx); err != nil {
34293480 log.Warn().Err(err).Msg("traffic manager shutdown error")
@@ -3451,14 +3502,7 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server
34513502 }
34523503
34533504 // Phase 3: Stop the registry backends.
3454- for _, registrySrv := range 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- }
3505+ shutdownHTTPServers(shutdownCtx, log, registrySrvs...)
34623506
34633507 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
34643508 log.Warn().Err(err).Msg("error during container shutdown")
@@ -3467,6 +3511,20 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server
34673511
34683512 cleanupInternalCredentials()
34693513 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+ }
34703528 }
34713529
34723530 func closeAppState(state out.AppState, log zerowrap.Logger) {
@@ -4096,7 +4154,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
40964154 v.SetDefault("volumes.auto_create", true)
40974155 v.SetDefault("volumes.prefix", "gordon")
40984156 v.SetDefault("volumes.preserve", true)
4099- v.SetDefault("deploy.pull_policy", "if-tag-changed")
41004157 v.SetDefault("backups.databases.enabled", false)
41014158 v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
41024159 v.SetDefault("backups.databases.storage_dir", "")
@@ -4136,15 +4193,6 @@ func loadConfig(v *viper.Viper, configPath string) error {
41364193 v.SetDefault("server.registry_allowed_ips", []string{})
41374194 v.SetDefault("server.proxy_allowed_ips", []string{})
41384195 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")
41484196
41494197 ConfigureViper(v, configPath)
41504198
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 (
44 "context"
55 "fmt"
66 "sort"
7+ "sync"
78
89 "github.com/bnema/zerowrap"
910
@@ -24,11 +25,28 @@ type AppServiceImpl struct {
2425 // core is the single configured apps service. It is built once and
2526 // reconfigured in place, never reconstructed per request.
2627 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
2743 }
2844
2945 // deployEngine is the subset of the deployment engine the app service needs.
3046 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)
3250 Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
3351 Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error)
3452 Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error)
@@ -37,13 +55,104 @@ type deployEngine interface {
3755
3856 // NewAppServiceImpl wires the driving-port implementation.
3957 func NewAppServiceImpl(store out.AppState, deploy deployEngine, secrets out.SecretWriter, log zerowrap.Logger) *AppServiceImpl {
58+ daemonCtx, daemonStop := context.WithCancel(context.Background())
4059 return &AppServiceImpl{
41- store: store,
42- deploy: deploy,
43- secrets: secrets,
44- log: log,
45- core: NewService(store, log),
60+ store: store,
61+ deploy: deploy,
62+ secrets: secrets,
63+ log: 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)
46143 }
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
47156 }
48157
49158 // WithEntrypoints supplies the installation entrypoint listeners used to
@@ -275,15 +384,50 @@ func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff,
275384 return domain.DiffAppSpec(desired.Spec, activeSpec(active)), nil
276385 }
277386
278-// Deploy implements in.AppService. A repeated idempotency key replays its
279-// stored journal: the engine claims the key atomically before any effect,
280-// so a duplicate request never deploys twice.
387+// Deploy implements in.AppService. It enrolls the request hand-off with the
388+// lifecycle first, so a deploy arriving after shutdown began is refused before
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.
281396 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})
283- if result == nil {
397+ done, err := s.beginDeploy()
398+ if err != nil {
284399 return nil, err
285400 }
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
287431 }
288432
289433 // 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 (
1616 // so this hand-written mock follows the same EXPECT()/Return() pattern.
1717 type mockDeployEngine struct {
1818 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)
1926 }
2027
2128 func newMockDeployEngine(t *testing.T) *mockDeployEngine {
@@ -25,11 +32,33 @@ func newMockDeployEngine(t *testing.T) *mockDeployEngine {
2532 return m
2633 }
2734
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+ }
2939 args := m.Called(ctx, input)
3040 if args.Get(0) == nil {
3141 return nil, args.Error(1)
3242 }
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+ }
3362 return args.Get(0).(*deployment.DeployResult), args.Error(1)
3463 }
3564
M internal/usecase/deployment/bind_policy_internal_test.go +64 -29
@@ -129,7 +129,7 @@ func TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation(t *testing.T) {
129129 },
130130 }
131131
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)
133133
134134 require.ErrorIs(t, err, domain.ErrBindPolicy)
135135 assert.Contains(t, err.Error(), "config")
@@ -187,7 +187,12 @@ func TestCheckImageVolumes_IncludesBindDestinations(t *testing.T) {
187187 })
188188 }
189189
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) {
191196 httpSpec := func() domain.AppService {
192197 return domain.AppService{
193198 Name: "web",
@@ -195,52 +200,78 @@ func TestHTTPEligibleAndSingleWriterGating(t *testing.T) {
195200 }
196201 }
197202
198- t.Run("http-only without binds is parallel eligible", func(t *testing.T) {
199- assert.True(t, httpEligible(httpSpec()))
203+ t.Run("stateless public HTTP service keeps the implicit HTTP probe", func(t *testing.T) {
204+ assert.True(t, implicitHTTPProbe(httpSpec()))
205+ assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(httpSpec()).Readiness.Type)
200206 assert.False(t, singleWriterRequired(httpSpec()))
201207 })
202208
203- t.Run("internal-only http is not overlap eligible", func(t *testing.T) {
204- spec := domain.AppService{
209+ t.Run("undeclared probe is not guessed for internal, mixed, or L4 services", func(t *testing.T) {
210+ internal := domain.AppService{
205211 Name: "api",
206212 HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
207213 }
208- assert.False(t, httpEligible(spec),
209- "an internal-only service has no public generation to cut over and must use interrupted replacement")
210- assert.False(t, singleWriterRequired(spec))
214+ assert.False(t, implicitHTTPProbe(internal),
215+ "an internal-only interface is not reachable through a loopback publish, so no HTTP probe is guessed")
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+ }
211240 })
212241
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) {
214249 spec := domain.AppService{
215- Name: "api",
216- HTTP: []domain.AppHTTPInterface{
217- {Host: "app.example.com", Port: 8080, TLS: domain.AppTLSAuto},
218- {Port: 9090, Visibility: domain.AppVisibilityInternal},
219- },
250+ Name: "db",
251+ TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}},
252+ Readiness: domain.AppReadiness{Type: domain.AppReadinessNone},
220253 }
221- assert.False(t, httpEligible(spec),
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))
254+ assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type)
224255 })
225256
226257 t.Run("writable bind is single-writer", func(t *testing.T) {
227258 spec := httpSpec()
228259 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")
230261 assert.True(t, singleWriterRequired(spec))
231262 })
232263
233264 t.Run("read-only bind is also single-writer", func(t *testing.T) {
234265 spec := httpSpec()
235266 spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}}
236- assert.False(t, httpEligible(spec))
267+ assert.False(t, implicitHTTPProbe(spec))
237268 assert.True(t, singleWriterRequired(spec))
238269 })
239270
240271 t.Run("volume is single-writer", func(t *testing.T) {
241272 spec := httpSpec()
242273 spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
243- assert.False(t, httpEligible(spec))
274+ assert.False(t, implicitHTTPProbe(spec))
244275 assert.True(t, singleWriterRequired(spec))
245276 })
246277 }
@@ -259,10 +290,13 @@ func TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation(t *testing.T)
259290 },
260291 }
261292
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)
263297
264- assert.Equal(t, domain.AppStepFailed, step.State)
265- assert.Contains(t, step.Error, "config")
298+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
299+ assert.Contains(t, op.Steps[0].Error, "config")
266300 assert.Equal(t, "failed", result.Result)
267301 runtime.AssertNotCalled(t, "RestartContainer")
268302 runtime.AssertExpectations(t)
@@ -281,14 +315,15 @@ func TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation(t *testing.
281315 Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
282316 },
283317 }
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+ }}}
285321 result := &LifecycleResult{Services: map[string]ServiceResult{}}
286322
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)
288324
289- assert.True(t, cont)
290- assert.Equal(t, domain.AppStepFailed, step.State)
291- assert.Contains(t, step.Error, "config")
325+ assert.Equal(t, domain.AppStepFailed, op.Steps[0].State)
326+ assert.Contains(t, op.Steps[0].Error, "config")
292327 runtime.AssertNotCalled(t, "StartContainer")
293328 runtime.AssertExpectations(t)
294329 }
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 (
2020
2121 func mockDeps(t *testing.T) (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) {
2222 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,
2442 outmocks.NewMockContainerRuntime(t),
2543 outmocks.NewMockImageResolver(t),
2644 outmocks.NewMockSecretProvider(t)
2745 }
2846
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+
2968 func mockRevision() domain.AppDesiredRevision {
3069 svc := webService()
3170 svc.Image = "docker.io/example/web:1.4.2"
@@ -74,6 +113,11 @@ func TestDeploy_PreflightFailureReturnsJournaledOperation(t *testing.T) {
74113 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Twice()
75114 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once()
76115 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+ })
77121
78122 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default())
79123 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
@@ -143,7 +187,7 @@ func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) {
143187 },
144188 zerowrap.Default(),
145189 ).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 },
147191 func(context.Context, string) error { return nil },
148192 ))
149193
@@ -189,7 +233,7 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
189233 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
190234 zerowrap.Default(),
191235 ).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 },
193237 func(context.Context, string) error { return nil },
194238 ))
195239
@@ -202,11 +246,12 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
202246 runtime.AssertNotCalled(t, "GetContainerNetworkInfo", mock.Anything, mock.Anything)
203247 }
204248
205-// TestDeploy_Mockery_HTTPRetiresOldAfterPublish proves retire-after-publish
206-// ordering: the replaced container is stopped+removed only after the new
207-// effective state is published (proxy already switched), and a retire
208-// failure records a cleanup warning without flipping the outcome.
209-func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
249+// TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish proves the
250+// sequential ordering: the superseded container is stopped and removed
251+// before the replacement is created, and ACTIVE names the replacement only
252+// after it exists. A public HTTP service without volumes is replaced exactly
253+// like every other service.
254+func TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish(t *testing.T) {
210255 ctx := context.Background()
211256 state, runtime, images, secrets := mockDeps(t)
212257 rev := mockRevision()
@@ -221,7 +266,20 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
221266 }, true, nil)
222267 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
223268
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()
225283 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
226284 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
227285 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
@@ -233,20 +291,11 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
233291 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
234292 },
235293 }, true, nil)
236- var publishedAt, retiredAt int
237- var step int
238294 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {
239295 step++
240296 publishedAt = step
241297 return nil
242298 }).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()
250299 state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
251300 // The superseded generation stops being recovery-inhibited only
252301 // once the replacement is published.
@@ -256,7 +305,7 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
256305 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
257306 zerowrap.Default(),
258307 ).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 },
260309 func(context.Context, string) error { return nil },
261310 ))
262311
@@ -264,12 +313,16 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) {
264313 require.NoError(t, err)
265314 require.NotNil(t, result)
266315 assert.Equal(t, "deployed", result.Services["web"].Result)
267- assert.Equal(t, "c-old", result.Services["web"].Retire)
268- assert.Greater(t, retiredAt, publishedAt, "retire runs after publication")
316+ assert.Less(t, replacedAt, createdAt, "the superseded generation is gone before the replacement is created")
317+ assert.Greater(t, publishedAt, createdAt, "ACTIVE is published after the replacement exists")
269318 assert.Empty(t, result.CleanupWarnings)
270319 }
271320
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) {
273326 ctx := context.Background()
274327 state, runtime, images, secrets := mockDeps(t)
275328 rev := mockRevision()
@@ -284,7 +337,11 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) {
284337 }, true, nil)
285338 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
286339
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()
288345 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
289346 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
290347 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) {
301358 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
302359 zerowrap.Default(),
303360 ).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 },
305362 func(context.Context, string) error { return errors.New("refused") },
306363 ))
307364
@@ -312,12 +369,13 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) {
312369 assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error")
313370 require.Len(t, result.Services["web"].Diagnostics, 1, "failure diagnostics are kept separately")
314371 assert.Equal(t, "boom", result.Services["web"].Diagnostics[0])
315- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)
316- runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", false)
372+ runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything)
317373 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)
318376 }
319377
320-func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
378+func TestDeploy_Mockery_VolumeReplacementMarksUnsafe(t *testing.T) {
321379 ctx := context.Background()
322380 state, runtime, images, secrets := mockDeps(t)
323381
@@ -361,8 +419,15 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
361419 }, true, nil)
362420 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
363421
364- runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
365- runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
422+ var order []string
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()
366431 // The superseded writer's confirmed disappearance releases its claims.
367432 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
368433 expectNetworkProvision(runtime, "app-uuid-blog", 1)
@@ -371,7 +436,10 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
371436 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool {
372437 return inhibition.App == "blog" && inhibition.Service == "web" &&
373438 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()
375443 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
376444 runtime.EXPECT().CreateVolume(mock.Anything, "gordon-blog--web--vol--d", mock.MatchedBy(func(labels map[string]string) bool {
377445 return labels[domain.LabelApp] == "blog" &&
@@ -386,7 +454,10 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
386454 require.GreaterOrEqual(t, ownershipSaves, 1,
387455 "ownership must be reserved before the runtime volume exists")
388456 }).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()
390461 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
391462 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
392463 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
@@ -411,7 +482,8 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) {
411482 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
412483 require.NoError(t, err)
413484 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")
415487 assert.True(t, result.Services["web"].RestartUnsafe)
416488 assert.True(t, restartUnsafeSaved, "the terminal ownership write must record the restart-unsafe service")
417489 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) {
639711 deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
640712 zerowrap.Default(),
641713 ).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 },
643715 func(context.Context, string) error { return nil },
644716 ))
645717 result, err := svc.Start(ctx, "blog", "")
M internal/usecase/deployment/execute.go +648 -203
@@ -21,49 +21,225 @@ const failLogTailLines = 50
2121 const failLogTailTimeout = 10 * time.Second
2222
2323 // Deploy executes a preflighted revision: fail-fast across services in
24-// sorted name order. HTTP-only services whose interfaces are all public,
25-// with no volumes and no binds, keep the old container serving until the
26-// replacement passes readiness, then switch with a bounded drain and
27-// retire the old container by exact ID. TCP/UDP, any internal HTTP
28-// interface, and bind- or volume-owning services use replacement with
29-// interruption. Volumes are never deleted: no RemoveVolume call, no
24+// sorted name order. Every service is replaced sequentially — withdraw
25+// traffic, retire the superseded container, start the replacement and wait
26+// for its readiness probe — so a deployment may briefly interrupt the
27+// service, and two Gordon-managed generations of one service never run at
28+// the same time. Volumes are never deleted: no RemoveVolume call, no
3029 // 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.
3134 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)
33102 if err != nil {
34103 return nil, err
35104 }
36105 defer release()
37- return s.deployLocked(ctx, input)
106+ return s.startDeployLocked(ctx, input)
38107 }
39108
40-func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployResult, error) {
109+func (s *Service) startDeployLocked(ctx context.Context, input DeployInput) (*StartDeployResult, error) {
41110 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42111 zerowrap.FieldLayer: "usecase",
43- zerowrap.FieldUseCase: "Deploy",
112+ zerowrap.FieldUseCase: "StartDeploy",
44113 "app": input.App,
45114 })
46115 log := zerowrap.FromCtx(ctx)
47116
48- pinned, op, replayed, err := s.preflightLocked(ctx, input)
49- if replayed || err != nil {
50- // The key already answered this request: its stored journal is
51- // the result and no workload is touched again. A failed preflight
52- // returns the journal together with its error.
53- return journaledOrNil(input, op, err)
117+ if err := s.deps.State.Recover(ctx); err != nil {
118+ return nil, fmt.Errorf("deployment: recover before deploy: %w", err)
119+ }
120+ // A mutation of this app never starts on top of an interrupted one: its
121+ // never-published candidates must be gone before this mutation creates a
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
54235 }
55236 sort.Slice(pinned, func(i, j int) bool { return pinned[i].name < pinned[j].name })
56237
57238 active, _, err := s.deps.State.LoadActive(ctx, input.App)
58239 if err != nil {
59240 loadErr := fmt.Errorf("deployment: load active: %w", err)
60- s.failOperation(ctx, op, loadErr, "error")
61- 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
241+ s.failOperation(ctx, &op, loadErr, "error")
242+ return journaledDeployResult(input, &op), loadErr
67243 }
68244
69245 result := &DeployResult{
@@ -75,29 +251,61 @@ func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployR
75251 // A targeted deploy is intentionally a partial plan: services omitted
76252 // from pinned remain active. Full deploys reconcile actual removals.
77253 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 {
79255 return result, err
80256 }
81257 }
82258 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 {
84260 return result, err
85261 }
86262 }
87263 op.Outcome = ComputeOutcome(result.Services)
88264 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))
89265 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 {
91267 log.Warn().Err(saveErr).Msg("deployment: failed to record deploy outcome")
92268 }
93269 return result, nil
94270 }
95271
96-// runServiceStep executes one pinned service in the journal: replace,
97-// publish ACTIVE, then cutover traffic and retire the replaced container.
98-// The step is recorded as succeeded only after the runtime effect, the
99-// ACTIVE publication, and the traffic apply all succeeded, so a journaled
100-// success is never ahead of what is routed.
272+// loadClaim verifies the claimed journal by app/op identity. The store is
273+// authoritative: the operation is always reloaded by app/op ID, so a stale
274+// in-process journal can never be executed (for example when another actor
275+// finalized the key between the claim and execution). A journal that does
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.
101309 func (s *Service) runServiceStep(
102310 ctx context.Context,
103311 app, revision string,
@@ -112,22 +320,29 @@ func (s *Service) runServiceStep(
112320 if eff, ok := active.Services[p.name]; ok {
113321 before = eff.Container
114322 }
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- }
120323 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{
122330 ID: stepID, Service: p.name,
123331 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
125341 }
126342 fail := func(err error) error {
127343 step.State = domain.AppStepFailed
128344 step.Error = err.Error()
129345 step.Diagnostics = svcResult.Diagnostics
130- op.Steps[index+1] = step
131346 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))
132347 op.Outcome = ComputeOutcome(result.Services)
133348 if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {
@@ -140,17 +355,18 @@ func (s *Service) runServiceStep(
140355 return fail(fmt.Errorf("deployment: service %q failed at %s: %s: %w",
141356 p.name, stepID, svcResult.Error, domain.ErrAppStateConflict))
142357 }
143- // Publish per-service effective state first: the proxy switches to the
144- // new binds at publication. Retire the replaced container only AFTER
145- // publication (no outage on crash between the two); a retire failure
146- // is a cleanup warning, not an outcome flip.
358+ // Publish the per-service effective state: the new binds become the
359+ // service's recorded backend before the proxy is repointed at them.
147360 if err := s.publishService(ctx, app, revision, p, svcResult, op.Op); err != nil {
148361 svcResult.Result = "failed"
149362 svcResult.Error = err.Error()
150363 result.Services[p.name] = svcResult
151364 return fail(err)
152365 }
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 {
154370 svcResult.Result = "failed"
155371 svcResult.Error = err.Error()
156372 result.Services[p.name] = svcResult
@@ -158,7 +374,7 @@ func (s *Service) runServiceStep(
158374 }
159375 result.Services[p.name] = svcResult
160376 step.State = domain.AppStepSucceeded
161- op.Steps[index+1] = step
377+ step.Error = ""
162378 if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil {
163379 log.Warn().Err(saveErr).Msg("deployment: failed to checkpoint service progress")
164380 }
@@ -310,175 +526,332 @@ func (s *Service) retireRemovedService(ctx context.Context, app, name string, ef
310526 return step, nil, nil
311527 }
312528
313-// cutoverService applies traffic after publication, then retires the
314-// replaced container. A traffic failure is returned to the caller, which
315-// records the failed step: ACTIVE is published but the new service is not
316-// routable, so the operation must not report success. The old container
317-// is retained when traffic activation fails.
318-func (s *Service) cutoverService(ctx context.Context, app string, p pinnedService, svcResult *ServiceResult, result *DeployResult) error {
319- if trafficErr := s.refreshTraffic(ctx, app); trafficErr != nil {
320- return trafficErr
321- }
322- if svcResult.Retire != "" {
323- drainWithDeadline(ctx, p.spec.StopGrace)
324- // The replaced generation keeps its inhibition: only a published
325- // replacement may clear it.
326- retired := s.retireContainer(ctx, app, retireOptions{
327- Service: p.name, Grace: svcResult.RetireGrace,
328- }, svcResult.Retire)
329- svcResult.CleanupWarnings = append(svcResult.CleanupWarnings, retired.Warnings...)
529+// candidateJournal durably records a freshly created replacement container
530+// before it can be started or join a network. An interrupted deployment then
531+// leaves a trace boot recovery removes, instead of an untracked running
532+// generation that could overlap the one recovery rebuilds.
533+//
534+// The window between the runtime creating a container and this write is the
535+// irreducible one: a container ID cannot be recorded before it exists.
536+// sync/atomic is not needed here: the journal write is synchronous.
537+type candidateJournal func(ctx context.Context, containerID string) error
538+
539+// journalCandidate records one created candidate in the operation journal.
540+// The step is addressed by index and read at write time, so a later append to
541+// the operation's steps can never leave the recorder writing to a stale
542+// element. A nil operation (an internal path with no journal) records nothing.
543+func (s *Service) journalCandidate(op *domain.AppOperation, index int) candidateJournal {
544+ if op == nil || index < 0 || index >= len(op.Steps) {
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
330553 }
331- return nil
332554 }
333555
334-// deployService replaces one service. beforeGrace is the effective stop
335-// grace of the container being replaced. It returns the terminal result
336-// and whether the service saw interruption.
337-func (s *Service) deployService(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {
338- if httpEligible(p.spec) {
339- return s.deployHTTP(ctx, app, revision, p, opID, before, beforeGrace)
556+// reconcileInterruptedDeploy converges the journal of a deployment that a
557+// crash, shutdown, or failed cleanup interrupted. A candidate that was
558+// created but never published is removed and its loopback claims released, so
559+// recovery can rebuild the published generation without ever running two
560+// generations of one service. The interrupted operation is then finalized as
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
340620 }
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
342631 }
343632
344-// httpEligible reports HTTP services whose generations may overlap while
345-// the candidate proves readiness. Every declared HTTP interface must be
346-// effective-public: the proxy repoints each public host to the candidate
347-// only after readiness, so overlap buys availability. Any internal HTTP
348-// interface is instead resolved over the app private network by service
349-// alias, and the candidate joins that network with the same alias as soon
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 {
633+// reconcileNeedsContainer reports whether one step still needs container
634+// work: a step that recorded a candidate. A terminal operation only retries
635+// the step that recorded a failed candidate; a pending step exists only in an
636+// interrupted operation.
637+func reconcileNeedsContainer(step domain.AppOperationStep, terminal bool) bool {
638+ if step.After == "" {
357639 return false
358640 }
359- for _, h := range spec.HTTP {
360- if !h.IsPublic() {
361- return false
362- }
641+ if terminal {
642+ return step.State == domain.AppStepFailed
363643 }
364644 return true
365645 }
366646
367-// singleWriterRequired reports services that must never have two generations
368-// running concurrently. Persistent volumes and any bind (especially a
369-// writable one) may be written by both, so they are treated identically.
370-func singleWriterRequired(spec domain.AppService) bool {
371- return len(spec.Volumes) > 0 || len(spec.Binds) > 0
647+// convergeInterruptedSteps retries the recorded candidate of every step that
648+// needs container work and accumulates the bounded leftovers in the journal.
649+// It reports whether the journal changed and fails closed on the first
650+// candidate that cannot be removed, before any later step or mutation runs.
651+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
372684 }
373685
374-// deployHTTP keeps the old container serving until the replacement passes
375-// readiness AND the new effective state is published: the proxy switches
376-// to the new loopback binds at publication, the old container drains,
377-// and only then is the old container retired by exact ID. Retire never
378-// precedes publication (no outage window on crash between the two).
379-func (s *Service) deployHTTP(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {
380- created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID)
381- if err != nil {
382- return s.failResult(revision, before, "", err), false
686+// convergeCandidate converges the container one step recorded. The published
687+// generation is kept; a never-published candidate is removed so recovery can
688+// rebuild the generation recorded in ACTIVE without two generations running
689+// at once. An interrupted operation's step is marked failed.
690+//
691+// A candidate that cannot be removed is returned as an error, not a
692+// recoverable warning: the orphan may still run, so every later mutation must
693+// fail closed instead of proceeding. The returned warnings are the leftovers
694+// 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+ }
383744 }
384- if err := s.waitServiceReady(ctx, app, created.ID, httpReadiness(p.spec), binds); err != nil {
385- // Old version keeps serving. Capture redacted diagnostics while
386- // the failed replacement still exists, then remove only that
387- // candidate.
388- tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))
389- cleanup := s.retireCandidate(ctx, app, p.name, created.ID)
390- return ServiceResult{
391- Result: "failed",
392- EffectiveRevision: revision,
393- Before: before,
394- After: created.ID,
395- Error: err.Error(),
396- Diagnostics: tail,
397- CleanupWarnings: cleanup,
398- }, false
745+ // Only reconciliation removes a recorded candidate: clearing After here is
746+ // what marks the step converged.
747+ step.After = ""
748+ return nil, nil
749+}
750+
751+// interruptedOutcome classifies an interrupted operation: a failure, unless
752+// every step had already succeeded.
753+func interruptedOutcome(op domain.AppOperation) string {
754+ if operationSucceeded(op) {
755+ return domain.AppOutcomeSuccess
399756 }
400- return ServiceResult{
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
757+ return domain.AppOutcomeFailed
410758 }
411759
412-// refreshTraffic rebuilds the proxy host index and applies the full
413-// HTTP/L4 graph after activation. A failure is fatal to the caller:
414-// ACTIVE is published but the new service is not routable, so the
415-// operation must not report success. Any service of this app whose
416-// fail-closed withdrawal was never applied is withdrawn again first, so
417-// a later publication never runs on top of unproven forwarding.
418-func (s *Service) refreshTraffic(ctx context.Context, app string) error {
419- if s.deps.Traffic == nil {
420- return nil
760+// operationSucceeded reports whether every step of an interrupted operation
761+// reached success: its effects ran and only the final outcome write was
762+// interrupted.
763+func operationSucceeded(op domain.AppOperation) bool {
764+ if len(op.Steps) == 0 {
765+ return false
421766 }
422- for _, service := range s.publication.pending(app) {
423- if err := s.withdrawForRecovery(ctx, app, service); err != nil {
424- return err
767+ for _, step := range op.Steps {
768+ if step.State != domain.AppStepSucceeded {
769+ return false
425770 }
426771 }
427- if err := s.deps.Traffic.RebuildTraffic(ctx); err != nil {
428- return fmt.Errorf("deployment: rebuild traffic for %q: %w", app, err)
429- }
430- return nil
772+ return true
431773 }
432774
433-// retireContainerID returns the exact container ID to retire after the
434-func retireContainerID(before, after string) string {
435- if before == "" || before == after {
436- return ""
775+// publishedContainer reports whether any service of the ACTIVE record names
776+// the container. A container that is the published generation is never a
777+// leftover, whichever service recorded it.
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+ }
437786 }
438- return before
787+ return false
439788 }
440789
441-// deployInterrupted stops the old instance after preflight, then creates
442-// the replacement. Volume-owning failures never restart the old image:
443-// the replacement may already have written data.
444-func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {
445- // Revalidate the current authorization and source before inhibiting or
790+// deployService replaces one service sequentially and returns its terminal
791+// result. before is the ACTIVE container being superseded (empty on a first
792+// deployment); beforeGrace is its effective stop grace.
793+//
794+// 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
446802 // retiring the serving generation. A reload between preflight and this
447803 // service step must fail without mutating the existing workload.
448804 if _, err := s.resolveServiceBinds(app, p.spec); err != nil {
449- return s.failResult(revision, before, "", err), true
450- }
451- // A volume-owning replacement may write while the old generation
452- // still exists and could be revived by native restart policy.
453- // Inhibit that generation durably BEFORE the write can happen, so
454- // boot or periodic recovery can never restart the old writer on
455- // top of the new one. Cleared once the safe generation is
456- // published (publishService) or the operator removes the app.
457- inhibited := before != "" && singleWriterRequired(p.spec)
458- if inhibited {
805+ return s.failResult(revision, before, "", err)
806+ }
807+ // Withdraw the service from traffic first and confirm it: a withdrawal
808+ // that cannot be applied must block every container mutation, so no
809+ // unverified generation stays routable. A first deployment has no
810+ // published generation to withdraw.
811+ if before != "" {
812+ if err := s.withdrawForRecovery(ctx, app, p.name); err != nil {
813+ return s.failResult(revision, before, "", err)
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) {
459823 if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil {
460- return s.failResult(revision, before, "", err), true
824+ return s.failResult(revision, before, "", err)
461825 }
462826 }
463827 if before != "" {
464- // The superseded writer must be confirmed gone before a new
465- // writer can touch the same data: a failed retirement aborts the
466- // replacement instead of allowing overlapping writers, and the
467- // generation keeps its inhibition and its claims.
828+ // The superseded generation must be confirmed gone before a new
829+ // generation starts: a failed retirement aborts the replacement
830+ // instead of allowing overlapping generations, and the generation
831+ // keeps its inhibition and its claims.
468832 retired := s.retireContainer(ctx, app, retireOptions{
469833 Service: p.name, Grace: beforeGrace,
470834 }, before)
471835 if !retired.Gone {
472- return s.failResult(revision, before, "", fmt.Errorf("deployment: retire superseded container %s: %s", before, cleanupDetail(retired))), true
836+ return s.failResult(revision, before, "", fmt.Errorf("deployment: retire superseded container %s: %s", before, cleanupDetail(retired)))
473837 }
474838 }
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)
476840 if err != nil {
477- return s.failResult(revision, before, "", err), true
478- }
479- if err := s.waitServiceReady(ctx, app, created.ID, p.spec, binds); err != nil {
480- // Capture redacted diagnostics while the candidate still exists,
481- // then remove only that candidate.
841+ result := s.failResult(revision, before, "", err)
842+ if created != nil {
843+ // The candidate exists: keep its ID in the terminal result so the
844+ // journal and the operator can still trace it.
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.
482855 tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))
483856 cleanup := s.retireCandidate(ctx, app, p.name, created.ID)
484857 return ServiceResult{
@@ -490,7 +863,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
490863 Error: err.Error(),
491864 Diagnostics: tail,
492865 CleanupWarnings: cleanup,
493- }, true
866+ }
494867 }
495868 return ServiceResult{
496869 Result: "deployed",
@@ -500,7 +873,35 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
500873 RestartUnsafe: singleWriterRequired(p.spec),
501874 BackendBinds: binds,
502875 UDPBackendBinds: udpBinds,
503- }, true
876+ }
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
504905 }
505906
506907 // reserveVolumeOwnership durably records one app-owned volume as
@@ -568,7 +969,7 @@ func volumeProvenanceLabels(app, appID, service, revision string) map[string]str
568969 // never container IPs. The runtime keeps native restarts; Gordon
569970 // reconciles intent at boot and in the monitor (accepted decision:
570971 // 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) {
572973 // Re-resolve binds from the current policy immediately before any
573974 // runtime mutation: a bind revoked since preflight must fail here,
574975 // before volume ownership or container creation.
@@ -580,13 +981,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
580981 if err != nil {
581982 return nil, nil, nil, err
582983 }
583- image := p.runtimeImage
584- if image == "" {
585- image = p.spec.Image
586- if p.digest != "" {
587- image = stripImageTag(p.spec.Image) + "@" + p.digest
588- }
589- }
984+ image := runtimeImageRef(p)
590985 // The incarnation network is verified or created before any
591986 // container exists, so a workload is only ever started on a
592987 // Gordon-owned network that no other app shares.
@@ -646,22 +1041,58 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
6461041 if err != nil {
6471042 return nil, nil, nil, err
6481043 }
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+ }
6491050 if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil {
6501051 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
651- return nil, nil, nil, err
1052+ return created, nil, nil, err
6521053 }
6531054 if err := s.deps.Runtime.StartContainer(ctx, created.ID); err != nil {
6541055 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)
6561057 }
6571058 binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec))
6581059 if err != nil {
6591060 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
660- return nil, nil, nil, err
1061+ return created, nil, nil, err
6611062 }
6621063 return created, binds, udpBinds, nil
6631064 }
6641065
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+
6651096 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
6661097 created, err := s.deps.Runtime.CreateContainer(ctx, config)
6671098 if err == nil {
@@ -927,7 +1358,8 @@ func sortedEnv(envMap map[string]string) []string {
9271358 return env
9281359 }
9291360
930-// publishService writes the per-service effective record after cutover.
1361+// publishService writes the per-service effective record once the
1362+// replacement passed readiness.
9311363 func (s *Service) publishService(ctx context.Context, app, revision string, p pinnedService, result ServiceResult, opID string) error {
9321364 if app == "" {
9331365 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
9821414 return nil
9831415 }
9841416
985-// recordOwnership stamps volume/secret/network ownership after cutover.
1417+// recordOwnership stamps volume/secret/network ownership after publication.
9861418 func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedService) error {
9871419 ownership, err := s.deps.State.LoadOwnership(ctx, app)
9881420 if err != nil {
@@ -1145,26 +1577,39 @@ func tailLines(r io.Reader, n int) ([]string, error) {
11451577 return lines, nil
11461578 }
11471579
1148-// httpReadiness forces the HTTP path for eligible cutover checks.
1149-func httpReadiness(spec domain.AppService) domain.AppService {
1150- if spec.Readiness.Type == "" || spec.Readiness.Type == domain.AppReadinessNone {
1151- spec.Readiness.Type = domain.AppReadinessHTTP
1152- }
1580+// deploymentReadiness resolves the readiness check a deploy or restart
1581+// runs. Readiness never depends on how a service is replaced: a declared
1582+// http, tcp, or log check is used as declared, and an undeclared check
1583+// ("none" or unset) keeps the implicit HTTP probe on a service Gordon can
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
11531593 return spec
11541594 }
11551595
1156-// drainWithDeadline pauses briefly so in-flight HTTP requests finish.
1157-func drainWithDeadline(ctx context.Context, grace time.Duration) {
1158- if grace <= 0 {
1159- grace = domain.AppDefaultStopGrace
1596+// implicitHTTPProbe reports whether a service that declares no readiness
1597+// check receives the implicit HTTP probe when a deploy or restart starts it.
1598+// Recovery verification of an already running generation keeps the declared
1599+// 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
11601603 }
1161- if grace > time.Second {
1162- 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 {
1605+ return false
11631606 }
1164- select {
1165- case <-time.After(grace):
1166- case <-ctx.Done():
1607+ for _, h := range spec.HTTP {
1608+ if !h.IsPublic() {
1609+ return false
1610+ }
11671611 }
1612+ return true
11681613 }
11691614
11701615 // 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) {
327327 assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network)
328328 return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
329329 },
330- httpGet: func(context.Context, string) (int, error) {
330+ httpGet: func(context.Context, string, string) (int, error) {
331331 loopbackCalls++
332332 return 200, nil
333333 },
@@ -346,7 +346,7 @@ func TestWaitServiceReady_PublicKeepsLoopback(t *testing.T) {
346346 loopbackCalls := 0
347347 service := NewService(Deps{}, zerowrap.Default()).
348348 WithProbeDeps(ProbeDeps{
349- httpGet: func(context.Context, string) (int, error) {
349+ httpGet: func(context.Context, string, string) (int, error) {
350350 loopbackCalls++
351351 return 200, nil
352352 },
M internal/usecase/deployment/lifecycle.go +123 -209
@@ -44,6 +44,9 @@ func (s *Service) stopLocked(ctx context.Context, app, opID string) (*LifecycleR
4444 if err := s.deps.State.Recover(ctx); err != nil {
4545 return nil, fmt.Errorf("deployment: recover before stop: %w", err)
4646 }
47+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
48+ return nil, err
49+ }
4750 op := domain.AppOperation{
4851 Kind: "stop", App: app,
4952 StartedAt: time.Now().UTC(),
@@ -147,6 +150,9 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
147150 if err := s.deps.State.Recover(ctx); err != nil {
148151 return nil, fmt.Errorf("deployment: recover before start: %w", err)
149152 }
153+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
154+ return nil, err
155+ }
150156 op := domain.AppOperation{
151157 Kind: "start", App: app,
152158 StartedAt: time.Now().UTC(),
@@ -176,12 +182,12 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
176182 result := &LifecycleResult{Op: op.Op, App: app, Verb: "start", Services: map[string]ServiceResult{}}
177183 for _, name := range sortedServiceNames(active) {
178184 eff := active.Services[name]
179- step := domain.AppOperationStep{ID: "service." + name + ".start", State: domain.AppStepPending, Before: eff.Container}
180- if s.ensureServiceRunning(ctx, app, opID, name, eff, &step, result) {
181- op.Steps = append(op.Steps, step)
182- continue
183- }
184- op.Steps = append(op.Steps, step)
185+ // The step is journaled before the runtime work, so a candidate
186+ // created by a redeploy is durably recorded as soon as it exists.
187+ op.Steps = append(op.Steps, domain.AppOperationStep{
188+ ID: "service." + name + ".start", State: domain.AppStepPending, Before: eff.Container, Service: name,
189+ })
190+ s.ensureServiceRunning(ctx, app, opID, name, eff, &op, len(op.Steps)-1, result)
185191 }
186192 op.Outcome = ComputeOutcome(result.Services)
187193 op.Warnings = journalWarnings(collectCleanupWarnings(result.Services))
@@ -196,10 +202,10 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
196202 }
197203
198204 // Restart restarts from pinned digests without re-resolution. Empty
199-// service means all services in sorted order. HTTP-only, volume-less
200-// services restart with zero downtime: the old container keeps serving
201-// until the candidate passes readiness and traffic has moved to it.
202-// HTTP-with-volume, TCP/UDP, and mixed services restart in place.
205+// service means all services in sorted order. Every service restarts in
206+// place: traffic is withdrawn, the same pinned container is restarted, its
207+// readiness probe is checked, and traffic is republished. No second
208+// container is created and the pinned digest never changes.
203209 func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) {
204210 release, err := s.acquireAppContext(ctx, app)
205211 if err != nil {
@@ -214,6 +220,9 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
214220 if err := s.deps.State.Recover(ctx); err != nil {
215221 return nil, fmt.Errorf("deployment: recover before restart: %w", err)
216222 }
223+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
224+ return nil, err
225+ }
217226 active, ok, err := s.deps.State.LoadActive(ctx, app)
218227 if err != nil {
219228 return nil, fmt.Errorf("deployment: load active: %w", err)
@@ -244,14 +253,12 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
244253 var failures []string
245254 for _, name := range names {
246255 eff := active.Services[name]
247- var step domain.AppOperationStep
248- var svcResult ServiceResult
249- if httpEligible(eff.Spec) {
250- step, svcResult = s.restartOneHTTPService(ctx, app, name, eff, op.Op)
251- } else {
252- step, svcResult = s.restartOneService(ctx, app, name, eff)
253- }
254- op.Steps = append(op.Steps, step)
256+ // The step is journaled before the runtime work, so a candidate
257+ // created by a rebuild of a missing generation is durably recorded.
258+ op.Steps = append(op.Steps, domain.AppOperationStep{
259+ ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container, Service: name,
260+ })
261+ svcResult := s.restartOneService(ctx, app, op.Op, name, eff, &op, len(op.Steps)-1)
255262 result.Services[name] = svcResult
256263 if svcResult.Result != "deployed" {
257264 failures = append(failures, name)
@@ -272,141 +279,21 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
272279 return result, nil
273280 }
274281
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-
398282 // restartOneService withdraws one service, restarts its exact container,
399283 // re-inspects its binds, and verifies readiness before it may be published
400284 // again. Every failure leaves the service withdrawn with its recorded binds
401-// cleared, so a failed restart is never served.
402-func (s *Service) restartOneService(ctx context.Context, app, name string, eff domain.AppEffectiveService) (domain.AppOperationStep, ServiceResult) {
403- step := domain.AppOperationStep{ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container}
285+// cleared, so a failed restart is never served. A generation whose recorded
286+// container no longer exists is rebuilt from the pinned ACTIVE digest, so a
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]
404290 result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container}
405- fail := func(msg string) (domain.AppOperationStep, ServiceResult) {
291+ fail := func(msg string) ServiceResult {
406292 step.State = domain.AppStepFailed
407293 step.Error = msg
294+ result.Result = "failed"
408295 result.Error = msg
409- return step, result
296+ return result
410297 }
411298 if eff.Container == "" {
412299 result.After = ""
@@ -427,6 +314,30 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
427314 return fail(err.Error())
428315 }
429316 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+ }
430341 s.clearServiceBinds(ctx, app, name)
431342 return fail(err.Error())
432343 }
@@ -438,13 +349,13 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
438349 s.clearServiceBinds(ctx, app, name)
439350 return fail(err.Error())
440351 }
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 {
442353 s.clearServiceBinds(ctx, app, name)
443354 return fail(err.Error())
444355 }
445356 step.State = domain.AppStepSucceeded
446357 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}
448359 }
449360
450361 // 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
468379 if err := s.deps.State.Recover(ctx); err != nil {
469380 return nil, fmt.Errorf("deployment: recover before remove: %w", err)
470381 }
382+ if err := s.reconcileInterruptedDeploy(ctx, app); err != nil {
383+ return nil, err
384+ }
471385 op := domain.AppOperation{
472386 Kind: "remove", App: app,
473387 StartedAt: time.Now().UTC(),
@@ -643,9 +557,18 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
643557 return fmt.Errorf("deployment: load active: %w", err)
644558 }
645559 if !ok || len(active.Services) == 0 {
646- return nil
560+ // 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)
647565 }
648566 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+ }
649572 for _, name := range sortedServiceNames(active) {
650573 if err := s.convergeStoppedService(ctx, app, name, active.Services[name]); err != nil {
651574 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 {
653576 }
654577 return nil
655578 }
579+ // startLocked reconciles the app's unfinished operation before it starts
580+ // anything, so the boot pass reconciles exactly once.
656581 result, err := s.startLocked(ctx, app, "")
657582 if err != nil {
658583 return fmt.Errorf("deployment: boot start %q: %w", app, err)
@@ -670,16 +595,16 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
670595 // may shift across any runtime restart (stop/start, daemon absence,
671596 // host reboot), so the recorded binds are re-inspected and persisted
672597 // before the proxy can dial them. Verification failure fails the step;
673-// a stale recorded bind is never served. It reports whether
674-// the caller should continue with the next service (true) or finish the
675-// step inline (false, reserved for future fallible publishes).
676-func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool {
598+// a stale recorded bind is never served. A recorded container that no
599+// longer exists is rebuilt from the revision pinned in ACTIVE.
600+func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, op *domain.AppOperation, index int, result *LifecycleResult) {
601+ step := &op.Steps[index]
677602 if eff.Container != "" {
678603 // Fail closed before any runtime mutation if a bind's policy was
679604 // revoked or became invalid since ACTIVE was published.
680605 if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
681606 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
682- return true
607+ return
683608 }
684609 // Boot recovery refuses a generation whose recovery is durably
685610 // inhibited: a replacement may already have written to its
@@ -687,106 +612,96 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri
687612 inhibited, err := s.recoveryInhibited(ctx, app, name, eff.Container)
688613 if err != nil {
689614 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
690- return true
615+ return
691616 }
692617 if inhibited {
693618 s.failServiceStep(ctx, app, name, eff, step, result,
694619 fmt.Sprintf("recovery inhibited for container %s", eff.Container))
695- return true
620+ return
696621 }
697622 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
699625 }
700626 // Restart by exact container ID when the runtime still has it.
701627 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
703630 }
704631 }
705- // Otherwise redeploy from the pinned active digest.
706- rev, err := s.deps.State.LoadRevision(ctx, app, eff.EffectiveRevision)
632+ // Otherwise rebuild from the revision pinned in ACTIVE.
633+ svcResult, err := s.redeployPinned(ctx, app, opID, name, eff, s.journalCandidate(op, index))
707634 if err != nil {
708635 step.State = domain.AppStepFailed
709636 step.Error = err.Error()
710- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
711- return true
637+ step.Diagnostics = svcResult.Diagnostics
638+ 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
712655 }
713656 runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest)
714657 if err != nil {
715- step.State = domain.AppStepFailed
716- step.Error = err.Error()
717- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
718- return true
658+ return failedServiceResult(eff.Container, err), err
719659 }
720660 if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
721- step.State = domain.AppStepFailed
722- step.Error = err.Error()
723- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
724- return true
661+ return failedServiceResult(eff.Container, err), err
725662 }
726663 pinned := pinnedService{
727664 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),
728665 appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
729666 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),
730667 }
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)
732669 if svcResult.Result == "failed" {
733- step.State = domain.AppStepFailed
734- step.Error = svcResult.Error
735- step.Diagnostics = svcResult.Diagnostics
736- result.Services[name] = svcResult
737- return true
670+ return svcResult, errors.New(svcResult.Error)
738671 }
739- result.Services[name] = svcResult
740- // The step is recorded as succeeded only after the effective state is
741- // published: a journaled success is never ahead of what the proxy can
742- // reach.
672+ // The step may only be recorded as succeeded once the effective state is
673+ // published: a journaled success is never ahead of what the proxy reaches.
743674 if err := s.publishService(ctx, app, rev.Revision, pinned, svcResult, opID); err != nil {
744- step.State = domain.AppStepFailed
745- step.Error = err.Error()
746- result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
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)
675+ svcResult.Result = "failed"
676+ svcResult.Error = err.Error()
677+ return svcResult, err
754678 }
755- step.State = domain.AppStepSucceeded
756- step.After = svcResult.After
757- return true
679+ return svcResult, nil
758680 }
759681
760-// retireAfterPublish retires the container a published replacement
761-// superseded. The replaced generation keeps its inhibition: the new
762-// generation's publication already cleared it, and a still-unpublished
763-// replacement never reaches here.
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
682+// failedServiceResult builds the terminal result of a service step that
683+// failed before any container existed.
684+func failedServiceResult(before string, err error) ServiceResult {
685+ return ServiceResult{Result: "failed", Before: before, Error: err.Error()}
771686 }
772687
773688 // verifyRunningService withdraws a running or freshly started generation,
774689 // re-inspects its loopback binds, and verifies readiness before the service
775690 // may be published again. Any failure leaves the service withdrawn with its
776691 // 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) {
778693 if err := s.withdrawForRecovery(ctx, app, name); err != nil {
779694 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
780- return true
695+ return
781696 }
782697 binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
783698 if err != nil {
784699 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
785- return true
700+ return
786701 }
787702 if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {
788703 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
789- return true
704+ return
790705 }
791706 // The service is running and its re-inspected loopback binds are
792707 // 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
794709 step.State = domain.AppStepSucceeded
795710 step.After = eff.Container
796711 result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds}
797- return true
798712 }
799713
800714 // 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) {
5454 ResourceLimits{MemoryBytes: 1 << 30, NanoCPUs: 2_000_000_000, PidsLimit: 256},
5555 )
5656 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)
5858 require.NoError(t, err)
5959 require.NotNil(t, created)
6060 assert.Empty(t, binds)
@@ -80,7 +80,7 @@ func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) {
8080
8181 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
8282 _, _, _, 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)
8484 require.NoError(t, err)
8585 }
8686
@@ -100,7 +100,7 @@ func TestCreateAndStart_RefusesForeignNetwork(t *testing.T) {
100100
101101 svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{})
102102 _, _, _, 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)
104104 require.ErrorIs(t, err, domain.ErrAppStateConflict)
105105 }
106106
@@ -130,7 +130,7 @@ func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) {
130130 name: "web",
131131 spec: domain.AppService{Name: "web"},
132132 sharedNetworks: []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}}},
133- }, "op-1")
133+ }, "op-1", nil)
134134 require.NoError(t, err)
135135 }
136136
@@ -163,7 +163,7 @@ func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T
163163 Name: "web",
164164 Volumes: []domain.AppVolume{{Name: "config", Path: "/config", ReadOnly: true}},
165165 },
166- }, "op-1")
166+ }, "op-1", nil)
167167 require.NoError(t, err)
168168 }
169169
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{
2222 Timeout: 5 * time.Second,
2323 }
2424
25-// httpGetOnce performs one HTTP GET without following redirects.
26-func httpGetOnce(ctx context.Context, url string) (int, error) {
27- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
25+// httpGetOnce performs one HTTP GET without following redirects. It dials
26+// probeURL's authority (the loopback published bind) while sending
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)
2831 if err != nil {
2932 return 0, err
3033 }
34+ if hostAuthority != "" {
35+ req.Host = hostAuthority
36+ }
3137 resp, err := probeHTTPClient.Do(req)
3238 if err != nil {
3339 return 0, err
M internal/usecase/deployment/publication_internal_test.go +4 -4
@@ -46,13 +46,13 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
4646 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
4747 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
4848 WithProbeDeps(NewTestProbeDeps(runtime,
49- func(context.Context, string) (int, error) { return 500, nil },
49+ func(context.Context, string, string) (int, error) { return 500, nil },
5050 func(context.Context, string) error { return nil },
5151 ))
5252
5353 step := domain.AppOperationStep{ID: "service.web.start"}
5454 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)
5656
5757 assert.Equal(t, domain.AppStepFailed, step.State)
5858 assert.Equal(t, "failed", result.Services["web"].Result)
@@ -81,13 +81,13 @@ func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) {
8181
8282 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
8383 WithProbeDeps(NewTestProbeDeps(runtime,
84- func(context.Context, string) (int, error) { return 200, nil },
84+ func(context.Context, string, string) (int, error) { return 200, nil },
8585 func(context.Context, string) error { return nil },
8686 ))
8787
8888 step := domain.AppOperationStep{ID: "service.web.start"}
8989 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)
9191
9292 assert.Equal(t, domain.AppStepSucceeded, step.State)
9393 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 {
2020 networkInfo func(ctx context.Context, containerID string) (string, int, error)
2121 containerStart func(ctx context.Context, containerID string) (time.Time, error)
2222 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)
2424 tcpDial func(ctx context.Context, addr string) error
2525 // networkProbe runs one bounded session against an internal port that
2626 // has no host publication. Nil disables the internal path: it must
@@ -67,7 +67,7 @@ func NewProbeDeps(runtime ProbeRuntime) ProbeDeps {
6767 // NewTestProbeDeps builds ProbeDeps with injectable HTTP/TCP probers.
6868 // Tests control L4 outcomes without dialing; logStream still reads the
6969 // 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 {
7171 deps := NewProbeDeps(runtime)
7272 deps.httpGet = httpGet
7373 deps.tcpDial = tcpDial
@@ -287,6 +287,7 @@ func waitServiceReadyWithDeps(ctx context.Context, deps ProbeDeps, containerID s
287287 // waitHTTPReadyWithDeps polls GET path until 2xx/3xx or timeout.
288288 func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppService, binds map[int]int) error {
289289 timeout := readinessTimeout(spec)
290+ containerPort := readinessContainerPort(spec)
290291 port, err := dialPort(spec, binds)
291292 if err != nil {
292293 return err
@@ -302,6 +303,11 @@ func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppS
302303 Path: pathPart,
303304 RawQuery: rawQuery,
304305 }).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))
305311 deadline := time.Now().Add(timeout)
306312 var lastAttempt string
307313 for {
@@ -309,7 +315,7 @@ func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppS
309315 return err
310316 }
311317 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)
313319 cancel()
314320 if err == nil && status >= 200 && status < 400 {
315321 return nil
M internal/usecase/deployment/readiness_internal_test.go +36 -6
@@ -2,6 +2,9 @@ package deployment
22
33 import (
44 "context"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
58 "testing"
69 "time"
710
@@ -13,11 +16,12 @@ import (
1316
1417 // TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority proves the probe URL is
1518 // 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.
1721 func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) {
18- var got string
19- deps := ProbeDeps{httpGet: func(_ context.Context, url string) (int, error) {
20- got = url
22+ var gotURL, gotHost string
23+ deps := ProbeDeps{httpGet: func(_ context.Context, url, hostAuthority string) (int, error) {
24+ gotURL, gotHost = url, hostAuthority
2125 return 200, nil
2226 }}
2327 spec := domain.AppService{
@@ -26,13 +30,39 @@ func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) {
2630 Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz?probe=1", Timeout: time.Second},
2731 }
2832 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}))
3060 }
3161
3262 // TestWaitHTTPReady_HangingProbeRespectsDeadline proves a workload that
3363 // never answers cannot hold the readiness operation open past its bound.
3464 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) {
3666 <-ctx.Done()
3767 return 0, ctx.Err()
3868 }}
M internal/usecase/deployment/reconcile_mock_test.go +4 -4
@@ -86,7 +86,7 @@ func recoveringService(
8686 state *outmocks.MockAppState,
8787 runtime *outmocks.MockContainerRuntime,
8888 traffic *recordingTraffic,
89- httpGet func(context.Context, string) (int, error),
89+ httpGet func(context.Context, string, string) (int, error),
9090 ) *deployment.Service {
9191 t.Helper()
9292 return deployment.NewService(deployment.Deps{
@@ -195,7 +195,7 @@ func TestReconcileRunning_StartsExitedExactIDThenVerifiesAndPublishes(t *testing
195195 })).Return(nil).Once()
196196
197197 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) {
199199 probedURL = url
200200 return 200, nil
201201 })
@@ -599,7 +599,7 @@ func TestReconcileRunning_NoHealthcheckNeverRestartsOnReadiness(t *testing.T) {
599599 // An execution unseen by this daemon is verified immediately because it
600600 // may have restarted while Gordon was absent. Readiness failure keeps it
601601 // 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) {
603603 return 500, nil
604604 })
605605 require.Error(t, svc.ReconcileRunning(ctx), "the unseen execution fails readiness and stays withdrawn")
@@ -796,7 +796,7 @@ func TestReconcileRunning_FailedReadinessPersistsNoBinds(t *testing.T) {
796796 Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
797797 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
798798
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) {
800800 return 500, nil
801801 })
802802 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
9696 require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID))
9797 }
9898
99-// TestInterruptedVolumeFailureLeavesOldGenerationInhibited proves the
99+// TestVolumeReplacementFailureLeavesOldGenerationInhibited proves the
100100 // marker is written BEFORE a volume-owning replacement can write and is
101101 // not cleared when that replacement fails: the old generation must never
102102 // be restarted on top of possibly-newer data.
103-func TestInterruptedVolumeFailureLeavesOldGenerationInhibited(t *testing.T) {
103+func TestVolumeReplacementFailureLeavesOldGenerationInhibited(t *testing.T) {
104104 ctx := context.Background()
105105 store := newTestStore(t)
106106 runtime := outmocks.NewMockContainerRuntime(t)
@@ -144,7 +144,7 @@ func TestInterruptedVolumeFailureLeavesOldGenerationInhibited(t *testing.T) {
144144 State: store, Runtime: runtime, Images: images, Secrets: secrets,
145145 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
146146 }, 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 },
148148 func(context.Context, string) error { return assert.AnError },
149149 ))
150150
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) {
133133 state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything)
134134 }
135135
136-// TestDeploy_RetirementFailureIsSurfacedAsAWarning proves a leftover of a
137-// successful deploy is journaled as a bounded warning instead of being
138-// dropped, and that the deploy itself still reports success.
139-func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
136+// TestDeploy_RetirementFailureBlocksReplacement proves the superseded
137+// generation must be confirmed gone before a replacement starts: a failed
138+// retirement aborts the service without creating a candidate, and the
139+// failure is journaled instead of being downgraded to a warning.
140+func TestDeploy_RetirementFailureBlocksReplacement(t *testing.T) {
140141 ctx := context.Background()
141142 state, runtime, images, secrets := mockDeps(t)
142143 rev := mockRevision()
143- rev.Spec.Services[0].StopGrace = time.Millisecond
144144
145145 oldActive := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
146146 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old", Spec: graceSpec(time.Second)},
@@ -155,18 +155,9 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
155155 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
156156 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
157157 state.EXPECT().LoadActive(mock.Anything, "blog").Return(oldActive, true, nil)
158- state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
159- state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
160- expectNetworkProvision(runtime, "app-blog", 1)
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.
158+ state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
159+ // The superseded container is retired with its own effective grace, and
160+ // its removal fails: the replacement must not start.
170161 runtime.EXPECT().StopContainer(mock.Anything, "c-old", time.Second).Return(nil).Once()
171162 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(assert.AnError).Once()
172163
@@ -180,17 +171,27 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
180171 svc := deployment.NewService(deployment.Deps{
181172 State: state, Runtime: runtime, Images: images, Secrets: secrets,
182173 }, 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 },
184175 func(context.Context, string) error { return nil },
185176 ))
186177
187178 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
188- require.NoError(t, err)
179+ require.Error(t, err)
189180 require.NotNil(t, result)
190- assert.Equal(t, "deployed", result.Services["web"].Result)
191- require.Len(t, result.CleanupWarnings, 1)
192- assert.Equal(t, "c-old", result.CleanupWarnings[0].Leftover)
193- assert.NotContains(t, result.CleanupWarnings[0].Detail, "unexpected", "warnings carry stable, log-free detail")
181+ assert.Equal(t, "failed", result.Services["web"].Result)
182+ assert.Contains(t, result.Services["web"].Error, "retire superseded container",
183+ "the unconfirmed retirement is the reported failure")
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)
194195
195196 require.NotEmpty(t, saved)
196197 last := saved[len(saved)-1]
@@ -198,8 +199,6 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) {
198199 assert.Equal(t, "preflight", last.Steps[0].ID)
199200 assert.Equal(t, domain.AppStepSucceeded, last.Steps[0].State)
200201 assert.Equal(t, "service.web.replace", last.Steps[1].ID)
201- assert.Equal(t, domain.AppStepSucceeded, last.Steps[1].State)
202- require.Len(t, last.Warnings, 1)
203- assert.Equal(t, "web", last.Warnings[0].Service)
204- assert.Equal(t, "c-old", last.Warnings[0].Leftover)
202+ assert.Equal(t, domain.AppStepFailed, last.Steps[1].State)
203+ assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
205204 }
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 {
103103 // the lock.
104104 bindMu sync.RWMutex
105105 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{}
106114 }
107115
108116 // NewService creates the deployment engine. All deps are required;
@@ -119,6 +127,43 @@ func NewService(deps Deps, log zerowrap.Logger) *Service {
119127 }
120128 }
121129
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+
122167 // WithGCBarrier wires the process-wide GC barrier. Every workload
123168 // mutation holds a shared lease from resource selection through the
124169 // durable publication of the resource's protection, so prune can never
@@ -258,7 +303,6 @@ type DeployResult struct {
258303 Outcome string
259304 Services map[string]ServiceResult
260305 CleanupWarnings []CleanupWarning
261- Interrupted []string
262306 // Removed names services retired because the new revision no longer
263307 // declares them.
264308 Removed []string
@@ -279,12 +323,6 @@ type ServiceResult struct {
279323 // publishes for the active record. Nil when the service declares
280324 // no UDP interface.
281325 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
288326 // CleanupWarnings are bounded leftovers of this service's terminal
289327 // path: a candidate that could not be removed, or a backend claim
290328 // that could not be released.
@@ -330,38 +368,21 @@ func newOpID() string {
330368 return "op-" + string(buf[:])
331369 }
332370
333-// Preflight resolves a captured revision without any workload mutation:
334-// image digests, secret presence, image-volume mapping, reservation
335-// recheck, and resource preconditions. It records the pinned digest
336-// table into the journal BEFORE any effect.
337-func (s *Service) Preflight(ctx context.Context, input DeployInput) ([]pinnedService, *domain.AppOperation, error) {
338- release, err := s.acquireAppContext(ctx, input.App)
339- if err != nil {
340- return nil, nil, err
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- }
371+// claimDeploymentLocked is the first phase of a deploy: it resolves the
372+// requested revision, enforces the targeted-deploy convergence rules, and
373+// claims the request key, persisting the non-terminal journal. It performs
374+// no image pull and no workload mutation, so a crash after it leaves a
375+// durable claim that reconciliation can converge. owned is true only for the
376+// single claimer of the key; a replay returns the stored journal instead.
377+// The caller holds the app lock and has already recovered the store.
378+func (s *Service) claimDeploymentLocked(ctx context.Context, input DeployInput) (domain.AppDesiredRevision, domain.AppOperation, bool, error) {
358379 rev, err := s.resolveRevision(ctx, input)
359380 if err != nil {
360- return nil, nil, false, err
381+ return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err
361382 }
362383 if input.Service != "" {
363384 if err := s.checkConverged(ctx, input.App, input.Service, rev); err != nil {
364- return nil, nil, false, err
385+ return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err
365386 }
366387 }
367388
@@ -374,26 +395,42 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
374395 }
375396 op, owned, err := s.claimOperation(ctx, input.Op, op, []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}})
376397 if err != nil {
377- return nil, nil, 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)
398+ return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err
383399 }
400+ return rev, op, owned, nil
401+}
384402
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)
386411 if err != nil {
387412 op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepFailed, Error: err.Error()}
388413 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 {
390415 log.Warn().Err(saveErr).Msg("deployment: failed to record preflight failure")
391416 }
392- return nil, &op, false, err
417+ return nil, err
393418 }
394419 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]
395432 for _, p := range pinned {
396- op.Steps = append(op.Steps, domain.AppOperationStep{
433+ plan = append(plan, domain.AppOperationStep{
397434 ID: "service." + p.name + ".replace",
398435 State: domain.AppStepPending,
399436 Service: p.name,
@@ -401,11 +438,37 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
401438 Image: p.runtimeImage,
402439 })
403440 }
404- if err := s.deps.State.SaveOperation(ctx, op); err != nil {
405- return nil, nil, false, fmt.Errorf("deployment: persist pinned table: %w", err)
441+ op.Steps = plan
442+ if err := s.deps.State.SaveOperation(ctx, *op); err != nil {
443+ return nil, fmt.Errorf("deployment: persist pinned table: %w", err)
406444 }
407445 log.Info().Str("op", op.Op).Str("revision", rev.Revision).Int("services", len(pinned)).Msg("deployment: preflight passed")
408- return pinned, &op, false, nil
446+ 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
409472 }
410473
411474 // claimOperation is the single claim point before any mutation effect.
@@ -418,6 +481,17 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
418481 // journal under a generated id.
419482 func (s *Service) claimOperation(ctx context.Context, key string, op domain.AppOperation, steps []domain.AppOperationStep) (domain.AppOperation, bool, error) {
420483 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+ }
421495 if key == "" {
422496 op.Op = newOpID()
423497 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
22
33 import (
44 "context"
5- "strings"
65 "testing"
76 "time"
87
@@ -48,333 +47,19 @@ func preflightService(
4847 secrets *outmocks.MockSecretProvider,
4948 ) *deployment.Service {
5049 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()
5154 return deployment.NewService(deployment.Deps{
5255 State: state, Runtime: runtime, Images: images, Secrets: secrets,
5356 ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
5457 }, 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 },
5659 func(context.Context, string) error { return nil },
5760 ))
5861 }
5962
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-
37863 func TestComputeOutcome_TerminalResults(t *testing.T) {
37964 deployed := deployment.ServiceResult{Result: "deployed"}
38065 failed := deployment.ServiceResult{Result: "failed", Error: "boom"}
@@ -412,6 +97,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
41297 // bad app: Start fails bind verification, binds withdrawn.
41398 state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once()
41499 state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil)
100+ state.EXPECT().LoadLatestOperation(mock.Anything, "bad").Return(domain.AppOperation{}, false, nil).Maybe()
415101 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
416102 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
417103 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()
@@ -423,6 +109,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
423109 state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once()
424110 // Start load + bind-refresh reload.
425111 state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil)
112+ state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()
426113 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()
427114 runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()
428115 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) {
434121 require.Error(t, err)
435122 assert.Contains(t, err.Error(), `"bad"`)
436123 }
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) {
5050 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
5151
5252 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()
5358 runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
5459 runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
5560 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return(
@@ -68,7 +73,7 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
6873 svc := deployment.NewService(deployment.Deps{
6974 State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic,
7075 }, 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 },
7277 func(context.Context, string) error { return nil },
7378 ))
7479
@@ -83,9 +88,10 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) {
8388 step, ok := opStep(last, "service.web.replace")
8489 require.True(t, ok)
8590 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 graph
87- // never accepted the new routing.
88- runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything)
91+ // The superseded container is gone: a rejected graph is reported as a
92+ // failure of the new generation, never as a return to the old one.
93+ runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-old", false)
94+ runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything)
8995 }
9096
9197 // 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
155155 docker push registry.mydomain.com/my-first-app:latest
156156 ```
157157
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.
159159
160160 ## Next Steps
161161

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