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 Unverified
B5690EEEBB952194The key is unknown
60 files · +5676 -1715
M README.md +1 -1
| @@ -157,7 +157,7 @@ gordon apps deploy blog --remote prod | |||||
| 157 | - Private Docker/Podman registry on your VPS | 157 | - Private Docker/Podman registry on your VPS | ||
| 158 | - Declarative apps: one TOML file per app, explicit apply then deploy | 158 | - Declarative apps: one TOML file per app, explicit apply then deploy | ||
| 159 | - Domain-to-container routing through a smart TCP edge reverse proxy | 159 | - Domain-to-container routing through a smart TCP edge reverse proxy | ||
| 160 | - | - HTTP zero-downtime updates (old container serves until replacement passes readiness) | 160 | + | - Sequential service replacement (withdraw traffic, replace one generation, verify readiness, republish) |
| 161 | - Remote CLI management (daemon is the sole writer) | 161 | - Remote CLI management (daemon is the sole writer) | ||
| 162 | - Declarative per-service volumes, retained across lifecycle operations | 162 | - Declarative per-service volumes, retained across lifecycle operations | ||
| 163 | - Per-service secrets in pass, app-wide public env in the manifest | 163 | - Per-service secrets in pass, app-wide public env in the manifest | ||
M docs/cli/apps.md +61 -1
| @@ -55,6 +55,27 @@ With `--deploy`, chains exactly the accepted revision into a deploy after | |||||
| 55 | persistence succeeds; the two outcomes are reported separately because a | 55 | persistence succeeds; the two outcomes are reported separately because a | ||
| 56 | deploy may fail after the apply succeeded. | 56 | deploy may fail after the apply succeeded. | ||
| 57 | 57 | ||||
| 58 | + | When the daemon accepts the deploy asynchronously (HTTP 202), the command | |||
| 59 | + | reuses the same by-key watch as `gordon apps deploy`: it polls the | |||
| 60 | + | operation journal until terminal, prints progress only when the operation | |||
| 61 | + | or step state changes, and never reissues the deploy. A terminal | |||
| 62 | + | `partial`/`failed` deploy exits nonzero while still reporting the | |||
| 63 | + | successful apply. Ctrl-C stops only local polling: the daemon-side | |||
| 64 | + | operation keeps running and the command prints how to resume it. | |||
| 65 | + | ||||
| 66 | + | With `--json`, stdout carries exactly one final combined document after the | |||
| 67 | + | deploy reaches a terminal state: | |||
| 68 | + | ||||
| 69 | + | ```json | |||
| 70 | + | { | |||
| 71 | + | "apply": { "app": "blog", "resulting_revision": "rev-b", "pending": true }, | |||
| 72 | + | "deploy": { "op": "op-1", "app": "blog", "status": "success", "outcome": "success" } | |||
| 73 | + | } | |||
| 74 | + | ``` | |||
| 75 | + | ||||
| 76 | + | No initial running document is emitted, and progress, transient warnings, | |||
| 77 | + | and Ctrl-C resume guidance go to stderr. | |||
| 78 | + | ||||
| 58 | `--dry-run` and `--deploy` cannot be combined. | 79 | `--dry-run` and `--deploy` cannot be combined. | ||
| 59 | 80 | ||||
| 60 | --- | 81 | --- | ||
| @@ -102,6 +123,33 @@ reused for a different request is refused. | |||||
| 102 | 123 | ||||
| 103 | --- | 124 | --- | ||
| 104 | 125 | ||||
| 126 | + | ## gordon apps operations watch | |||
| 127 | + | ||||
| 128 | + | ```bash | |||
| 129 | + | gordon apps operations watch APP --key KEY [--json] | |||
| 130 | + | ``` | |||
| 131 | + | ||||
| 132 | + | Polls the operation journal by request key until the operation reaches a | |||
| 133 | + | terminal state, then renders the terminal journal. It never reissues the | |||
| 134 | + | mutation, so it is the safe way to resume after an interrupted deploy or to | |||
| 135 | + | follow an operation started elsewhere. Progress is printed only when the | |||
| 136 | + | operation or step state changes; unchanged polls are not repeated. | |||
| 137 | + | ||||
| 138 | + | Exit status is nonzero for terminal `partial`/`failed` outcomes and for an | |||
| 139 | + | interrupted watch. Ctrl-C stops only local polling: the daemon-side | |||
| 140 | + | operation is not cancelled and keeps running, and the command prints the | |||
| 141 | + | command to resume. With `--json`, stdout carries exactly one final document | |||
| 142 | + | and progress goes to stderr. | |||
| 143 | + | ||||
| 144 | + | ### Flags | |||
| 145 | + | ||||
| 146 | + | | Flag | Description | | |||
| 147 | + | |------|-------------| | |||
| 148 | + | | `--key` | Request key (required) | | |||
| 149 | + | | `--json` | Output as JSON | | |||
| 150 | + | ||||
| 151 | + | --- | |||
| 152 | + | ||||
| 105 | ## gordon apps diff | 153 | ## gordon apps diff | ||
| 106 | 154 | ||||
| 107 | ```bash | 155 | ```bash | ||
| @@ -205,6 +253,14 @@ Activates a revision (default: desired head). Fail-fast across services: | |||||
| 205 | the first failure stops the deploy, successful services are preserved, | 253 | the first failure stops the deploy, successful services are preserved, | ||
| 206 | later services stay unchanged. | 254 | later services stay unchanged. | ||
| 207 | 255 | ||||
| 256 | + | When the daemon accepts the deploy and runs the two phases in the | |||
| 257 | + | background (HTTP 202), the command polls the operation journal by key until | |||
| 258 | + | it is terminal and prints concise progress only when the operation or step | |||
| 259 | + | state changes. It never reissues the mutation. In `--json` mode stdout | |||
| 260 | + | carries one final document and progress goes to stderr. Ctrl-C stops only | |||
| 261 | + | local polling (the daemon-side operation keeps running) and prints the | |||
| 262 | + | `gordon apps operations watch` command to resume. | |||
| 263 | + | ||||
| 208 | --- | 264 | --- | ||
| 209 | 265 | ||||
| 210 | ## gordon apps restart | 266 | ## gordon apps restart | ||
| @@ -288,9 +344,12 @@ gordon apps apply --file blog.toml --remote prod | |||||
| 288 | # Write secret values for registered names | 344 | # Write secret values for registered names | ||
| 289 | gordon apps secrets set blog --service web DATABASE_URL=... --remote prod | 345 | gordon apps secrets set blog --service web DATABASE_URL=... --remote prod | ||
| 290 | 346 | ||||
| 291 | - | # Deploy the accepted revision | 347 | + | # Deploy the accepted revision (polls 202 operations to terminal) |
| 292 | gordon apps deploy blog --remote prod | 348 | gordon apps deploy blog --remote prod | ||
| 293 | 349 | ||||
| 350 | + | # Resume watching an interrupted or externally started operation | |||
| 351 | + | gordon apps operations watch blog --key <operation-key> --remote prod | |||
| 352 | + | ||||
| 294 | # Inspect | 353 | # Inspect | ||
| 295 | gordon apps show blog --remote prod | 354 | gordon apps show blog --remote prod | ||
| 296 | gordon apps status blog --remote prod | 355 | gordon apps status blog --remote prod | ||
| @@ -300,3 +359,4 @@ gordon apps status blog --remote prod | |||||
| 300 | 359 | ||||
| 301 | - [CLI Overview](./index.md) | 360 | - [CLI Overview](./index.md) | ||
| 302 | - [Push Command](./push.md) | 361 | - [Push Command](./push.md) | ||
| 362 | + | - [Deployment](../deployment/index.md) | |||
M docs/cli/index.md +1 -0
| @@ -68,6 +68,7 @@ gordon apps stop blog | |||||
| 68 | gordon apps start blog | 68 | gordon apps start blog | ||
| 69 | gordon apps remove blog | 69 | gordon apps remove blog | ||
| 70 | gordon apps operations show blog --key <operation-key> | 70 | gordon apps operations show blog --key <operation-key> | ||
| 71 | + | gordon apps operations watch blog --key <operation-key> | |||
| 71 | gordon apps secrets list blog | 72 | gordon apps secrets list blog | ||
| 72 | gordon config validate --file /path/to/gordon.toml | 73 | gordon config validate --file /path/to/gordon.toml | ||
| 73 | 74 | ||||
M docs/concepts.md +11 -8
| @@ -69,18 +69,21 @@ DATABASE_URL = "database-url" | |||||
| 69 | 69 | ||||
| 70 | ## Updates | 70 | ## Updates | ||
| 71 | 71 | ||||
| 72 | - | For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains with a bounded deadline, and retires that service's old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged. | 72 | + | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services. Gordon never runs two Gordon-managed generations of the same service at the same time. |
| 73 | 73 | ||||
| 74 | - | ``` | 74 | + | Every replaced service follows the same flow: preflight completes before anything is disrupted (image resolution and pull, secrets, volumes, networks, bind policy, reservations); the service is withdrawn from traffic; the old container is stopped and removed; the new container is created and started; the declared readiness probe runs; the new `ACTIVE` state is published; and traffic is rebuilt and published for the new container. |
| 75 | - | Time ─────────────────────────────────────────────> | |||
| 76 | 75 | ||||
| 77 | - | Old Container: [═══════════════════] | |||
| 78 | - | ↓ retire | |||
| 79 | - | New Container: [═════════════════════════> | |||
| 80 | - | ↑ start ↑ traffic routed | |||
| 81 | ``` | 76 | ``` | ||
| 77 | + | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic | |||
| 78 | + | ``` | |||
| 79 | + | ||||
| 80 | + | If preflight fails, the running service is left untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. | |||
| 81 | + | ||||
| 82 | + | Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. This flow covers every service, including TCP, UDP, mixed, and volume-owning services. An open UDP socket is not application readiness, and Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | |||
| 83 | + | ||||
| 84 | + | A deploy interrupted after the replacement container was created but before it was published leaves the failure explicit: the candidate is recorded in the deployment journal, and Gordon removes it before it creates or rebuilds any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. The next recovery pass republishes routing within its 15-second cadence if the final traffic publication was the step that failed. | |||
| 82 | 85 | ||||
| 83 | - | TCP, UDP, mixed, and volume-owning services replace with interruption — no zero-downtime promise. An open UDP socket is not application readiness. Gordon never restarts an old volume-owning image automatically after a replacement may have written data. | 86 | + | `gordon apps restart` is separate from deploy and stays in place: it withdraws traffic, restarts the same pinned container, verifies readiness, and republishes traffic. No second container is created. When the recorded container is gone, restart rebuilds the service from its pinned `ACTIVE` digest and publishes it. |
| 84 | 87 | ||||
| 85 | ## Deletion and Cleanup Lifecycle | 88 | ## Deletion and Cleanup Lifecycle | ||
| 86 | 89 | ||||
D docs/config/deploy.md +0 -121
| @@ -1,121 +0,0 @@ | |||||
| 1 | - | # Deploy Configuration | |||
| 2 | - | ||||
| 3 | - | Controls how Gordon pulls images during deployment. | |||
| 4 | - | ||||
| 5 | - | ## Example | |||
| 6 | - | ||||
| 7 | - | ```toml | |||
| 8 | - | [deploy] | |||
| 9 | - | pull_policy = "if-tag-changed" | |||
| 10 | - | readiness_mode = "auto" | |||
| 11 | - | health_timeout = "90s" | |||
| 12 | - | readiness_delay = "5s" | |||
| 13 | - | drain_mode = "auto" | |||
| 14 | - | drain_timeout = "30s" | |||
| 15 | - | drain_delay = "2s" | |||
| 16 | - | ``` | |||
| 17 | - | ||||
| 18 | - | ## Settings | |||
| 19 | - | ||||
| 20 | - | ### `deploy.pull_policy` | |||
| 21 | - | ||||
| 22 | - | How to decide when to pull an image: | |||
| 23 | - | ||||
| 24 | - | - `always`: always pull, even if the tag exists locally. | |||
| 25 | - | - `if-not-present`: only pull when the tag is missing locally. | |||
| 26 | - | - `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`). | |||
| 27 | - | ||||
| 28 | - | Default: `if-tag-changed` | |||
| 29 | - | ||||
| 30 | - | ### `deploy.readiness_delay` | |||
| 31 | - | ||||
| 32 | - | How long Gordon waits after a container first reports `running` before it is | |||
| 33 | - | considered ready for traffic. | |||
| 34 | - | ||||
| 35 | - | - Uses Go duration format (examples: `"5s"`, `"15s"`, `"1m"`). | |||
| 36 | - | - If the container briefly exits right after this delay, Gordon now waits up to | |||
| 37 | - | 30 additional seconds for it to recover before failing the deploy. | |||
| 38 | - | ||||
| 39 | - | Default: `"5s"` | |||
| 40 | - | ||||
| 41 | - | ### `deploy.readiness_mode` | |||
| 42 | - | ||||
| 43 | - | How Gordon determines that a newly started container is ready for traffic. | |||
| 44 | - | ||||
| 45 | - | - `auto`: use Docker health status if the container has a healthcheck; otherwise | |||
| 46 | - | fall back to `deploy.readiness_delay`. | |||
| 47 | - | - `docker-health`: require a Docker healthcheck and wait for `healthy`. | |||
| 48 | - | Deploy fails with `no healthcheck detected` if absent. | |||
| 49 | - | - `delay`: always use `deploy.readiness_delay`. | |||
| 50 | - | ||||
| 51 | - | Default: `"auto"` | |||
| 52 | - | ||||
| 53 | - | ### `deploy.health_timeout` | |||
| 54 | - | ||||
| 55 | - | Maximum time Gordon waits for health-based readiness in `auto`/`docker-health` | |||
| 56 | - | mode before failing the deploy. | |||
| 57 | - | ||||
| 58 | - | - Uses Go duration format (examples: `"30s"`, `"90s"`, `"2m"`). | |||
| 59 | - | - Default `"90s"` balances typical app startup time with fast failure feedback. | |||
| 60 | - | - Increase for slow cold-starts, migrations, or heavy warmup workloads. | |||
| 61 | - | ||||
| 62 | - | Default: `"90s"` | |||
| 63 | - | ||||
| 64 | - | ### `deploy.drain_delay` | |||
| 65 | - | ||||
| 66 | - | How long Gordon waits after synchronous proxy cache invalidation before stopping | |||
| 67 | - | the previous container during zero-downtime replacement. | |||
| 68 | - | ||||
| 69 | - | - Uses Go duration format (examples: `"2s"`, `"10s"`, `"1m"`). | |||
| 70 | - | - Applied only when a previous container exists and cache invalidation was | |||
| 71 | - | triggered for the deployed domain. | |||
| 72 | - | - If explicitly set to `"0s"` (or a negative duration), delay is disabled and | |||
| 73 | - | Gordon switches immediately after invalidation. | |||
| 74 | - | ||||
| 75 | - | Default: `"2s"` | |||
| 76 | - | ||||
| 77 | - | ### `deploy.drain_mode` | |||
| 78 | - | ||||
| 79 | - | How Gordon decides when it is safe to stop the previous container after routing | |||
| 80 | - | traffic to the new one. | |||
| 81 | - | ||||
| 82 | - | - `auto`: wait for in-flight proxy requests to drain when available; otherwise | |||
| 83 | - | fall back to `deploy.drain_delay`. | |||
| 84 | - | - `inflight`: wait for in-flight proxy requests on the previous container to | |||
| 85 | - | reach zero (bounded by `deploy.drain_timeout`). | |||
| 86 | - | - `delay`: always use `deploy.drain_delay`. | |||
| 87 | - | ||||
| 88 | - | Default: `"auto"` | |||
| 89 | - | ||||
| 90 | - | ### `deploy.drain_timeout` | |||
| 91 | - | ||||
| 92 | - | Maximum time Gordon waits for in-flight request drain before continuing with old | |||
| 93 | - | container shutdown. | |||
| 94 | - | ||||
| 95 | - | - Uses Go duration format (examples: `"10s"`, `"30s"`, `"1m"`). | |||
| 96 | - | - Default `"30s"` is a practical upper bound for most HTTP workloads. | |||
| 97 | - | - Increase for long-lived requests (large uploads, SSE, long polling). | |||
| 98 | - | ||||
| 99 | - | Default: `"30s"` | |||
| 100 | - | ||||
| 101 | - | ## Container security profile | |||
| 102 | - | ||||
| 103 | - | Runtime hardening is configured under `[containers]`: | |||
| 104 | - | ||||
| 105 | - | ```toml | |||
| 106 | - | [containers] | |||
| 107 | - | security_profile = "compat" # "compat" or "strict" | |||
| 108 | - | ``` | |||
| 109 | - | ||||
| 110 | - | - `compat` is the default and preserves existing image compatibility while keeping `no-new-privileges` and capability drop/add defaults. | |||
| 111 | - | - `strict` enables a read-only root filesystem, drops all capabilities, and only adds `NET_BIND_SERVICE`. Images that write outside mounted volumes or need extra Linux capabilities may require changes before using this profile. | |||
| 112 | - | ||||
| 113 | - | ## Notes | |||
| 114 | - | ||||
| 115 | - | - Changes to `deploy.pull_policy` require a restart to take effect. | |||
| 116 | - | - Changes to `deploy.readiness_mode` require a restart to take effect. | |||
| 117 | - | - Changes to `deploy.health_timeout` require a restart to take effect. | |||
| 118 | - | - Changes to `deploy.readiness_delay` require a restart to take effect. | |||
| 119 | - | - Changes to `deploy.drain_mode` require a restart to take effect. | |||
| 120 | - | - Changes to `deploy.drain_timeout` require a restart to take effect. | |||
| 121 | - | - Changes to `deploy.drain_delay` require a restart to take effect. | |||
M docs/config/index.md +0 -23
| @@ -60,16 +60,6 @@ per_ip_rps = 50 # Max requests/second per IP | |||||
| 60 | burst = 100 # Burst size | 60 | burst = 100 # Burst size | ||
| 61 | trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For | 61 | trusted_proxies = [] # IPs/CIDRs trusted for X-Forwarded-For | ||
| 62 | 62 | ||||
| 63 | - | # Deploy behavior | |||
| 64 | - | [deploy] | |||
| 65 | - | pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed | |||
| 66 | - | readiness_mode = "auto" # auto, docker-health, delay | |||
| 67 | - | health_timeout = "90s" # Max wait for health-based readiness | |||
| 68 | - | readiness_delay = "5s" # Wait after running before ready | |||
| 69 | - | drain_mode = "auto" # auto, inflight, delay | |||
| 70 | - | drain_timeout = "30s" # Max wait for in-flight drain | |||
| 71 | - | drain_delay = "2s" # Wait after proxy invalidation before stopping the old container | |||
| 72 | - | ||||
| 73 | # Container runtime profile | 63 | # Container runtime profile | ||
| 74 | [containers] | 64 | [containers] | ||
| 75 | security_profile = "compat" # compat or strict | 65 | security_profile = "compat" # compat or strict | ||
| @@ -151,7 +141,6 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G | |||||
| 151 | | `[server]` | Core server settings | [Server](./server.md) | | 141 | | `[server]` | Core server settings | [Server](./server.md) | | ||
| 152 | | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) | | 142 | | `[auth]` | Authentication and secrets backend | [Auth](./auth.md) | | ||
| 153 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | 143 | | `[api.rate_limit]` | Rate limiting configuration | [Rate Limiting](./rate-limiting.md) | | ||
| 154 | - | | `[deploy]` | Deployment behavior | [Deploy](./deploy.md) | | |||
| 155 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | 144 | | `[logging]` | Logging configuration | [Logging](./logging.md) | | ||
| 156 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | 145 | | `[telemetry]` | OpenTelemetry observability export | [Telemetry](./telemetry.md) | | ||
| 157 | | `[env]` | Installation secret store location | [Environment](./env.md) | | 146 | | `[env]` | Installation secret store location | [Environment](./env.md) | | ||
| @@ -180,13 +169,6 @@ Docker Hub (`docker.io` and `registry-1.docker.io`), `ghcr.io`, `quay.io`, and G | |||||
| 180 | | `api.rate_limit.per_ip_rps` | `50` | | 169 | | `api.rate_limit.per_ip_rps` | `50` | | ||
| 181 | | `api.rate_limit.burst` | `100` | | 170 | | `api.rate_limit.burst` | `100` | | ||
| 182 | | `api.rate_limit.trusted_proxies` | `[]` | | 171 | | `api.rate_limit.trusted_proxies` | `[]` | | ||
| 183 | - | | `deploy.pull_policy` | `"if-tag-changed"` | | |||
| 184 | - | | `deploy.readiness_mode` | `"auto"` | | |||
| 185 | - | | `deploy.health_timeout` | `"90s"` | | |||
| 186 | - | | `deploy.readiness_delay` | `"5s"` | | |||
| 187 | - | | `deploy.drain_mode` | `"auto"` | | |||
| 188 | - | | `deploy.drain_timeout` | `"30s"` | | |||
| 189 | - | | `deploy.drain_delay` | `"2s"` | | |||
| 190 | | `containers.security_profile` | `"compat"` | | 172 | | `containers.security_profile` | `"compat"` | | ||
| 191 | | `logging.level` | `"info"` | | 173 | | `logging.level` | `"info"` | | ||
| 192 | | `logging.format` | `"console"` | | 174 | | `logging.format` | `"console"` | | ||
| @@ -244,11 +226,6 @@ gordon reload | |||||
| 244 | | `server.max_blob_chunk_size` | | 226 | | `server.max_blob_chunk_size` | | ||
| 245 | | `server.max_blob_size` | | 227 | | `server.max_blob_size` | | ||
| 246 | | `auth.*` | | 228 | | `auth.*` | | ||
| 247 | - | | `deploy.readiness_mode` | | |||
| 248 | - | | `deploy.readiness_delay` | | |||
| 249 | - | | `deploy.health_timeout` | | |||
| 250 | - | | `deploy.drain_mode` | | |||
| 251 | - | | `deploy.drain_timeout` | | |||
| 252 | 229 | ||||
| 253 | ## Environment Variable Override | 230 | ## Environment Variable Override | ||
| 254 | 231 | ||||
M docs/config/reference.md +0 -19
| @@ -116,18 +116,6 @@ trace_sample_rate = 1.0 # Fraction of traces to sample (0.0 | |||||
| 116 | [env] | 116 | [env] | ||
| 117 | dir = "" # Env files directory (default: {data_dir}/env) | 117 | dir = "" # Env files directory (default: {data_dir}/env) | ||
| 118 | 118 | ||||
| 119 | - | # ============================================================================= | |||
| 120 | - | # DEPLOYMENT | |||
| 121 | - | # ============================================================================= | |||
| 122 | - | [deploy] | |||
| 123 | - | pull_policy = "if-tag-changed" # "always", "if-tag-changed", "never" | |||
| 124 | - | readiness_mode = "auto" # "auto", "docker-health", "delay" | |||
| 125 | - | health_timeout = "90s" # Max wait for health-based readiness | |||
| 126 | - | readiness_delay = "5s" # Wait after running before considered ready | |||
| 127 | - | drain_mode = "auto" # "auto", "inflight", "delay" | |||
| 128 | - | drain_timeout = "30s" # Max wait for in-flight request drain | |||
| 129 | - | drain_delay = "2s" # Wait after cache invalidation before old stop | |||
| 130 | - | ||||
| 131 | # ============================================================================= | 119 | # ============================================================================= | ||
| 132 | # CONTAINERS | 120 | # CONTAINERS | ||
| 133 | # ============================================================================= | 121 | # ============================================================================= | ||
| @@ -287,13 +275,6 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 287 | | `telemetry.metrics` | `true` | Export metrics | | 275 | | `telemetry.metrics` | `true` | Export metrics | | ||
| 288 | | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs | | 276 | | `telemetry.logs` | `true` | Bridge zerolog to OTLP logs | | ||
| 289 | | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) | | 277 | | `telemetry.trace_sample_rate` | `1.0` | Fraction of traces to sample (0.0–1.0) | | ||
| 290 | - | | `deploy.pull_policy` | `"if-tag-changed"` | Pull on tag change | | |||
| 291 | - | | `deploy.readiness_mode` | `"auto"` | Readiness strategy (`auto`, `docker-health`, `delay`) | | |||
| 292 | - | | `deploy.health_timeout` | `"90s"` | Max wait for health-based readiness before deploy fails | | |||
| 293 | - | | `deploy.readiness_delay` | `"5s"` | Delay before container is considered ready | | |||
| 294 | - | | `deploy.drain_mode` | `"auto"` | Drain strategy (`auto`, `inflight`, `delay`) | | |||
| 295 | - | | `deploy.drain_timeout` | `"30s"` | Max wait for in-flight request drain before old stop | | |||
| 296 | - | | `deploy.drain_delay` | `"2s"` | Delay before stopping previous container after cache invalidation | | |||
| 297 | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | 278 | | `containers.security_profile` | `"compat"` | Runtime hardening profile: `compat` preserves existing behavior, `strict` enables read-only rootfs and narrower capabilities | | ||
| 298 | | `network_isolation.enabled` | `true` | Network isolation enabled | | 279 | | `network_isolation.enabled` | `true` | Network isolation enabled | | ||
| 299 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | 280 | | `network_isolation.network_prefix` | `"gordon"` | Network prefix | | ||
M docs/config/security-hardening.md +0 -1
| @@ -161,6 +161,5 @@ Use `strict` for images designed to write only to mounted volumes and run withou | |||||
| 161 | - [Auth](./auth.md) | 161 | - [Auth](./auth.md) | ||
| 162 | - [Images](./images.md) | 162 | - [Images](./images.md) | ||
| 163 | - [Network Isolation](./network-isolation.md) | 163 | - [Network Isolation](./network-isolation.md) | ||
| 164 | - | - [Deploy](./deploy.md) | |||
| 165 | - [Volumes](./volumes.md) | 164 | - [Volumes](./volumes.md) | ||
| 166 | - [Reference](./reference.md) | 165 | - [Reference](./reference.md) | ||
M docs/deployment/index.md +6 -7
| @@ -141,17 +141,16 @@ docker push gordon.example.com/myapp:$VERSION | |||||
| 141 | 141 | ||||
| 142 | ## Updates | 142 | ## Updates | ||
| 143 | 143 | ||||
| 144 | - | For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. Deployment stops at the first service failure: already successful services are preserved, later services stay unchanged. | 144 | + | Gordon deploys an app's services one at a time in sorted order. A deploy may cause a short service interruption; there is no zero-downtime promise for app services, and Gordon never runs two Gordon-managed generations of the same service at the same time. |
| 145 | 145 | ||||
| 146 | ``` | 146 | ``` | ||
| 147 | - | Timeline ─────────────────────────────────────────> | 147 | + | Preflight ─► withdraw traffic ─► stop + remove old ─► start new ─► readiness ─► publish ACTIVE ─► route traffic |
| 148 | - | ||||
| 149 | - | Old Container: [═══════════════════] | |||
| 150 | - | ↓ retire | |||
| 151 | - | New Container: [═════════════════════════> | |||
| 152 | - | ↑ start ↑ traffic routed | |||
| 153 | ``` | 148 | ``` | ||
| 154 | 149 | ||||
| 150 | + | Preflight (image resolution and pull, secrets, volumes, networks, bind policy, reservations) completes before anything is disrupted, so a preflight failure leaves the running service untouched. If replacement fails after the old container was removed, the failure is explicit: Gordon does not recreate the old container during that operation and does not promise automatic data rollback. A stateful service's recovery inhibition stays in place until its never-published candidate is confirmed removed; a later boot/start/restart then clears it and rebuilds the generation `ACTIVE` records from its pinned digest, while a candidate that cannot be removed fails recovery closed. Deployment stops at the first service failure: services already deployed in that run are kept and are not rolled back. | |||
| 151 | + | ||||
| 152 | + | If a deploy is interrupted between creating the replacement container and publishing it, that candidate is recorded durably and Gordon removes it before creating or rebuilding any generation of that app — at boot and before every mutation — so two generations of one service never run together. An interrupted operation is finalized instead of staying in flight: as a failure, or as the success it was when every step had already completed. If the recorded candidate cannot be removed, reconciliation fails closed: the operation is not finalized, stays the latest journal, and every later mutation is refused so a newer operation can never mask the orphan. A failed final traffic publication is republished by the next recovery pass, within 15 seconds by default. | |||
| 153 | + | ||||
| 155 | ## Related | 154 | ## Related | ||
| 156 | 155 | ||||
| 157 | - [GitHub Actions](./github-actions.md) | 156 | - [GitHub Actions](./github-actions.md) | ||
M docs/deployment/rollback.md +2 -2
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Rollback | 1 | # Rollback | ||
| 2 | 2 | ||||
| 3 | - | Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in v2.50: rolling back means applying a manifest that references the previous tag (or re-pushing it) and deploying again. | 3 | + | Roll forward to a previous version when a deploy misbehaves. There is no historical rollback command in v2.50: rolling back means applying and deploying a previous manifest or image reference. Redeploying a mutable tag (for example `latest`) does not guarantee the previous image bytes; keep immutable versioned tags. |
| 4 | 4 | ||||
| 5 | ## Roll Forward to a Previous Tag | 5 | ## Roll Forward to a Previous Tag | ||
| 6 | 6 | ||||
| @@ -12,7 +12,7 @@ gordon apps apply --file blog.toml --remote prod | |||||
| 12 | gordon apps deploy blog --remote prod | 12 | gordon apps deploy blog --remote prod | ||
| 13 | ``` | 13 | ``` | ||
| 14 | 14 | ||||
| 15 | - | For HTTP services without volumes, the previous (broken) container keeps serving until the replacement passes readiness, so the recovery itself is zero-downtime. | 15 | + | A rollback deploy replaces services one at a time like any other deploy: a short service interruption is possible, and Gordon never runs two generations of the same service at the same time. If the replacement fails after the old container was removed, Gordon does not recreate it and does not promise automatic data rollback. |
| 16 | 16 | ||||
| 17 | ## Re-push the Previous Image as latest | 17 | ## Re-push the Previous Image as latest | ||
| 18 | 18 | ||||
M docs/getting-started.md +2 -2
| @@ -176,7 +176,7 @@ What these commands do: | |||||
| 176 | 176 | ||||
| 177 | - `gordon push` builds, uploads, and stores the image. It never deploys. | 177 | - `gordon push` builds, uploads, and stores the image. It never deploys. | ||
| 178 | - `gordon apps apply` validates the manifest and persists it as desired state. | 178 | - `gordon apps apply` validates the manifest and persists it as desired state. | ||
| 179 | - | - `gordon apps deploy` activates the accepted revision: pulls the image, starts the container, waits for readiness, switches traffic, retires the old container. | 179 | + | - `gordon apps deploy` activates the accepted revision: runs preflight, withdraws the service from traffic, stops and removes the old container, starts the new container, waits for readiness, publishes the new `ACTIVE` state, and routes traffic to it. |
| 180 | 180 | ||||
| 181 | If the app needs secrets, register their names in the manifest (`[service.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file: | 181 | If the app needs secrets, register their names in the manifest (`[service.secrets]` maps ENV name to secret name), then write values — values stay in pass, never in the file: | ||
| 182 | 182 | ||||
| @@ -198,7 +198,7 @@ gordon push myapp --build --remote prod | |||||
| 198 | gordon apps deploy blog --remote prod | 198 | gordon apps deploy blog --remote prod | ||
| 199 | ``` | 199 | ``` | ||
| 200 | 200 | ||||
| 201 | - | For HTTP services without volumes, Gordon keeps the old container serving until the replacement passes readiness, then switches traffic, drains, and retires the old container. TCP/UDP and volume-owning services replace with interruption. | 201 | + | Gordon deploys services one at a time in sorted order. A deploy may cause a short service interruption, and Gordon never runs two Gordon-managed generations of the same service at the same time. If preflight fails, the running service is left untouched. This applies to every service, including TCP, UDP, and volume-owning services. |
| 202 | 202 | ||||
| 203 | ## Next Steps | 203 | ## Next Steps | ||
| 204 | 204 | ||||
M docs/index.md +1 -1
| @@ -10,7 +10,7 @@ Gordon runs on your VPS and provides: | |||||
| 10 | - **Declarative Apps** - One TOML file per app, explicit apply then deploy | 10 | - **Declarative Apps** - One TOML file per app, explicit apply then deploy | ||
| 11 | - **HTTP Reverse Proxy** - Routes app hosts to containers | 11 | - **HTTP Reverse Proxy** - Routes app hosts to containers | ||
| 12 | - **Push, Apply, Deploy** - Push stores images; deploy is always explicit | 12 | - **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 |
| 14 | - **Single Binary** - ~15MB RAM footprint | 14 | - **Single Binary** - ~15MB RAM footprint | ||
| 15 | 15 | ||||
| 16 | ## How It Works | 16 | ## How It Works | ||
M gordon.toml.example +0 -9
| @@ -55,15 +55,6 @@ per_ip_rps = 50 | |||||
| 55 | burst = 100 | 55 | burst = 100 | ||
| 56 | trusted_proxies = [] | 56 | trusted_proxies = [] | ||
| 57 | 57 | ||||
| 58 | - | [deploy] | |||
| 59 | - | pull_policy = "if-tag-changed" | |||
| 60 | - | readiness_mode = "auto" | |||
| 61 | - | health_timeout = "90s" | |||
| 62 | - | readiness_delay = "5s" | |||
| 63 | - | drain_mode = "auto" | |||
| 64 | - | drain_timeout = "30s" | |||
| 65 | - | drain_delay = "2s" | |||
| 66 | - | ||||
| 67 | [containers] | 58 | [containers] | ||
| 68 | # "compat" preserves broad image compatibility. "strict" enables a read-only | 59 | # "compat" preserves broad image compatibility. "strict" enables a read-only | ||
| 69 | # root filesystem and narrower Linux capabilities. | 60 | # root filesystem and narrower Linux capabilities. | ||
M internal/adapters/dto/admin_apps.go +6 -0
| @@ -103,6 +103,11 @@ type AppSecretMetadataDTO struct { | |||||
| 103 | Presence string `json:"presence"` | 103 | Presence string `json:"presence"` | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | + | // AppStatusRunning is the wire status of an operation whose effects are | |||
| 107 | + | // still executing in the background. A terminal operation reports its | |||
| 108 | + | // persisted outcome as the status instead. | |||
| 109 | + | const AppStatusRunning = "running" | |||
| 110 | + | ||||
| 106 | // AppDeployResponse reports deploy outcome with terminal results. | 111 | // AppDeployResponse reports deploy outcome with terminal results. | ||
| 107 | // Effective and Retained are filled from current app state when the app | 112 | // Effective and Retained are filled from current app state when the app | ||
| 108 | // still exists; they are omitted when it does not. | 113 | // still exists; they are omitted when it does not. | ||
| @@ -110,6 +115,7 @@ type AppDeployResponse struct { | |||||
| 110 | Op string `json:"op"` | 115 | Op string `json:"op"` | ||
| 111 | App string `json:"app"` | 116 | App string `json:"app"` | ||
| 112 | Revision string `json:"revision"` | 117 | Revision string `json:"revision"` | ||
| 118 | + | Status string `json:"status"` | |||
| 113 | Outcome string `json:"outcome"` | 119 | Outcome string `json:"outcome"` | ||
| 114 | Services map[string]AppServiceResultDTO `json:"services"` | 120 | Services map[string]AppServiceResultDTO `json:"services"` | ||
| 115 | Steps []AppStepDTO `json:"steps"` | 121 | Steps []AppStepDTO `json:"steps"` | ||
M internal/adapters/dto/admin_apps_test.go +2 -1
| @@ -63,6 +63,7 @@ func TestAppDeployResponseKeys(t *testing.T) { | |||||
| 63 | Op: "op-abc", | 63 | Op: "op-abc", | ||
| 64 | App: "blog", | 64 | App: "blog", | ||
| 65 | Revision: "rev-new", | 65 | Revision: "rev-new", | ||
| 66 | + | Status: AppStatusRunning, | |||
| 66 | Outcome: "success", | 67 | Outcome: "success", | ||
| 67 | Services: map[string]AppServiceResultDTO{ | 68 | Services: map[string]AppServiceResultDTO{ | ||
| 68 | "web": {Result: "deployed", EffectiveRevision: "rev-new"}, | 69 | "web": {Result: "deployed", EffectiveRevision: "rev-new"}, | ||
| @@ -75,7 +76,7 @@ func TestAppDeployResponseKeys(t *testing.T) { | |||||
| 75 | Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}}, | 76 | Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}}, | ||
| 76 | } | 77 | } | ||
| 77 | keys := dtoJSONKeys(t, resp) | 78 | keys := dtoJSONKeys(t, resp) | ||
| 78 | - | for _, key := range []string{"op", "app", "revision", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} { | 79 | + | for _, key := range []string{"op", "app", "revision", "status", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} { |
| 79 | assert.Contains(t, keys, key) | 80 | assert.Contains(t, keys, key) | ||
| 80 | } | 81 | } | ||
| 81 | svc := keys["services"].(map[string]any)["web"].(map[string]any) | 82 | svc := keys["services"].(map[string]any)["web"].(map[string]any) | ||
M internal/adapters/in/cli/apps.go +90 -21
| @@ -95,10 +95,16 @@ func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut | |||||
| 95 | } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil { | 95 | } else if rerr := renderAppDeployResponse(out, &conflict.Response); rerr != nil { | ||
| 96 | return rerr, true | 96 | return rerr, true | ||
| 97 | } | 97 | } | ||
| 98 | + | return appOpConflictMessage(op, app, key, conflict), true | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // appOpConflictMessage is the by-key recovery guidance shared by the human | |||
| 102 | + | // and JSON renderings of a journaled 409 conflict. | |||
| 103 | + | func appOpConflictMessage(op, app, key string, conflict *remote.AppOpConflictError) error { | |||
| 98 | return fmt.Errorf( | 104 | return fmt.Errorf( | ||
| 99 | "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+ | 105 | "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+ | ||
| 100 | "gordon apps operations show %s --key %s before retrying with the same key: %w", | 106 | "gordon apps operations show %s --key %s before retrying with the same key: %w", | ||
| 101 | - | op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true | 107 | + | op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, conflict) |
| 102 | } | 108 | } | ||
| 103 | 109 | ||||
| 104 | // newAppsCmd creates the `apps` parent command. | 110 | // newAppsCmd creates the `apps` parent command. | ||
| @@ -156,7 +162,7 @@ deploy may fail after the apply succeeded.`, | |||||
| 156 | } | 162 | } | ||
| 157 | defer handle.close() | 163 | defer handle.close() | ||
| 158 | plane := handle.plane | 164 | plane := handle.plane | ||
| 159 | - | return runAppsApply(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), file, dryRun, chainDeploy, jsonOut) | 165 | + | return runAppsApply(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), file, dryRun, chainDeploy, jsonOut) |
| 160 | }, | 166 | }, | ||
| 161 | } | 167 | } | ||
| 162 | cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)") | 168 | cmd.Flags().StringVar(&file, "file", "", "Path to the app manifest file (required)") | ||
| @@ -166,7 +172,7 @@ deploy may fail after the apply succeeded.`, | |||||
| 166 | return cmd | 172 | return cmd | ||
| 167 | } | 173 | } | ||
| 168 | 174 | ||||
| 169 | - | func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error { | 175 | + | func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out, errOut io.Writer, file string, dryRun, chainDeploy, jsonOut bool) error { |
| 170 | if dryRun && chainDeploy { | 176 | if dryRun && chainDeploy { | ||
| 171 | return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy") | 177 | return fmt.Errorf("cannot combine --dry-run with --deploy: dry-run persists nothing to deploy") | ||
| 172 | } | 178 | } | ||
| @@ -190,29 +196,86 @@ func runAppsApply(ctx context.Context, plane ControlPlane, _ io.Reader, out io.W | |||||
| 190 | } | 196 | } | ||
| 191 | return renderAppApply(out, resp, dryRun) | 197 | return renderAppApply(out, resp, dryRun) | ||
| 192 | } | 198 | } | ||
| 193 | - | if !jsonOut { | 199 | + | if jsonOut { |
| 194 | - | if err := renderAppApply(out, resp, dryRun); err != nil { | 200 | + | return runAppsApplyDeployJSON(ctx, plane, resp, out, errOut) |
| 195 | - | return err | 201 | + | } |
| 196 | - | } | 202 | + | if err := renderAppApply(out, resp, dryRun); err != nil { |
| 203 | + | return err | |||
| 197 | } | 204 | } | ||
| 198 | - | deployResp, key, deployErr := plane.DeployApp(ctx, resp.App, dto.AppDeployRequest{Revision: resp.ResultingRevision}) | 205 | + | return runAppsApplyDeploy(ctx, plane, resp, out, errOut) |
| 199 | - | if deployErr != nil { | 206 | + | } |
| 200 | - | if conflictErr, ok := renderAppOpConflict(out, "deploy", resp.App, key, deployErr, jsonOut); ok { | 207 | + | |
| 201 | - | return fmt.Errorf("apply of %s succeeded (%s); %w", | 208 | + | // appApplyDeployDocument is the single machine-readable apply+deploy result. |
| 202 | - | resp.App, resp.ResultingRevision, conflictErr) | 209 | + | // The apply is always included so a failed chained deploy never hides that |
| 210 | + | // the persist itself succeeded. | |||
| 211 | + | type appApplyDeployDocument struct { | |||
| 212 | + | Apply *dto.AppApplyResponse `json:"apply"` | |||
| 213 | + | Deploy *dto.AppDeployResponse `json:"deploy"` | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // runAppsApplyDeploy follows a chained deploy in human mode. The apply outcome | |||
| 217 | + | // is already rendered, then an accepted (202 running) deploy reuses the shared | |||
| 218 | + | // by-key watch: progress only on change, terminal journal, and Ctrl-C guidance | |||
| 219 | + | // that the daemon-side operation keeps running. A terminal partial/failed | |||
| 220 | + | // outcome exits nonzero without hiding the successful apply. | |||
| 221 | + | func runAppsApplyDeploy(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error { | |||
| 222 | + | deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision}) | |||
| 223 | + | if err != nil { | |||
| 224 | + | if conflictErr, ok := renderAppOpConflict(out, "deploy", apply.App, key, err, false); ok { | |||
| 225 | + | return applySucceededError(apply, conflictErr) | |||
| 203 | } | 226 | } | ||
| 204 | - | return fmt.Errorf("apply of %s succeeded (%s); %w", | 227 | + | return applySucceededError(apply, appMutationError("deploy", apply.App, key, err)) |
| 205 | - | resp.App, resp.ResultingRevision, appMutationError("deploy", resp.App, key, deployErr)) | |||
| 206 | } | 228 | } | ||
| 207 | - | if jsonOut { | 229 | + | if deployResp.Status == dto.AppStatusRunning { |
| 208 | - | return writeJSON(out, struct { | 230 | + | if werr := watchOperation(ctx, plane, apply.App, key, deployResp, out, errOut, false); werr != nil { |
| 209 | - | Apply *dto.AppApplyResponse `json:"apply"` | 231 | + | return applySucceededError(apply, werr) |
| 210 | - | Deploy *dto.AppDeployResponse `json:"deploy"` | 232 | + | } |
| 211 | - | }{Apply: resp, Deploy: deployResp}) | 233 | + | return nil |
| 212 | } | 234 | } | ||
| 213 | return renderAppDeployResponse(out, deployResp) | 235 | return renderAppDeployResponse(out, deployResp) | ||
| 214 | } | 236 | } | ||
| 215 | 237 | ||||
| 238 | + | // runAppsApplyDeployJSON follows a chained deploy and writes exactly one final | |||
| 239 | + | // {apply,deploy} document once the operation is terminal. The mutation is | |||
| 240 | + | // issued once and observed through the existing by-key endpoint; no initial | |||
| 241 | + | // running document is emitted, and progress, transient warnings, and Ctrl-C | |||
| 242 | + | // resume guidance go to errOut so stdout stays machine-readable. | |||
| 243 | + | func runAppsApplyDeployJSON(ctx context.Context, plane ControlPlane, apply *dto.AppApplyResponse, out, errOut io.Writer) error { | |||
| 244 | + | deployResp, key, err := plane.DeployApp(ctx, apply.App, dto.AppDeployRequest{Revision: apply.ResultingRevision}) | |||
| 245 | + | if err != nil { | |||
| 246 | + | var conflict *remote.AppOpConflictError | |||
| 247 | + | if errors.As(err, &conflict) { | |||
| 248 | + | if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: &conflict.Response}); werr != nil { | |||
| 249 | + | return werr | |||
| 250 | + | } | |||
| 251 | + | return applySucceededError(apply, appOpConflictMessage("deploy", apply.App, key, conflict)) | |||
| 252 | + | } | |||
| 253 | + | return applySucceededError(apply, appMutationError("deploy", apply.App, key, err)) | |||
| 254 | + | } | |||
| 255 | + | terminal := deployResp | |||
| 256 | + | if deployResp.Status == dto.AppStatusRunning { | |||
| 257 | + | op, werr := awaitOperation(ctx, plane, apply.App, key, deployResp, errOut) | |||
| 258 | + | if werr != nil { | |||
| 259 | + | return applySucceededError(apply, werr) | |||
| 260 | + | } | |||
| 261 | + | terminal = op | |||
| 262 | + | } | |||
| 263 | + | if werr := writeJSON(out, appApplyDeployDocument{Apply: apply, Deploy: terminal}); werr != nil { | |||
| 264 | + | return werr | |||
| 265 | + | } | |||
| 266 | + | if terminal.Outcome == domain.AppOutcomeSuccess { | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | return applySucceededError(apply, &OperationFailedError{App: apply.App, Op: terminal.Op, Outcome: terminal.Outcome}) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // applySucceededError preserves the apply outcome in every chained-deploy | |||
| 273 | + | // failure path: the desired state was already persisted, so the caller must | |||
| 274 | + | // not be told the apply failed. | |||
| 275 | + | func applySucceededError(apply *dto.AppApplyResponse, cause error) error { | |||
| 276 | + | return fmt.Errorf("apply of %s succeeded (%s); %w", apply.App, apply.ResultingRevision, cause) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 216 | func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error { | 279 | func renderAppApply(out io.Writer, resp *dto.AppApplyResponse, dryRun bool) error { | ||
| 217 | if resp.Noop { | 280 | if resp.Noop { | ||
| 218 | return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision)))) | 281 | return cliWriteLine(out, cliRenderMuted(fmt.Sprintf("No changes for %s%s", resp.App, appRevisionSuffix(resp.ResultingRevision)))) | ||
| @@ -719,7 +782,7 @@ func newAppDeployCmd() *cobra.Command { | |||||
| 719 | } | 782 | } | ||
| 720 | defer handle.close() | 783 | defer handle.close() | ||
| 721 | plane := handle.plane | 784 | plane := handle.plane | ||
| 722 | - | return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), jsonOut) | 785 | + | return runAppDeploy(cmd.Context(), plane, args[0], revision, service, cmd.OutOrStdout(), cmd.ErrOrStderr(), jsonOut) |
| 723 | }, | 786 | }, | ||
| 724 | } | 787 | } | ||
| 725 | cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)") | 788 | cmd.Flags().StringVar(&revision, "revision", "", "Revision to activate (default: desired head)") | ||
| @@ -728,7 +791,10 @@ func newAppDeployCmd() *cobra.Command { | |||||
| 728 | return cmd | 791 | return cmd | ||
| 729 | } | 792 | } | ||
| 730 | 793 | ||||
| 731 | - | func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out io.Writer, jsonOut bool) error { | 794 | + | // runAppDeploy issues one deploy mutation and, when the daemon answers 202 |
| 795 | + | // with a running journal, polls the existing by-key endpoint to terminal | |||
| 796 | + | // rather than reissuing the mutation or hiding the outcome. | |||
| 797 | + | func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, service string, out, errOut io.Writer, jsonOut bool) error { | |||
| 732 | resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service}) | 798 | resp, key, err := plane.DeployApp(ctx, app, dto.AppDeployRequest{Revision: revision, Service: service}) | ||
| 733 | if err != nil { | 799 | if err != nil { | ||
| 734 | if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok { | 800 | if conflictErr, ok := renderAppOpConflict(out, "deploy", app, key, err, jsonOut); ok { | ||
| @@ -736,6 +802,9 @@ func runAppDeploy(ctx context.Context, plane ControlPlane, app, revision, servic | |||||
| 736 | } | 802 | } | ||
| 737 | return appMutationError("deploy", app, key, err) | 803 | return appMutationError("deploy", app, key, err) | ||
| 738 | } | 804 | } | ||
| 805 | + | if resp.Status == dto.AppStatusRunning { | |||
| 806 | + | return watchOperation(ctx, plane, app, key, resp, out, errOut, jsonOut) | |||
| 807 | + | } | |||
| 739 | if jsonOut { | 808 | if jsonOut { | ||
| 740 | return writeJSON(out, resp) | 809 | return writeJSON(out, resp) | ||
| 741 | } | 810 | } | ||
A internal/adapters/in/cli/apps_apply_watch_test.go +187 -0
| @@ -0,0 +1,187 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | // Tests for the `apps apply --deploy` chain over a 202 accepted deploy: | |||
| 4 | + | // it must reuse the shared by-key watch (progress only on change, terminal | |||
| 5 | + | // journal, Ctrl-C resume) and must never reissue the deploy POST. JSON mode | |||
| 6 | + | // keeps stdout to exactly one final combined {apply,deploy} document while | |||
| 7 | + | // progress and resume guidance go to stderr. | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "bytes" | |||
| 11 | + | "context" | |||
| 12 | + | "encoding/json" | |||
| 13 | + | "io" | |||
| 14 | + | "strings" | |||
| 15 | + | "testing" | |||
| 16 | + | ||||
| 17 | + | "github.com/stretchr/testify/assert" | |||
| 18 | + | "github.com/stretchr/testify/mock" | |||
| 19 | + | "github.com/stretchr/testify/require" | |||
| 20 | + | ||||
| 21 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | func acceptedApply() *dto.AppApplyResponse { | |||
| 25 | + | return &dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestRunAppsApply_DeployAcceptedPollsToSuccess(t *testing.T) { | |||
| 29 | + | shortenOperationPoll(t) | |||
| 30 | + | plane := appPlane(t) | |||
| 31 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 32 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |||
| 33 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once() | |||
| 34 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 35 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 36 | + | ||||
| 37 | + | var out, errOut bytes.Buffer | |||
| 38 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut, | |||
| 39 | + | writeManifest(t, "[app]\nname = \"blog\"\n"), false, true, false)) | |||
| 40 | + | text := out.String() | |||
| 41 | + | assert.Contains(t, text, "Applied blog (rev-b)", "the apply outcome stays visible") | |||
| 42 | + | assert.Contains(t, text, "op op-1:", "the accepted deploy reports progress through the watch path") | |||
| 43 | + | assert.Contains(t, text, "op-1 blog: success", "the terminal journal is rendered") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestRunAppsApply_DeployAcceptedNoSecondMutation(t *testing.T) { | |||
| 47 | + | shortenOperationPoll(t) | |||
| 48 | + | plane := appPlane(t) | |||
| 49 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 50 | + | // .Once() proves the accepted deploy is observed by key, never re-POSTed. | |||
| 51 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 52 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 53 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 54 | + | ||||
| 55 | + | var out, errOut bytes.Buffer | |||
| 56 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut, | |||
| 57 | + | writeManifest(t, "x"), false, true, false)) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestRunAppsApply_DeployAcceptedUnchangedStateNotReprinted(t *testing.T) { | |||
| 61 | + | shortenOperationPoll(t) | |||
| 62 | + | plane := appPlane(t) | |||
| 63 | + | step := dto.AppStepDTO{ID: "service.web.start", State: "pending"} | |||
| 64 | + | advanced := runningOp("op-1", | |||
| 65 | + | dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}, | |||
| 66 | + | dto.AppStepDTO{ID: "service.web.health", State: "pending"}) | |||
| 67 | + | ||||
| 68 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 69 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once() | |||
| 70 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(runningOp("op-1", step), nil).Once() | |||
| 71 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once() | |||
| 72 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 73 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 74 | + | ||||
| 75 | + | var out, errOut bytes.Buffer | |||
| 76 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut, | |||
| 77 | + | writeManifest(t, "x"), false, true, false)) | |||
| 78 | + | assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"), | |||
| 79 | + | "initial and one advanced poll render progress; the unchanged poll is not reprinted") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestRunAppsApply_DeployAcceptedPartialFailsButKeepsApply(t *testing.T) { | |||
| 83 | + | shortenOperationPoll(t) | |||
| 84 | + | plane := appPlane(t) | |||
| 85 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 86 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 87 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{ | |||
| 88 | + | Op: "op-1", App: "blog", Status: "partial", Outcome: "partial", | |||
| 89 | + | Services: map[string]dto.AppServiceResultDTO{ | |||
| 90 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"}, | |||
| 91 | + | }, | |||
| 92 | + | }, nil).Once() | |||
| 93 | + | ||||
| 94 | + | var out, errOut bytes.Buffer | |||
| 95 | + | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut, | |||
| 96 | + | writeManifest(t, "x"), false, true, false) | |||
| 97 | + | require.Error(t, err, "a terminal partial deploy must exit nonzero") | |||
| 98 | + | assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)") | |||
| 99 | + | assert.Contains(t, err.Error(), "partial") | |||
| 100 | + | assert.Contains(t, out.String(), "Applied blog (rev-b)", "the apply outcome stays visible") | |||
| 101 | + | assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing") | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestRunAppsApply_DeployAcceptedJSONKeepsStdoutSingleCombinedDocument(t *testing.T) { | |||
| 105 | + | shortenOperationPoll(t) | |||
| 106 | + | plane := appPlane(t) | |||
| 107 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 108 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |||
| 109 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once() | |||
| 110 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 111 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once() | |||
| 112 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 113 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 114 | + | ||||
| 115 | + | var out, errOut bytes.Buffer | |||
| 116 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut, | |||
| 117 | + | writeManifest(t, "x"), false, true, true)) | |||
| 118 | + | ||||
| 119 | + | dec := json.NewDecoder(bytes.NewReader(out.Bytes())) | |||
| 120 | + | var got appApplyDeployDocument | |||
| 121 | + | require.NoError(t, dec.Decode(&got)) | |||
| 122 | + | assert.Equal(t, "apply-1", got.Apply.Intent) | |||
| 123 | + | assert.Equal(t, "op-1", got.Deploy.Op) | |||
| 124 | + | assert.Equal(t, "success", got.Deploy.Outcome) | |||
| 125 | + | var extra json.RawMessage | |||
| 126 | + | assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document") | |||
| 127 | + | assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout") | |||
| 128 | + | assert.NotContains(t, out.String(), dto.AppStatusRunning, "no initial running JSON is emitted") | |||
| 129 | + | assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestRunAppsApply_DeployAcceptedJSONFailureKeepsApplyDocument(t *testing.T) { | |||
| 133 | + | shortenOperationPoll(t) | |||
| 134 | + | plane := appPlane(t) | |||
| 135 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 136 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 137 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 138 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "failed", Outcome: "failed"}, nil).Once() | |||
| 139 | + | ||||
| 140 | + | var out, errOut bytes.Buffer | |||
| 141 | + | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &errOut, | |||
| 142 | + | writeManifest(t, "x"), false, true, true) | |||
| 143 | + | require.Error(t, err) | |||
| 144 | + | assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)") | |||
| 145 | + | ||||
| 146 | + | dec := json.NewDecoder(bytes.NewReader(out.Bytes())) | |||
| 147 | + | var got appApplyDeployDocument | |||
| 148 | + | require.NoError(t, dec.Decode(&got)) | |||
| 149 | + | assert.Equal(t, "rev-b", got.Apply.ResultingRevision, "the failed deploy must not hide the successful apply") | |||
| 150 | + | assert.Equal(t, "failed", got.Deploy.Outcome) | |||
| 151 | + | var extra json.RawMessage | |||
| 152 | + | assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document") | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestRunAppsApply_DeployAcceptedCancelPrintsResume(t *testing.T) { | |||
| 156 | + | plane := appPlane(t) | |||
| 157 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 158 | + | cancel() | |||
| 159 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 160 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 161 | + | ||||
| 162 | + | var out, errOut bytes.Buffer | |||
| 163 | + | err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut, | |||
| 164 | + | writeManifest(t, "x"), false, true, false) | |||
| 165 | + | require.Error(t, err) | |||
| 166 | + | assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)") | |||
| 167 | + | text := out.String() | |||
| 168 | + | assert.Contains(t, text, "not cancelled", "Ctrl-C must not imply a server-side abort") | |||
| 169 | + | assert.Contains(t, text, "gordon apps operations watch blog --key key-1") | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestRunAppsApply_DeployAcceptedCancelKeepsJSONStdoutClean(t *testing.T) { | |||
| 173 | + | plane := appPlane(t) | |||
| 174 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 175 | + | cancel() | |||
| 176 | + | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(acceptedApply(), nil).Once() | |||
| 177 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 178 | + | ||||
| 179 | + | var out, errOut bytes.Buffer | |||
| 180 | + | err := runAppsApply(ctx, plane, strings.NewReader(""), &out, &errOut, | |||
| 181 | + | writeManifest(t, "x"), false, true, true) | |||
| 182 | + | require.Error(t, err) | |||
| 183 | + | assert.Contains(t, err.Error(), "apply of blog succeeded (rev-b)") | |||
| 184 | + | assert.Empty(t, out.String(), "JSON stdout stays empty when interrupted before terminal") | |||
| 185 | + | assert.Contains(t, errOut.String(), "not cancelled") | |||
| 186 | + | assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1") | |||
| 187 | + | } | |||
M internal/adapters/in/cli/apps_findings_test.go +2 -2
| @@ -24,7 +24,7 @@ func TestRunAppsApply_DryRunOmitsEmptyRevisionAndRendersDiff(t *testing.T) { | |||||
| 24 | Intent: "apply-7", | 24 | Intent: "apply-7", | ||
| 25 | }, nil).Once() | 25 | }, nil).Once() | ||
| 26 | var out bytes.Buffer | 26 | var out bytes.Buffer | ||
| 27 | - | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | 27 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, |
| 28 | writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false)) | 28 | writeManifest(t, "[app]\nname = \"blog\"\n"), true, false, false)) | ||
| 29 | text := out.String() | 29 | text := out.String() | ||
| 30 | assert.Contains(t, text, "Validated (dry-run) blog") | 30 | assert.Contains(t, text, "Validated (dry-run) blog") | ||
| @@ -38,7 +38,7 @@ func TestRunAppsApply_NoopOmitsEmptyRevision(t *testing.T) { | |||||
| 38 | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything). | 38 | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything). | ||
| 39 | Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once() | 39 | Return(&dto.AppApplyResponse{App: "blog", Noop: true}, nil).Once() | ||
| 40 | var out bytes.Buffer | 40 | var out bytes.Buffer | ||
| 41 | - | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | 41 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, |
| 42 | writeManifest(t, "x"), true, false, false)) | 42 | writeManifest(t, "x"), true, false, false)) | ||
| 43 | assert.Contains(t, out.String(), "No changes for blog") | 43 | assert.Contains(t, out.String(), "No changes for blog") | ||
| 44 | assert.NotContains(t, out.String(), "()") | 44 | assert.NotContains(t, out.String(), "()") | ||
M internal/adapters/in/cli/apps_operations.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | func newAppsOperationsCmd() *cobra.Command { | 11 | func newAppsOperationsCmd() *cobra.Command { | ||
| 12 | cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"} | 12 | cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"} | ||
| 13 | - | cmd.AddCommand(newAppsOperationsShowCmd()) | 13 | + | cmd.AddCommand(newAppsOperationsShowCmd(), newAppsOperationsWatchCmd()) |
| 14 | return cmd | 14 | return cmd | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
A internal/adapters/in/cli/apps_operations_watch.go +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | ||||
| 8 | + | "github.com/spf13/cobra" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // newAppsOperationsWatchCmd creates `operations watch APP --key KEY`: it | |||
| 12 | + | // resumes observing an existing operation through the by-key endpoint until | |||
| 13 | + | // it is terminal, never reissuing the mutation. | |||
| 14 | + | func newAppsOperationsWatchCmd() *cobra.Command { | |||
| 15 | + | var key string | |||
| 16 | + | var jsonOut bool | |||
| 17 | + | cmd := &cobra.Command{ | |||
| 18 | + | Use: "watch APP", | |||
| 19 | + | Short: "Poll an app operation until it is terminal", | |||
| 20 | + | Long: `Polls the by-key operation journal until the operation is terminal. | |||
| 21 | + | ||||
| 22 | + | Use it to resume watching an operation from an earlier interrupted run | |||
| 23 | + | without reissuing the mutation. Ctrl-C stops only local polling; the | |||
| 24 | + | daemon-side operation keeps running and the command prints how to resume.`, | |||
| 25 | + | Args: cobra.ExactArgs(1), | |||
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 27 | + | handle, err := resolveAppPlane() | |||
| 28 | + | if err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | defer handle.close() | |||
| 32 | + | return runAppsOperationsWatch(cmd.Context(), handle.plane, cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], key, jsonOut) | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | cmd.Flags().StringVar(&key, "key", "", "Request key (required)") | |||
| 36 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |||
| 37 | + | _ = cmd.MarkFlagRequired("key") | |||
| 38 | + | return cmd | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func runAppsOperationsWatch(ctx context.Context, plane ControlPlane, out, errOut io.Writer, app, key string, jsonOut bool) error { | |||
| 42 | + | if app == "" || key == "" { | |||
| 43 | + | return fmt.Errorf("APP and --key are required") | |||
| 44 | + | } | |||
| 45 | + | return watchOperation(ctx, plane, app, key, nil, out, errOut, jsonOut) | |||
| 46 | + | } | |||
M internal/adapters/in/cli/apps_test.go +8 -8
| @@ -41,7 +41,7 @@ func writeManifest(t *testing.T, content string) string { | |||||
| 41 | 41 | ||||
| 42 | func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) { | 42 | func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) { | ||
| 43 | plane := appPlane(t) | 43 | plane := appPlane(t) | ||
| 44 | - | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "x.toml", true, true, false) | 44 | + | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "x.toml", true, true, false) |
| 45 | require.Error(t, err) | 45 | require.Error(t, err) | ||
| 46 | assert.Contains(t, err.Error(), "--dry-run") | 46 | assert.Contains(t, err.Error(), "--dry-run") | ||
| 47 | assert.Contains(t, err.Error(), "--deploy") | 47 | assert.Contains(t, err.Error(), "--deploy") | ||
| @@ -49,7 +49,7 @@ func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) { | |||||
| 49 | 49 | ||||
| 50 | func TestRunAppsApply_RequiresFile(t *testing.T) { | 50 | func TestRunAppsApply_RequiresFile(t *testing.T) { | ||
| 51 | plane := appPlane(t) | 51 | plane := appPlane(t) | ||
| 52 | - | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, "", false, false, false) | 52 | + | err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", false, false, false) |
| 53 | require.Error(t, err) | 53 | require.Error(t, err) | ||
| 54 | assert.Contains(t, err.Error(), "--file") | 54 | assert.Contains(t, err.Error(), "--file") | ||
| 55 | } | 55 | } | ||
| @@ -63,7 +63,7 @@ func TestRunAppsApply_JSONParity(t *testing.T) { | |||||
| 63 | plane := appPlane(t) | 63 | plane := appPlane(t) | ||
| 64 | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once() | 64 | plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once() | ||
| 65 | var out bytes.Buffer | 65 | var out bytes.Buffer | ||
| 66 | - | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | 66 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, |
| 67 | writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true)) | 67 | writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true)) | ||
| 68 | var got dto.AppApplyResponse | 68 | var got dto.AppApplyResponse | ||
| 69 | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | 69 | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | ||
| @@ -77,7 +77,7 @@ func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) { | |||||
| 77 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | 77 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | ||
| 78 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() | 78 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() | ||
| 79 | var out bytes.Buffer | 79 | var out bytes.Buffer | ||
| 80 | - | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | 80 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, |
| 81 | writeManifest(t, "x"), false, true, false)) | 81 | writeManifest(t, "x"), false, true, false)) | ||
| 82 | assert.Contains(t, out.String(), "rev-b") | 82 | assert.Contains(t, out.String(), "rev-b") | ||
| 83 | } | 83 | } | ||
| @@ -89,7 +89,7 @@ func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) { | |||||
| 89 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | 89 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | ||
| 90 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() | 90 | &dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once() | ||
| 91 | var out bytes.Buffer | 91 | var out bytes.Buffer | ||
| 92 | - | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, | 92 | + | require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{}, |
| 93 | writeManifest(t, "x"), false, true, true)) | 93 | writeManifest(t, "x"), false, true, true)) | ||
| 94 | var got struct { | 94 | var got struct { | ||
| 95 | Apply dto.AppApplyResponse `json:"apply"` | 95 | Apply dto.AppApplyResponse `json:"apply"` | ||
| @@ -204,7 +204,7 @@ func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) { | |||||
| 204 | plane := appPlane(t) | 204 | plane := appPlane(t) | ||
| 205 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | 205 | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | ||
| 206 | nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once() | 206 | nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once() | ||
| 207 | - | err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, false) | 207 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &bytes.Buffer{}, &bytes.Buffer{}, false) |
| 208 | require.Error(t, err) | 208 | require.Error(t, err) | ||
| 209 | assert.Contains(t, err.Error(), "outcome-unknown") | 209 | assert.Contains(t, err.Error(), "outcome-unknown") | ||
| 210 | assert.Contains(t, err.Error(), "key-abc") | 210 | assert.Contains(t, err.Error(), "key-abc") | ||
| @@ -223,7 +223,7 @@ func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) { | |||||
| 223 | }, | 223 | }, | ||
| 224 | }).Once() | 224 | }).Once() | ||
| 225 | var out bytes.Buffer | 225 | var out bytes.Buffer | ||
| 226 | - | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, false) | 226 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, false) |
| 227 | require.Error(t, err, "conflict must retain failure semantics") | 227 | require.Error(t, err, "conflict must retain failure semantics") | ||
| 228 | text := out.String() | 228 | text := out.String() | ||
| 229 | assert.Contains(t, text, "web:") | 229 | assert.Contains(t, text, "web:") | ||
| @@ -246,7 +246,7 @@ func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) { | |||||
| 246 | }, | 246 | }, | ||
| 247 | }).Once() | 247 | }).Once() | ||
| 248 | var out bytes.Buffer | 248 | var out bytes.Buffer | ||
| 249 | - | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, true) | 249 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &bytes.Buffer{}, true) |
| 250 | require.Error(t, err) | 250 | require.Error(t, err) | ||
| 251 | var got dto.AppDeployResponse | 251 | var got dto.AppDeployResponse | ||
| 252 | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | 252 | require.NoError(t, json.Unmarshal(out.Bytes(), &got)) | ||
A internal/adapters/in/cli/apps_watch.go +225 -0
| @@ -0,0 +1,225 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | // Operation watch: a 202 mutation (or an explicit resume) is observed | |||
| 4 | + | // through the existing by-key journal endpoint until it reaches a terminal | |||
| 5 | + | // state. Polling is intentionally local: the CLI never reissues the | |||
| 6 | + | // mutation and never asks the daemon to cancel the operation, so Ctrl-C | |||
| 7 | + | // only stops this process from watching. | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "fmt" | |||
| 12 | + | "io" | |||
| 13 | + | "strings" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 17 | + | "github.com/bnema/gordon/internal/domain" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // operationPollInterval is the pause between by-key polls of a running | |||
| 21 | + | // operation. It is a package variable (no flag) so command tests can | |||
| 22 | + | // shorten it; production uses a modest one-second cadence. | |||
| 23 | + | var operationPollInterval = time.Second | |||
| 24 | + | ||||
| 25 | + | // operationPollMaxTransientErrors bounds consecutive transient polling | |||
| 26 | + | // failures so a broken connection fails loudly instead of spinning or | |||
| 27 | + | // hiding the error. | |||
| 28 | + | const operationPollMaxTransientErrors = 5 | |||
| 29 | + | ||||
| 30 | + | // OperationFailedError reports a terminal non-success watch outcome. The | |||
| 31 | + | // terminal journal is rendered before this error is returned so the | |||
| 32 | + | // command exits nonzero without hiding the terminal detail. | |||
| 33 | + | type OperationFailedError struct { | |||
| 34 | + | App string | |||
| 35 | + | Op string | |||
| 36 | + | Outcome string | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (e *OperationFailedError) Error() string { | |||
| 40 | + | return fmt.Sprintf("operation %s for app %s did not succeed (outcome %s)", e.Op, e.App, e.Outcome) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // operationResumeCommand is the concise command that resumes polling an | |||
| 44 | + | // existing operation without reissuing the mutation. | |||
| 45 | + | func operationResumeCommand(app, key string) string { | |||
| 46 | + | cmd := fmt.Sprintf("gordon apps operations watch %s --key %s", app, key) | |||
| 47 | + | if remoteFlag != "" { | |||
| 48 | + | cmd += " --remote " + remoteFlag | |||
| 49 | + | } | |||
| 50 | + | return cmd | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // watchOperation polls the by-key journal until the operation is terminal, | |||
| 54 | + | // reports progress while it runs, then renders the terminal journal. In | |||
| 55 | + | // JSON mode stdout carries exactly one final document and progress goes to | |||
| 56 | + | // errOut. A terminal non-success outcome renders the journal and returns | |||
| 57 | + | // OperationFailedError. | |||
| 58 | + | func watchOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, out, errOut io.Writer, jsonOut bool) error { | |||
| 59 | + | if app == "" || key == "" { | |||
| 60 | + | return fmt.Errorf("APP and --key are required") | |||
| 61 | + | } | |||
| 62 | + | progressOut := out | |||
| 63 | + | if jsonOut { | |||
| 64 | + | progressOut = errOut | |||
| 65 | + | } | |||
| 66 | + | op, err := awaitOperation(ctx, plane, app, key, initial, progressOut) | |||
| 67 | + | if err != nil { | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | if jsonOut { | |||
| 71 | + | if err := writeJSON(out, op); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | } else if err := renderAppDeployResponse(out, op); err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | if op.Outcome == domain.AppOutcomeSuccess { | |||
| 78 | + | return nil | |||
| 79 | + | } | |||
| 80 | + | return &OperationFailedError{App: app, Op: op.Op, Outcome: op.Outcome} | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // awaitOperation polls OperationByKey until the journal is terminal. The | |||
| 84 | + | // optional initial response (the 202 mutation body) is reported before the | |||
| 85 | + | // first poll so its state is not lost. Progress is written only when the | |||
| 86 | + | // operation/step fingerprint changes. | |||
| 87 | + | func awaitOperation(ctx context.Context, plane ControlPlane, app, key string, initial *dto.AppDeployResponse, progressOut io.Writer) (*dto.AppDeployResponse, error) { | |||
| 88 | + | lastProgress := "" | |||
| 89 | + | transient := 0 | |||
| 90 | + | for { | |||
| 91 | + | op := initial | |||
| 92 | + | initial = nil | |||
| 93 | + | if op == nil { | |||
| 94 | + | got, err := plane.OperationByKey(ctx, app, key) | |||
| 95 | + | if err != nil { | |||
| 96 | + | if perr := handlePollError(ctx, progressOut, app, key, err, &transient); perr != nil { | |||
| 97 | + | return nil, perr | |||
| 98 | + | } | |||
| 99 | + | continue | |||
| 100 | + | } | |||
| 101 | + | transient = 0 | |||
| 102 | + | op = got | |||
| 103 | + | } | |||
| 104 | + | if op.Status != dto.AppStatusRunning { | |||
| 105 | + | return op, nil | |||
| 106 | + | } | |||
| 107 | + | if sig := operationProgressSignature(op); sig != lastProgress { | |||
| 108 | + | lastProgress = sig | |||
| 109 | + | if err := renderOperationProgress(progressOut, op); err != nil { | |||
| 110 | + | return nil, err | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | if err := waitOperationPoll(ctx); err != nil { | |||
| 114 | + | return nil, stopOperationWatch(progressOut, app, key, err) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // handlePollError classifies one by-key lookup error. It returns nil when | |||
| 120 | + | // the caller should poll again and a terminal error otherwise: 404 is | |||
| 121 | + | // terminal, cancellation stops local polling, and any other failure is | |||
| 122 | + | // retried up to operationPollMaxTransientErrors consecutive times. | |||
| 123 | + | func handlePollError(ctx context.Context, progressOut io.Writer, app, key string, err error, transient *int) error { | |||
| 124 | + | if ctxErr := ctx.Err(); ctxErr != nil { | |||
| 125 | + | return stopOperationWatch(progressOut, app, key, ctxErr) | |||
| 126 | + | } | |||
| 127 | + | if isRemoteNotFoundError(err) { | |||
| 128 | + | return fmt.Errorf("operation %s for app %s is no longer recorded; nothing to resume: %w", key, app, err) | |||
| 129 | + | } | |||
| 130 | + | *transient++ | |||
| 131 | + | if *transient > operationPollMaxTransientErrors { | |||
| 132 | + | return fmt.Errorf( | |||
| 133 | + | "gave up watching operation %s for app %s after %d consecutive polling errors: %w; resume with %s", | |||
| 134 | + | key, app, *transient, err, operationResumeCommand(app, key)) | |||
| 135 | + | } | |||
| 136 | + | if werr := cliWriteLine(progressOut, cliRenderWarning(fmt.Sprintf( | |||
| 137 | + | "operation watch: transient polling error (%d/%d): %v", *transient, operationPollMaxTransientErrors, err))); werr != nil { | |||
| 138 | + | return werr | |||
| 139 | + | } | |||
| 140 | + | if werr := waitOperationPoll(ctx); werr != nil { | |||
| 141 | + | return stopOperationWatch(progressOut, app, key, werr) | |||
| 142 | + | } | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // waitOperationPoll sleeps for one polling interval, aborting early when | |||
| 147 | + | // the command context is cancelled. | |||
| 148 | + | func waitOperationPoll(ctx context.Context) error { | |||
| 149 | + | if err := ctx.Err(); err != nil { | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | timer := time.NewTimer(operationPollInterval) | |||
| 153 | + | defer timer.Stop() | |||
| 154 | + | select { | |||
| 155 | + | case <-ctx.Done(): | |||
| 156 | + | return ctx.Err() | |||
| 157 | + | case <-timer.C: | |||
| 158 | + | return nil | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // stopOperationWatch reports an interrupted watch without implying any | |||
| 163 | + | // server-side cancellation, then returns the interruption as an error. The | |||
| 164 | + | // resume command is printed so the user can continue watching later. | |||
| 165 | + | func stopOperationWatch(progressOut io.Writer, app, key string, cause error) error { | |||
| 166 | + | if err := cliWriteLine(progressOut, cliRenderWarning("polling stopped; the operation is not cancelled and keeps running on the daemon")); err != nil { | |||
| 167 | + | return err | |||
| 168 | + | } | |||
| 169 | + | if err := cliWriteLine(progressOut, cliRenderMeta("resume:", operationResumeCommand(app, key))); err != nil { | |||
| 170 | + | return err | |||
| 171 | + | } | |||
| 172 | + | return fmt.Errorf("operation watch interrupted: %w", cause) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // operationProgressSignature fingerprints the visible operation/step state | |||
| 176 | + | // so unchanged polls are not reprinted. | |||
| 177 | + | func operationProgressSignature(op *dto.AppDeployResponse) string { | |||
| 178 | + | var b strings.Builder | |||
| 179 | + | b.WriteString(op.Status) | |||
| 180 | + | b.WriteByte(0) | |||
| 181 | + | b.WriteString(op.Outcome) | |||
| 182 | + | for _, step := range op.Steps { | |||
| 183 | + | b.WriteByte(0) | |||
| 184 | + | b.WriteString(step.ID) | |||
| 185 | + | b.WriteByte('=') | |||
| 186 | + | b.WriteString(step.State) | |||
| 187 | + | b.WriteByte(':') | |||
| 188 | + | b.WriteString(step.Detail) | |||
| 189 | + | b.WriteByte(':') | |||
| 190 | + | b.WriteString(step.Error) | |||
| 191 | + | } | |||
| 192 | + | return b.String() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // renderOperationProgress writes one concise progress line for the current | |||
| 196 | + | // operation/step state. | |||
| 197 | + | func renderOperationProgress(out io.Writer, op *dto.AppDeployResponse) error { | |||
| 198 | + | detail := op.Status | |||
| 199 | + | if step, ok := activeOperationStep(op.Steps); ok { | |||
| 200 | + | detail = fmt.Sprintf("%s %s: %s", op.Status, step.ID, step.State) | |||
| 201 | + | if step.Error != "" { | |||
| 202 | + | detail += ": " + sanitizeTerminalText(step.Error) | |||
| 203 | + | } else if step.Detail != "" { | |||
| 204 | + | detail += ": " + sanitizeTerminalText(step.Detail) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | return cliWriteLine(out, cliRenderMeta("op "+op.Op+":", detail)) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // activeOperationStep returns the step still to run (pending) when one | |||
| 211 | + | // exists, otherwise the last journaled step. | |||
| 212 | + | func activeOperationStep(steps []dto.AppStepDTO) (dto.AppStepDTO, bool) { | |||
| 213 | + | if len(steps) == 0 { | |||
| 214 | + | return dto.AppStepDTO{}, false | |||
| 215 | + | } | |||
| 216 | + | for _, step := range steps { | |||
| 217 | + | switch step.State { | |||
| 218 | + | case domain.AppStepSucceeded, domain.AppStepFailed, domain.AppStepNotRun: | |||
| 219 | + | continue | |||
| 220 | + | default: | |||
| 221 | + | return step, true | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | return steps[len(steps)-1], true | |||
| 225 | + | } | |||
A internal/adapters/in/cli/apps_watch_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | // Tests for 202 deploy polling and `apps operations watch`: state-change | |||
| 4 | + | // progress, machine-readable JSON, terminal rendering/exit, bounded | |||
| 5 | + | // transient failures, fast 404, and Ctrl-C resume without server abort. | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "bytes" | |||
| 9 | + | "context" | |||
| 10 | + | "encoding/json" | |||
| 11 | + | "errors" | |||
| 12 | + | "io" | |||
| 13 | + | "net/http" | |||
| 14 | + | "strings" | |||
| 15 | + | "testing" | |||
| 16 | + | "time" | |||
| 17 | + | ||||
| 18 | + | "github.com/stretchr/testify/assert" | |||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 20 | + | "github.com/stretchr/testify/require" | |||
| 21 | + | ||||
| 22 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 23 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // shortenOperationPoll makes the polling cadence near-instant for tests. | |||
| 27 | + | func shortenOperationPoll(t *testing.T) { | |||
| 28 | + | t.Helper() | |||
| 29 | + | prev := operationPollInterval | |||
| 30 | + | operationPollInterval = time.Millisecond | |||
| 31 | + | t.Cleanup(func() { operationPollInterval = prev }) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func runningOp(op string, steps ...dto.AppStepDTO) *dto.AppDeployResponse { | |||
| 35 | + | return &dto.AppDeployResponse{Op: op, App: "blog", Status: dto.AppStatusRunning, Steps: steps} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestRunAppDeploy_PollsAcceptedToTerminal(t *testing.T) { | |||
| 39 | + | shortenOperationPoll(t) | |||
| 40 | + | plane := appPlane(t) | |||
| 41 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |||
| 42 | + | runningOp("op-1"), "key-1", nil).Once() | |||
| 43 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 44 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once() | |||
| 45 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 46 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 47 | + | ||||
| 48 | + | var out, errOut bytes.Buffer | |||
| 49 | + | require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false)) | |||
| 50 | + | text := out.String() | |||
| 51 | + | assert.Contains(t, text, "op op-1:") | |||
| 52 | + | assert.Contains(t, text, "running") | |||
| 53 | + | assert.Contains(t, text, "success") | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestRunAppDeploy_UnchangedStateNotReprinted(t *testing.T) { | |||
| 57 | + | shortenOperationPoll(t) | |||
| 58 | + | plane := appPlane(t) | |||
| 59 | + | step := dto.AppStepDTO{ID: "service.web.start", State: "pending"} | |||
| 60 | + | unchanged := runningOp("op-1", step) | |||
| 61 | + | advanced := runningOp("op-1", | |||
| 62 | + | dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}, | |||
| 63 | + | dto.AppStepDTO{ID: "service.web.health", State: "pending"}) | |||
| 64 | + | ||||
| 65 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1", step), "key-1", nil).Once() | |||
| 66 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(unchanged, nil).Once() | |||
| 67 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(advanced, nil).Once() | |||
| 68 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 69 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 70 | + | ||||
| 71 | + | var out, errOut bytes.Buffer | |||
| 72 | + | require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false)) | |||
| 73 | + | assert.Equal(t, 2, strings.Count(out.String(), "op op-1:"), | |||
| 74 | + | "initial and one advanced poll render progress; the unchanged poll is not reprinted") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestRunAppDeploy_AcceptedIssuesNoSecondMutation(t *testing.T) { | |||
| 78 | + | shortenOperationPoll(t) | |||
| 79 | + | plane := appPlane(t) | |||
| 80 | + | // .Once() on DeployApp proves the accepted path never re-POSTs; the | |||
| 81 | + | // by-key lookups are the only way the outcome is observed. | |||
| 82 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 83 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 84 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 85 | + | ||||
| 86 | + | var out, errOut bytes.Buffer | |||
| 87 | + | require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false)) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestRunAppDeploy_TerminalPartialRendersAndFails(t *testing.T) { | |||
| 91 | + | shortenOperationPoll(t) | |||
| 92 | + | plane := appPlane(t) | |||
| 93 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(runningOp("op-1"), "key-1", nil).Once() | |||
| 94 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(&dto.AppDeployResponse{ | |||
| 95 | + | Op: "op-1", App: "blog", Status: "partial", Outcome: "partial", | |||
| 96 | + | Services: map[string]dto.AppServiceResultDTO{ | |||
| 97 | + | "web": {Result: "failed", EffectiveRevision: "rev-b", Error: "boom"}, | |||
| 98 | + | }, | |||
| 99 | + | }, nil).Once() | |||
| 100 | + | ||||
| 101 | + | var out, errOut bytes.Buffer | |||
| 102 | + | err := runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, false) | |||
| 103 | + | require.Error(t, err, "a terminal partial outcome must exit nonzero") | |||
| 104 | + | assert.Contains(t, err.Error(), "partial") | |||
| 105 | + | assert.Contains(t, out.String(), "boom", "the terminal journal is rendered before failing") | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestRunAppDeploy_JSONKeepsStdoutMachineReadable(t *testing.T) { | |||
| 109 | + | shortenOperationPoll(t) | |||
| 110 | + | plane := appPlane(t) | |||
| 111 | + | plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return( | |||
| 112 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), "key-1", nil).Once() | |||
| 113 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 114 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "succeeded"}), nil).Once() | |||
| 115 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 116 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 117 | + | ||||
| 118 | + | var out, errOut bytes.Buffer | |||
| 119 | + | require.NoError(t, runAppDeploy(context.Background(), plane, "blog", "", "", &out, &errOut, true)) | |||
| 120 | + | ||||
| 121 | + | dec := json.NewDecoder(bytes.NewReader(out.Bytes())) | |||
| 122 | + | var got dto.AppDeployResponse | |||
| 123 | + | require.NoError(t, dec.Decode(&got)) | |||
| 124 | + | assert.Equal(t, "op-1", got.Op) | |||
| 125 | + | assert.Equal(t, "success", got.Outcome) | |||
| 126 | + | var extra json.RawMessage | |||
| 127 | + | assert.ErrorIs(t, dec.Decode(&extra), io.EOF, "stdout carries exactly one JSON document") | |||
| 128 | + | assert.NotContains(t, out.String(), "op op-1:", "progress must not pollute JSON stdout") | |||
| 129 | + | assert.Contains(t, errOut.String(), "op op-1:", "progress goes to stderr in JSON mode") | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestWatchOperation_CancelPrintsResumeWithoutAbort(t *testing.T) { | |||
| 133 | + | plane := appPlane(t) | |||
| 134 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 135 | + | cancel() | |||
| 136 | + | ||||
| 137 | + | var out, errOut bytes.Buffer | |||
| 138 | + | err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, false) | |||
| 139 | + | require.Error(t, err) | |||
| 140 | + | assert.ErrorIs(t, err, context.Canceled) | |||
| 141 | + | text := out.String() | |||
| 142 | + | assert.Contains(t, text, "gordon apps operations watch blog --key key-1") | |||
| 143 | + | assert.Contains(t, text, "not cancelled") | |||
| 144 | + | assert.NotContains(t, text, "cancel op") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestWatchOperation_CancelKeepsJSONStdoutClean(t *testing.T) { | |||
| 148 | + | plane := appPlane(t) | |||
| 149 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 150 | + | cancel() | |||
| 151 | + | ||||
| 152 | + | var out, errOut bytes.Buffer | |||
| 153 | + | err := watchOperation(ctx, plane, "blog", "key-1", runningOp("op-1"), &out, &errOut, true) | |||
| 154 | + | require.Error(t, err) | |||
| 155 | + | assert.Empty(t, out.String(), "JSON stdout stays empty on interruption") | |||
| 156 | + | assert.Contains(t, errOut.String(), "gordon apps operations watch blog --key key-1") | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func TestRunAppsOperationsWatch_ResumesExisting(t *testing.T) { | |||
| 160 | + | shortenOperationPoll(t) | |||
| 161 | + | plane := appPlane(t) | |||
| 162 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 163 | + | runningOp("op-1", dto.AppStepDTO{ID: "service.web.start", State: "pending"}), nil).Once() | |||
| 164 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return( | |||
| 165 | + | &dto.AppDeployResponse{Op: "op-1", App: "blog", Status: "success", Outcome: "success"}, nil).Once() | |||
| 166 | + | ||||
| 167 | + | var out, errOut bytes.Buffer | |||
| 168 | + | require.NoError(t, runAppsOperationsWatch(context.Background(), plane, &out, &errOut, "blog", "key-1", false)) | |||
| 169 | + | assert.Contains(t, out.String(), "success") | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestAwaitOperation_TransientErrorsAreBounded(t *testing.T) { | |||
| 173 | + | shortenOperationPoll(t) | |||
| 174 | + | plane := appPlane(t) | |||
| 175 | + | boom := errors.New("connection refused") | |||
| 176 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "key-1").Return(nil, boom).Times(operationPollMaxTransientErrors + 1) | |||
| 177 | + | ||||
| 178 | + | var progress bytes.Buffer | |||
| 179 | + | _, err := awaitOperation(context.Background(), plane, "blog", "key-1", nil, &progress) | |||
| 180 | + | require.Error(t, err) | |||
| 181 | + | assert.Contains(t, err.Error(), "gave up watching") | |||
| 182 | + | assert.Contains(t, err.Error(), "gordon apps operations watch blog --key key-1") | |||
| 183 | + | assert.Contains(t, progress.String(), "transient polling error") | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func TestAwaitOperation_NotFoundFailsFast(t *testing.T) { | |||
| 187 | + | plane := appPlane(t) | |||
| 188 | + | plane.EXPECT().OperationByKey(mock.Anything, "blog", "gone").Return(nil, | |||
| 189 | + | &remote.HTTPError{StatusCode: http.StatusNotFound, Status: "404 Not Found", Body: "unknown operation"}).Once() | |||
| 190 | + | ||||
| 191 | + | _, err := awaitOperation(context.Background(), plane, "blog", "gone", nil, &bytes.Buffer{}) | |||
| 192 | + | require.Error(t, err) | |||
| 193 | + | assert.Contains(t, err.Error(), "no longer recorded") | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestAppsOperationsWatchCmd_Flags(t *testing.T) { | |||
| 197 | + | cmd := newAppsOperationsWatchCmd() | |||
| 198 | + | assert.Equal(t, "watch APP", cmd.Use) | |||
| 199 | + | assert.NotNil(t, cmd.Flags().Lookup("key")) | |||
| 200 | + | assert.NotNil(t, cmd.Flags().Lookup("json")) | |||
| 201 | + | ||||
| 202 | + | subs := map[string]bool{} | |||
| 203 | + | for _, sub := range newAppsOperationsCmd().Commands() { | |||
| 204 | + | subs[sub.Name()] = true | |||
| 205 | + | } | |||
| 206 | + | assert.True(t, subs["show"]) | |||
| 207 | + | assert.True(t, subs["watch"]) | |||
| 208 | + | } | |||
M internal/adapters/in/cli/remote/apps.go +7 -5
| @@ -214,11 +214,13 @@ func (e *AppOpConflictError) Error() string { | |||||
| 214 | return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome) | 214 | return fmt.Sprintf("%s: %s %s outcome %s", e.Status, e.Response.Op, e.Response.App, e.Response.Outcome) | ||
| 215 | } | 215 | } | ||
| 216 | 216 | ||||
| 217 | - | // parseDeployResponse decodes a deploy/lifecycle mutation response. A 409 | 217 | + | // parseDeployResponse decodes a deploy/lifecycle mutation response. A 202 |
| 218 | - | // carries the journaled AppDeployResponse body (per-service results, | 218 | + | // Accepted is a success shape: it carries the same running AppDeployResponse |
| 219 | - | // steps, effective/observed state) instead of an error envelope; it is | 219 | + | // journal as a 200 and is polled via OperationByKey, so it decodes through |
| 220 | - | // decoded and surfaced as AppOpConflictError so the CLI can render the | 220 | + | // the generic 2xx path. A 409 carries the journaled AppDeployResponse body |
| 221 | - | // journal while retaining failure semantics. | 221 | + | // (per-service results, steps, effective/observed state) instead of an error |
| 222 | + | // envelope; it is decoded and surfaced as AppOpConflictError so the CLI can | |||
| 223 | + | // render the journal while retaining failure semantics. | |||
| 222 | func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) { | 224 | func parseDeployResponse(resp *http.Response, action, app string) (*dto.AppDeployResponse, error) { | ||
| 223 | if resp.StatusCode == http.StatusConflict { | 225 | if resp.StatusCode == http.StatusConflict { | ||
| 224 | return decodeDeployConflict(resp, action, app) | 226 | return decodeDeployConflict(resp, action, app) | ||
M internal/adapters/in/cli/remote/apps_test.go +24 -0
| @@ -105,6 +105,30 @@ func TestClientLifecycle_MutationsCarryKeys(t *testing.T) { | |||||
| 105 | assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key") | 105 | assert.NotEqual(t, keys[0], keys[1], "each mutation gets a fresh key") | ||
| 106 | } | 106 | } | ||
| 107 | 107 | ||||
| 108 | + | // TestClientDeployApp_AcceptedRunningJournal proves the remote client accepts | |||
| 109 | + | // the 202 running response: it decodes the journal and returns the key so the | |||
| 110 | + | // caller can poll operations/by-key. | |||
| 111 | + | func TestClientDeployApp_AcceptedRunningJournal(t *testing.T) { | |||
| 112 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 113 | + | require.Equal(t, "/admin/apps/blog/deploy", r.URL.Path) | |||
| 114 | + | require.Equal(t, http.MethodPost, r.Method) | |||
| 115 | + | require.NotEmpty(t, r.Header.Get("Idempotency-Key")) | |||
| 116 | + | w.Header().Set("Content-Type", "application/json") | |||
| 117 | + | w.WriteHeader(http.StatusAccepted) | |||
| 118 | + | _ = json.NewEncoder(w).Encode(dto.AppDeployResponse{ | |||
| 119 | + | Op: "op-run", App: "blog", Revision: "rev-b", Status: dto.AppStatusRunning, | |||
| 120 | + | }) | |||
| 121 | + | })) | |||
| 122 | + | defer srv.Close() | |||
| 123 | + | ||||
| 124 | + | resp, key, err := NewClient(srv.URL).DeployApp(context.Background(), "blog", dto.AppDeployRequest{}) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | require.NotNil(t, resp) | |||
| 127 | + | assert.Equal(t, "op-run", resp.Op) | |||
| 128 | + | assert.Equal(t, dto.AppStatusRunning, resp.Status) | |||
| 129 | + | assert.NotEmpty(t, key) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 108 | func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) { | 132 | func TestClientDeployApp_ConflictDecodesJournal(t *testing.T) { | ||
| 109 | journal := dto.AppDeployResponse{ | 133 | journal := dto.AppDeployResponse{ | ||
| 110 | Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", | 134 | Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed", | ||
M internal/adapters/in/http/admin/handler_apps.go +21 -1
| @@ -309,8 +309,15 @@ func (h *Handler) handleAppDeploy(w http.ResponseWriter, r *http.Request, app st | |||||
| 309 | return | 309 | return | ||
| 310 | } | 310 | } | ||
| 311 | status := http.StatusOK | 311 | status := http.StatusOK | ||
| 312 | - | if err != nil { | 312 | + | switch { |
| 313 | + | case err != nil: | |||
| 313 | status = http.StatusConflict | 314 | status = http.StatusConflict | ||
| 315 | + | case op != nil && !op.Terminal(): | |||
| 316 | + | // Newly owned: the claim is durable and its effects run on the | |||
| 317 | + | // daemon context, so answer 202 with the running operation and let | |||
| 318 | + | // the client observe completion through operations/by-key. A | |||
| 319 | + | // terminal replay instead answers 200 with the stored outcome. | |||
| 320 | + | status = http.StatusAccepted | |||
| 314 | } | 321 | } | ||
| 315 | h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false)) | 322 | h.sendJSON(w, status, h.appMutationResponse(ctx, svc, app, op, false)) | ||
| 316 | } | 323 | } | ||
| @@ -498,6 +505,13 @@ func (h *Handler) sendAppError(w http.ResponseWriter, status int, code, message, | |||||
| 498 | } | 505 | } | ||
| 499 | 506 | ||||
| 500 | func isMappedPreflightError(err error, op *domain.AppOperation) bool { | 507 | func isMappedPreflightError(err error, op *domain.AppOperation) bool { | ||
| 508 | + | // A non-terminal journal is a running replay, never a preflight | |||
| 509 | + | // failure: its stored operation is the response regardless of which | |||
| 510 | + | // steps have started, so the mapped error envelope must not replace | |||
| 511 | + | // it. Only an absent or terminal journal can map a preflight error. | |||
| 512 | + | if op != nil && !op.Terminal() { | |||
| 513 | + | return false | |||
| 514 | + | } | |||
| 501 | for _, step := range op.Steps { | 515 | for _, step := range op.Steps { | ||
| 502 | if strings.HasPrefix(step.ID, "service.") { | 516 | if strings.HasPrefix(step.ID, "service.") { | ||
| 503 | return false | 517 | return false | ||
| @@ -561,6 +575,12 @@ func toAppDeployResponse(app string, op *domain.AppOperation, includeDiagnostics | |||||
| 561 | resp.Op = op.Op | 575 | resp.Op = op.Op | ||
| 562 | resp.Revision = op.InputRevision | 576 | resp.Revision = op.InputRevision | ||
| 563 | resp.Outcome = op.Outcome | 577 | resp.Outcome = op.Outcome | ||
| 578 | + | // A non-terminal operation is still running; a terminal one reports | |||
| 579 | + | // its persisted outcome as the stable status. | |||
| 580 | + | resp.Status = dto.AppStatusRunning | |||
| 581 | + | if op.Terminal() { | |||
| 582 | + | resp.Status = op.Outcome | |||
| 583 | + | } | |||
| 564 | resp.Services = map[string]dto.AppServiceResultDTO{} | 584 | resp.Services = map[string]dto.AppServiceResultDTO{} | ||
| 565 | for _, step := range op.Steps { | 585 | for _, step := range op.Steps { | ||
| 566 | service, ok := strings.CutPrefix(step.ID, "service.") | 586 | service, ok := strings.CutPrefix(step.ID, "service.") | ||
M internal/adapters/in/http/admin/handler_apps_test.go +219 -0
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "net/http" | 8 | "net/http" | ||
| 9 | "net/http/httptest" | 9 | "net/http/httptest" | ||
| 10 | + | "sort" | |||
| 10 | "testing" | 11 | "testing" | ||
| 11 | "time" | 12 | "time" | ||
| 12 | 13 | ||||
| @@ -52,6 +53,32 @@ func appsRequest(t *testing.T, handler *Handler, method, target string, body any | |||||
| 52 | return rec | 53 | return rec | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 56 | + | // appsServerDo sends a request through a test server backed by the apps | |||
| 57 | + | // handler and returns the real HTTP status code and body. The scopes are | |||
| 58 | + | // injected by newScopedTestServer because the server, not the client, owns | |||
| 59 | + | // the request context. | |||
| 60 | + | func appsServerDo(t *testing.T, srv *httptest.Server, method, target string, body any) (int, []byte) { | |||
| 61 | + | t.Helper() | |||
| 62 | + | var reader io.Reader | |||
| 63 | + | if body != nil { | |||
| 64 | + | raw, err := json.Marshal(body) | |||
| 65 | + | require.NoError(t, err) | |||
| 66 | + | reader = bytes.NewReader(raw) | |||
| 67 | + | } | |||
| 68 | + | req, err := http.NewRequest(method, srv.URL+target, reader) | |||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | if method != http.MethodGet { | |||
| 71 | + | req.Header.Set("Idempotency-Key", "test-operation-key") | |||
| 72 | + | } | |||
| 73 | + | resp, err := srv.Client().Do(req) | |||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | defer func() { require.NoError(t, resp.Body.Close()) }() | |||
| 76 | + | assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) | |||
| 77 | + | raw, err := io.ReadAll(resp.Body) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | return resp.StatusCode, raw | |||
| 80 | + | } | |||
| 81 | + | ||||
| 55 | const validAppManifest = ` | 82 | const validAppManifest = ` | ||
| 56 | name = "blog" | 83 | name = "blog" | ||
| 57 | [[service]] | 84 | [[service]] | ||
| @@ -159,6 +186,198 @@ func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) { | |||||
| 159 | assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail) | 186 | assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail) | ||
| 160 | } | 187 | } | ||
| 161 | 188 | ||||
| 189 | + | // TestHandler_AppDeploy_RunningReturns202 proves a newly owned deploy whose | |||
| 190 | + | // effects are still executing answers 202 Accepted with the durable | |||
| 191 | + | // operation identity and a stable running status, so the client can recover | |||
| 192 | + | // completion through GET operations/by-key. The claim and its journal are | |||
| 193 | + | // already persisted when the handler answers. | |||
| 194 | + | func TestHandler_AppDeploy_RunningReturns202(t *testing.T) { | |||
| 195 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 196 | + | handler := appsTestHandler(t, appSvc) | |||
| 197 | + | ||||
| 198 | + | running := &domain.AppOperation{ | |||
| 199 | + | Op: "op-run", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 200 | + | Steps: []domain.AppOperationStep{ | |||
| 201 | + | {ID: "preflight", State: domain.AppStepSucceeded}, | |||
| 202 | + | {ID: "service.web.replace", State: domain.AppStepPending}, | |||
| 203 | + | }, | |||
| 204 | + | } | |||
| 205 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, nil).Once() | |||
| 206 | + | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |||
| 207 | + | ||||
| 208 | + | srv := newScopedTestServer(t, handler, "admin:apps:write") | |||
| 209 | + | status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{}) | |||
| 210 | + | require.Equal(t, http.StatusAccepted, status) | |||
| 211 | + | ||||
| 212 | + | var resp dto.AppDeployResponse | |||
| 213 | + | require.NoError(t, json.Unmarshal(body, &resp)) | |||
| 214 | + | assert.Equal(t, "op-run", resp.Op) | |||
| 215 | + | assert.Equal(t, "running", resp.Status) | |||
| 216 | + | assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome") | |||
| 217 | + | require.Contains(t, resp.Services, "web") | |||
| 218 | + | assert.Equal(t, "pending", resp.Services["web"].Result) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // TestHandler_AppDeploy_TerminalReplayReturnsOK proves a duplicate idempotency | |||
| 222 | + | // key whose stored operation already reached a successful terminal outcome | |||
| 223 | + | // replays with 200 and the stored status instead of 202. | |||
| 224 | + | func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) { | |||
| 225 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 226 | + | handler := appsTestHandler(t, appSvc) | |||
| 227 | + | ||||
| 228 | + | terminal := &domain.AppOperation{ | |||
| 229 | + | Op: "op-done", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 230 | + | Outcome: domain.AppOutcomeSuccess, | |||
| 231 | + | Steps: []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}}, | |||
| 232 | + | } | |||
| 233 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(terminal, nil).Once() | |||
| 234 | + | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |||
| 235 | + | ||||
| 236 | + | srv := newScopedTestServer(t, handler, "admin:apps:write") | |||
| 237 | + | status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{}) | |||
| 238 | + | require.Equal(t, http.StatusOK, status) | |||
| 239 | + | ||||
| 240 | + | var resp dto.AppDeployResponse | |||
| 241 | + | require.NoError(t, json.Unmarshal(body, &resp)) | |||
| 242 | + | assert.Equal(t, "op-done", resp.Op) | |||
| 243 | + | assert.Equal(t, "success", resp.Status) | |||
| 244 | + | assert.Equal(t, "success", resp.Outcome) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // TestHandler_AppDeploy_TerminalFailureReplayIsVisible proves a replay of a | |||
| 248 | + | // failed operation keeps its conflict mapping while still surfacing the | |||
| 249 | + | // stored failure and per-service result, never a 202. | |||
| 250 | + | func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) { | |||
| 251 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 252 | + | handler := appsTestHandler(t, appSvc) | |||
| 253 | + | ||||
| 254 | + | failed := &domain.AppOperation{ | |||
| 255 | + | Op: "op-fail", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 256 | + | Outcome: domain.AppOutcomeFailed, | |||
| 257 | + | Steps: []domain.AppOperationStep{{ | |||
| 258 | + | ID: "service.web.replace", State: domain.AppStepFailed, Error: "boom", | |||
| 259 | + | }}, | |||
| 260 | + | } | |||
| 261 | + | replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict) | |||
| 262 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(failed, replayErr).Once() | |||
| 263 | + | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |||
| 264 | + | ||||
| 265 | + | srv := newScopedTestServer(t, handler, "admin:apps:write") | |||
| 266 | + | status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{}) | |||
| 267 | + | require.Equal(t, http.StatusConflict, status) | |||
| 268 | + | ||||
| 269 | + | var resp dto.AppDeployResponse | |||
| 270 | + | require.NoError(t, json.Unmarshal(body, &resp)) | |||
| 271 | + | assert.Equal(t, "op-fail", resp.Op) | |||
| 272 | + | assert.Equal(t, "failed", resp.Status) | |||
| 273 | + | assert.Equal(t, "failed", resp.Outcome) | |||
| 274 | + | require.Contains(t, resp.Services, "web") | |||
| 275 | + | assert.Equal(t, "failed", resp.Services["web"].Result) | |||
| 276 | + | assert.Equal(t, "boom", resp.Services["web"].Error) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // assertRunningReplayJournal pins the replay contract shared by a running | |||
| 280 | + | // replay before and after any service step has started: 409, the operation | |||
| 281 | + | // journal DTO with a running status and no terminal outcome, and never the | |||
| 282 | + | // mapped preflight error envelope. The top-level key set is asserted so both | |||
| 283 | + | // cases are proven to share one wire shape. | |||
| 284 | + | func assertRunningReplayJournal(t *testing.T, status int, body []byte, opID, lastStep string) { | |||
| 285 | + | t.Helper() | |||
| 286 | + | require.Equal(t, http.StatusConflict, status) | |||
| 287 | + | ||||
| 288 | + | var shape map[string]json.RawMessage | |||
| 289 | + | require.NoError(t, json.Unmarshal(body, &shape)) | |||
| 290 | + | _, hasError := shape["error"] | |||
| 291 | + | assert.False(t, hasError, "a running replay must not degrade to the preflight error envelope") | |||
| 292 | + | keys := make([]string, 0, len(shape)) | |||
| 293 | + | for key := range shape { | |||
| 294 | + | keys = append(keys, key) | |||
| 295 | + | } | |||
| 296 | + | sort.Strings(keys) | |||
| 297 | + | assert.Equal(t, | |||
| 298 | + | []string{"app", "effective", "op", "outcome", "retained", "revision", "services", "status", "steps"}, | |||
| 299 | + | keys, "a running replay always returns the operation journal shape") | |||
| 300 | + | ||||
| 301 | + | var resp dto.AppDeployResponse | |||
| 302 | + | require.NoError(t, json.Unmarshal(body, &resp)) | |||
| 303 | + | assert.Equal(t, opID, resp.Op) | |||
| 304 | + | assert.Equal(t, "running", resp.Status) | |||
| 305 | + | assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome") | |||
| 306 | + | found := false | |||
| 307 | + | for _, step := range resp.Steps { | |||
| 308 | + | if step.ID == lastStep { | |||
| 309 | + | found = true | |||
| 310 | + | assert.Equal(t, domain.AppStepPending, step.State) | |||
| 311 | + | } | |||
| 312 | + | } | |||
| 313 | + | assert.True(t, found, "the journal must expose step %q", lastStep) | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // TestHandler_AppDeploy_RunningReplayBeforeServiceSteps proves a same-key | |||
| 317 | + | // replay of an operation still in flight answers 409 with the stored journal | |||
| 318 | + | // even before any service step has started. A non-terminal claim is a running | |||
| 319 | + | // journal, not a preflight failure, so the mapped error envelope never | |||
| 320 | + | // replaces it. | |||
| 321 | + | func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) { | |||
| 322 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 323 | + | handler := appsTestHandler(t, appSvc) | |||
| 324 | + | ||||
| 325 | + | running := &domain.AppOperation{ | |||
| 326 | + | Op: "op-run-pre", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 327 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}, | |||
| 328 | + | } | |||
| 329 | + | replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict) | |||
| 330 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once() | |||
| 331 | + | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |||
| 332 | + | ||||
| 333 | + | srv := newScopedTestServer(t, handler, "admin:apps:write") | |||
| 334 | + | status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{}) | |||
| 335 | + | ||||
| 336 | + | assertRunningReplayJournal(t, status, body, "op-run-pre", "preflight") | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | // TestHandler_AppDeploy_RunningReplayAfterServiceSteps is the after-steps | |||
| 340 | + | // twin: the same running replay, now with a pending service step, must answer | |||
| 341 | + | // the identical 409 journal shape. Only the journal contents may differ from | |||
| 342 | + | // the before-steps case, never the envelope. | |||
| 343 | + | func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) { | |||
| 344 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 345 | + | handler := appsTestHandler(t, appSvc) | |||
| 346 | + | ||||
| 347 | + | running := &domain.AppOperation{ | |||
| 348 | + | Op: "op-run-post", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 349 | + | Steps: []domain.AppOperationStep{ | |||
| 350 | + | {ID: "preflight", State: domain.AppStepSucceeded}, | |||
| 351 | + | {ID: "service.web.replace", State: domain.AppStepPending}, | |||
| 352 | + | }, | |||
| 353 | + | } | |||
| 354 | + | replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict) | |||
| 355 | + | appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", "test-operation-key").Return(running, replayErr).Once() | |||
| 356 | + | appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once() | |||
| 357 | + | ||||
| 358 | + | srv := newScopedTestServer(t, handler, "admin:apps:write") | |||
| 359 | + | status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{}) | |||
| 360 | + | ||||
| 361 | + | assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace") | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | // TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request | |||
| 365 | + | // validation contract: a deploy without a valid key never reaches the | |||
| 366 | + | // service. | |||
| 367 | + | func TestHandler_AppDeploy_RejectsMissingIdempotencyKey(t *testing.T) { | |||
| 368 | + | appSvc := inmocks.NewMockAppService(t) | |||
| 369 | + | handler := appsTestHandler(t, appSvc) | |||
| 370 | + | ||||
| 371 | + | srv := newScopedTestServer(t, handler, "admin:apps:write") | |||
| 372 | + | req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/apps/blog/deploy", nil) | |||
| 373 | + | require.NoError(t, err) | |||
| 374 | + | resp, err := srv.Client().Do(req) | |||
| 375 | + | require.NoError(t, err) | |||
| 376 | + | defer func() { require.NoError(t, resp.Body.Close()) }() | |||
| 377 | + | ||||
| 378 | + | require.Equal(t, http.StatusBadRequest, resp.StatusCode) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 162 | func TestHandler_AppList_RequiresScope(t *testing.T) { | 381 | func TestHandler_AppList_RequiresScope(t *testing.T) { | ||
| 163 | appSvc := inmocks.NewMockAppService(t) | 382 | appSvc := inmocks.NewMockAppService(t) | ||
| 164 | handler := appsTestHandler(t, appSvc) | 383 | handler := appsTestHandler(t, appSvc) | ||
A internal/app/app_deploy_handler_lifecycle_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/mock" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 18 | + | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |||
| 19 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 20 | + | "github.com/bnema/gordon/internal/domain" | |||
| 21 | + | "github.com/bnema/gordon/internal/usecase/apps" | |||
| 22 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // blockingHandlerDeployEngine implements the deployment-engine port the | |||
| 26 | + | // daemon-owned app service executes through. ExecuteDeploy reports the | |||
| 27 | + | // context it received and blocks until released, so request-cancellation | |||
| 28 | + | // independence is observable at the HTTP handler boundary. | |||
| 29 | + | type blockingHandlerDeployEngine struct { | |||
| 30 | + | started chan context.Context | |||
| 31 | + | release chan struct{} | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (e *blockingHandlerDeployEngine) StartDeploy(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 35 | + | op := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"} | |||
| 36 | + | return &deployment.StartDeployResult{ | |||
| 37 | + | Claim: deployment.DeployClaim{App: "blog", Op: "op-1", Service: "web", Revision: "rev-1", Journal: op}, | |||
| 38 | + | Owned: true, | |||
| 39 | + | }, nil | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (e *blockingHandlerDeployEngine) ExecuteDeploy(ctx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 43 | + | e.started <- ctx | |||
| 44 | + | <-e.release | |||
| 45 | + | return &deployment.DeployResult{Op: "op-1", App: "blog"}, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (e *blockingHandlerDeployEngine) AbandonDeploy(context.Context, deployment.DeployClaim) error { | |||
| 49 | + | return nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (e *blockingHandlerDeployEngine) Stop(context.Context, string, string) (*deployment.LifecycleResult, error) { | |||
| 53 | + | return nil, nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (e *blockingHandlerDeployEngine) Start(context.Context, string, string) (*deployment.LifecycleResult, error) { | |||
| 57 | + | return nil, nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (e *blockingHandlerDeployEngine) Restart(context.Context, string, string, string) (*deployment.LifecycleResult, error) { | |||
| 61 | + | return nil, nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (e *blockingHandlerDeployEngine) Remove(context.Context, string, string) (*deployment.LifecycleResult, error) { | |||
| 65 | + | return nil, nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // TestAppDeployHandler_RequestCancellationDoesNotAbortExecution drives the | |||
| 69 | + | // admin deploy handler over the real daemon-owned AppServiceImpl and proves | |||
| 70 | + | // the request boundary is inert: the handler answers 202 with the persisted | |||
| 71 | + | // running journal promptly, and cancelling the request context afterwards | |||
| 72 | + | // never reaches the in-flight replacement. The handler owns no goroutine; the | |||
| 73 | + | // execution derives from the daemon context. | |||
| 74 | + | func TestAppDeployHandler_RequestCancellationDoesNotAbortExecution(t *testing.T) { | |||
| 75 | + | store := outmocks.NewMockAppState(t) | |||
| 76 | + | running := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"} | |||
| 77 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(running, nil).Once() | |||
| 78 | + | // Show resolves no app: the response carries the journal alone. | |||
| 79 | + | store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once() | |||
| 80 | + | ||||
| 81 | + | release := make(chan struct{}) | |||
| 82 | + | var releaseOnce sync.Once | |||
| 83 | + | unblock := func() { releaseOnce.Do(func() { close(release) }) } | |||
| 84 | + | ||||
| 85 | + | engine := &blockingHandlerDeployEngine{started: make(chan context.Context, 1), release: release} | |||
| 86 | + | svc := apps.NewAppServiceImpl(store, engine, nil, zerowrap.Default()).WithDaemonContext(context.Background()) | |||
| 87 | + | t.Cleanup(func() { | |||
| 88 | + | unblock() | |||
| 89 | + | _ = svc.Shutdown(context.Background()) | |||
| 90 | + | }) | |||
| 91 | + | ||||
| 92 | + | handler := admin.NewHandler(admin.HandlerDeps{Log: zerowrap.Default(), AppSvc: svc}) | |||
| 93 | + | ||||
| 94 | + | reqCtx, cancelRequest := context.WithCancel( | |||
| 95 | + | context.WithValue(context.Background(), domain.ContextKeyScopes, []string{"admin:apps:write"})) | |||
| 96 | + | defer cancelRequest() | |||
| 97 | + | req := httptest.NewRequest(http.MethodPost, "/admin/apps/blog/deploy", nil) | |||
| 98 | + | req.Header.Set("Idempotency-Key", "key-1") | |||
| 99 | + | req = req.WithContext(reqCtx) | |||
| 100 | + | rec := httptest.NewRecorder() | |||
| 101 | + | ||||
| 102 | + | serveDone := make(chan struct{}) | |||
| 103 | + | go func() { | |||
| 104 | + | defer close(serveDone) | |||
| 105 | + | handler.ServeHTTP(rec, req) | |||
| 106 | + | }() | |||
| 107 | + | ||||
| 108 | + | var execCtx context.Context | |||
| 109 | + | select { | |||
| 110 | + | case execCtx = <-engine.started: | |||
| 111 | + | case <-time.After(3 * time.Second): | |||
| 112 | + | t.Fatal("background deploy execution never started") | |||
| 113 | + | } | |||
| 114 | + | select { | |||
| 115 | + | case <-serveDone: | |||
| 116 | + | case <-time.After(3 * time.Second): | |||
| 117 | + | t.Fatal("handler did not answer promptly") | |||
| 118 | + | } | |||
| 119 | + | require.Equal(t, http.StatusAccepted, rec.Code) | |||
| 120 | + | // The persisted journal is what the handler answers with: the client can | |||
| 121 | + | // poll the same op through operations/by-key. | |||
| 122 | + | var resp dto.AppDeployResponse | |||
| 123 | + | require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) | |||
| 124 | + | assert.Equal(t, "op-1", resp.Op) | |||
| 125 | + | assert.Equal(t, dto.AppStatusRunning, resp.Status) | |||
| 126 | + | ||||
| 127 | + | // The request is over and its context is cancelled: the daemon-owned | |||
| 128 | + | // execution must be unaffected. | |||
| 129 | + | cancelRequest() | |||
| 130 | + | select { | |||
| 131 | + | case <-execCtx.Done(): | |||
| 132 | + | t.Fatal("request cancellation aborted the daemon-owned execution") | |||
| 133 | + | case <-time.After(50 * time.Millisecond): | |||
| 134 | + | } | |||
| 135 | + | } | |||
M internal/app/kernel.go +33 -5
| @@ -29,7 +29,12 @@ type Kernel struct { | |||||
| 29 | volumeSvc in.VolumeService | 29 | volumeSvc in.VolumeService | ||
| 30 | publicTLSSvc in.PublicTLSService | 30 | publicTLSSvc in.PublicTLSService | ||
| 31 | appSvc in.AppService | 31 | appSvc in.AppService | ||
| 32 | - | cleanup func() | 32 | + | // appAdmin is the daemon-owned app lifecycle (when the full wiring is |
| 33 | + | // available). Close cancels and joins its in-flight background deploy | |||
| 34 | + | // executions before any other kernel resource is torn down. | |||
| 35 | + | appAdmin appAdministration | |||
| 36 | + | log zerowrap.Logger | |||
| 37 | + | cleanup func() | |||
| 33 | } | 38 | } | ||
| 34 | 39 | ||||
| 35 | // NewKernel initializes local services without starting server listeners. | 40 | // NewKernel initializes local services without starting server listeners. | ||
| @@ -79,7 +84,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 79 | cleanup() | 84 | cleanup() | ||
| 80 | } | 85 | } | ||
| 81 | 86 | ||||
| 82 | - | return &Kernel{ | 87 | + | kernel := &Kernel{ |
| 83 | authEnabled: cfg.Auth.Enabled, | 88 | authEnabled: cfg.Auth.Enabled, | ||
| 84 | configSvc: svc.configSvc, | 89 | configSvc: svc.configSvc, | ||
| 85 | secretSvc: svc.secretSvc, | 90 | secretSvc: svc.secretSvc, | ||
| @@ -92,8 +97,15 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 92 | volumeSvc: svc.volumeSvc, | 97 | volumeSvc: svc.volumeSvc, | ||
| 93 | publicTLSSvc: svc.publicTLSSvc, | 98 | publicTLSSvc: svc.publicTLSSvc, | ||
| 94 | appSvc: svc.appSvc, | 99 | appSvc: svc.appSvc, | ||
| 100 | + | log: log, | |||
| 95 | cleanup: wrappedCleanup, | 101 | cleanup: wrappedCleanup, | ||
| 96 | - | }, 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 | |||
| 97 | } else { | 109 | } else { | ||
| 98 | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | 110 | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | ||
| 99 | } | 111 | } | ||
| @@ -116,6 +128,7 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 116 | authEnabled: cfg.Auth.Enabled, | 128 | authEnabled: cfg.Auth.Enabled, | ||
| 117 | configSvc: configSvc, | 129 | configSvc: configSvc, | ||
| 118 | secretSvc: secretSvc, | 130 | secretSvc: secretSvc, | ||
| 131 | + | log: log, | |||
| 119 | cleanup: cleanup, | 132 | cleanup: cleanup, | ||
| 120 | }, nil | 133 | }, nil | ||
| 121 | } | 134 | } | ||
| @@ -124,11 +137,26 @@ func quietInitLogger(Config) (zerowrap.Logger, func(), error) { | |||||
| 124 | return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil | 137 | return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard}), func() {}, nil | ||
| 125 | } | 138 | } | ||
| 126 | 139 | ||||
| 140 | + | // Close tears the kernel down. It first cancels and joins daemon-owned app | |||
| 141 | + | // administration on a bounded context, so a background deploy execution is | |||
| 142 | + | // never torn down mid-flight alongside the state and runtime it uses. When | |||
| 143 | + | // that quiescence times out the remaining cleanup is skipped and the error is | |||
| 144 | + | // returned: an unfinished execution may still be writing to state. | |||
| 127 | func (k *Kernel) Close() error { | 145 | func (k *Kernel) Close() error { | ||
| 128 | - | if k == nil || k.cleanup == nil { | 146 | + | if k == nil { |
| 129 | return nil | 147 | return nil | ||
| 130 | } | 148 | } | ||
| 131 | - | k.cleanup() | 149 | + | if k.appAdmin != nil { |
| 150 | + | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |||
| 151 | + | err := quiesceAppAdministration(ctx, k.appAdmin, k.log) | |||
| 152 | + | cancel() | |||
| 153 | + | if err != nil { | |||
| 154 | + | return err | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | if k.cleanup != nil { | |||
| 158 | + | k.cleanup() | |||
| 159 | + | } | |||
| 132 | return nil | 160 | return nil | ||
| 133 | } | 161 | } | ||
| 134 | 162 | ||||
M internal/app/kernel_test.go +56 -0
| @@ -1,11 +1,14 @@ | |||||
| 1 | package app | 1 | package app | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | "os" | 6 | "os" | ||
| 6 | "path/filepath" | 7 | "path/filepath" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 9 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 13 | ) | ||
| 11 | 14 | ||||
| @@ -36,3 +39,56 @@ secrets_backend = "unsafe" | |||||
| 36 | require.NotNil(t, kernel.Config()) | 39 | require.NotNil(t, kernel.Config()) | ||
| 37 | require.NotNil(t, kernel.Secrets()) | 40 | require.NotNil(t, kernel.Secrets()) | ||
| 38 | } | 41 | } | ||
| 42 | + | ||||
| 43 | + | // TestKernelClose_QuiescenceTimeoutSkipsCleanup proves the fail-closed kernel | |||
| 44 | + | // Close ordering: daemon-owned app administration is cancelled and joined on | |||
| 45 | + | // a bounded context before any remaining kernel resource is torn down, and | |||
| 46 | + | // when that quiescence times out the dependency teardown is skipped and the | |||
| 47 | + | // error is returned, so an unfinished deploy is never dropped onto a closed | |||
| 48 | + | // state or runtime. | |||
| 49 | + | func TestKernelClose_QuiescenceTimeoutSkipsCleanup(t *testing.T) { | |||
| 50 | + | t.Parallel() | |||
| 51 | + | ||||
| 52 | + | order := []string{} | |||
| 53 | + | admin := &orderRecordingAdmin{order: &order, err: errors.New("in-flight deploy did not unwind")} | |||
| 54 | + | cleanedUp := false | |||
| 55 | + | kernel := &Kernel{ | |||
| 56 | + | appAdmin: admin, | |||
| 57 | + | log: zerowrap.Default(), | |||
| 58 | + | cleanup: func() { cleanedUp = true; order = append(order, "cleanup") }, | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | require.Error(t, kernel.Close()) | |||
| 62 | + | require.Equal(t, []string{"app-shutdown"}, order, "teardown must stop when quiescence times out") | |||
| 63 | + | assert.False(t, cleanedUp) | |||
| 64 | + | assert.True(t, admin.deadline, "app administration shutdown must run on a bounded context") | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // TestKernelClose_CleansUpAfterQuiescence keeps the normal path: once app | |||
| 68 | + | // administration joined cleanly, the kernel still tears its resources down. | |||
| 69 | + | func TestKernelClose_CleansUpAfterQuiescence(t *testing.T) { | |||
| 70 | + | t.Parallel() | |||
| 71 | + | ||||
| 72 | + | order := []string{} | |||
| 73 | + | admin := &orderRecordingAdmin{order: &order} | |||
| 74 | + | kernel := &Kernel{ | |||
| 75 | + | appAdmin: admin, | |||
| 76 | + | log: zerowrap.Default(), | |||
| 77 | + | cleanup: func() { order = append(order, "cleanup") }, | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | require.NoError(t, kernel.Close()) | |||
| 81 | + | require.Equal(t, []string{"app-shutdown", "cleanup"}, order) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // TestKernelClose_WithoutAppAdministrationStillCleansUp keeps the minimal | |||
| 85 | + | // kernel path (no full service wiring) closing without a nil-interface trap. | |||
| 86 | + | func TestKernelClose_WithoutAppAdministrationStillCleansUp(t *testing.T) { | |||
| 87 | + | t.Parallel() | |||
| 88 | + | ||||
| 89 | + | cleaned := false | |||
| 90 | + | kernel := &Kernel{log: zerowrap.Default(), cleanup: func() { cleaned = true }} | |||
| 91 | + | ||||
| 92 | + | require.NoError(t, kernel.Close()) | |||
| 93 | + | assert.True(t, cleaned) | |||
| 94 | + | } | |||
M internal/app/run.go +79 -31
| @@ -839,6 +839,28 @@ func containerResourceLimits(cfg Config) (deployment.ResourceLimits, error) { | |||||
| 839 | return limits, nil | 839 | return limits, nil | ||
| 840 | } | 840 | } | ||
| 841 | 841 | ||||
| 842 | + | // appDeployEngine is the deployment-engine subset the daemon-owned app | |||
| 843 | + | // service executes through. It mirrors the usecase's own port so the | |||
| 844 | + | // composition root stays decoupled from engine internals. | |||
| 845 | + | type appDeployEngine interface { | |||
| 846 | + | StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) | |||
| 847 | + | AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error | |||
| 848 | + | ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) | |||
| 849 | + | Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | |||
| 850 | + | Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | |||
| 851 | + | Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error) | |||
| 852 | + | Remove(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | |||
| 853 | + | } | |||
| 854 | + | ||||
| 855 | + | // newAppDaemonService builds the daemon-owned app administration service. | |||
| 856 | + | // ctx is the daemon/supervisor lifecycle context: background deploy | |||
| 857 | + | // executions derive from it and are cancelled by Shutdown during graceful | |||
| 858 | + | // teardown. It is deliberately never an HTTP request context, so request | |||
| 859 | + | // cancellation cannot abort an in-flight replacement. | |||
| 860 | + | func newAppDaemonService(ctx context.Context, store out.AppState, deploy appDeployEngine, secrets out.SecretWriter, log zerowrap.Logger) *apps.AppServiceImpl { | |||
| 861 | + | return apps.NewAppServiceImpl(store, deploy, secrets, log).WithDaemonContext(ctx) | |||
| 862 | + | } | |||
| 863 | + | ||||
| 842 | // initApps wires the single v2.50 app engine: bbolt state, image digests, | 864 | // initApps wires the single v2.50 app engine: bbolt state, image digests, | ||
| 843 | // pass-backed secrets, deployment/lifecycle execution, and traffic | 865 | // pass-backed secrets, deployment/lifecycle execution, and traffic | ||
| 844 | // activation. The daemon is the sole app-state writer; CLI reaches the | 866 | // activation. The daemon is the sole app-state writer; CLI reaches the | ||
| @@ -909,7 +931,7 @@ func (si *serviceInit) initApps() error { | |||||
| 909 | return err | 931 | return err | ||
| 910 | } | 932 | } | ||
| 911 | si.svc.appDeploySvc.SetBindPolicies(mountPolicies) | 933 | si.svc.appDeploySvc.SetBindPolicies(mountPolicies) | ||
| 912 | - | appSvcImpl := apps.NewAppServiceImpl(store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log). | 934 | + | appSvcImpl := newAppDaemonService(si.ctx, store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log). |
| 913 | WithEntrypoints(appEntrypointListeners(si.cfg)). | 935 | WithEntrypoints(appEntrypointListeners(si.cfg)). | ||
| 914 | WithGCBarrier(si.svc.gcBarrier). | 936 | WithGCBarrier(si.svc.gcBarrier). | ||
| 915 | WithImagePolicy(imagePolicy). | 937 | WithImagePolicy(imagePolicy). | ||
| @@ -3085,8 +3107,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 3085 | // so no new mutation can begin during shutdown. | 3107 | // so no new mutation can begin during shutdown. | ||
| 3086 | localAdmin.Close() | 3108 | localAdmin.Close() | ||
| 3087 | registrySrvs := []*http.Server{registrySrv, internalRegistrySrv} | 3109 | registrySrvs := []*http.Server{registrySrv, internalRegistrySrv} | ||
| 3088 | - | gracefulShutdown(registrySrvs, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, svc.appMonitor, svc.appState, log) | 3110 | + | return gracefulShutdown(registrySrvs, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, svc.appMonitor, svc.appSvcImpl, svc.appState, log) |
| 3089 | - | return nil | |||
| 3090 | } | 3111 | } | ||
| 3091 | 3112 | ||||
| 3092 | // reconcileAppsAtBoot reconciles declarative apps intended to run, | 3113 | // reconcileAppsAtBoot reconciles declarative apps intended to run, | ||
| @@ -3401,9 +3422,35 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan <-cha | |||||
| 3401 | } | 3422 | } | ||
| 3402 | } | 3423 | } | ||
| 3403 | 3424 | ||||
| 3425 | + | // appAdministration is the daemon-owned application lifecycle torn down | |||
| 3426 | + | // during graceful shutdown: it refuses new background deploys, cancels | |||
| 3427 | + | // in-flight executions on the daemon context, and joins them. | |||
| 3428 | + | type appAdministration interface { | |||
| 3429 | + | Shutdown(context.Context) error | |||
| 3430 | + | } | |||
| 3431 | + | ||||
| 3432 | + | // quiesceAppAdministration cancels and joins daemon-owned app work on the | |||
| 3433 | + | // bounded shutdown context. A non-nil error means the bound expired while an | |||
| 3434 | + | // execution was still unwinding: the caller must stop tearing down the state | |||
| 3435 | + | // and runtime that execution still uses. | |||
| 3436 | + | func quiesceAppAdministration(ctx context.Context, appAdmin appAdministration, log zerowrap.Logger) error { | |||
| 3437 | + | if appAdmin == nil { | |||
| 3438 | + | return nil | |||
| 3439 | + | } | |||
| 3440 | + | if err := appAdmin.Shutdown(ctx); err != nil { | |||
| 3441 | + | log.Error().Err(err).Msg("app administration did not quiesce before the shutdown deadline") | |||
| 3442 | + | return err | |||
| 3443 | + | } | |||
| 3444 | + | return nil | |||
| 3445 | + | } | |||
| 3446 | + | ||||
| 3404 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | 3447 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | ||
| 3405 | - | // the container service and cleans up runtime files. | 3448 | + | // the container service and cleans up runtime files. It returns an error when |
| 3406 | - | func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, monitor *appMonitor, appState out.AppState, log zerowrap.Logger) { | 3449 | + | // app administration could not quiesce: in that case the remaining teardown |
| 3450 | + | // is skipped and the error is propagated to the process entry point, which | |||
| 3451 | + | // exits non-zero instead of closing state or runtime under an in-flight | |||
| 3452 | + | // ExecuteDeploy. | |||
| 3453 | + | func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, monitor *appMonitor, appAdmin appAdministration, appState out.AppState, log zerowrap.Logger) error { | |||
| 3407 | log.Info().Msg("shutting down Gordon...") | 3454 | log.Info().Msg("shutting down Gordon...") | ||
| 3408 | 3455 | ||||
| 3409 | // Phase 0: stop periodic app recovery first and wait for any in-flight | 3456 | // Phase 0: stop periodic app recovery first and wait for any in-flight | ||
| @@ -3414,16 +3461,20 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server | |||||
| 3414 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | 3461 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| 3415 | defer shutdownCancel() | 3462 | defer shutdownCancel() | ||
| 3416 | 3463 | ||||
| 3417 | - | // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted | 3464 | + | // Phase 0.5: quiesce daemon-owned app administration. New background |
| 3418 | - | for _, srv := range []*http.Server{tlsSrv, proxySrv} { | 3465 | + | // deploys are refused and in-flight executions are cancelled on the |
| 3419 | - | if srv == nil { | 3466 | + | // daemon context and joined here — before traffic, runtime, or the app |
| 3420 | - | continue | 3467 | + | // state store is torn down, so no execution can write to closed state. |
| 3421 | - | } | 3468 | + | // A timeout is fail-closed: the unfinished execution still owns the |
| 3422 | - | if err := srv.Shutdown(shutdownCtx); err != nil { | 3469 | + | // state and runtime it uses, so teardown stops here and the error makes |
| 3423 | - | log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error") | 3470 | + | // the process exit non-zero rather than close resources underneath it. |
| 3424 | - | } | 3471 | + | if err := quiesceAppAdministration(shutdownCtx, appAdmin, log); err != nil { |
| 3472 | + | return fmt.Errorf("app administration quiescence: %w", err) | |||
| 3425 | } | 3473 | } | ||
| 3426 | 3474 | ||||
| 3475 | + | // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted | |||
| 3476 | + | shutdownHTTPServers(shutdownCtx, log, tlsSrv, proxySrv) | |||
| 3477 | + | ||||
| 3427 | if trafficManager != nil { | 3478 | if trafficManager != nil { | ||
| 3428 | if err := trafficManager.Shutdown(shutdownCtx); err != nil { | 3479 | if err := trafficManager.Shutdown(shutdownCtx); err != nil { | ||
| 3429 | log.Warn().Err(err).Msg("traffic manager shutdown error") | 3480 | log.Warn().Err(err).Msg("traffic manager shutdown error") | ||
| @@ -3451,14 +3502,7 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server | |||||
| 3451 | } | 3502 | } | ||
| 3452 | 3503 | ||||
| 3453 | // Phase 3: Stop the registry backends. | 3504 | // Phase 3: Stop the registry backends. | ||
| 3454 | - | for _, registrySrv := range registrySrvs { | 3505 | + | shutdownHTTPServers(shutdownCtx, log, registrySrvs...) |
| 3455 | - | if registrySrv == nil { | |||
| 3456 | - | continue | |||
| 3457 | - | } | |||
| 3458 | - | if err := registrySrv.Shutdown(shutdownCtx); err != nil { | |||
| 3459 | - | log.Warn().Err(err).Str("addr", registrySrv.Addr).Msg("server shutdown error") | |||
| 3460 | - | } | |||
| 3461 | - | } | |||
| 3462 | 3506 | ||||
| 3463 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | 3507 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | ||
| 3464 | log.Warn().Err(err).Msg("error during container shutdown") | 3508 | log.Warn().Err(err).Msg("error during container shutdown") | ||
| @@ -3467,6 +3511,20 @@ func gracefulShutdown(registrySrvs []*http.Server, proxySrv, tlsSrv *http.Server | |||||
| 3467 | 3511 | ||||
| 3468 | cleanupInternalCredentials() | 3512 | cleanupInternalCredentials() | ||
| 3469 | log.Info().Msg("Gordon stopped") | 3513 | log.Info().Msg("Gordon stopped") | ||
| 3514 | + | return nil | |||
| 3515 | + | } | |||
| 3516 | + | ||||
| 3517 | + | // shutdownHTTPServers gracefully stops each non-nil server on the shutdown | |||
| 3518 | + | // context, logging per-server failures so one listener cannot mask another. | |||
| 3519 | + | func shutdownHTTPServers(ctx context.Context, log zerowrap.Logger, servers ...*http.Server) { | |||
| 3520 | + | for _, srv := range servers { | |||
| 3521 | + | if srv == nil { | |||
| 3522 | + | continue | |||
| 3523 | + | } | |||
| 3524 | + | if err := srv.Shutdown(ctx); err != nil { | |||
| 3525 | + | log.Warn().Err(err).Str("addr", srv.Addr).Msg("server shutdown error") | |||
| 3526 | + | } | |||
| 3527 | + | } | |||
| 3470 | } | 3528 | } | ||
| 3471 | 3529 | ||||
| 3472 | func closeAppState(state out.AppState, log zerowrap.Logger) { | 3530 | func closeAppState(state out.AppState, log zerowrap.Logger) { | ||
| @@ -4096,7 +4154,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 4096 | v.SetDefault("volumes.auto_create", true) | 4154 | v.SetDefault("volumes.auto_create", true) | ||
| 4097 | v.SetDefault("volumes.prefix", "gordon") | 4155 | v.SetDefault("volumes.prefix", "gordon") | ||
| 4098 | v.SetDefault("volumes.preserve", true) | 4156 | v.SetDefault("volumes.preserve", true) | ||
| 4099 | - | v.SetDefault("deploy.pull_policy", "if-tag-changed") | |||
| 4100 | v.SetDefault("backups.databases.enabled", false) | 4157 | v.SetDefault("backups.databases.enabled", false) | ||
| 4101 | v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily)) | 4158 | v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily)) | ||
| 4102 | v.SetDefault("backups.databases.storage_dir", "") | 4159 | v.SetDefault("backups.databases.storage_dir", "") | ||
| @@ -4136,15 +4193,6 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 4136 | v.SetDefault("server.registry_allowed_ips", []string{}) | 4193 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 4137 | v.SetDefault("server.proxy_allowed_ips", []string{}) | 4194 | v.SetDefault("server.proxy_allowed_ips", []string{}) | ||
| 4138 | v.SetDefault("server.registry_listen_address", "") | 4195 | v.SetDefault("server.registry_listen_address", "") | ||
| 4139 | - | v.SetDefault("deploy.readiness_delay", "5s") | |||
| 4140 | - | v.SetDefault("deploy.readiness_mode", "auto") | |||
| 4141 | - | v.SetDefault("deploy.health_timeout", "90s") | |||
| 4142 | - | v.SetDefault("deploy.stabilization_delay", "2s") | |||
| 4143 | - | v.SetDefault("deploy.tcp_probe_timeout", "30s") | |||
| 4144 | - | v.SetDefault("deploy.http_probe_timeout", "60s") | |||
| 4145 | - | v.SetDefault("deploy.attachment_readiness_timeout", "30s") | |||
| 4146 | - | v.SetDefault("deploy.drain_mode", "auto") | |||
| 4147 | - | v.SetDefault("deploy.drain_timeout", "30s") | |||
| 4148 | 4196 | ||||
| 4149 | ConfigureViper(v, configPath) | 4197 | ConfigureViper(v, configPath) | ||
| 4150 | 4198 | ||||
A internal/app/run_app_lifecycle_test.go +172 -0
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | "github.com/bnema/gordon/internal/usecase/container" | |||
| 17 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // daemonCtxKey tags the daemon/supervisor lifecycle context so a test can | |||
| 21 | + | // prove the execution context descends from it and not from a request. | |||
| 22 | + | type daemonCtxKey struct{} | |||
| 23 | + | ||||
| 24 | + | // fakeAppDeployEngine implements the deployment-engine port used by | |||
| 25 | + | // newAppDaemonService. ExecuteDeploy reports the context it received and | |||
| 26 | + | // blocks until that context ends, so cancellation is observable. | |||
| 27 | + | type fakeAppDeployEngine struct { | |||
| 28 | + | executeStarted chan context.Context | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (f *fakeAppDeployEngine) StartDeploy(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 32 | + | return &deployment.StartDeployResult{ | |||
| 33 | + | Owned: true, | |||
| 34 | + | Claim: deployment.DeployClaim{ | |||
| 35 | + | App: "blog", Op: "op-1", Service: "web", Revision: "rev-1", | |||
| 36 | + | Journal: domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}, | |||
| 37 | + | }, | |||
| 38 | + | }, nil | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (f *fakeAppDeployEngine) ExecuteDeploy(ctx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 42 | + | f.executeStarted <- ctx | |||
| 43 | + | <-ctx.Done() | |||
| 44 | + | return nil, ctx.Err() | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (f *fakeAppDeployEngine) AbandonDeploy(context.Context, deployment.DeployClaim) error { | |||
| 48 | + | return nil | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func (f *fakeAppDeployEngine) Stop(context.Context, string, string) (*deployment.LifecycleResult, error) { | |||
| 52 | + | return nil, nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func (f *fakeAppDeployEngine) Start(context.Context, string, string) (*deployment.LifecycleResult, error) { | |||
| 56 | + | return nil, nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (f *fakeAppDeployEngine) Restart(context.Context, string, string, string) (*deployment.LifecycleResult, error) { | |||
| 60 | + | return nil, nil | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func (f *fakeAppDeployEngine) Remove(context.Context, string, string) (*deployment.LifecycleResult, error) { | |||
| 64 | + | return nil, nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy proves the | |||
| 68 | + | // bootstrap wiring used by initApps: the app service derives background | |||
| 69 | + | // executions from the daemon/supervisor context, that context — not the | |||
| 70 | + | // request context — owns the in-flight replacement, and daemon cancellation | |||
| 71 | + | // reaches it. | |||
| 72 | + | func TestNewAppDaemonService_DaemonCancellationReachesInFlightDeploy(t *testing.T) { | |||
| 73 | + | store := outmocks.NewMockAppState(t) | |||
| 74 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1"). | |||
| 75 | + | Return(domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1"}, nil).Maybe() | |||
| 76 | + | ||||
| 77 | + | engine := &fakeAppDeployEngine{executeStarted: make(chan context.Context, 1)} | |||
| 78 | + | daemonCtx, cancelDaemon := context.WithCancel(context.WithValue(context.Background(), daemonCtxKey{}, "daemon")) | |||
| 79 | + | svc := newAppDaemonService(daemonCtx, store, engine, nil, zerowrap.Default()) | |||
| 80 | + | t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) }) | |||
| 81 | + | ||||
| 82 | + | requestCtx, cancelRequest := context.WithCancel(context.Background()) | |||
| 83 | + | defer cancelRequest() | |||
| 84 | + | ||||
| 85 | + | deployDone := make(chan struct{}) | |||
| 86 | + | go func() { | |||
| 87 | + | defer close(deployDone) | |||
| 88 | + | _, _ = svc.Deploy(requestCtx, "blog", "rev-1", "web", "key-1") | |||
| 89 | + | }() | |||
| 90 | + | ||||
| 91 | + | var execCtx context.Context | |||
| 92 | + | select { | |||
| 93 | + | case execCtx = <-engine.executeStarted: | |||
| 94 | + | case <-time.After(3 * time.Second): | |||
| 95 | + | t.Fatal("background deploy execution never started") | |||
| 96 | + | } | |||
| 97 | + | assert.Equal(t, "daemon", execCtx.Value(daemonCtxKey{}), "execution must run on the daemon context") | |||
| 98 | + | ||||
| 99 | + | // The request ending is unrelated to the daemon-owned execution. | |||
| 100 | + | cancelRequest() | |||
| 101 | + | select { | |||
| 102 | + | case <-execCtx.Done(): | |||
| 103 | + | t.Fatal("request cancellation aborted the daemon-owned execution") | |||
| 104 | + | case <-time.After(50 * time.Millisecond): | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | cancelDaemon() | |||
| 108 | + | select { | |||
| 109 | + | case <-execCtx.Done(): | |||
| 110 | + | case <-time.After(3 * time.Second): | |||
| 111 | + | t.Fatal("daemon context cancellation did not reach the in-flight execution") | |||
| 112 | + | } | |||
| 113 | + | <-deployDone | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // orderRecordingAdmin records when app administration quiescence runs and | |||
| 117 | + | // on which context, so shutdown ordering is observable. | |||
| 118 | + | type orderRecordingAdmin struct { | |||
| 119 | + | order *[]string | |||
| 120 | + | deadline bool | |||
| 121 | + | err error | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (a *orderRecordingAdmin) Shutdown(ctx context.Context) error { | |||
| 125 | + | *a.order = append(*a.order, "app-shutdown") | |||
| 126 | + | _, a.deadline = ctx.Deadline() | |||
| 127 | + | return a.err | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce proves | |||
| 131 | + | // the fail-closed shutdown ordering: when app administration cannot unwind on | |||
| 132 | + | // the bounded shutdown context, the remaining state and runtime teardown is | |||
| 133 | + | // skipped and the error is returned for the process to exit non-zero, so an | |||
| 134 | + | // in-flight execution can never write to a closed store. | |||
| 135 | + | func TestGracefulShutdown_FailsClosedWhenAppAdministrationDoesNotQuiesce(t *testing.T) { | |||
| 136 | + | // Keep internal-credential cleanup inside the test temp dir. | |||
| 137 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 138 | + | ||||
| 139 | + | order := []string{} | |||
| 140 | + | admin := &orderRecordingAdmin{order: &order, err: errors.New("in-flight execution did not unwind")} | |||
| 141 | + | ||||
| 142 | + | store := outmocks.NewMockAppState(t) | |||
| 143 | + | ||||
| 144 | + | containerSvc := container.NewService(nil, nil, nil, nil, container.Config{}) | |||
| 145 | + | err := gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default()) | |||
| 146 | + | ||||
| 147 | + | require.Error(t, err) | |||
| 148 | + | assert.Contains(t, err.Error(), "quiescence") | |||
| 149 | + | require.Equal(t, []string{"app-shutdown"}, order, "state teardown must not run under an unfinished execution") | |||
| 150 | + | store.AssertNotCalled(t, "Close") | |||
| 151 | + | assert.True(t, admin.deadline, "app administration shutdown must run on the bounded shutdown context") | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState keeps the | |||
| 155 | + | // normal ordering: app administration is cancelled and joined on the bounded | |||
| 156 | + | // shutdown context before the app state store closes. | |||
| 157 | + | func TestGracefulShutdown_QuiescesAppAdministrationBeforeClosingState(t *testing.T) { | |||
| 158 | + | // Keep internal-credential cleanup inside the test temp dir. | |||
| 159 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 160 | + | ||||
| 161 | + | order := []string{} | |||
| 162 | + | admin := &orderRecordingAdmin{order: &order} | |||
| 163 | + | ||||
| 164 | + | store := outmocks.NewMockAppState(t) | |||
| 165 | + | store.EXPECT().Close().Run(func() { order = append(order, "state-close") }).Return(nil) | |||
| 166 | + | ||||
| 167 | + | containerSvc := container.NewService(nil, nil, nil, nil, container.Config{}) | |||
| 168 | + | require.NoError(t, gracefulShutdown(nil, nil, nil, containerSvc, nil, nil, nil, nil, nil, admin, store, zerowrap.Default())) | |||
| 169 | + | ||||
| 170 | + | require.Equal(t, []string{"app-shutdown", "state-close"}, order) | |||
| 171 | + | assert.True(t, admin.deadline, "app administration shutdown must run on the bounded shutdown context") | |||
| 172 | + | } | |||
M internal/usecase/apps/app_service.go +156 -12
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "sort" | 6 | "sort" | ||
| 7 | + | "sync" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/zerowrap" | 9 | "github.com/bnema/zerowrap" | ||
| 9 | 10 | ||||
| @@ -24,11 +25,28 @@ type AppServiceImpl struct { | |||||
| 24 | // core is the single configured apps service. It is built once and | 25 | // core is the single configured apps service. It is built once and | ||
| 25 | // reconfigured in place, never reconstructed per request. | 26 | // reconfigured in place, never reconstructed per request. | ||
| 26 | core *Service | 27 | core *Service | ||
| 28 | + | ||||
| 29 | + | // lifecycleMu guards the daemon lifecycle. daemonCtx is the | |||
| 30 | + | // daemon-owned parent for background executions: it is never a request | |||
| 31 | + | // context, so request cancellation cannot abort an in-flight deploy. | |||
| 32 | + | // stopped gates new executions once Shutdown runs, handoffs tracks the | |||
| 33 | + | // Deploy request hand-offs (claim through scheduling or settling) so | |||
| 34 | + | // Shutdown never closes state under a claim it did not wait for, and | |||
| 35 | + | // executions tracks the in-flight background executions so Shutdown can | |||
| 36 | + | // wait for them. | |||
| 37 | + | lifecycleMu sync.Mutex | |||
| 38 | + | daemonCtx context.Context | |||
| 39 | + | daemonStop context.CancelFunc | |||
| 40 | + | stopped bool | |||
| 41 | + | handoffs sync.WaitGroup | |||
| 42 | + | executions sync.WaitGroup | |||
| 27 | } | 43 | } | ||
| 28 | 44 | ||||
| 29 | // deployEngine is the subset of the deployment engine the app service needs. | 45 | // deployEngine is the subset of the deployment engine the app service needs. | ||
| 30 | type deployEngine interface { | 46 | type deployEngine interface { | ||
| 31 | - | Deploy(ctx context.Context, input deployment.DeployInput) (*deployment.DeployResult, error) | 47 | + | StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) |
| 48 | + | AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error | |||
| 49 | + | ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) | |||
| 32 | Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | 50 | Stop(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | ||
| 33 | Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | 51 | Start(ctx context.Context, app, opID string) (*deployment.LifecycleResult, error) | ||
| 34 | Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error) | 52 | Restart(ctx context.Context, app, service, opID string) (*deployment.LifecycleResult, error) | ||
| @@ -37,13 +55,104 @@ type deployEngine interface { | |||||
| 37 | 55 | ||||
| 38 | // NewAppServiceImpl wires the driving-port implementation. | 56 | // NewAppServiceImpl wires the driving-port implementation. | ||
| 39 | func NewAppServiceImpl(store out.AppState, deploy deployEngine, secrets out.SecretWriter, log zerowrap.Logger) *AppServiceImpl { | 57 | func NewAppServiceImpl(store out.AppState, deploy deployEngine, secrets out.SecretWriter, log zerowrap.Logger) *AppServiceImpl { | ||
| 58 | + | daemonCtx, daemonStop := context.WithCancel(context.Background()) | |||
| 40 | return &AppServiceImpl{ | 59 | return &AppServiceImpl{ | ||
| 41 | - | store: store, | 60 | + | store: store, |
| 42 | - | deploy: deploy, | 61 | + | deploy: deploy, |
| 43 | - | secrets: secrets, | 62 | + | secrets: secrets, |
| 44 | - | log: log, | 63 | + | log: log, |
| 45 | - | core: NewService(store, log), | 64 | + | core: NewService(store, log), |
| 65 | + | daemonCtx: daemonCtx, | |||
| 66 | + | daemonStop: daemonStop, | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // WithDaemonContext sets the parent context that owns background deploy | |||
| 71 | + | // executions. It must be called during wiring, before the first Deploy. The | |||
| 72 | + | // derived context is cancelled by Shutdown; request contexts are never used | |||
| 73 | + | // as its parent. | |||
| 74 | + | func (s *AppServiceImpl) WithDaemonContext(parent context.Context) *AppServiceImpl { | |||
| 75 | + | if parent == nil { | |||
| 76 | + | parent = context.Background() | |||
| 77 | + | } | |||
| 78 | + | s.lifecycleMu.Lock() | |||
| 79 | + | defer s.lifecycleMu.Unlock() | |||
| 80 | + | if s.stopped { | |||
| 81 | + | return s | |||
| 82 | + | } | |||
| 83 | + | ctx, cancel := context.WithCancel(parent) | |||
| 84 | + | if s.daemonStop != nil { | |||
| 85 | + | s.daemonStop() | |||
| 86 | + | } | |||
| 87 | + | s.daemonCtx, s.daemonStop = ctx, cancel | |||
| 88 | + | return s | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Shutdown stops scheduling background deploy executions, cancels the daemon | |||
| 92 | + | // context so in-flight executions abort with a recoverable non-terminal | |||
| 93 | + | // journal, and waits for the in-flight hand-offs and executions to unwind or | |||
| 94 | + | // for ctx to expire. Waiting for the hand-offs first means a Deploy that | |||
| 95 | + | // claimed an operation just before Shutdown always schedules or settles it | |||
| 96 | + | // before Shutdown returns. | |||
| 97 | + | func (s *AppServiceImpl) Shutdown(ctx context.Context) error { | |||
| 98 | + | s.lifecycleMu.Lock() | |||
| 99 | + | if !s.stopped { | |||
| 100 | + | s.stopped = true | |||
| 101 | + | s.daemonStop() | |||
| 102 | + | } | |||
| 103 | + | s.lifecycleMu.Unlock() | |||
| 104 | + | ||||
| 105 | + | done := make(chan struct{}) | |||
| 106 | + | go func() { | |||
| 107 | + | s.handoffs.Wait() | |||
| 108 | + | s.executions.Wait() | |||
| 109 | + | close(done) | |||
| 110 | + | }() | |||
| 111 | + | select { | |||
| 112 | + | case <-done: | |||
| 113 | + | return nil | |||
| 114 | + | case <-ctx.Done(): | |||
| 115 | + | return ctx.Err() | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // scheduleExecution runs the claimed operation's effects on the daemon | |||
| 120 | + | // context. Only the owner of a freshly claimed key reaches it, so a duplicate | |||
| 121 | + | // key can never spawn a second execution. The journal claimed by StartDeploy | |||
| 122 | + | // is already durable when the goroutine starts. When Shutdown won the race | |||
| 123 | + | // between the claim and scheduling, the claim is settled without starting: | |||
| 124 | + | // the live marker is released and the journal converged as interrupted, so it | |||
| 125 | + | // can neither execute nor stay marked live forever. | |||
| 126 | + | func (s *AppServiceImpl) scheduleExecution(claim deployment.DeployClaim) error { | |||
| 127 | + | opFields := map[string]any{ | |||
| 128 | + | zerowrap.FieldLayer: "usecase", | |||
| 129 | + | zerowrap.FieldUseCase: "Deploy", | |||
| 130 | + | "app": claim.App, | |||
| 131 | + | "op": claim.Op, | |||
| 132 | + | } | |||
| 133 | + | s.lifecycleMu.Lock() | |||
| 134 | + | if s.stopped { | |||
| 135 | + | settleCtx := zerowrap.CtxWithFields(context.WithoutCancel(s.daemonCtx), opFields) | |||
| 136 | + | s.lifecycleMu.Unlock() | |||
| 137 | + | settleLog := zerowrap.FromCtx(settleCtx) | |||
| 138 | + | settleLog.Warn().Msg("apps: deploy claimed during shutdown; settling the claim without executing") | |||
| 139 | + | if err := s.deploy.AbandonDeploy(settleCtx, claim); err != nil { | |||
| 140 | + | settleLog.Warn().Err(err).Msg("apps: failed to settle a deploy claimed during shutdown") | |||
| 141 | + | } | |||
| 142 | + | return fmt.Errorf("apps: deploy claimed during shutdown: %w", domain.ErrAppStateConflict) | |||
| 46 | } | 143 | } | ||
| 144 | + | ctx := zerowrap.CtxWithFields(s.daemonCtx, opFields) | |||
| 145 | + | s.executions.Add(1) | |||
| 146 | + | s.lifecycleMu.Unlock() | |||
| 147 | + | ||||
| 148 | + | go func() { | |||
| 149 | + | defer s.executions.Done() | |||
| 150 | + | execLog := zerowrap.FromCtx(ctx) | |||
| 151 | + | if _, err := s.deploy.ExecuteDeploy(ctx, claim); err != nil { | |||
| 152 | + | execLog.Warn().Err(err).Msg("apps: background deploy execution failed") | |||
| 153 | + | } | |||
| 154 | + | }() | |||
| 155 | + | return nil | |||
| 47 | } | 156 | } | ||
| 48 | 157 | ||||
| 49 | // WithEntrypoints supplies the installation entrypoint listeners used to | 158 | // WithEntrypoints supplies the installation entrypoint listeners used to | ||
| @@ -275,15 +384,50 @@ func (s *AppServiceImpl) Diff(ctx context.Context, app string) (domain.AppDiff, | |||||
| 275 | return domain.DiffAppSpec(desired.Spec, activeSpec(active)), nil | 384 | return domain.DiffAppSpec(desired.Spec, activeSpec(active)), nil | ||
| 276 | } | 385 | } | ||
| 277 | 386 | ||||
| 278 | - | // Deploy implements in.AppService. A repeated idempotency key replays its | 387 | + | // Deploy implements in.AppService. It enrolls the request hand-off with the |
| 279 | - | // stored journal: the engine claims the key atomically before any effect, | 388 | + | // lifecycle first, so a deploy arriving after shutdown began is refused before |
| 280 | - | // so a duplicate request never deploys twice. | 389 | + | // any claim, and one already in flight is always scheduled or settled before |
| 390 | + | // Shutdown returns. StartDeploy then claims and persists the operation under | |||
| 391 | + | // the app lock, and the effects run in the background on the daemon context, | |||
| 392 | + | // so Deploy returns the still-running journal promptly and request | |||
| 393 | + | // cancellation cannot abort an in-flight replacement. A repeated idempotency | |||
| 394 | + | // key is never owned twice: its stored journal replays instead of spawning a | |||
| 395 | + | // second execution, and only the owner schedules any work. | |||
| 281 | func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error) { | 396 | func (s *AppServiceImpl) Deploy(ctx context.Context, app, revision, service, idempotencyKey string) (*domain.AppOperation, error) { | ||
| 282 | - | result, err := s.deploy.Deploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey}) | 397 | + | done, err := s.beginDeploy() |
| 283 | - | if result == nil { | 398 | + | if err != nil { |
| 284 | return nil, err | 399 | return nil, err | ||
| 285 | } | 400 | } | ||
| 286 | - | return s.operationResult(ctx, app, result.Op, err) | 401 | + | defer done() |
| 402 | + | ||||
| 403 | + | started, err := s.deploy.StartDeploy(ctx, deployment.DeployInput{App: app, Revision: revision, Service: service, Op: idempotencyKey}) | |||
| 404 | + | if err != nil { | |||
| 405 | + | return nil, err | |||
| 406 | + | } | |||
| 407 | + | if !started.Owned { | |||
| 408 | + | // The key already answered this request: its stored journal is the | |||
| 409 | + | // result and no workload is touched again. | |||
| 410 | + | return s.operationResult(ctx, app, started.Claim.Op, started.ReplayError()) | |||
| 411 | + | } | |||
| 412 | + | if err := s.scheduleExecution(started.Claim); err != nil { | |||
| 413 | + | return s.operationResult(ctx, app, started.Claim.Op, err) | |||
| 414 | + | } | |||
| 415 | + | return s.operationResult(ctx, app, started.Claim.Op, nil) | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | // beginDeploy enrolls one Deploy hand-off before the engine claims anything. | |||
| 419 | + | // It refuses the request once Shutdown began, and otherwise guarantees Shutdown | |||
| 420 | + | // waits for the hand-off (claim, schedule, or settle) to finish before state is | |||
| 421 | + | // torn down, so no claim is made or settled against closed state. The returned | |||
| 422 | + | // done func must be called exactly once. | |||
| 423 | + | func (s *AppServiceImpl) beginDeploy() (func(), error) { | |||
| 424 | + | s.lifecycleMu.Lock() | |||
| 425 | + | defer s.lifecycleMu.Unlock() | |||
| 426 | + | if s.stopped { | |||
| 427 | + | return nil, fmt.Errorf("apps: deploy refused: daemon is shutting down: %w", domain.ErrAppStateConflict) | |||
| 428 | + | } | |||
| 429 | + | s.handoffs.Add(1) | |||
| 430 | + | return s.handoffs.Done, nil | |||
| 287 | } | 431 | } | ||
| 288 | 432 | ||||
| 289 | // Stop implements in.AppService. | 433 | // Stop implements in.AppService. | ||
A internal/usecase/apps/deploy_lifecycle_test.go +387 -0
| @@ -0,0 +1,387 @@ | |||||
| 1 | + | package apps_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 17 | + | "github.com/bnema/gordon/internal/usecase/apps" | |||
| 18 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | type deployCall struct { | |||
| 22 | + | op *domain.AppOperation | |||
| 23 | + | err error | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // callDeploy runs Deploy off the test goroutine so a blocking execution can | |||
| 27 | + | // never hang the test: only a prompt return is awaited. | |||
| 28 | + | func callDeploy(t *testing.T, svc *apps.AppServiceImpl, ctx context.Context, key string) <-chan deployCall { | |||
| 29 | + | t.Helper() | |||
| 30 | + | out := make(chan deployCall, 1) | |||
| 31 | + | go func() { | |||
| 32 | + | op, err := svc.Deploy(ctx, "blog", "rev-1", "web", key) | |||
| 33 | + | out <- deployCall{op: op, err: err} | |||
| 34 | + | }() | |||
| 35 | + | return out | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func awaitDeploy(t *testing.T, ch <-chan deployCall) deployCall { | |||
| 39 | + | t.Helper() | |||
| 40 | + | select { | |||
| 41 | + | case res := <-ch: | |||
| 42 | + | return res | |||
| 43 | + | case <-time.After(3 * time.Second): | |||
| 44 | + | t.Fatal("Deploy did not return promptly") | |||
| 45 | + | return deployCall{} | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // gate returns a release channel and an idempotent closer, so a blocking test | |||
| 50 | + | // execution is always unblocked before Shutdown waits on it. | |||
| 51 | + | func gate() (chan struct{}, func()) { | |||
| 52 | + | ch := make(chan struct{}) | |||
| 53 | + | var once sync.Once | |||
| 54 | + | return ch, func() { once.Do(func() { close(ch) }) } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func runningDeployOp() domain.AppOperation { | |||
| 58 | + | return domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1", StartedAt: time.Now().UTC()} | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func ownedStart(op domain.AppOperation) *deployment.StartDeployResult { | |||
| 62 | + | return &deployment.StartDeployResult{ | |||
| 63 | + | Claim: deployment.DeployClaim{App: op.App, Op: op.Op, Service: "web", Revision: "rev-1", Journal: op}, | |||
| 64 | + | Owned: true, | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func replayStart(op domain.AppOperation) *deployment.StartDeployResult { | |||
| 69 | + | return &deployment.StartDeployResult{ | |||
| 70 | + | Claim: deployment.DeployClaim{App: op.App, Op: op.Op, Service: "web", Revision: "rev-1", Journal: op}, | |||
| 71 | + | Owned: false, | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // TestAppServiceImpl_Deploy_ReturnsRunningJournalAfterPersistence proves the | |||
| 76 | + | // request path is two-phase: StartDeploy has already journaled the operation | |||
| 77 | + | // when the background execution starts, and Deploy returns that non-terminal | |||
| 78 | + | // journal without waiting for the replacement. | |||
| 79 | + | func TestAppServiceImpl_Deploy_ReturnsRunningJournalAfterPersistence(t *testing.T) { | |||
| 80 | + | ctx := context.Background() | |||
| 81 | + | store := newMockAppState(t) | |||
| 82 | + | deploy := newMockDeployEngine(t) | |||
| 83 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()) | |||
| 84 | + | t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) }) | |||
| 85 | + | ||||
| 86 | + | op := runningDeployOp() | |||
| 87 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil) | |||
| 88 | + | ||||
| 89 | + | startInputs := make(chan deployment.DeployInput, 1) | |||
| 90 | + | deploy.startDeployFn = func(_ context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 91 | + | startInputs <- input | |||
| 92 | + | return ownedStart(op), nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | persistedBeforeExecution := make(chan error, 1) | |||
| 96 | + | release, releaseExecution := gate() | |||
| 97 | + | t.Cleanup(releaseExecution) | |||
| 98 | + | deploy.executeDeployFn = func(execCtx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 99 | + | // The claimed journal must be durable and queryable before any effect. | |||
| 100 | + | stored, err := store.LoadOperation(execCtx, claim.App, claim.Op) | |||
| 101 | + | if err == nil && (stored.Op != claim.Op || stored.Terminal()) { | |||
| 102 | + | err = errors.New("claimed journal is not the persisted running operation") | |||
| 103 | + | } | |||
| 104 | + | persistedBeforeExecution <- err | |||
| 105 | + | <-release | |||
| 106 | + | return &deployment.DeployResult{Op: claim.Op, App: claim.App}, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 110 | + | require.NoError(t, res.err) | |||
| 111 | + | require.NotNil(t, res.op) | |||
| 112 | + | assert.Equal(t, "op-1", res.op.Op) | |||
| 113 | + | assert.False(t, res.op.Terminal(), "Deploy returns the still-running journal") | |||
| 114 | + | assert.Equal(t, deployment.DeployInput{App: "blog", Revision: "rev-1", Service: "web", Op: "key-1"}, <-startInputs) | |||
| 115 | + | ||||
| 116 | + | select { | |||
| 117 | + | case err := <-persistedBeforeExecution: | |||
| 118 | + | require.NoError(t, err, "execution must start only after the journal is persisted") | |||
| 119 | + | case <-time.After(3 * time.Second): | |||
| 120 | + | t.Fatal("background execution never started") | |||
| 121 | + | } | |||
| 122 | + | releaseExecution() | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // TestAppServiceImpl_Deploy_RequestCancellationDoesNotCancelExecution proves | |||
| 126 | + | // the execution runs on the daemon context, never on the request context. | |||
| 127 | + | func TestAppServiceImpl_Deploy_RequestCancellationDoesNotCancelExecution(t *testing.T) { | |||
| 128 | + | requestCtx, cancelRequest := context.WithCancel(context.Background()) | |||
| 129 | + | defer cancelRequest() | |||
| 130 | + | store := newMockAppState(t) | |||
| 131 | + | deploy := newMockDeployEngine(t) | |||
| 132 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()) | |||
| 133 | + | t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) }) | |||
| 134 | + | ||||
| 135 | + | op := runningDeployOp() | |||
| 136 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil) | |||
| 137 | + | deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 138 | + | return ownedStart(op), nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | execCtxCh := make(chan context.Context, 1) | |||
| 142 | + | release, releaseExecution := gate() | |||
| 143 | + | t.Cleanup(releaseExecution) | |||
| 144 | + | deploy.executeDeployFn = func(execCtx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 145 | + | execCtxCh <- execCtx | |||
| 146 | + | <-release | |||
| 147 | + | return nil, nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | res := awaitDeploy(t, callDeploy(t, svc, requestCtx, "key-1")) | |||
| 151 | + | require.NoError(t, res.err) | |||
| 152 | + | require.NotNil(t, res.op) | |||
| 153 | + | ||||
| 154 | + | execCtx := <-execCtxCh | |||
| 155 | + | cancelRequest() | |||
| 156 | + | select { | |||
| 157 | + | case <-requestCtx.Done(): | |||
| 158 | + | default: | |||
| 159 | + | t.Fatal("request context was not cancelled") | |||
| 160 | + | } | |||
| 161 | + | require.NotEqual(t, requestCtx, execCtx, "execution must not run on the request context") | |||
| 162 | + | select { | |||
| 163 | + | case <-execCtx.Done(): | |||
| 164 | + | t.Fatal("request cancellation cancelled the in-flight execution") | |||
| 165 | + | case <-time.After(50 * time.Millisecond): | |||
| 166 | + | } | |||
| 167 | + | releaseExecution() | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // TestAppServiceImpl_Deploy_DuplicateKeyNeverSpawnsSecondExecution proves the | |||
| 171 | + | // claimed key is the only execution: a replay returns the stored journal, an | |||
| 172 | + | // in-flight replay reports a conflict, and ExecuteDeploy runs exactly once. | |||
| 173 | + | func TestAppServiceImpl_Deploy_DuplicateKeyNeverSpawnsSecondExecution(t *testing.T) { | |||
| 174 | + | ctx := context.Background() | |||
| 175 | + | store := newMockAppState(t) | |||
| 176 | + | deploy := newMockDeployEngine(t) | |||
| 177 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()) | |||
| 178 | + | t.Cleanup(func() { require.NoError(t, svc.Shutdown(context.Background())) }) | |||
| 179 | + | ||||
| 180 | + | op := runningDeployOp() | |||
| 181 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil) | |||
| 182 | + | ||||
| 183 | + | var startCalls atomic.Int32 | |||
| 184 | + | deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 185 | + | if startCalls.Add(1) == 1 { | |||
| 186 | + | return ownedStart(op), nil | |||
| 187 | + | } | |||
| 188 | + | return replayStart(op), nil | |||
| 189 | + | } | |||
| 190 | + | var execCalls atomic.Int32 | |||
| 191 | + | execStarted := make(chan struct{}) | |||
| 192 | + | release, releaseExecution := gate() | |||
| 193 | + | t.Cleanup(releaseExecution) | |||
| 194 | + | deploy.executeDeployFn = func(context.Context, deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 195 | + | execCalls.Add(1) | |||
| 196 | + | close(execStarted) | |||
| 197 | + | <-release | |||
| 198 | + | return &deployment.DeployResult{Op: "op-1"}, nil | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | first := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 202 | + | require.NoError(t, first.err) | |||
| 203 | + | require.NotNil(t, first.op) | |||
| 204 | + | select { | |||
| 205 | + | case <-execStarted: | |||
| 206 | + | case <-time.After(3 * time.Second): | |||
| 207 | + | t.Fatal("first execution never started") | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | second := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 211 | + | require.ErrorIs(t, second.err, domain.ErrAppStateConflict, "an in-flight replay is never a second execution") | |||
| 212 | + | require.NotNil(t, second.op) | |||
| 213 | + | assert.Equal(t, "op-1", second.op.Op) | |||
| 214 | + | assert.Equal(t, int32(1), execCalls.Load(), "a duplicate key never spawns a second execution") | |||
| 215 | + | releaseExecution() | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // TestAppServiceImpl_Deploy_TerminalFailureRemainsQueryable proves a failed | |||
| 219 | + | // execution is recorded once, stays readable by key, and its replay reports | |||
| 220 | + | // the stored failure without executing again. | |||
| 221 | + | func TestAppServiceImpl_Deploy_TerminalFailureRemainsQueryable(t *testing.T) { | |||
| 222 | + | ctx := context.Background() | |||
| 223 | + | store := newMockAppState(t) | |||
| 224 | + | deploy := newMockDeployEngine(t) | |||
| 225 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()) | |||
| 226 | + | ||||
| 227 | + | failed := runningDeployOp() | |||
| 228 | + | failed.Outcome = domain.AppOutcomeFailed | |||
| 229 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(failed, nil) | |||
| 230 | + | ||||
| 231 | + | var startCalls atomic.Int32 | |||
| 232 | + | deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 233 | + | if startCalls.Add(1) == 1 { | |||
| 234 | + | return ownedStart(runningDeployOp()), nil | |||
| 235 | + | } | |||
| 236 | + | return replayStart(failed), nil | |||
| 237 | + | } | |||
| 238 | + | var execCalls atomic.Int32 | |||
| 239 | + | executed := make(chan struct{}, 1) | |||
| 240 | + | deploy.executeDeployFn = func(context.Context, deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 241 | + | execCalls.Add(1) | |||
| 242 | + | executed <- struct{}{} | |||
| 243 | + | return nil, errors.New("replacement failed") | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 247 | + | require.NoError(t, res.err, "a failed execution is reported through the journal, not the start call") | |||
| 248 | + | require.NotNil(t, res.op) | |||
| 249 | + | assert.True(t, res.op.Terminal()) | |||
| 250 | + | // Join the background execution before asserting the journal, so the | |||
| 251 | + | // count is deterministic rather than a race with the goroutine. | |||
| 252 | + | <-executed | |||
| 253 | + | assert.Equal(t, int32(1), execCalls.Load()) | |||
| 254 | + | ||||
| 255 | + | // A terminal replay stays queryable and never executes again, even | |||
| 256 | + | // though it is answered as a conflict. | |||
| 257 | + | retry := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 258 | + | require.ErrorIs(t, retry.err, domain.ErrAppStateConflict) | |||
| 259 | + | require.NotNil(t, retry.op) | |||
| 260 | + | assert.Equal(t, domain.AppOutcomeFailed, retry.op.Outcome) | |||
| 261 | + | assert.Equal(t, int32(1), execCalls.Load(), "a terminal replay never executes again") | |||
| 262 | + | ||||
| 263 | + | require.NoError(t, svc.Shutdown(context.Background())) | |||
| 264 | + | ||||
| 265 | + | got, err := svc.OperationByKey(ctx, "blog", "op-1") | |||
| 266 | + | require.NoError(t, err) | |||
| 267 | + | assert.Equal(t, domain.AppOutcomeFailed, got.Outcome, "terminal failure remains queryable by key") | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // TestAppServiceImpl_Deploy_ShutdownLeavesRecoverableNonTerminalJournal proves | |||
| 271 | + | // graceful shutdown cancels the execution and leaves the journal persisted | |||
| 272 | + | // non-terminal for reconciliation, instead of losing or finalizing it. | |||
| 273 | + | func TestAppServiceImpl_Deploy_ShutdownLeavesRecoverableNonTerminalJournal(t *testing.T) { | |||
| 274 | + | ctx := context.Background() | |||
| 275 | + | store := newMockAppState(t) | |||
| 276 | + | deploy := newMockDeployEngine(t) | |||
| 277 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()). | |||
| 278 | + | WithDaemonContext(context.Background()) | |||
| 279 | + | ||||
| 280 | + | op := runningDeployOp() | |||
| 281 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil) | |||
| 282 | + | deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 283 | + | return ownedStart(op), nil | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | execCtxCh := make(chan context.Context, 1) | |||
| 287 | + | deploy.executeDeployFn = func(execCtx context.Context, _ deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 288 | + | execCtxCh <- execCtx | |||
| 289 | + | <-execCtx.Done() | |||
| 290 | + | return nil, execCtx.Err() | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 294 | + | require.NoError(t, res.err) | |||
| 295 | + | require.NotNil(t, res.op) | |||
| 296 | + | ||||
| 297 | + | execCtx := <-execCtxCh | |||
| 298 | + | require.NoError(t, svc.Shutdown(context.Background()), "Shutdown waits for the execution to unwind") | |||
| 299 | + | require.ErrorIs(t, execCtx.Err(), context.Canceled, "shutdown cancels the in-flight execution") | |||
| 300 | + | ||||
| 301 | + | got, err := svc.OperationByKey(ctx, "blog", "op-1") | |||
| 302 | + | require.NoError(t, err) | |||
| 303 | + | assert.False(t, got.Terminal(), "shutdown must leave the non-terminal journal for reconciliation") | |||
| 304 | + | assert.Equal(t, "op-1", got.Op) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | // TestAppServiceImpl_Deploy_RefusesAfterShutdownBeforeClaim proves a deploy | |||
| 308 | + | // that arrives after shutdown began is refused with a state conflict before | |||
| 309 | + | // the engine can claim it, so neither a durable 202-running journal nor a | |||
| 310 | + | // workload effect can be produced. The engine mock has no expectations, so | |||
| 311 | + | // any StartDeploy or ExecuteDeploy call fails the test. | |||
| 312 | + | func TestAppServiceImpl_Deploy_RefusesAfterShutdownBeforeClaim(t *testing.T) { | |||
| 313 | + | ctx := context.Background() | |||
| 314 | + | store := newMockAppState(t) | |||
| 315 | + | deploy := newMockDeployEngine(t) | |||
| 316 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()) | |||
| 317 | + | ||||
| 318 | + | require.NoError(t, svc.Shutdown(context.Background())) | |||
| 319 | + | ||||
| 320 | + | res := awaitDeploy(t, callDeploy(t, svc, ctx, "key-1")) | |||
| 321 | + | require.ErrorIs(t, res.err, domain.ErrAppStateConflict, "a deploy after shutdown is refused with a state conflict") | |||
| 322 | + | require.Nil(t, res.op, "a refused deploy never produces a journal to answer 202") | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | // TestAppServiceImpl_Deploy_ShutdownRacingClaimSettlesWithoutExecuting proves | |||
| 326 | + | // the claim/schedule hand-off is closed against shutdown: once Shutdown starts | |||
| 327 | + | // after StartDeploy claimed the operation but before it was scheduled, the | |||
| 328 | + | // owned claim is settled without starting, Shutdown waits for that settle, and | |||
| 329 | + | // the request answers a conflict instead of a false 202-running orphan. | |||
| 330 | + | func TestAppServiceImpl_Deploy_ShutdownRacingClaimSettlesWithoutExecuting(t *testing.T) { | |||
| 331 | + | ctx := context.Background() | |||
| 332 | + | store := newMockAppState(t) | |||
| 333 | + | deploy := newMockDeployEngine(t) | |||
| 334 | + | svc := apps.NewAppServiceImpl(store, deploy, newMockSecretWriter(t), zerowrap.Default()) | |||
| 335 | + | ||||
| 336 | + | op := runningDeployOp() | |||
| 337 | + | store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil) | |||
| 338 | + | ||||
| 339 | + | claimed := make(chan struct{}) | |||
| 340 | + | releaseClaim := make(chan struct{}) | |||
| 341 | + | deploy.startDeployFn = func(context.Context, deployment.DeployInput) (*deployment.StartDeployResult, error) { | |||
| 342 | + | close(claimed) | |||
| 343 | + | <-releaseClaim | |||
| 344 | + | return ownedStart(op), nil | |||
| 345 | + | } | |||
| 346 | + | var execCalls atomic.Int32 | |||
| 347 | + | deploy.executeDeployFn = func(context.Context, deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 348 | + | execCalls.Add(1) | |||
| 349 | + | return nil, nil | |||
| 350 | + | } | |||
| 351 | + | abandoned := make(chan deployment.DeployClaim, 1) | |||
| 352 | + | deploy.abandonDeployFn = func(_ context.Context, claim deployment.DeployClaim) error { | |||
| 353 | + | abandoned <- claim | |||
| 354 | + | return nil | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | resCh := callDeploy(t, svc, ctx, "key-1") | |||
| 358 | + | <-claimed | |||
| 359 | + | ||||
| 360 | + | shutdownDone := make(chan error, 1) | |||
| 361 | + | go func() { shutdownDone <- svc.Shutdown(context.Background()) }() | |||
| 362 | + | ||||
| 363 | + | // Shutdown must wait for the hand-off rather than return while the claim | |||
| 364 | + | // is still being made. | |||
| 365 | + | select { | |||
| 366 | + | case <-shutdownDone: | |||
| 367 | + | t.Fatal("Shutdown returned before the in-flight claim hand-off settled") | |||
| 368 | + | case <-time.After(50 * time.Millisecond): | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // The claim completes now; shutdown is already set, so it must be settled | |||
| 372 | + | // rather than executed. | |||
| 373 | + | close(releaseClaim) | |||
| 374 | + | ||||
| 375 | + | select { | |||
| 376 | + | case claim := <-abandoned: | |||
| 377 | + | assert.Equal(t, "op-1", claim.Op) | |||
| 378 | + | case <-time.After(3 * time.Second): | |||
| 379 | + | t.Fatal("the raced claim was not settled") | |||
| 380 | + | } | |||
| 381 | + | require.NoError(t, <-shutdownDone, "Shutdown waits for the settle and returns cleanly") | |||
| 382 | + | ||||
| 383 | + | res := awaitDeploy(t, resCh) | |||
| 384 | + | require.ErrorIs(t, res.err, domain.ErrAppStateConflict, "a settled claim answers a conflict, never 202-running") | |||
| 385 | + | require.NotNil(t, res.op) | |||
| 386 | + | assert.Equal(t, int32(0), execCalls.Load(), "a claim settled during shutdown never executes") | |||
| 387 | + | } | |||
M internal/usecase/apps/mocks_test.go +30 -1
| @@ -16,6 +16,13 @@ import ( | |||||
| 16 | // so this hand-written mock follows the same EXPECT()/Return() pattern. | 16 | // so this hand-written mock follows the same EXPECT()/Return() pattern. | ||
| 17 | type mockDeployEngine struct { | 17 | type mockDeployEngine struct { | ||
| 18 | mock.Mock | 18 | mock.Mock | ||
| 19 | + | ||||
| 20 | + | // startDeployFn, abandonDeployFn and executeDeployFn override the testify | |||
| 21 | + | // expectations when set, for tests that script per-call results or block | |||
| 22 | + | // execution. | |||
| 23 | + | startDeployFn func(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) | |||
| 24 | + | abandonDeployFn func(ctx context.Context, claim deployment.DeployClaim) error | |||
| 25 | + | executeDeployFn func(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) | |||
| 19 | } | 26 | } | ||
| 20 | 27 | ||||
| 21 | func newMockDeployEngine(t *testing.T) *mockDeployEngine { | 28 | func newMockDeployEngine(t *testing.T) *mockDeployEngine { | ||
| @@ -25,11 +32,33 @@ func newMockDeployEngine(t *testing.T) *mockDeployEngine { | |||||
| 25 | return m | 32 | return m | ||
| 26 | } | 33 | } | ||
| 27 | 34 | ||||
| 28 | - | func (m *mockDeployEngine) Deploy(ctx context.Context, input deployment.DeployInput) (*deployment.DeployResult, error) { | 35 | + | func (m *mockDeployEngine) StartDeploy(ctx context.Context, input deployment.DeployInput) (*deployment.StartDeployResult, error) { |
| 36 | + | if m.startDeployFn != nil { | |||
| 37 | + | return m.startDeployFn(ctx, input) | |||
| 38 | + | } | |||
| 29 | args := m.Called(ctx, input) | 39 | args := m.Called(ctx, input) | ||
| 30 | if args.Get(0) == nil { | 40 | if args.Get(0) == nil { | ||
| 31 | return nil, args.Error(1) | 41 | return nil, args.Error(1) | ||
| 32 | } | 42 | } | ||
| 43 | + | return args.Get(0).(*deployment.StartDeployResult), args.Error(1) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (m *mockDeployEngine) AbandonDeploy(ctx context.Context, claim deployment.DeployClaim) error { | |||
| 47 | + | if m.abandonDeployFn != nil { | |||
| 48 | + | return m.abandonDeployFn(ctx, claim) | |||
| 49 | + | } | |||
| 50 | + | args := m.Called(ctx, claim) | |||
| 51 | + | return args.Error(0) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (m *mockDeployEngine) ExecuteDeploy(ctx context.Context, claim deployment.DeployClaim) (*deployment.DeployResult, error) { | |||
| 55 | + | if m.executeDeployFn != nil { | |||
| 56 | + | return m.executeDeployFn(ctx, claim) | |||
| 57 | + | } | |||
| 58 | + | args := m.Called(ctx, claim) | |||
| 59 | + | if args.Get(0) == nil { | |||
| 60 | + | return nil, args.Error(1) | |||
| 61 | + | } | |||
| 33 | return args.Get(0).(*deployment.DeployResult), args.Error(1) | 62 | return args.Get(0).(*deployment.DeployResult), args.Error(1) | ||
| 34 | } | 63 | } | ||
| 35 | 64 | ||||
M internal/usecase/deployment/bind_policy_internal_test.go +64 -29
| @@ -129,7 +129,7 @@ func TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation(t *testing.T) { | |||||
| 129 | }, | 129 | }, | ||
| 130 | } | 130 | } | ||
| 131 | 131 | ||||
| 132 | - | _, _, _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1") | 132 | + | _, _, _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1", nil) |
| 133 | 133 | ||||
| 134 | require.ErrorIs(t, err, domain.ErrBindPolicy) | 134 | require.ErrorIs(t, err, domain.ErrBindPolicy) | ||
| 135 | assert.Contains(t, err.Error(), "config") | 135 | assert.Contains(t, err.Error(), "config") | ||
| @@ -187,7 +187,12 @@ func TestCheckImageVolumes_IncludesBindDestinations(t *testing.T) { | |||||
| 187 | }) | 187 | }) | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | - | func TestHTTPEligibleAndSingleWriterGating(t *testing.T) { | 190 | + | // TestSingleWriterRequiredAndImplicitHTTPReadiness proves the two |
| 191 | + | // independent service rules: singleWriterRequired marks the stateful | |||
| 192 | + | // services whose superseded generation must stay recovery-inhibited, and | |||
| 193 | + | // implicitHTTPProbe decides which undeclared readiness checks keep the | |||
| 194 | + | // implicit HTTP probe. | |||
| 195 | + | func TestSingleWriterRequiredAndImplicitHTTPReadiness(t *testing.T) { | |||
| 191 | httpSpec := func() domain.AppService { | 196 | httpSpec := func() domain.AppService { | ||
| 192 | return domain.AppService{ | 197 | return domain.AppService{ | ||
| 193 | Name: "web", | 198 | Name: "web", | ||
| @@ -195,52 +200,78 @@ func TestHTTPEligibleAndSingleWriterGating(t *testing.T) { | |||||
| 195 | } | 200 | } | ||
| 196 | } | 201 | } | ||
| 197 | 202 | ||||
| 198 | - | t.Run("http-only without binds is parallel eligible", func(t *testing.T) { | 203 | + | t.Run("stateless public HTTP service keeps the implicit HTTP probe", func(t *testing.T) { |
| 199 | - | assert.True(t, httpEligible(httpSpec())) | 204 | + | assert.True(t, implicitHTTPProbe(httpSpec())) |
| 205 | + | assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(httpSpec()).Readiness.Type) | |||
| 200 | assert.False(t, singleWriterRequired(httpSpec())) | 206 | assert.False(t, singleWriterRequired(httpSpec())) | ||
| 201 | }) | 207 | }) | ||
| 202 | 208 | ||||
| 203 | - | t.Run("internal-only http is not overlap eligible", func(t *testing.T) { | 209 | + | t.Run("undeclared probe is not guessed for internal, mixed, or L4 services", func(t *testing.T) { |
| 204 | - | spec := domain.AppService{ | 210 | + | internal := domain.AppService{ |
| 205 | Name: "api", | 211 | Name: "api", | ||
| 206 | HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}}, | 212 | HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}}, | ||
| 207 | } | 213 | } | ||
| 208 | - | assert.False(t, httpEligible(spec), | 214 | + | assert.False(t, implicitHTTPProbe(internal), |
| 209 | - | "an internal-only service has no public generation to cut over and must use interrupted replacement") | 215 | + | "an internal-only interface is not reachable through a loopback publish, so no HTTP probe is guessed") |
| 210 | - | assert.False(t, singleWriterRequired(spec)) | 216 | + | assert.Empty(t, deploymentReadiness(internal).Readiness.Type) |
| 217 | + | assert.False(t, singleWriterRequired(internal)) | |||
| 218 | + | ||||
| 219 | + | mixed := httpSpec() | |||
| 220 | + | mixed.HTTP = append(mixed.HTTP, domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal}) | |||
| 221 | + | assert.False(t, implicitHTTPProbe(mixed)) | |||
| 222 | + | assert.Empty(t, deploymentReadiness(mixed).Readiness.Type) | |||
| 223 | + | assert.False(t, singleWriterRequired(mixed)) | |||
| 224 | + | ||||
| 225 | + | l4Only := domain.AppService{ | |||
| 226 | + | Name: "db", | |||
| 227 | + | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, | |||
| 228 | + | } | |||
| 229 | + | assert.False(t, implicitHTTPProbe(l4Only), "an HTTP GET proves nothing about an L4 workload") | |||
| 230 | + | assert.Empty(t, deploymentReadiness(l4Only).Readiness.Type) | |||
| 231 | + | }) | |||
| 232 | + | ||||
| 233 | + | t.Run("declared probes are never replaced by the implicit HTTP probe", func(t *testing.T) { | |||
| 234 | + | for _, declared := range []string{domain.AppReadinessHTTP, domain.AppReadinessTCP, domain.AppReadinessLog} { | |||
| 235 | + | spec := httpSpec() | |||
| 236 | + | spec.Readiness = domain.AppReadiness{Type: declared} | |||
| 237 | + | assert.False(t, implicitHTTPProbe(spec), "%s is an explicit declaration", declared) | |||
| 238 | + | assert.Equal(t, declared, deploymentReadiness(spec).Readiness.Type) | |||
| 239 | + | } | |||
| 211 | }) | 240 | }) | ||
| 212 | 241 | ||||
| 213 | - | t.Run("mixed public and internal http must interrupt", func(t *testing.T) { | 242 | + | t.Run("an explicit none on a probeable service keeps the implicit HTTP probe", func(t *testing.T) { |
| 243 | + | spec := httpSpec() | |||
| 244 | + | spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessNone} | |||
| 245 | + | assert.Equal(t, domain.AppReadinessHTTP, deploymentReadiness(spec).Readiness.Type) | |||
| 246 | + | }) | |||
| 247 | + | ||||
| 248 | + | t.Run("an explicit none on an L4 service stays unchecked", func(t *testing.T) { | |||
| 214 | spec := domain.AppService{ | 249 | spec := domain.AppService{ | ||
| 215 | - | Name: "api", | 250 | + | Name: "db", |
| 216 | - | HTTP: []domain.AppHTTPInterface{ | 251 | + | TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 5432, Publish: "5432"}}, |
| 217 | - | {Host: "app.example.com", Port: 8080, TLS: domain.AppTLSAuto}, | 252 | + | Readiness: domain.AppReadiness{Type: domain.AppReadinessNone}, |
| 218 | - | {Port: 9090, Visibility: domain.AppVisibilityInternal}, | |||
| 219 | - | }, | |||
| 220 | } | 253 | } | ||
| 221 | - | assert.False(t, httpEligible(spec), | 254 | + | assert.Equal(t, domain.AppReadinessNone, deploymentReadiness(spec).Readiness.Type) |
| 222 | - | "the candidate is resolvable by service alias over the private network before readiness, so any internal interface must use interrupted replacement") | |||
| 223 | - | assert.False(t, singleWriterRequired(spec)) | |||
| 224 | }) | 255 | }) | ||
| 225 | 256 | ||||
| 226 | t.Run("writable bind is single-writer", func(t *testing.T) { | 257 | t.Run("writable bind is single-writer", func(t *testing.T) { | ||
| 227 | spec := httpSpec() | 258 | spec := httpSpec() | ||
| 228 | spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}} | 259 | spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}} | ||
| 229 | - | assert.False(t, httpEligible(spec), "a bind must disable parallel HTTP replacement") | 260 | + | assert.False(t, implicitHTTPProbe(spec), "a stateful service must declare its own readiness") |
| 230 | assert.True(t, singleWriterRequired(spec)) | 261 | assert.True(t, singleWriterRequired(spec)) | ||
| 231 | }) | 262 | }) | ||
| 232 | 263 | ||||
| 233 | t.Run("read-only bind is also single-writer", func(t *testing.T) { | 264 | t.Run("read-only bind is also single-writer", func(t *testing.T) { | ||
| 234 | spec := httpSpec() | 265 | spec := httpSpec() | ||
| 235 | spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}} | 266 | spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}} | ||
| 236 | - | assert.False(t, httpEligible(spec)) | 267 | + | assert.False(t, implicitHTTPProbe(spec)) |
| 237 | assert.True(t, singleWriterRequired(spec)) | 268 | assert.True(t, singleWriterRequired(spec)) | ||
| 238 | }) | 269 | }) | ||
| 239 | 270 | ||||
| 240 | t.Run("volume is single-writer", func(t *testing.T) { | 271 | t.Run("volume is single-writer", func(t *testing.T) { | ||
| 241 | spec := httpSpec() | 272 | spec := httpSpec() | ||
| 242 | spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | 273 | spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | ||
| 243 | - | assert.False(t, httpEligible(spec)) | 274 | + | assert.False(t, implicitHTTPProbe(spec)) |
| 244 | assert.True(t, singleWriterRequired(spec)) | 275 | assert.True(t, singleWriterRequired(spec)) | ||
| 245 | }) | 276 | }) | ||
| 246 | } | 277 | } | ||
| @@ -259,10 +290,13 @@ func TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) | |||||
| 259 | }, | 290 | }, | ||
| 260 | } | 291 | } | ||
| 261 | 292 | ||||
| 262 | - | step, result := svc.restartOneService(context.Background(), "blog", "web", eff) | 293 | + | op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{ |
| 294 | + | ID: "service.web.restart", State: domain.AppStepPending, Before: eff.Container, | |||
| 295 | + | }}} | |||
| 296 | + | result := svc.restartOneService(context.Background(), "blog", "op-1", "web", eff, op, 0) | |||
| 263 | 297 | ||||
| 264 | - | assert.Equal(t, domain.AppStepFailed, step.State) | 298 | + | assert.Equal(t, domain.AppStepFailed, op.Steps[0].State) |
| 265 | - | assert.Contains(t, step.Error, "config") | 299 | + | assert.Contains(t, op.Steps[0].Error, "config") |
| 266 | assert.Equal(t, "failed", result.Result) | 300 | assert.Equal(t, "failed", result.Result) | ||
| 267 | runtime.AssertNotCalled(t, "RestartContainer") | 301 | runtime.AssertNotCalled(t, "RestartContainer") | ||
| 268 | runtime.AssertExpectations(t) | 302 | runtime.AssertExpectations(t) | ||
| @@ -281,14 +315,15 @@ func TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation(t *testing. | |||||
| 281 | Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}, | 315 | Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}, | ||
| 282 | }, | 316 | }, | ||
| 283 | } | 317 | } | ||
| 284 | - | step := &domain.AppOperationStep{} | 318 | + | op := &domain.AppOperation{Op: "op-1", Steps: []domain.AppOperationStep{{ |
| 319 | + | ID: "service.web.start", State: domain.AppStepPending, Before: eff.Container, | |||
| 320 | + | }}} | |||
| 285 | result := &LifecycleResult{Services: map[string]ServiceResult{}} | 321 | result := &LifecycleResult{Services: map[string]ServiceResult{}} | ||
| 286 | 322 | ||||
| 287 | - | cont := svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, step, result) | 323 | + | svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, op, 0, result) |
| 288 | 324 | ||||
| 289 | - | assert.True(t, cont) | 325 | + | assert.Equal(t, domain.AppStepFailed, op.Steps[0].State) |
| 290 | - | assert.Equal(t, domain.AppStepFailed, step.State) | 326 | + | assert.Contains(t, op.Steps[0].Error, "config") |
| 291 | - | assert.Contains(t, step.Error, "config") | |||
| 292 | runtime.AssertNotCalled(t, "StartContainer") | 327 | runtime.AssertNotCalled(t, "StartContainer") | ||
| 293 | runtime.AssertExpectations(t) | 328 | runtime.AssertExpectations(t) | ||
| 294 | } | 329 | } | ||
A internal/usecase/deployment/crash_recovery_test.go +664 -0
| @@ -0,0 +1,664 @@ | |||||
| 1 | + | package deployment_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/adapters/out/appstate" | |||
| 14 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // seedRevision materializes one revision of an app, so a later recovery can | |||
| 20 | + | // rebuild that generation from its pinned digest. | |||
| 21 | + | func seedRevision(t *testing.T, ctx context.Context, store *appstate.Store, intentID, supersedes string, rev domain.AppDesiredRevision) { | |||
| 22 | + | t.Helper() | |||
| 23 | + | require.NoError(t, store.StageApply(ctx, domain.AppApplyIntent{ | |||
| 24 | + | Intent: intentID, App: rev.App, Revision: rev.Revision, Supersedes: supersedes, | |||
| 25 | + | SourceSHA256: "0f1e2d", Spec: rev.Spec, CreatedAt: time.Now().UTC(), | |||
| 26 | + | })) | |||
| 27 | + | require.NoError(t, store.CommitApply(ctx, rev.App, intentID)) | |||
| 28 | + | require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID)) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // seedReplaceableApp seeds one app whose ACTIVE generation (rev-0) has a | |||
| 32 | + | // materialized superseding revision (rev-1). The published container c-old is | |||
| 33 | + | // gone from the runtime: the caller decides what the runtime reports for it. | |||
| 34 | + | func seedReplaceableApp(t *testing.T, ctx context.Context, store *appstate.Store) (domain.AppDesiredRevision, domain.AppDesiredRevision) { | |||
| 35 | + | t.Helper() | |||
| 36 | + | spec := webService() | |||
| 37 | + | spec.Secrets = map[string]string{} | |||
| 38 | + | spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: time.Second} | |||
| 39 | + | activeRev := testRevision("blog", spec) | |||
| 40 | + | activeRev.Revision = "rev-0" | |||
| 41 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"})) | |||
| 42 | + | require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 43 | + | "web": { | |||
| 44 | + | Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image, | |||
| 45 | + | Digest: restartTestDigest, Spec: spec, BackendBinds: map[int]int{8080: 18080}, | |||
| 46 | + | }, | |||
| 47 | + | }})) | |||
| 48 | + | require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"})) | |||
| 49 | + | seedRevision(t, ctx, store, "intent-0", "", activeRev) | |||
| 50 | + | desired := testRevision("blog", spec) | |||
| 51 | + | seedRevision(t, ctx, store, "intent-1", "rev-0", desired) | |||
| 52 | + | return activeRev, desired | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // TestDeploy_CandidateIsJournaledBeforeItStarts proves the crash window is | |||
| 56 | + | // closed as far as a container ID allows: the candidate ID reaches the | |||
| 57 | + | // durable journal before the container is started, and the journal is | |||
| 58 | + | // finalized as a success only after publication. | |||
| 59 | + | func TestDeploy_CandidateIsJournaledBeforeItStarts(t *testing.T) { | |||
| 60 | + | ctx := context.Background() | |||
| 61 | + | store := newTestStore(t) | |||
| 62 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 63 | + | images := outmocks.NewMockImageResolver(t) | |||
| 64 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 65 | + | seedReplaceableApp(t, ctx, store) | |||
| 66 | + | ||||
| 67 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once() | |||
| 68 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once() | |||
| 69 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 70 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 71 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 72 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 73 | + | ||||
| 74 | + | journaledBeforeStart := false | |||
| 75 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-new").RunAndReturn(func(context.Context, string) error { | |||
| 76 | + | op, ok, err := store.LoadLatestOperation(context.Background(), "blog") | |||
| 77 | + | require.NoError(t, err) | |||
| 78 | + | require.True(t, ok, "the replacement is journaled before it starts") | |||
| 79 | + | require.False(t, op.Terminal(), "the journal is still in flight while the candidate starts") | |||
| 80 | + | step, found := opStep(op, "service.web.replace") | |||
| 81 | + | journaledBeforeStart = found && step.After == "c-new" && step.State == domain.AppStepPending | |||
| 82 | + | return nil | |||
| 83 | + | }).Once() | |||
| 84 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |||
| 85 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 86 | + | ||||
| 87 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 88 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 89 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 90 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 91 | + | func(context.Context, string) error { return nil }, | |||
| 92 | + | )) | |||
| 93 | + | ||||
| 94 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-journal"}) | |||
| 95 | + | require.NoError(t, err) | |||
| 96 | + | require.NotNil(t, result) | |||
| 97 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 98 | + | assert.True(t, journaledBeforeStart, | |||
| 99 | + | "the candidate ID is durable before the container is started") | |||
| 100 | + | ||||
| 101 | + | op, err := store.LoadOperation(context.Background(), "blog", "op-journal") | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | assert.Equal(t, domain.AppOutcomeSuccess, op.Outcome) | |||
| 104 | + | step, found := opStep(op, "service.web.replace") | |||
| 105 | + | require.True(t, found) | |||
| 106 | + | assert.Equal(t, domain.AppStepSucceeded, step.State) | |||
| 107 | + | assert.Equal(t, "c-new", step.After) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // crashDuringReplacement runs one replacement deploy and interrupts it | |||
| 111 | + | // between creating the candidate and publishing it. The candidate is left | |||
| 112 | + | // running, ACTIVE still names the superseded generation, and the operation | |||
| 113 | + | // journal stays in flight: the state a process crash or a failed cleanup | |||
| 114 | + | // leaves behind. | |||
| 115 | + | func crashDuringReplacement(t *testing.T, store *appstate.Store, opKey string) { | |||
| 116 | + | t.Helper() | |||
| 117 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 118 | + | defer cancel() | |||
| 119 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 120 | + | images := outmocks.NewMockImageResolver(t) | |||
| 121 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 122 | + | ||||
| 123 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once() | |||
| 124 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once() | |||
| 125 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 126 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 127 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 128 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-orphan", Name: "web"}, nil).Once() | |||
| 129 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-orphan").Return(nil).Once() | |||
| 130 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-orphan", mock.Anything).Return( | |||
| 131 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 132 | + | // The canceled context also refuses the candidate cleanup, so the | |||
| 133 | + | // candidate outlives the operation exactly as a crash would leave it. | |||
| 134 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).RunAndReturn( | |||
| 135 | + | func(removeCtx context.Context, _ string, _ bool) error { | |||
| 136 | + | return removeCtx.Err() | |||
| 137 | + | }).Once() | |||
| 138 | + | ||||
| 139 | + | probeStarted := make(chan struct{}) | |||
| 140 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 141 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 142 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 143 | + | func(probeCtx context.Context, _, _ string) (int, error) { | |||
| 144 | + | select { | |||
| 145 | + | case <-probeStarted: | |||
| 146 | + | default: | |||
| 147 | + | close(probeStarted) | |||
| 148 | + | } | |||
| 149 | + | <-probeCtx.Done() | |||
| 150 | + | return 0, probeCtx.Err() | |||
| 151 | + | }, | |||
| 152 | + | func(context.Context, string) error { return nil }, | |||
| 153 | + | )) | |||
| 154 | + | done := make(chan error, 1) | |||
| 155 | + | go func() { | |||
| 156 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: opKey}) | |||
| 157 | + | done <- err | |||
| 158 | + | }() | |||
| 159 | + | select { | |||
| 160 | + | case <-probeStarted: | |||
| 161 | + | case <-time.After(5 * time.Second): | |||
| 162 | + | t.Fatal("timed out waiting for the readiness probe") | |||
| 163 | + | } | |||
| 164 | + | cancel() | |||
| 165 | + | require.Error(t, <-done) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // TestBootRecovery_RemovesUnpublishedCandidateBeforeRebuildingPublished | |||
| 169 | + | // proves the crash window between creating a replacement and publishing it is | |||
| 170 | + | // recoverable: an interruption leaves the candidate journaled but unpublished, | |||
| 171 | + | // and boot recovery removes that candidate before it rebuilds the generation | |||
| 172 | + | // recorded in ACTIVE, so two generations of one service never run together. | |||
| 173 | + | func TestBootRecovery_RemovesUnpublishedCandidateBeforeRebuildingPublished(t *testing.T) { | |||
| 174 | + | ctx := context.Background() | |||
| 175 | + | store := newTestStore(t) | |||
| 176 | + | images := outmocks.NewMockImageResolver(t) | |||
| 177 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 178 | + | seedReplaceableApp(t, ctx, store) | |||
| 179 | + | crashDuringReplacement(t, store, "op-crash") | |||
| 180 | + | ||||
| 181 | + | // The crash left the candidate durable but unpublished, and ACTIVE still | |||
| 182 | + | // names the superseded generation. | |||
| 183 | + | interrupted, err := store.LoadOperation(context.Background(), "blog", "op-crash") | |||
| 184 | + | require.NoError(t, err) | |||
| 185 | + | assert.False(t, interrupted.Terminal(), "an interrupted operation stays in flight until recovery finalizes it") | |||
| 186 | + | crashStep, found := opStep(interrupted, "service.web.replace") | |||
| 187 | + | require.True(t, found) | |||
| 188 | + | assert.Equal(t, domain.AppStepPending, crashStep.State) | |||
| 189 | + | assert.Equal(t, "c-orphan", crashStep.After, "the created candidate is durably recorded") | |||
| 190 | + | active, ok, err := store.LoadActive(context.Background(), "blog") | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | require.True(t, ok) | |||
| 193 | + | assert.Equal(t, "c-old", active.Services["web"].Container) | |||
| 194 | + | ||||
| 195 | + | // Boot recovery: the orphan is removed before the published generation is | |||
| 196 | + | // rebuilt from its pinned revision. | |||
| 197 | + | var order []string | |||
| 198 | + | runtime2 := outmocks.NewMockContainerRuntime(t) | |||
| 199 | + | runtime2.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).RunAndReturn( | |||
| 200 | + | func(context.Context, string, bool) error { | |||
| 201 | + | order = append(order, "remove-orphan") | |||
| 202 | + | return nil | |||
| 203 | + | }).Once() | |||
| 204 | + | runtime2.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once() | |||
| 205 | + | runtime2.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once() | |||
| 206 | + | runtime2.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 207 | + | runtime2.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |||
| 208 | + | expectNetworkProvision(runtime2, "app-blog", 1) | |||
| 209 | + | runtime2.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn( | |||
| 210 | + | func(context.Context, *domain.ContainerConfig) (*domain.Container, error) { | |||
| 211 | + | order = append(order, "rebuild-published-generation") | |||
| 212 | + | return &domain.Container{ID: "c-rebuilt", Name: "web"}, nil | |||
| 213 | + | }).Once() | |||
| 214 | + | runtime2.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once() | |||
| 215 | + | runtime2.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return( | |||
| 216 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 217 | + | ||||
| 218 | + | rebooted := deployment.NewService(deployment.Deps{ | |||
| 219 | + | State: store, Runtime: runtime2, Images: images, Secrets: secrets, | |||
| 220 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime2, | |||
| 221 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 222 | + | func(context.Context, string) error { return nil }, | |||
| 223 | + | )) | |||
| 224 | + | // Boot recovery runs on a fresh process context: the crashed deploy's | |||
| 225 | + | // context is gone with it. | |||
| 226 | + | require.NoError(t, rebooted.ReconcileBoot(ctx)) | |||
| 227 | + | ||||
| 228 | + | assert.Equal(t, []string{"remove-orphan", "rebuild-published-generation"}, order, | |||
| 229 | + | "the unpublished candidate is gone before the published generation is rebuilt") | |||
| 230 | + | runtime2.AssertNotCalled(t, "StartContainer", mock.Anything, "c-orphan") | |||
| 231 | + | runtime2.AssertNotCalled(t, "RestartContainer", mock.Anything, "c-orphan", mock.Anything) | |||
| 232 | + | ||||
| 233 | + | // The interrupted operation is finalized as failed, so a repeat of its key | |||
| 234 | + | // replays an explicit failure instead of a permanent in-flight claim. | |||
| 235 | + | finalized, err := store.LoadOperation(context.Background(), "blog", "op-crash") | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | require.True(t, finalized.Terminal(), "boot recovery finalizes the interrupted operation") | |||
| 238 | + | assert.Equal(t, domain.AppOutcomeFailed, finalized.Outcome) | |||
| 239 | + | finalStep, found := opStep(finalized, "service.web.replace") | |||
| 240 | + | require.True(t, found) | |||
| 241 | + | assert.Equal(t, domain.AppStepFailed, finalStep.State) | |||
| 242 | + | assert.Contains(t, finalStep.Error, "unpublished candidate was removed") | |||
| 243 | + | ||||
| 244 | + | // ACTIVE now names the rebuilt generation: recovery converged the app | |||
| 245 | + | // instead of leaving it pointing at a container that no longer exists. | |||
| 246 | + | converged, ok, err := store.LoadActive(context.Background(), "blog") | |||
| 247 | + | require.NoError(t, err) | |||
| 248 | + | require.True(t, ok) | |||
| 249 | + | assert.Equal(t, "c-rebuilt", converged.Services["web"].Container) | |||
| 250 | + | assert.Equal(t, "rev-0", converged.Services["web"].EffectiveRevision) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement | |||
| 254 | + | // proves the live case: when a cleanup failed and Gordon kept running, the | |||
| 255 | + | // next mutation of the app removes the leftover candidate before it creates a | |||
| 256 | + | // new generation, so two generations of one service never run together even | |||
| 257 | + | // without a restart. It also finalizes the interrupted journal. | |||
| 258 | + | func TestDeploy_ReconcilesInterruptedPredecessorBeforeCreatingAReplacement(t *testing.T) { | |||
| 259 | + | ctx := context.Background() | |||
| 260 | + | store := newTestStore(t) | |||
| 261 | + | images := outmocks.NewMockImageResolver(t) | |||
| 262 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 263 | + | seedReplaceableApp(t, ctx, store) | |||
| 264 | + | crashDuringReplacement(t, store, "op-crash") | |||
| 265 | + | ||||
| 266 | + | var order []string | |||
| 267 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 268 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).RunAndReturn( | |||
| 269 | + | func(context.Context, string, bool) error { | |||
| 270 | + | order = append(order, "remove-leftover-candidate") | |||
| 271 | + | return nil | |||
| 272 | + | }).Once() | |||
| 273 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once() | |||
| 274 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once() | |||
| 275 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 276 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |||
| 277 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 278 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn( | |||
| 279 | + | func(context.Context, *domain.ContainerConfig) (*domain.Container, error) { | |||
| 280 | + | order = append(order, "create-new-generation") | |||
| 281 | + | return &domain.Container{ID: "c-next", Name: "web"}, nil | |||
| 282 | + | }).Once() | |||
| 283 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-next").Return(nil).Once() | |||
| 284 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-next", mock.Anything).Return( | |||
| 285 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 286 | + | ||||
| 287 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 288 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 289 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 290 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 291 | + | func(context.Context, string) error { return nil }, | |||
| 292 | + | )) | |||
| 293 | + | ||||
| 294 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next"}) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | require.NotNil(t, result) | |||
| 297 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 298 | + | assert.Equal(t, []string{"remove-leftover-candidate", "create-new-generation"}, order) | |||
| 299 | + | ||||
| 300 | + | active, ok, err := store.LoadActive(ctx, "blog") | |||
| 301 | + | require.NoError(t, err) | |||
| 302 | + | require.True(t, ok) | |||
| 303 | + | assert.Equal(t, "c-next", active.Services["web"].Container) | |||
| 304 | + | ||||
| 305 | + | interrupted, err := store.LoadOperation(ctx, "blog", "op-crash") | |||
| 306 | + | require.NoError(t, err) | |||
| 307 | + | require.True(t, interrupted.Terminal(), "the interrupted operation is finalized") | |||
| 308 | + | assert.Equal(t, domain.AppOutcomeFailed, interrupted.Outcome) | |||
| 309 | + | step, found := opStep(interrupted, "service.web.replace") | |||
| 310 | + | require.True(t, found) | |||
| 311 | + | assert.Equal(t, domain.AppStepFailed, step.State) | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // TestBootRecovery_RemovesCandidateOfAnInterruptedFirstDeployment proves the | |||
| 315 | + | // same recovery works when the interrupted operation is the app's FIRST | |||
| 316 | + | // deployment: there is no ACTIVE generation to converge, and the leftover | |||
| 317 | + | // candidate is still removed and the journal finalized. | |||
| 318 | + | func TestBootRecovery_RemovesCandidateOfAnInterruptedFirstDeployment(t *testing.T) { | |||
| 319 | + | ctx := context.Background() | |||
| 320 | + | store := newTestStore(t) | |||
| 321 | + | images := outmocks.NewMockImageResolver(t) | |||
| 322 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 323 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"})) | |||
| 324 | + | require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{ | |||
| 325 | + | Op: "op-seeded", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(), | |||
| 326 | + | Steps: []domain.AppOperationStep{ | |||
| 327 | + | {ID: "preflight", State: domain.AppStepSucceeded}, | |||
| 328 | + | {ID: "service.web.replace", State: domain.AppStepPending, Service: "web", Before: "c-old", After: "c-orphan"}, | |||
| 329 | + | }, | |||
| 330 | + | })) | |||
| 331 | + | ||||
| 332 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 333 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once() | |||
| 334 | + | ||||
| 335 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 336 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 337 | + | }, zerowrap.Default()) | |||
| 338 | + | require.NoError(t, svc.ReconcileBoot(ctx)) | |||
| 339 | + | ||||
| 340 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 341 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, "c-orphan") | |||
| 342 | + | ||||
| 343 | + | finalized, err := store.LoadOperation(ctx, "blog", "op-seeded") | |||
| 344 | + | require.NoError(t, err) | |||
| 345 | + | require.True(t, finalized.Terminal()) | |||
| 346 | + | assert.Equal(t, domain.AppOutcomeFailed, finalized.Outcome) | |||
| 347 | + | step, found := opStep(finalized, "service.web.replace") | |||
| 348 | + | require.True(t, found) | |||
| 349 | + | assert.Equal(t, domain.AppStepFailed, step.State) | |||
| 350 | + | assert.Contains(t, step.Error, "unpublished candidate was removed") | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer proves a | |||
| 354 | + | // restart that falls back to a rebuild does not leave a durable inhibition for | |||
| 355 | + | // a container that is already gone: nothing could ever revive it, and the | |||
| 356 | + | // marker would refuse every later start, recovery pass, and restart. | |||
| 357 | + | func TestRestart_FailedRebuildClearsTheInhibitionOfTheMissingContainer(t *testing.T) { | |||
| 358 | + | ctx := context.Background() | |||
| 359 | + | store := newTestStore(t) | |||
| 360 | + | images := outmocks.NewMockImageResolver(t) | |||
| 361 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 362 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 363 | + | ||||
| 364 | + | spec := webService() | |||
| 365 | + | spec.Secrets = map[string]string{} | |||
| 366 | + | spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | |||
| 367 | + | spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 50 * time.Millisecond} | |||
| 368 | + | rev := testRevision("blog", spec) | |||
| 369 | + | rev.Revision = "rev-0" | |||
| 370 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"})) | |||
| 371 | + | require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 372 | + | "web": { | |||
| 373 | + | Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image, | |||
| 374 | + | Digest: restartTestDigest, Spec: spec, BackendBinds: map[int]int{8080: 18080}, | |||
| 375 | + | }, | |||
| 376 | + | }})) | |||
| 377 | + | require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"})) | |||
| 378 | + | seedRevision(t, ctx, store, "intent-0", "", rev) | |||
| 379 | + | ||||
| 380 | + | runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 381 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 382 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |||
| 383 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 384 | + | runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() | |||
| 385 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuild", Name: "web"}, nil).Once() | |||
| 386 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-rebuild").Return(nil).Once() | |||
| 387 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuild", mock.Anything).Return( | |||
| 388 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 389 | + | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-rebuild", false).Return(nil, assert.AnError).Once() | |||
| 390 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-rebuild", true).Return(nil).Once() | |||
| 391 | + | ||||
| 392 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 393 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 394 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 395 | + | func(context.Context, string, string) (int, error) { return 503, nil }, | |||
| 396 | + | func(context.Context, string) error { return assert.AnError }, | |||
| 397 | + | )) | |||
| 398 | + | ||||
| 399 | + | result, err := svc.Restart(ctx, "blog", "web", "op-restart-failed") | |||
| 400 | + | require.Error(t, err) | |||
| 401 | + | require.NotNil(t, result) | |||
| 402 | + | assert.Equal(t, "failed", result.Services["web"].Result) | |||
| 403 | + | ||||
| 404 | + | inhibitions, loadErr := store.LoadRecoveryInhibitions(ctx, "blog") | |||
| 405 | + | require.NoError(t, loadErr) | |||
| 406 | + | assert.Empty(t, inhibitions, | |||
| 407 | + | "the inhibition of a container proven gone must not survive a failed rebuild") | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | // TestBootRecovery_RetriesLeftoverOfATerminalFailedOperation proves a | |||
| 411 | + | // leftover candidate recorded by an operation that already reached a terminal | |||
| 412 | + | // failure is still converged: the journal keeps the candidate ID, and the next | |||
| 413 | + | // recovery pass removes it. | |||
| 414 | + | func TestBootRecovery_RetriesLeftoverOfATerminalFailedOperation(t *testing.T) { | |||
| 415 | + | ctx := context.Background() | |||
| 416 | + | store := newTestStore(t) | |||
| 417 | + | images := outmocks.NewMockImageResolver(t) | |||
| 418 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 419 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog", Stopped: true})) | |||
| 420 | + | require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 421 | + | "web": {Container: "c-old", EffectiveRevision: "rev-0", Spec: headlessSpec()}, | |||
| 422 | + | }})) | |||
| 423 | + | require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{ | |||
| 424 | + | Op: "op-failed", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(), | |||
| 425 | + | Outcome: domain.AppOutcomeFailed, | |||
| 426 | + | Steps: []domain.AppOperationStep{ | |||
| 427 | + | {ID: "preflight", State: domain.AppStepSucceeded}, | |||
| 428 | + | {ID: "service.web.replace", State: domain.AppStepFailed, Service: "web", Before: "c-old", After: "c-orphan", Error: "readiness"}, | |||
| 429 | + | }, | |||
| 430 | + | })) | |||
| 431 | + | ||||
| 432 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 433 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once() | |||
| 434 | + | runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running"}, nil).Once() | |||
| 435 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 436 | + | ||||
| 437 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 438 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 439 | + | }, zerowrap.Default()) | |||
| 440 | + | require.NoError(t, svc.ReconcileBoot(ctx)) | |||
| 441 | + | ||||
| 442 | + | runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-orphan", true) | |||
| 443 | + | ||||
| 444 | + | // The removal is recorded: the step no longer retries the candidate on a | |||
| 445 | + | // later pass, and the removed container id stays visible for audit. | |||
| 446 | + | convergedOp, err := store.LoadOperation(ctx, "blog", "op-failed") | |||
| 447 | + | require.NoError(t, err) | |||
| 448 | + | step, found := opStep(convergedOp, "service.web.replace") | |||
| 449 | + | require.True(t, found) | |||
| 450 | + | assert.Empty(t, step.After, "a converged candidate must not be retried on the next pass") | |||
| 451 | + | assert.Contains(t, step.Detail, "c-orphan", "the removed candidate stays in the audit trail") | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // TestBootRecovery_InterruptionAfterTheLastStepIsASuccess proves an operation | |||
| 455 | + | // whose effects all ran but whose final outcome write was interrupted is | |||
| 456 | + | // classified as the success it was, and the published generation is kept. | |||
| 457 | + | func TestBootRecovery_InterruptionAfterTheLastStepIsASuccess(t *testing.T) { | |||
| 458 | + | ctx := context.Background() | |||
| 459 | + | store := newTestStore(t) | |||
| 460 | + | images := outmocks.NewMockImageResolver(t) | |||
| 461 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 462 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog", Stopped: true})) | |||
| 463 | + | require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 464 | + | "web": {Container: "c-published", EffectiveRevision: "rev-0", Spec: headlessSpec()}, | |||
| 465 | + | }})) | |||
| 466 | + | require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{ | |||
| 467 | + | Op: "op-unfinished", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(), | |||
| 468 | + | Steps: []domain.AppOperationStep{ | |||
| 469 | + | {ID: "preflight", State: domain.AppStepSucceeded}, | |||
| 470 | + | {ID: "service.web.replace", State: domain.AppStepSucceeded, Service: "web", Before: "c-old", After: "c-published"}, | |||
| 471 | + | }, | |||
| 472 | + | })) | |||
| 473 | + | ||||
| 474 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 475 | + | runtime.EXPECT().InspectContainer(mock.Anything, "c-published").Return(&domain.Container{ID: "c-published", Status: "exited"}, nil).Once() | |||
| 476 | + | ||||
| 477 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 478 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 479 | + | }, zerowrap.Default()) | |||
| 480 | + | require.NoError(t, svc.ReconcileBoot(ctx)) | |||
| 481 | + | ||||
| 482 | + | reconciled, err := store.LoadOperation(ctx, "blog", "op-unfinished") | |||
| 483 | + | require.NoError(t, err) | |||
| 484 | + | require.True(t, reconciled.Terminal()) | |||
| 485 | + | assert.Equal(t, domain.AppOutcomeSuccess, reconciled.Outcome) | |||
| 486 | + | step, found := opStep(reconciled, "service.web.replace") | |||
| 487 | + | require.True(t, found) | |||
| 488 | + | assert.Equal(t, domain.AppStepSucceeded, step.State, "a step that already succeeded is left as recorded") | |||
| 489 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-published", mock.Anything) | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | // TestBootRecovery_FailsClosedWhenCandidateCannotBeRemoved proves | |||
| 493 | + | // reconciliation never proceeds while an orphan candidate may still run: the | |||
| 494 | + | // removal failure aborts the mutation, the interrupted journal is not | |||
| 495 | + | // finalized, and no newer operation is created to mask the leftover. | |||
| 496 | + | func TestBootRecovery_FailsClosedWhenCandidateCannotBeRemoved(t *testing.T) { | |||
| 497 | + | ctx := context.Background() | |||
| 498 | + | store := newTestStore(t) | |||
| 499 | + | images := outmocks.NewMockImageResolver(t) | |||
| 500 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 501 | + | seedReplaceableApp(t, ctx, store) | |||
| 502 | + | require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{ | |||
| 503 | + | Op: "op-crash", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(), | |||
| 504 | + | Steps: []domain.AppOperationStep{ | |||
| 505 | + | {ID: "service.web.replace", State: domain.AppStepPending, Service: "web", Before: "c-old", After: "c-orphan"}, | |||
| 506 | + | }, | |||
| 507 | + | })) | |||
| 508 | + | ||||
| 509 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 510 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(assert.AnError) | |||
| 511 | + | ||||
| 512 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 513 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 514 | + | }, zerowrap.Default()) | |||
| 515 | + | ||||
| 516 | + | err := svc.ReconcileBoot(ctx) | |||
| 517 | + | require.Error(t, err) | |||
| 518 | + | assert.Contains(t, err.Error(), "could not be removed") | |||
| 519 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 520 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 521 | + | ||||
| 522 | + | // The leftover stays journaled and non-terminal, so it is still the latest | |||
| 523 | + | // operation. | |||
| 524 | + | leftover, err := store.LoadOperation(ctx, "blog", "op-crash") | |||
| 525 | + | require.NoError(t, err) | |||
| 526 | + | assert.False(t, leftover.Terminal(), "a leftover that could not be removed is never finalized") | |||
| 527 | + | ||||
| 528 | + | // A later mutation fails closed on the same leftover and opens no journal | |||
| 529 | + | // that could mask it. | |||
| 530 | + | _, err = svc.Deploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next"}) | |||
| 531 | + | require.Error(t, err) | |||
| 532 | + | assert.Contains(t, err.Error(), "could not be removed") | |||
| 533 | + | _, err = store.LoadOperation(ctx, "blog", "op-next") | |||
| 534 | + | require.ErrorIs(t, err, domain.ErrAppOperationNotFound) | |||
| 535 | + | } | |||
| 536 | + | ||||
| 537 | + | // TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive proves | |||
| 538 | + | // the crash window is fully recoverable for a volume-owning service: an | |||
| 539 | + | // interruption after the superseded container was retired leaves the | |||
| 540 | + | // replacement-pending inhibition behind, boot recovery removes the | |||
| 541 | + | // never-published candidate, clears that stale inhibition, and rebuilds the | |||
| 542 | + | // generation ACTIVE records from its pinned revision. | |||
| 543 | + | func TestBootRecovery_ClearsReplacementInhibitionAndRebuildsFromActive(t *testing.T) { | |||
| 544 | + | ctx := context.Background() | |||
| 545 | + | store := newTestStore(t) | |||
| 546 | + | images := outmocks.NewMockImageResolver(t) | |||
| 547 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 548 | + | ||||
| 549 | + | spec := webService() | |||
| 550 | + | spec.Secrets = map[string]string{} | |||
| 551 | + | spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | |||
| 552 | + | spec.Readiness = domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: time.Second} | |||
| 553 | + | rev := testRevision("blog", spec) | |||
| 554 | + | rev.Revision = "rev-0" | |||
| 555 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"})) | |||
| 556 | + | require.NoError(t, store.SaveActive(ctx, domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 557 | + | "web": { | |||
| 558 | + | Container: "c-old", EffectiveRevision: "rev-0", Image: spec.Image, | |||
| 559 | + | Digest: restartTestDigest, Spec: spec, BackendBinds: map[int]int{8080: 18080}, | |||
| 560 | + | }, | |||
| 561 | + | }})) | |||
| 562 | + | require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"})) | |||
| 563 | + | seedRevision(t, ctx, store, "intent-0", "", rev) | |||
| 564 | + | // A replacement interrupted after it retired c-old and created its | |||
| 565 | + | // candidate: ACTIVE still names c-old, the candidate is journaled but | |||
| 566 | + | // unpublished, and c-old carries the replacement-pending inhibition. | |||
| 567 | + | require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{ | |||
| 568 | + | Op: "op-crash", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC(), | |||
| 569 | + | Steps: []domain.AppOperationStep{ | |||
| 570 | + | {ID: "preflight", State: domain.AppStepSucceeded}, | |||
| 571 | + | {ID: "service.web.replace", State: domain.AppStepPending, Service: "web", Before: "c-old", After: "c-orphan"}, | |||
| 572 | + | }, | |||
| 573 | + | })) | |||
| 574 | + | require.NoError(t, store.SaveRecoveryInhibition(ctx, domain.AppRecoveryInhibition{ | |||
| 575 | + | App: "blog", Service: "web", ContainerID: "c-old", | |||
| 576 | + | Reason: domain.AppInhibitReplacementPending, Operation: "op-crash", | |||
| 577 | + | })) | |||
| 578 | + | ||||
| 579 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 580 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-orphan", true).Return(nil).Once() | |||
| 581 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(false, nil).Once() | |||
| 582 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-old").Return(domain.ErrContainerNotFound).Once() | |||
| 583 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 584 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |||
| 585 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 586 | + | runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() | |||
| 587 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once() | |||
| 588 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once() | |||
| 589 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return( | |||
| 590 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 591 | + | ||||
| 592 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 593 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 594 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 595 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 596 | + | func(context.Context, string) error { return nil }, | |||
| 597 | + | )) | |||
| 598 | + | require.NoError(t, svc.ReconcileBoot(ctx)) | |||
| 599 | + | ||||
| 600 | + | inhibitions, err := store.LoadRecoveryInhibitions(ctx, "blog") | |||
| 601 | + | require.NoError(t, err) | |||
| 602 | + | assert.Empty(t, inhibitions, "the stale replacement inhibition must not survive recovery") | |||
| 603 | + | ||||
| 604 | + | active, ok, err := store.LoadActive(ctx, "blog") | |||
| 605 | + | require.NoError(t, err) | |||
| 606 | + | require.True(t, ok) | |||
| 607 | + | assert.Equal(t, "c-rebuilt", active.Services["web"].Container) | |||
| 608 | + | assert.Equal(t, "rev-0", active.Services["web"].EffectiveRevision) | |||
| 609 | + | ||||
| 610 | + | finalized, err := store.LoadOperation(ctx, "blog", "op-crash") | |||
| 611 | + | require.NoError(t, err) | |||
| 612 | + | require.True(t, finalized.Terminal()) | |||
| 613 | + | assert.Equal(t, domain.AppOutcomeFailed, finalized.Outcome) | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | // TestRestart_RebuildsMissingActiveContainer proves restart is not a dead end | |||
| 617 | + | // when the recorded container is gone: the service is rebuilt from the pinned | |||
| 618 | + | // ACTIVE digest and published again. | |||
| 619 | + | func TestRestart_RebuildsMissingActiveContainer(t *testing.T) { | |||
| 620 | + | ctx := context.Background() | |||
| 621 | + | store := newTestStore(t) | |||
| 622 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 623 | + | images := outmocks.NewMockImageResolver(t) | |||
| 624 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 625 | + | seedReplaceableApp(t, ctx, store) | |||
| 626 | + | ||||
| 627 | + | runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 628 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(domain.ErrContainerNotFound).Once() | |||
| 629 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(domain.ErrContainerNotFound).Once() | |||
| 630 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 631 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-rebuilt", Name: "web"}, nil).Once() | |||
| 632 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-rebuilt").Return(nil).Once() | |||
| 633 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-rebuilt", mock.Anything).Return( | |||
| 634 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18082, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 635 | + | ||||
| 636 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 637 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 638 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 639 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 640 | + | func(context.Context, string) error { return nil }, | |||
| 641 | + | )) | |||
| 642 | + | ||||
| 643 | + | result, err := svc.Restart(ctx, "blog", "web", "op-restart-missing") | |||
| 644 | + | require.NoError(t, err) | |||
| 645 | + | require.NotNil(t, result) | |||
| 646 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 647 | + | assert.Equal(t, "c-rebuilt", result.Services["web"].After, | |||
| 648 | + | "the missing generation is rebuilt from the pinned ACTIVE digest") | |||
| 649 | + | ||||
| 650 | + | active, ok, err := store.LoadActive(context.Background(), "blog") | |||
| 651 | + | require.NoError(t, err) | |||
| 652 | + | require.True(t, ok) | |||
| 653 | + | assert.Equal(t, "c-rebuilt", active.Services["web"].Container) | |||
| 654 | + | assert.Equal(t, "rev-0", active.Services["web"].EffectiveRevision, | |||
| 655 | + | "the rebuild reuses the pinned revision") | |||
| 656 | + | ||||
| 657 | + | op, err := store.LoadOperation(context.Background(), "blog", "op-restart-missing") | |||
| 658 | + | require.NoError(t, err) | |||
| 659 | + | assert.Equal(t, domain.AppOutcomeSuccess, op.Outcome) | |||
| 660 | + | step, found := opStep(op, "service.web.restart") | |||
| 661 | + | require.True(t, found) | |||
| 662 | + | assert.Equal(t, domain.AppStepSucceeded, step.State) | |||
| 663 | + | assert.Equal(t, "c-rebuilt", step.After) | |||
| 664 | + | } | |||
M internal/usecase/deployment/deploy_mock_test.go +105 -33
| @@ -20,12 +20,51 @@ import ( | |||||
| 20 | 20 | ||||
| 21 | func mockDeps(t *testing.T) (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) { | 21 | func mockDeps(t *testing.T) (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) { | ||
| 22 | t.Helper() | 22 | t.Helper() | ||
| 23 | - | return outmocks.NewMockAppState(t), | 23 | + | state := outmocks.NewMockAppState(t) |
| 24 | + | // Every app mutation reconciles an interrupted predecessor first. These | |||
| 25 | + | // tests exercise the mutation itself, so they see no interrupted | |||
| 26 | + | // operation; tests that seed one use their own expectation or a real | |||
| 27 | + | // store. | |||
| 28 | + | state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything). | |||
| 29 | + | Return(domain.AppOperation{}, false, nil).Maybe() | |||
| 30 | + | // ExecuteDeploy always reloads the claimed operation from the store by | |||
| 31 | + | // app/op identity. Mock-backed execution sees the freshly claimed | |||
| 32 | + | // non-terminal journal shape (a single pending preflight step) unless a | |||
| 33 | + | // test overrides it with expectStoredOperation. | |||
| 34 | + | state.EXPECT().LoadOperation(mock.Anything, mock.Anything, mock.Anything). | |||
| 35 | + | RunAndReturn(func(_ context.Context, app, opID string) (domain.AppOperation, error) { | |||
| 36 | + | return domain.AppOperation{ | |||
| 37 | + | Kind: "deploy", App: app, Op: opID, | |||
| 38 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}, | |||
| 39 | + | }, nil | |||
| 40 | + | }).Maybe() | |||
| 41 | + | return state, | |||
| 24 | outmocks.NewMockContainerRuntime(t), | 42 | outmocks.NewMockContainerRuntime(t), | ||
| 25 | outmocks.NewMockImageResolver(t), | 43 | outmocks.NewMockImageResolver(t), | ||
| 26 | outmocks.NewMockSecretProvider(t) | 44 | outmocks.NewMockSecretProvider(t) | ||
| 27 | } | 45 | } | ||
| 28 | 46 | ||||
| 47 | + | // expectStoredOperation makes one store record authoritative for the next | |||
| 48 | + | // ExecuteDeploy reload. The operation identity is echoed from the request so | |||
| 49 | + | // an unkeyed claim matches too. It replaces the catch-all default installed | |||
| 50 | + | // by mockDeps, letting a test prove that a terminal or mismatched store | |||
| 51 | + | // record overrides a stale in-process journal. | |||
| 52 | + | func expectStoredOperation(state *outmocks.MockAppState, op domain.AppOperation) { | |||
| 53 | + | kept := state.ExpectedCalls[:0] | |||
| 54 | + | for _, call := range state.ExpectedCalls { | |||
| 55 | + | if call.Method != "LoadOperation" { | |||
| 56 | + | kept = append(kept, call) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | state.ExpectedCalls = kept | |||
| 60 | + | state.EXPECT().LoadOperation(mock.Anything, op.App, mock.Anything). | |||
| 61 | + | RunAndReturn(func(_ context.Context, app, opID string) (domain.AppOperation, error) { | |||
| 62 | + | op.App = app | |||
| 63 | + | op.Op = opID | |||
| 64 | + | return op, nil | |||
| 65 | + | }) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 29 | func mockRevision() domain.AppDesiredRevision { | 68 | func mockRevision() domain.AppDesiredRevision { | ||
| 30 | svc := webService() | 69 | svc := webService() | ||
| 31 | svc.Image = "docker.io/example/web:1.4.2" | 70 | svc.Image = "docker.io/example/web:1.4.2" | ||
| @@ -74,6 +113,11 @@ func TestDeploy_PreflightFailureReturnsJournaledOperation(t *testing.T) { | |||||
| 74 | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Twice() | 113 | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Twice() | ||
| 75 | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once() | 114 | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once() | ||
| 76 | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError).Once() | 115 | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError).Once() | ||
| 116 | + | // The store record carries the revision the claim was started for. | |||
| 117 | + | expectStoredOperation(state, domain.AppOperation{ | |||
| 118 | + | Kind: "deploy", App: "blog", InputRevision: rev.Revision, | |||
| 119 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}, | |||
| 120 | + | }) | |||
| 77 | 121 | ||||
| 78 | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default()) | 122 | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default()) | ||
| 79 | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | 123 | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | ||
| @@ -143,7 +187,7 @@ func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) { | |||||
| 143 | }, | 187 | }, | ||
| 144 | zerowrap.Default(), | 188 | zerowrap.Default(), | ||
| 145 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 189 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 146 | - | func(context.Context, string) (int, error) { return 200, nil }, | 190 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 147 | func(context.Context, string) error { return nil }, | 191 | func(context.Context, string) error { return nil }, | ||
| 148 | )) | 192 | )) | ||
| 149 | 193 | ||||
| @@ -189,7 +233,7 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) { | |||||
| 189 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | 233 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | ||
| 190 | zerowrap.Default(), | 234 | zerowrap.Default(), | ||
| 191 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 235 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 192 | - | func(_ context.Context, url string) (int, error) { probedURL = url; return 200, nil }, | 236 | + | func(_ context.Context, url, _ string) (int, error) { probedURL = url; return 200, nil }, |
| 193 | func(context.Context, string) error { return nil }, | 237 | func(context.Context, string) error { return nil }, | ||
| 194 | )) | 238 | )) | ||
| 195 | 239 | ||||
| @@ -202,11 +246,12 @@ func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) { | |||||
| 202 | runtime.AssertNotCalled(t, "GetContainerNetworkInfo", mock.Anything, mock.Anything) | 246 | runtime.AssertNotCalled(t, "GetContainerNetworkInfo", mock.Anything, mock.Anything) | ||
| 203 | } | 247 | } | ||
| 204 | 248 | ||||
| 205 | - | // TestDeploy_Mockery_HTTPRetiresOldAfterPublish proves retire-after-publish | 249 | + | // TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish proves the |
| 206 | - | // ordering: the replaced container is stopped+removed only after the new | 250 | + | // sequential ordering: the superseded container is stopped and removed |
| 207 | - | // effective state is published (proxy already switched), and a retire | 251 | + | // before the replacement is created, and ACTIVE names the replacement only |
| 208 | - | // failure records a cleanup warning without flipping the outcome. | 252 | + | // after it exists. A public HTTP service without volumes is replaced exactly |
| 209 | - | func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) { | 253 | + | // like every other service. |
| 254 | + | func TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish(t *testing.T) { | |||
| 210 | ctx := context.Background() | 255 | ctx := context.Background() | ||
| 211 | state, runtime, images, secrets := mockDeps(t) | 256 | state, runtime, images, secrets := mockDeps(t) | ||
| 212 | rev := mockRevision() | 257 | rev := mockRevision() | ||
| @@ -221,7 +266,20 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) { | |||||
| 221 | }, true, nil) | 266 | }, true, nil) | ||
| 222 | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | 267 | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | ||
| 223 | 268 | ||||
| 224 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | 269 | + | var replacedAt, createdAt, publishedAt int |
| 270 | + | var step int | |||
| 271 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error { | |||
| 272 | + | step++ | |||
| 273 | + | replacedAt = step | |||
| 274 | + | return nil | |||
| 275 | + | }).Once() | |||
| 276 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 277 | + | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | |||
| 278 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, *domain.ContainerConfig) (*domain.Container, error) { | |||
| 279 | + | step++ | |||
| 280 | + | createdAt = step | |||
| 281 | + | return &domain.Container{ID: "c-new", Name: "web"}, nil | |||
| 282 | + | }).Once() | |||
| 225 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | 283 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | ||
| 226 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | 284 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | ||
| 227 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | 285 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | ||
| @@ -233,20 +291,11 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) { | |||||
| 233 | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"}, | 291 | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"}, | ||
| 234 | }, | 292 | }, | ||
| 235 | }, true, nil) | 293 | }, true, nil) | ||
| 236 | - | var publishedAt, retiredAt int | |||
| 237 | - | var step int | |||
| 238 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error { | 294 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error { | ||
| 239 | step++ | 295 | step++ | ||
| 240 | publishedAt = step | 296 | publishedAt = step | ||
| 241 | return nil | 297 | return nil | ||
| 242 | }).Once() | 298 | }).Once() | ||
| 243 | - | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error { | |||
| 244 | - | step++ | |||
| 245 | - | retiredAt = step | |||
| 246 | - | return nil | |||
| 247 | - | }).Once() | |||
| 248 | - | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 249 | - | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | |||
| 250 | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | 299 | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | ||
| 251 | // The superseded generation stops being recovery-inhibited only | 300 | // The superseded generation stops being recovery-inhibited only | ||
| 252 | // once the replacement is published. | 301 | // once the replacement is published. | ||
| @@ -256,7 +305,7 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) { | |||||
| 256 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | 305 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | ||
| 257 | zerowrap.Default(), | 306 | zerowrap.Default(), | ||
| 258 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 307 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 259 | - | func(context.Context, string) (int, error) { return 200, nil }, | 308 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 260 | func(context.Context, string) error { return nil }, | 309 | func(context.Context, string) error { return nil }, | ||
| 261 | )) | 310 | )) | ||
| 262 | 311 | ||||
| @@ -264,12 +313,16 @@ func TestDeploy_Mockery_HTTPRetiresOldAfterPublish(t *testing.T) { | |||||
| 264 | require.NoError(t, err) | 313 | require.NoError(t, err) | ||
| 265 | require.NotNil(t, result) | 314 | require.NotNil(t, result) | ||
| 266 | assert.Equal(t, "deployed", result.Services["web"].Result) | 315 | assert.Equal(t, "deployed", result.Services["web"].Result) | ||
| 267 | - | assert.Equal(t, "c-old", result.Services["web"].Retire) | 316 | + | assert.Less(t, replacedAt, createdAt, "the superseded generation is gone before the replacement is created") |
| 268 | - | assert.Greater(t, retiredAt, publishedAt, "retire runs after publication") | 317 | + | assert.Greater(t, publishedAt, createdAt, "ACTIVE is published after the replacement exists") |
| 269 | assert.Empty(t, result.CleanupWarnings) | 318 | assert.Empty(t, result.CleanupWarnings) | ||
| 270 | } | 319 | } | ||
| 271 | 320 | ||||
| 272 | - | func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) { | 321 | + | // TestDeploy_Mockery_HTTPReadinessFailureLeavesNoOldGeneration proves a |
| 322 | + | // failed replacement never falls back to the generation it already retired: | |||
| 323 | + | // the superseded container stays gone, the unready candidate is removed, and | |||
| 324 | + | // nothing is published in its place. | |||
| 325 | + | func TestDeploy_Mockery_HTTPReadinessFailureLeavesNoOldGeneration(t *testing.T) { | |||
| 273 | ctx := context.Background() | 326 | ctx := context.Background() | ||
| 274 | state, runtime, images, secrets := mockDeps(t) | 327 | state, runtime, images, secrets := mockDeps(t) | ||
| 275 | rev := mockRevision() | 328 | rev := mockRevision() | ||
| @@ -284,7 +337,11 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) { | |||||
| 284 | }, true, nil) | 337 | }, true, nil) | ||
| 285 | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | 338 | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | ||
| 286 | 339 | ||||
| 287 | - | // Replacement created + started; readiness fails fast. | 340 | + | // The superseded generation is retired before the replacement is created, |
| 341 | + | // then the replacement fails readiness fast. | |||
| 342 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 343 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 344 | + | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | |||
| 288 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | 345 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | ||
| 289 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | 346 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | ||
| 290 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | 347 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | ||
| @@ -301,7 +358,7 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) { | |||||
| 301 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | 358 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | ||
| 302 | zerowrap.Default(), | 359 | zerowrap.Default(), | ||
| 303 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 360 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 304 | - | func(context.Context, string) (int, error) { return 500, nil }, | 361 | + | func(context.Context, string, string) (int, error) { return 500, nil }, |
| 305 | func(context.Context, string) error { return errors.New("refused") }, | 362 | func(context.Context, string) error { return errors.New("refused") }, | ||
| 306 | )) | 363 | )) | ||
| 307 | 364 | ||||
| @@ -312,12 +369,13 @@ func TestDeploy_Mockery_HTTPFailureKeepsOld(t *testing.T) { | |||||
| 312 | assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error") | 369 | assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error") | ||
| 313 | require.Len(t, result.Services["web"].Diagnostics, 1, "failure diagnostics are kept separately") | 370 | require.Len(t, result.Services["web"].Diagnostics, 1, "failure diagnostics are kept separately") | ||
| 314 | assert.Equal(t, "boom", result.Services["web"].Diagnostics[0]) | 371 | assert.Equal(t, "boom", result.Services["web"].Diagnostics[0]) | ||
| 315 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything) | 372 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything) |
| 316 | - | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", false) | |||
| 317 | runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything) | 373 | runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything) | ||
| 374 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 375 | + | state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything) | |||
| 318 | } | 376 | } | ||
| 319 | 377 | ||||
| 320 | - | func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) { | 378 | + | func TestDeploy_Mockery_VolumeReplacementMarksUnsafe(t *testing.T) { |
| 321 | ctx := context.Background() | 379 | ctx := context.Background() | ||
| 322 | state, runtime, images, secrets := mockDeps(t) | 380 | state, runtime, images, secrets := mockDeps(t) | ||
| 323 | 381 | ||||
| @@ -361,8 +419,15 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) { | |||||
| 361 | }, true, nil) | 419 | }, true, nil) | ||
| 362 | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | 420 | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | ||
| 363 | 421 | ||||
| 364 | - | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | 422 | + | var order []string |
| 365 | - | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | 423 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error { |
| 424 | + | order = append(order, "stop-old") | |||
| 425 | + | return nil | |||
| 426 | + | }).Once() | |||
| 427 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).RunAndReturn(func(context.Context, string, bool) error { | |||
| 428 | + | order = append(order, "remove-old") | |||
| 429 | + | return nil | |||
| 430 | + | }).Once() | |||
| 366 | // The superseded writer's confirmed disappearance releases its claims. | 431 | // The superseded writer's confirmed disappearance releases its claims. | ||
| 367 | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | 432 | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | ||
| 368 | expectNetworkProvision(runtime, "app-uuid-blog", 1) | 433 | expectNetworkProvision(runtime, "app-uuid-blog", 1) | ||
| @@ -371,7 +436,10 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) { | |||||
| 371 | state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool { | 436 | state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool { | ||
| 372 | return inhibition.App == "blog" && inhibition.Service == "web" && | 437 | return inhibition.App == "blog" && inhibition.Service == "web" && | ||
| 373 | inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitReplacementPending | 438 | inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitReplacementPending | ||
| 374 | - | })).Return(nil).Once() | 439 | + | })).RunAndReturn(func(context.Context, domain.AppRecoveryInhibition) error { |
| 440 | + | order = append(order, "inhibit") | |||
| 441 | + | return nil | |||
| 442 | + | }).Once() | |||
| 375 | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | 443 | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | ||
| 376 | runtime.EXPECT().CreateVolume(mock.Anything, "gordon-blog--web--vol--d", mock.MatchedBy(func(labels map[string]string) bool { | 444 | runtime.EXPECT().CreateVolume(mock.Anything, "gordon-blog--web--vol--d", mock.MatchedBy(func(labels map[string]string) bool { | ||
| 377 | return labels[domain.LabelApp] == "blog" && | 445 | return labels[domain.LabelApp] == "blog" && | ||
| @@ -386,7 +454,10 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) { | |||||
| 386 | require.GreaterOrEqual(t, ownershipSaves, 1, | 454 | require.GreaterOrEqual(t, ownershipSaves, 1, | ||
| 387 | "ownership must be reserved before the runtime volume exists") | 455 | "ownership must be reserved before the runtime volume exists") | ||
| 388 | }).Return(nil).Once() | 456 | }).Return(nil).Once() | ||
| 389 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | 457 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, *domain.ContainerConfig) (*domain.Container, error) { |
| 458 | + | order = append(order, "create-new") | |||
| 459 | + | return &domain.Container{ID: "c-new", Name: "web"}, nil | |||
| 460 | + | }).Once() | |||
| 390 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | 461 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | ||
| 391 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | 462 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | ||
| 392 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | 463 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool { | ||
| @@ -411,7 +482,8 @@ func TestDeploy_Mockery_InterruptedVolumeMarksUnsafe(t *testing.T) { | |||||
| 411 | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | 482 | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | ||
| 412 | require.NoError(t, err) | 483 | require.NoError(t, err) | ||
| 413 | require.NotNil(t, result) | 484 | require.NotNil(t, result) | ||
| 414 | - | assert.Equal(t, []string{"web"}, result.Interrupted) | 485 | + | assert.Equal(t, []string{"inhibit", "stop-old", "remove-old", "create-new"}, order, |
| 486 | + | "the durable inhibition is written before the superseded writer can be stopped, and the writer is gone before the replacement can write") | |||
| 415 | assert.True(t, result.Services["web"].RestartUnsafe) | 487 | assert.True(t, result.Services["web"].RestartUnsafe) | ||
| 416 | assert.True(t, restartUnsafeSaved, "the terminal ownership write must record the restart-unsafe service") | 488 | assert.True(t, restartUnsafeSaved, "the terminal ownership write must record the restart-unsafe service") | ||
| 417 | assert.GreaterOrEqual(t, ownershipSaves, 2, "the volume reservation and the terminal ownership write must both be durable") | 489 | assert.GreaterOrEqual(t, ownershipSaves, 2, "the volume reservation and the terminal ownership write must both be durable") | ||
| @@ -639,7 +711,7 @@ func TestStart_RunningContainerRefreshesShiftedBinds(t *testing.T) { | |||||
| 639 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | 711 | deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, | ||
| 640 | zerowrap.Default(), | 712 | zerowrap.Default(), | ||
| 641 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 713 | ).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 642 | - | func(context.Context, string) (int, error) { return 200, nil }, | 714 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 643 | func(context.Context, string) error { return nil }, | 715 | func(context.Context, string) error { return nil }, | ||
| 644 | )) | 716 | )) | ||
| 645 | result, err := svc.Start(ctx, "blog", "") | 717 | result, err := svc.Start(ctx, "blog", "") | ||
M internal/usecase/deployment/execute.go +648 -203
| @@ -21,49 +21,225 @@ const failLogTailLines = 50 | |||||
| 21 | const failLogTailTimeout = 10 * time.Second | 21 | const failLogTailTimeout = 10 * time.Second | ||
| 22 | 22 | ||||
| 23 | // Deploy executes a preflighted revision: fail-fast across services in | 23 | // Deploy executes a preflighted revision: fail-fast across services in | ||
| 24 | - | // sorted name order. HTTP-only services whose interfaces are all public, | 24 | + | // sorted name order. Every service is replaced sequentially — withdraw |
| 25 | - | // with no volumes and no binds, keep the old container serving until the | 25 | + | // traffic, retire the superseded container, start the replacement and wait |
| 26 | - | // replacement passes readiness, then switch with a bounded drain and | 26 | + | // for its readiness probe — so a deployment may briefly interrupt the |
| 27 | - | // retire the old container by exact ID. TCP/UDP, any internal HTTP | 27 | + | // service, and two Gordon-managed generations of one service never run at |
| 28 | - | // interface, and bind- or volume-owning services use replacement with | 28 | + | // the same time. Volumes are never deleted: no RemoveVolume call, no |
| 29 | - | // interruption. Volumes are never deleted: no RemoveVolume call, no | |||
| 30 | // volume-deletion flags on container removal. | 29 | // volume-deletion flags on container removal. | ||
| 30 | + | // | |||
| 31 | + | // It is the synchronous composition of the two engine phases: StartDeploy | |||
| 32 | + | // claims the key and journals the operation, then ExecuteDeploy runs the | |||
| 33 | + | // effects for the owner only. | |||
| 31 | func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) { | 34 | func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) { | ||
| 32 | - | release, err := s.acquireAppContext(ctx, input.App) | 35 | + | started, err := s.StartDeploy(ctx, input) |
| 36 | + | if err != nil { | |||
| 37 | + | return nil, err | |||
| 38 | + | } | |||
| 39 | + | if !started.Owned { | |||
| 40 | + | // The key already answered this request: its stored journal is the | |||
| 41 | + | // result and no workload is touched again. | |||
| 42 | + | return journaledOrNil(input, &started.Claim.Journal, started.ReplayError()) | |||
| 43 | + | } | |||
| 44 | + | return s.ExecuteDeploy(ctx, started.Claim) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // DeployClaim is the durable identity of one started deploy operation. It is | |||
| 48 | + | // the hand-off between the two phases: StartDeploy returns it and | |||
| 49 | + | // ExecuteDeploy consumes it. App and Op are the durable identity, so a | |||
| 50 | + | // caller that reconstructs the claim from them alone (for example after a | |||
| 51 | + | // restart, with no in-process state) leaves Journal and Resolved empty and | |||
| 52 | + | // ExecuteDeploy loads and resolves both from the store. | |||
| 53 | + | type DeployClaim struct { | |||
| 54 | + | App string | |||
| 55 | + | Op string | |||
| 56 | + | Service string | |||
| 57 | + | Revision string | |||
| 58 | + | // Resolved is the revision StartDeploy resolved for this claim. Empty | |||
| 59 | + | // when the claim was reconstructed from identity alone. | |||
| 60 | + | Resolved domain.AppDesiredRevision | |||
| 61 | + | // Journal is the claimed operation held in process. Its Op is empty when | |||
| 62 | + | // the claim was reconstructed from identity alone. | |||
| 63 | + | Journal domain.AppOperation | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // StartDeployResult reports whether this caller owns the claimed operation. | |||
| 67 | + | // Owned is true only for the single caller that claimed Op. Every idempotent | |||
| 68 | + | // replay (the same key already answered) reports Owned false, and the stored | |||
| 69 | + | // journal in Claim.Journal is the result. Only an owner may pass the claim to | |||
| 70 | + | // ExecuteDeploy. | |||
| 71 | + | type StartDeployResult struct { | |||
| 72 | + | Claim DeployClaim | |||
| 73 | + | Owned bool | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // ReplayError is the explicit disposition of a claim this caller does not | |||
| 77 | + | // own: nil for a terminal success, and a conflict for an in-flight, | |||
| 78 | + | // interrupted, or failed operation, so a replay is never mistaken for a | |||
| 79 | + | // second execution. It is nil for an owned operation. | |||
| 80 | + | func (r StartDeployResult) ReplayError() error { | |||
| 81 | + | if r.Owned { | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | return replayError(r.Claim.Journal) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // StartDeploy performs the first phase of a deploy. Under the app lock it | |||
| 88 | + | // converges any interrupted predecessor, resolves the request to a revision | |||
| 89 | + | // and runs the targeted-deploy convergence checks, atomically claims the | |||
| 90 | + | // request key, and persists the non-terminal journal. It performs no image | |||
| 91 | + | // pull, pinning, or workload mutation, so a crash right after it leaves a | |||
| 92 | + | // durable claim that reconciliation can converge. | |||
| 93 | + | // | |||
| 94 | + | // The returned claim distinguishes the newly owned operation (Owned true) | |||
| 95 | + | // from an idempotent replay (Owned false): the same key is never handed to | |||
| 96 | + | // two owners, and a replay never executes effects again. | |||
| 97 | + | func (s *Service) StartDeploy(ctx context.Context, input DeployInput) (*StartDeployResult, error) { | |||
| 98 | + | // Planning and claiming acquire no runtime resource, so StartDeploy takes | |||
| 99 | + | // only the per-app coordinator. ExecuteDeploy takes the GC shared lease | |||
| 100 | + | // across resource acquisition and publication, where it is required. | |||
| 101 | + | release, err := s.coord.acquire(ctx, input.App) | |||
| 33 | if err != nil { | 102 | if err != nil { | ||
| 34 | return nil, err | 103 | return nil, err | ||
| 35 | } | 104 | } | ||
| 36 | defer release() | 105 | defer release() | ||
| 37 | - | return s.deployLocked(ctx, input) | 106 | + | return s.startDeployLocked(ctx, input) |
| 38 | } | 107 | } | ||
| 39 | 108 | ||||
| 40 | - | func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployResult, error) { | 109 | + | func (s *Service) startDeployLocked(ctx context.Context, input DeployInput) (*StartDeployResult, error) { |
| 41 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 110 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 42 | zerowrap.FieldLayer: "usecase", | 111 | zerowrap.FieldLayer: "usecase", | ||
| 43 | - | zerowrap.FieldUseCase: "Deploy", | 112 | + | zerowrap.FieldUseCase: "StartDeploy", |
| 44 | "app": input.App, | 113 | "app": input.App, | ||
| 45 | }) | 114 | }) | ||
| 46 | log := zerowrap.FromCtx(ctx) | 115 | log := zerowrap.FromCtx(ctx) | ||
| 47 | 116 | ||||
| 48 | - | pinned, op, replayed, err := s.preflightLocked(ctx, input) | 117 | + | if err := s.deps.State.Recover(ctx); err != nil { |
| 49 | - | if replayed || err != nil { | 118 | + | return nil, fmt.Errorf("deployment: recover before deploy: %w", err) |
| 50 | - | // The key already answered this request: its stored journal is | 119 | + | } |
| 51 | - | // the result and no workload is touched again. A failed preflight | 120 | + | // A mutation of this app never starts on top of an interrupted one: its |
| 52 | - | // returns the journal together with its error. | 121 | + | // never-published candidates must be gone before this mutation creates a |
| 53 | - | return journaledOrNil(input, op, err) | 122 | + | // generation of the same service. ACTIVE is materialized first, so the |
| 123 | + | // published generation cannot be mistaken for a leftover. | |||
| 124 | + | if err := s.reconcileInterruptedDeploy(ctx, input.App); err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | rev, op, owned, err := s.claimDeploymentLocked(ctx, input) | |||
| 128 | + | if err != nil { | |||
| 129 | + | return nil, err | |||
| 130 | + | } | |||
| 131 | + | if owned { | |||
| 132 | + | // The claimed operation is now live in this process. It is | |||
| 133 | + | // registered before the caller releases the coordinator, so a | |||
| 134 | + | // foreground mutation that takes the lock in the gap before | |||
| 135 | + | // ExecuteDeploy cannot reconcile the claim away. | |||
| 136 | + | s.markOperationLive(input.App, op.Op) | |||
| 137 | + | } else { | |||
| 138 | + | log.Info().Str("op", op.Op).Msg("deployment: replayed an already claimed operation") | |||
| 139 | + | } | |||
| 140 | + | return &StartDeployResult{ | |||
| 141 | + | Claim: DeployClaim{ | |||
| 142 | + | App: input.App, Op: op.Op, Service: input.Service, Revision: input.Revision, | |||
| 143 | + | Resolved: rev, Journal: op, | |||
| 144 | + | }, | |||
| 145 | + | Owned: owned, | |||
| 146 | + | }, nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // ExecuteDeploy performs the second phase of a deploy: it reacquires the app | |||
| 150 | + | // lock, loads and verifies the claimed journal by app/op identity, runs the | |||
| 151 | + | // image pull/pin and the sequential replacement, and records the terminal | |||
| 152 | + | // outcome. A claim that is already terminal, or that does not match its | |||
| 153 | + | // identity, is never executed: it replays its stored outcome instead. An | |||
| 154 | + | // operation that stops mid-execution (shutdown or cancellation) leaves its | |||
| 155 | + | // non-terminal journal behind for reconciliation, so it can never run twice. | |||
| 156 | + | // | |||
| 157 | + | // The operation stays live until this returns: the store record, not the | |||
| 158 | + | // in-process claim, is what executes, and the live marker is cleared only | |||
| 159 | + | // after the outcome was recorded or a recoverable non-terminal claim was | |||
| 160 | + | // left behind, while the app lock is still held. | |||
| 161 | + | func (s *Service) ExecuteDeploy(ctx context.Context, claim DeployClaim) (*DeployResult, error) { | |||
| 162 | + | release, err := s.acquireAppContext(ctx, claim.App) | |||
| 163 | + | if err != nil { | |||
| 164 | + | // The claim is left as persisted: recoverable, but no longer owned | |||
| 165 | + | // here, so a later reconciliation can converge it. | |||
| 166 | + | s.clearOperationLive(claim.App, claim.Op) | |||
| 167 | + | return nil, err | |||
| 168 | + | } | |||
| 169 | + | defer release() | |||
| 170 | + | // Registered after release, so it runs first: the marker is dropped | |||
| 171 | + | // before the app lock is released, and never while a live goroutine may | |||
| 172 | + | // still persist state. | |||
| 173 | + | defer s.clearOperationLive(claim.App, claim.Op) | |||
| 174 | + | return s.executeLocked(ctx, claim) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // AbandonDeploy settles a claim this process owns but will never execute, such | |||
| 178 | + | // as when daemon shutdown races the hand-off between StartDeploy and | |||
| 179 | + | // ExecuteDeploy. It drops the in-process live marker and converges the still | |||
| 180 | + | // non-terminal journal as an interrupted operation, so no durable claim stays | |||
| 181 | + | // marked live forever and a later reconciliation reads a terminal outcome | |||
| 182 | + | // instead of a permanently in-flight key. It takes the app lock but performs | |||
| 183 | + | // no workload effect. | |||
| 184 | + | func (s *Service) AbandonDeploy(ctx context.Context, claim DeployClaim) error { | |||
| 185 | + | if claim.App == "" || claim.Op == "" { | |||
| 186 | + | return fmt.Errorf("deployment: abandon requires an app and operation identity: %w", domain.ErrAppStateConflict) | |||
| 187 | + | } | |||
| 188 | + | // The marker is dropped before any fallible step: even if convergence | |||
| 189 | + | // fails, the claim is no longer owned here, so a later reconciliation | |||
| 190 | + | // (foreground or boot) can converge it instead of skipping it as live. | |||
| 191 | + | s.clearOperationLive(claim.App, claim.Op) | |||
| 192 | + | release, err := s.coord.acquire(ctx, claim.App) | |||
| 193 | + | if err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | defer release() | |||
| 197 | + | return s.reconcileInterruptedDeploy(ctx, claim.App) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // executeLocked runs the claimed, non-terminal operation. The caller holds | |||
| 201 | + | // the app lock. Every failure that happens before a terminal outcome is | |||
| 202 | + | // recorded (load active, revision, service step, traffic) leaves the journal | |||
| 203 | + | // with its step state, never a silent success. | |||
| 204 | + | func (s *Service) executeLocked(ctx context.Context, claim DeployClaim) (*DeployResult, error) { | |||
| 205 | + | input := DeployInput{App: claim.App, Revision: claim.Revision, Service: claim.Service} | |||
| 206 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 207 | + | zerowrap.FieldLayer: "usecase", | |||
| 208 | + | zerowrap.FieldUseCase: "ExecuteDeploy", | |||
| 209 | + | "app": claim.App, | |||
| 210 | + | "op": claim.Op, | |||
| 211 | + | }) | |||
| 212 | + | log := zerowrap.FromCtx(ctx) | |||
| 213 | + | ||||
| 214 | + | op, err := s.loadClaim(ctx, claim) | |||
| 215 | + | if err != nil { | |||
| 216 | + | return nil, err | |||
| 217 | + | } | |||
| 218 | + | if op.Terminal() { | |||
| 219 | + | // The claim was finalized between the phases: never execute a settled | |||
| 220 | + | // key, replay its stored outcome instead. | |||
| 221 | + | return journaledDeployResult(input, &op), replayError(op) | |||
| 222 | + | } | |||
| 223 | + | // The claimed revision is authoritative for the whole execution: it was | |||
| 224 | + | // resolved when the key was claimed and is what the journal records, so a | |||
| 225 | + | // desired-state change between the two phases must never redirect this | |||
| 226 | + | // operation to a different revision than the one it pinned. | |||
| 227 | + | rev, err := s.claimRevision(ctx, claim, op) | |||
| 228 | + | if err != nil { | |||
| 229 | + | s.failOperation(ctx, &op, err, "error") | |||
| 230 | + | return journaledDeployResult(input, &op), err | |||
| 231 | + | } | |||
| 232 | + | pinned, err := s.pinPreflightLocked(ctx, input.App, input.Service, rev, &op) | |||
| 233 | + | if err != nil { | |||
| 234 | + | return journaledDeployResult(input, &op), err | |||
| 54 | } | 235 | } | ||
| 55 | sort.Slice(pinned, func(i, j int) bool { return pinned[i].name < pinned[j].name }) | 236 | sort.Slice(pinned, func(i, j int) bool { return pinned[i].name < pinned[j].name }) | ||
| 56 | 237 | ||||
| 57 | active, _, err := s.deps.State.LoadActive(ctx, input.App) | 238 | active, _, err := s.deps.State.LoadActive(ctx, input.App) | ||
| 58 | if err != nil { | 239 | if err != nil { | ||
| 59 | loadErr := fmt.Errorf("deployment: load active: %w", err) | 240 | loadErr := fmt.Errorf("deployment: load active: %w", err) | ||
| 60 | - | s.failOperation(ctx, op, loadErr, "error") | 241 | + | s.failOperation(ctx, &op, loadErr, "error") |
| 61 | - | return journaledDeployResult(input, op), loadErr | 242 | + | return journaledDeployResult(input, &op), loadErr |
| 62 | - | } | |||
| 63 | - | rev, err := s.resolveRevision(ctx, input) | |||
| 64 | - | if err != nil { | |||
| 65 | - | s.failOperation(ctx, op, err, "error") | |||
| 66 | - | return journaledDeployResult(input, op), err | |||
| 67 | } | 243 | } | ||
| 68 | 244 | ||||
| 69 | result := &DeployResult{ | 245 | result := &DeployResult{ | ||
| @@ -75,29 +251,61 @@ func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployR | |||||
| 75 | // A targeted deploy is intentionally a partial plan: services omitted | 251 | // A targeted deploy is intentionally a partial plan: services omitted | ||
| 76 | // from pinned remain active. Full deploys reconcile actual removals. | 252 | // from pinned remain active. Full deploys reconcile actual removals. | ||
| 77 | if input.Service == "" { | 253 | if input.Service == "" { | ||
| 78 | - | if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, op, result, log); err != nil { | 254 | + | if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, &op, result, log); err != nil { |
| 79 | return result, err | 255 | return result, err | ||
| 80 | } | 256 | } | ||
| 81 | } | 257 | } | ||
| 82 | for i, p := range pinned { | 258 | for i, p := range pinned { | ||
| 83 | - | if err := s.runServiceStep(ctx, input.App, rev.Revision, i, p, op, active, result, log); err != nil { | 259 | + | if err := s.runServiceStep(ctx, input.App, rev.Revision, i, p, &op, active, result, log); err != nil { |
| 84 | return result, err | 260 | return result, err | ||
| 85 | } | 261 | } | ||
| 86 | } | 262 | } | ||
| 87 | op.Outcome = ComputeOutcome(result.Services) | 263 | op.Outcome = ComputeOutcome(result.Services) | ||
| 88 | op.Warnings = journalWarnings(collectCleanupWarnings(result.Services)) | 264 | op.Warnings = journalWarnings(collectCleanupWarnings(result.Services)) | ||
| 89 | result.CleanupWarnings = collectCleanupWarnings(result.Services) | 265 | result.CleanupWarnings = collectCleanupWarnings(result.Services) | ||
| 90 | - | if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil { | 266 | + | if saveErr := s.deps.State.SaveOperation(ctx, op); saveErr != nil { |
| 91 | log.Warn().Err(saveErr).Msg("deployment: failed to record deploy outcome") | 267 | log.Warn().Err(saveErr).Msg("deployment: failed to record deploy outcome") | ||
| 92 | } | 268 | } | ||
| 93 | return result, nil | 269 | return result, nil | ||
| 94 | } | 270 | } | ||
| 95 | 271 | ||||
| 96 | - | // runServiceStep executes one pinned service in the journal: replace, | 272 | + | // loadClaim verifies the claimed journal by app/op identity. The store is |
| 97 | - | // publish ACTIVE, then cutover traffic and retire the replaced container. | 273 | + | // authoritative: the operation is always reloaded by app/op ID, so a stale |
| 98 | - | // The step is recorded as succeeded only after the runtime effect, the | 274 | + | // in-process journal can never be executed (for example when another actor |
| 99 | - | // ACTIVE publication, and the traffic apply all succeeded, so a journaled | 275 | + | // finalized the key between the claim and execution). A journal that does |
| 100 | - | // success is never ahead of what is routed. | 276 | + | // not match the identity, or is not a deploy, is refused. |
| 277 | + | func (s *Service) loadClaim(ctx context.Context, claim DeployClaim) (domain.AppOperation, error) { | |||
| 278 | + | if claim.App == "" || claim.Op == "" { | |||
| 279 | + | return domain.AppOperation{}, fmt.Errorf("deployment: execute requires an app and operation identity: %w", domain.ErrAppStateConflict) | |||
| 280 | + | } | |||
| 281 | + | op, err := s.deps.State.LoadOperation(ctx, claim.App, claim.Op) | |||
| 282 | + | if err != nil { | |||
| 283 | + | return domain.AppOperation{}, fmt.Errorf("deployment: load claimed operation %q: %w", claim.Op, err) | |||
| 284 | + | } | |||
| 285 | + | if op.App != claim.App || op.Op != claim.Op { | |||
| 286 | + | return domain.AppOperation{}, fmt.Errorf("deployment: claim does not identify app %q operation %q: %w", claim.App, claim.Op, domain.ErrAppStateConflict) | |||
| 287 | + | } | |||
| 288 | + | if op.Kind != "deploy" { | |||
| 289 | + | return domain.AppOperation{}, fmt.Errorf("deployment: operation %q is %q, not a deploy: %w", claim.Op, op.Kind, domain.ErrAppStateConflict) | |||
| 290 | + | } | |||
| 291 | + | return op, nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | // claimRevision returns the revision StartDeploy captured. A claim | |||
| 295 | + | // reconstructed from identity alone resolves the revision its journal | |||
| 296 | + | // recorded, so a resumed execution pins the revision it was started for. | |||
| 297 | + | func (s *Service) claimRevision(ctx context.Context, claim DeployClaim, op domain.AppOperation) (domain.AppDesiredRevision, error) { | |||
| 298 | + | if claim.Resolved.Revision != "" { | |||
| 299 | + | return claim.Resolved, nil | |||
| 300 | + | } | |||
| 301 | + | return s.resolveRevision(ctx, DeployInput{App: claim.App, Revision: op.InputRevision, Service: claim.Service}) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // runServiceStep executes one pinned service in the journal: replace it, | |||
| 305 | + | // publish its ACTIVE entry, then publish traffic for the new container. The | |||
| 306 | + | // step is recorded as succeeded only after the runtime effect, the ACTIVE | |||
| 307 | + | // publication, and the traffic apply all succeeded, so a journaled success | |||
| 308 | + | // is never ahead of what is routed. | |||
| 101 | func (s *Service) runServiceStep( | 309 | func (s *Service) runServiceStep( | ||
| 102 | ctx context.Context, | 310 | ctx context.Context, | ||
| 103 | app, revision string, | 311 | app, revision string, | ||
| @@ -112,22 +320,29 @@ func (s *Service) runServiceStep( | |||||
| 112 | if eff, ok := active.Services[p.name]; ok { | 320 | if eff, ok := active.Services[p.name]; ok { | ||
| 113 | before = eff.Container | 321 | before = eff.Container | ||
| 114 | } | 322 | } | ||
| 115 | - | svcResult, interrupted := s.deployService(ctx, app, revision, p, op.Op, before, activeStopGrace(active, p.name)) | |||
| 116 | - | result.Services[p.name] = svcResult | |||
| 117 | - | if interrupted { | |||
| 118 | - | result.Interrupted = append(result.Interrupted, p.name) | |||
| 119 | - | } | |||
| 120 | stepID := "service." + p.name + ".replace" | 323 | stepID := "service." + p.name + ".replace" | ||
| 121 | - | step := domain.AppOperationStep{ | 324 | + | // The step is journaled before the replacement runs and carries the |
| 325 | + | // candidate's container ID as soon as it exists, so an interrupted deploy | |||
| 326 | + | // leaves a trace boot recovery can clean up instead of an untracked | |||
| 327 | + | // generation. | |||
| 328 | + | step := &op.Steps[index+1] | |||
| 329 | + | *step = domain.AppOperationStep{ | |||
| 122 | ID: stepID, Service: p.name, | 330 | ID: stepID, Service: p.name, | ||
| 123 | Digest: p.digest, Image: p.runtimeImage, | 331 | Digest: p.digest, Image: p.runtimeImage, | ||
| 124 | - | Before: before, After: svcResult.After, | 332 | + | Before: before, State: domain.AppStepPending, |
| 333 | + | } | |||
| 334 | + | svcResult := s.deployService(ctx, app, revision, p, op.Op, before, activeStopGrace(active, p.name), s.journalCandidate(op, index+1)) | |||
| 335 | + | result.Services[p.name] = svcResult | |||
| 336 | + | // A recorded candidate is authoritative while the step is unresolved: a | |||
| 337 | + | // replacement that failed after creating its container must stay traceable | |||
| 338 | + | // for reconciliation. | |||
| 339 | + | if svcResult.After != "" { | |||
| 340 | + | step.After = svcResult.After | |||
| 125 | } | 341 | } | ||
| 126 | fail := func(err error) error { | 342 | fail := func(err error) error { | ||
| 127 | step.State = domain.AppStepFailed | 343 | step.State = domain.AppStepFailed | ||
| 128 | step.Error = err.Error() | 344 | step.Error = err.Error() | ||
| 129 | step.Diagnostics = svcResult.Diagnostics | 345 | step.Diagnostics = svcResult.Diagnostics | ||
| 130 | - | op.Steps[index+1] = step | |||
| 131 | op.Warnings = journalWarnings(collectCleanupWarnings(result.Services)) | 346 | op.Warnings = journalWarnings(collectCleanupWarnings(result.Services)) | ||
| 132 | op.Outcome = ComputeOutcome(result.Services) | 347 | op.Outcome = ComputeOutcome(result.Services) | ||
| 133 | if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil { | 348 | if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil { | ||
| @@ -140,17 +355,18 @@ func (s *Service) runServiceStep( | |||||
| 140 | return fail(fmt.Errorf("deployment: service %q failed at %s: %s: %w", | 355 | return fail(fmt.Errorf("deployment: service %q failed at %s: %s: %w", | ||
| 141 | p.name, stepID, svcResult.Error, domain.ErrAppStateConflict)) | 356 | p.name, stepID, svcResult.Error, domain.ErrAppStateConflict)) | ||
| 142 | } | 357 | } | ||
| 143 | - | // Publish per-service effective state first: the proxy switches to the | 358 | + | // Publish the per-service effective state: the new binds become the |
| 144 | - | // new binds at publication. Retire the replaced container only AFTER | 359 | + | // service's recorded backend before the proxy is repointed at them. |
| 145 | - | // publication (no outage on crash between the two); a retire failure | |||
| 146 | - | // is a cleanup warning, not an outcome flip. | |||
| 147 | if err := s.publishService(ctx, app, revision, p, svcResult, op.Op); err != nil { | 360 | if err := s.publishService(ctx, app, revision, p, svcResult, op.Op); err != nil { | ||
| 148 | svcResult.Result = "failed" | 361 | svcResult.Result = "failed" | ||
| 149 | svcResult.Error = err.Error() | 362 | svcResult.Error = err.Error() | ||
| 150 | result.Services[p.name] = svcResult | 363 | result.Services[p.name] = svcResult | ||
| 151 | return fail(err) | 364 | return fail(err) | ||
| 152 | } | 365 | } | ||
| 153 | - | if err := s.cutoverService(ctx, app, p, &svcResult, result); err != nil { | 366 | + | // Rebuild and publish the traffic graph. ACTIVE already names the new |
| 367 | + | // container, but the operation is a failure until routing accepted it, | |||
| 368 | + | // so a rejected graph must never be journaled as success. | |||
| 369 | + | if err := s.refreshTraffic(ctx, app); err != nil { | |||
| 154 | svcResult.Result = "failed" | 370 | svcResult.Result = "failed" | ||
| 155 | svcResult.Error = err.Error() | 371 | svcResult.Error = err.Error() | ||
| 156 | result.Services[p.name] = svcResult | 372 | result.Services[p.name] = svcResult | ||
| @@ -158,7 +374,7 @@ func (s *Service) runServiceStep( | |||||
| 158 | } | 374 | } | ||
| 159 | result.Services[p.name] = svcResult | 375 | result.Services[p.name] = svcResult | ||
| 160 | step.State = domain.AppStepSucceeded | 376 | step.State = domain.AppStepSucceeded | ||
| 161 | - | op.Steps[index+1] = step | 377 | + | step.Error = "" |
| 162 | if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil { | 378 | if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil { | ||
| 163 | log.Warn().Err(saveErr).Msg("deployment: failed to checkpoint service progress") | 379 | log.Warn().Err(saveErr).Msg("deployment: failed to checkpoint service progress") | ||
| 164 | } | 380 | } | ||
| @@ -310,175 +526,332 @@ func (s *Service) retireRemovedService(ctx context.Context, app, name string, ef | |||||
| 310 | return step, nil, nil | 526 | return step, nil, nil | ||
| 311 | } | 527 | } | ||
| 312 | 528 | ||||
| 313 | - | // cutoverService applies traffic after publication, then retires the | 529 | + | // candidateJournal durably records a freshly created replacement container |
| 314 | - | // replaced container. A traffic failure is returned to the caller, which | 530 | + | // before it can be started or join a network. An interrupted deployment then |
| 315 | - | // records the failed step: ACTIVE is published but the new service is not | 531 | + | // leaves a trace boot recovery removes, instead of an untracked running |
| 316 | - | // routable, so the operation must not report success. The old container | 532 | + | // generation that could overlap the one recovery rebuilds. |
| 317 | - | // is retained when traffic activation fails. | 533 | + | // |
| 318 | - | func (s *Service) cutoverService(ctx context.Context, app string, p pinnedService, svcResult *ServiceResult, result *DeployResult) error { | 534 | + | // The window between the runtime creating a container and this write is the |
| 319 | - | if trafficErr := s.refreshTraffic(ctx, app); trafficErr != nil { | 535 | + | // irreducible one: a container ID cannot be recorded before it exists. |
| 320 | - | return trafficErr | 536 | + | // sync/atomic is not needed here: the journal write is synchronous. |
| 321 | - | } | 537 | + | type candidateJournal func(ctx context.Context, containerID string) error |
| 322 | - | if svcResult.Retire != "" { | 538 | + | |
| 323 | - | drainWithDeadline(ctx, p.spec.StopGrace) | 539 | + | // journalCandidate records one created candidate in the operation journal. |
| 324 | - | // The replaced generation keeps its inhibition: only a published | 540 | + | // The step is addressed by index and read at write time, so a later append to |
| 325 | - | // replacement may clear it. | 541 | + | // the operation's steps can never leave the recorder writing to a stale |
| 326 | - | retired := s.retireContainer(ctx, app, retireOptions{ | 542 | + | // element. A nil operation (an internal path with no journal) records nothing. |
| 327 | - | Service: p.name, Grace: svcResult.RetireGrace, | 543 | + | func (s *Service) journalCandidate(op *domain.AppOperation, index int) candidateJournal { |
| 328 | - | }, svcResult.Retire) | 544 | + | if op == nil || index < 0 || index >= len(op.Steps) { |
| 329 | - | svcResult.CleanupWarnings = append(svcResult.CleanupWarnings, retired.Warnings...) | 545 | + | return nil |
| 546 | + | } | |||
| 547 | + | return func(ctx context.Context, containerID string) error { | |||
| 548 | + | op.Steps[index].After = containerID | |||
| 549 | + | if err := s.deps.State.SaveOperation(ctx, *op); err != nil { | |||
| 550 | + | return fmt.Errorf("deployment: record candidate %s: %w", containerID, err) | |||
| 551 | + | } | |||
| 552 | + | return nil | |||
| 330 | } | 553 | } | ||
| 331 | - | return nil | |||
| 332 | } | 554 | } | ||
| 333 | 555 | ||||
| 334 | - | // deployService replaces one service. beforeGrace is the effective stop | 556 | + | // reconcileInterruptedDeploy converges the journal of a deployment that a |
| 335 | - | // grace of the container being replaced. It returns the terminal result | 557 | + | // crash, shutdown, or failed cleanup interrupted. A candidate that was |
| 336 | - | // and whether the service saw interruption. | 558 | + | // created but never published is removed and its loopback claims released, so |
| 337 | - | func (s *Service) deployService(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) { | 559 | + | // recovery can rebuild the published generation without ever running two |
| 338 | - | if httpEligible(p.spec) { | 560 | + | // generations of one service. The interrupted operation is then finalized as |
| 339 | - | return s.deployHTTP(ctx, app, revision, p, opID, before, beforeGrace) | 561 | + | // failed: a repeat of its key replays an explicit failure instead of a |
| 562 | + | // permanent in-flight claim. | |||
| 563 | + | // | |||
| 564 | + | // It also retries the candidates of a failed operation whose cleanup did not | |||
| 565 | + | // complete, so a leftover stays visible and is eventually removed. The | |||
| 566 | + | // published generation recorded in ACTIVE is never touched. | |||
| 567 | + | // | |||
| 568 | + | // It runs at boot and before every mutation of the app, always under the app | |||
| 569 | + | // lock, so a non-terminal journal is definitively interrupted, and a mutation | |||
| 570 | + | // that cannot reconcile a predecessor does not proceed. It reads the | |||
| 571 | + | // operation journal first and only then ACTIVE, so the common case of a | |||
| 572 | + | // successful operation costs one state read. | |||
| 573 | + | // | |||
| 574 | + | // Reconciliation fails closed on a candidate that cannot be removed: an | |||
| 575 | + | // orphan that may still run aborts the caller instead of being journaled as a | |||
| 576 | + | // recoverable leftover. Because the journal is never finalized while such a | |||
| 577 | + | // leftover exists, it stays the latest operation and a newer operation can | |||
| 578 | + | // never be created on top of it and mask it. | |||
| 579 | + | func (s *Service) reconcileInterruptedDeploy(ctx context.Context, app string) error { | |||
| 580 | + | op, ok, err := s.deps.State.LoadLatestOperation(ctx, app) | |||
| 581 | + | if err != nil { | |||
| 582 | + | return fmt.Errorf("deployment: load interrupted operation for %q: %w", app, err) | |||
| 583 | + | } | |||
| 584 | + | if !ok { | |||
| 585 | + | return nil | |||
| 586 | + | } | |||
| 587 | + | if !op.Terminal() && s.operationLive(app, op.Op) { | |||
| 588 | + | // The operation is owned by a live goroutine in this process that | |||
| 589 | + | // may still be persisting its outcome. Reconcile it only after that | |||
| 590 | + | // owner has exited; until then the store record is authoritative and | |||
| 591 | + | // must not be finalized here. | |||
| 592 | + | return nil | |||
| 593 | + | } | |||
| 594 | + | terminal := op.Terminal() | |||
| 595 | + | needsActive := false | |||
| 596 | + | for _, step := range op.Steps { | |||
| 597 | + | if reconcileNeedsContainer(step, terminal) { | |||
| 598 | + | needsActive = true | |||
| 599 | + | break | |||
| 600 | + | } | |||
| 601 | + | } | |||
| 602 | + | var active domain.AppActive | |||
| 603 | + | if needsActive { | |||
| 604 | + | active, _, err = s.deps.State.LoadActive(ctx, app) | |||
| 605 | + | if err != nil { | |||
| 606 | + | return fmt.Errorf("deployment: load active for interrupted operation: %w", err) | |||
| 607 | + | } | |||
| 608 | + | } | |||
| 609 | + | log := zerowrap.FromCtx(ctx) | |||
| 610 | + | // write is true when the journal itself changes: an interrupted operation | |||
| 611 | + | // is always finalized, a terminal one only when a leftover must be | |||
| 612 | + | // reported. | |||
| 613 | + | changed, err := s.convergeInterruptedSteps(ctx, app, &op, active, terminal, log) | |||
| 614 | + | if err != nil { | |||
| 615 | + | return err | |||
| 616 | + | } | |||
| 617 | + | write := !terminal || changed | |||
| 618 | + | if !write { | |||
| 619 | + | return nil | |||
| 340 | } | 620 | } | ||
| 341 | - | return s.deployInterrupted(ctx, app, revision, p, opID, before, beforeGrace) | 621 | + | if !terminal { |
| 622 | + | // The effects ran and only the outcome write was interrupted: that is | |||
| 623 | + | // a success, not a failure. | |||
| 624 | + | op.Outcome = interruptedOutcome(op) | |||
| 625 | + | } | |||
| 626 | + | if err := s.deps.State.SaveOperation(ctx, op); err != nil { | |||
| 627 | + | return fmt.Errorf("deployment: finalize interrupted operation for %q: %w", app, err) | |||
| 628 | + | } | |||
| 629 | + | log.Warn().Str("app", app).Str("op", op.Op).Msg("deployment: reconciled an unfinished operation") | |||
| 630 | + | return nil | |||
| 342 | } | 631 | } | ||
| 343 | 632 | ||||
| 344 | - | // httpEligible reports HTTP services whose generations may overlap while | 633 | + | // reconcileNeedsContainer reports whether one step still needs container |
| 345 | - | // the candidate proves readiness. Every declared HTTP interface must be | 634 | + | // work: a step that recorded a candidate. A terminal operation only retries |
| 346 | - | // effective-public: the proxy repoints each public host to the candidate | 635 | + | // the step that recorded a failed candidate; a pending step exists only in an |
| 347 | - | // only after readiness, so overlap buys availability. Any internal HTTP | 636 | + | // interrupted operation. |
| 348 | - | // interface is instead resolved over the app private network by service | 637 | + | func reconcileNeedsContainer(step domain.AppOperationStep, terminal bool) bool { |
| 349 | - | // alias, and the candidate joins that network with the same alias as soon | 638 | + | if step.After == "" { |
| 350 | - | // as it starts — before readiness — so an internal consumer could reach an | |||
| 351 | - | // unready generation with no cutover gate to stop it. Such a service takes | |||
| 352 | - | // interrupted replacement like L4 services. Volumes, binds, and L4 ports | |||
| 353 | - | // remain interrupted because two generations must not share their state or | |||
| 354 | - | // publications. | |||
| 355 | - | func httpEligible(spec domain.AppService) bool { | |||
| 356 | - | if len(spec.HTTP) == 0 || len(spec.TCP) > 0 || len(spec.UDP) > 0 || len(spec.Volumes) > 0 || len(spec.Binds) > 0 { | |||
| 357 | return false | 639 | return false | ||
| 358 | } | 640 | } | ||
| 359 | - | for _, h := range spec.HTTP { | 641 | + | if terminal { |
| 360 | - | if !h.IsPublic() { | 642 | + | return step.State == domain.AppStepFailed |
| 361 | - | return false | |||
| 362 | - | } | |||
| 363 | } | 643 | } | ||
| 364 | return true | 644 | return true | ||
| 365 | } | 645 | } | ||
| 366 | 646 | ||||
| 367 | - | // singleWriterRequired reports services that must never have two generations | 647 | + | // convergeInterruptedSteps retries the recorded candidate of every step that |
| 368 | - | // running concurrently. Persistent volumes and any bind (especially a | 648 | + | // needs container work and accumulates the bounded leftovers in the journal. |
| 369 | - | // writable one) may be written by both, so they are treated identically. | 649 | + | // It reports whether the journal changed and fails closed on the first |
| 370 | - | func singleWriterRequired(spec domain.AppService) bool { | 650 | + | // candidate that cannot be removed, before any later step or mutation runs. |
| 371 | - | return len(spec.Volumes) > 0 || len(spec.Binds) > 0 | 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 | |||
| 372 | } | 684 | } | ||
| 373 | 685 | ||||
| 374 | - | // deployHTTP keeps the old container serving until the replacement passes | 686 | + | // convergeCandidate converges the container one step recorded. The published |
| 375 | - | // readiness AND the new effective state is published: the proxy switches | 687 | + | // generation is kept; a never-published candidate is removed so recovery can |
| 376 | - | // to the new loopback binds at publication, the old container drains, | 688 | + | // rebuild the generation recorded in ACTIVE without two generations running |
| 377 | - | // and only then is the old container retired by exact ID. Retire never | 689 | + | // at once. An interrupted operation's step is marked failed. |
| 378 | - | // precedes publication (no outage window on crash between the two). | 690 | + | // |
| 379 | - | func (s *Service) deployHTTP(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) { | 691 | + | // A candidate that cannot be removed is returned as an error, not a |
| 380 | - | created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID) | 692 | + | // recoverable warning: the orphan may still run, so every later mutation must |
| 381 | - | if err != nil { | 693 | + | // fail closed instead of proceeding. The returned warnings are the leftovers |
| 382 | - | return s.failResult(revision, before, "", err), false | 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 | + | } | |||
| 383 | } | 744 | } | ||
| 384 | - | if err := s.waitServiceReady(ctx, app, created.ID, httpReadiness(p.spec), binds); err != nil { | 745 | + | // Only reconciliation removes a recorded candidate: clearing After here is |
| 385 | - | // Old version keeps serving. Capture redacted diagnostics while | 746 | + | // what marks the step converged. |
| 386 | - | // the failed replacement still exists, then remove only that | 747 | + | step.After = "" |
| 387 | - | // candidate. | 748 | + | return nil, nil |
| 388 | - | tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID)) | 749 | + | } |
| 389 | - | cleanup := s.retireCandidate(ctx, app, p.name, created.ID) | 750 | + | |
| 390 | - | return ServiceResult{ | 751 | + | // interruptedOutcome classifies an interrupted operation: a failure, unless |
| 391 | - | Result: "failed", | 752 | + | // every step had already succeeded. |
| 392 | - | EffectiveRevision: revision, | 753 | + | func interruptedOutcome(op domain.AppOperation) string { |
| 393 | - | Before: before, | 754 | + | if operationSucceeded(op) { |
| 394 | - | After: created.ID, | 755 | + | return domain.AppOutcomeSuccess |
| 395 | - | Error: err.Error(), | |||
| 396 | - | Diagnostics: tail, | |||
| 397 | - | CleanupWarnings: cleanup, | |||
| 398 | - | }, false | |||
| 399 | } | 756 | } | ||
| 400 | - | return ServiceResult{ | 757 | + | return domain.AppOutcomeFailed |
| 401 | - | Result: "deployed", | |||
| 402 | - | EffectiveRevision: revision, | |||
| 403 | - | Before: before, | |||
| 404 | - | After: created.ID, | |||
| 405 | - | BackendBinds: binds, | |||
| 406 | - | UDPBackendBinds: udpBinds, | |||
| 407 | - | Retire: retireContainerID(before, created.ID), | |||
| 408 | - | RetireGrace: beforeGrace, | |||
| 409 | - | }, false | |||
| 410 | } | 758 | } | ||
| 411 | 759 | ||||
| 412 | - | // refreshTraffic rebuilds the proxy host index and applies the full | 760 | + | // operationSucceeded reports whether every step of an interrupted operation |
| 413 | - | // HTTP/L4 graph after activation. A failure is fatal to the caller: | 761 | + | // reached success: its effects ran and only the final outcome write was |
| 414 | - | // ACTIVE is published but the new service is not routable, so the | 762 | + | // interrupted. |
| 415 | - | // operation must not report success. Any service of this app whose | 763 | + | func operationSucceeded(op domain.AppOperation) bool { |
| 416 | - | // fail-closed withdrawal was never applied is withdrawn again first, so | 764 | + | if len(op.Steps) == 0 { |
| 417 | - | // a later publication never runs on top of unproven forwarding. | 765 | + | return false |
| 418 | - | func (s *Service) refreshTraffic(ctx context.Context, app string) error { | |||
| 419 | - | if s.deps.Traffic == nil { | |||
| 420 | - | return nil | |||
| 421 | } | 766 | } | ||
| 422 | - | for _, service := range s.publication.pending(app) { | 767 | + | for _, step := range op.Steps { |
| 423 | - | if err := s.withdrawForRecovery(ctx, app, service); err != nil { | 768 | + | if step.State != domain.AppStepSucceeded { |
| 424 | - | return err | 769 | + | return false |
| 425 | } | 770 | } | ||
| 426 | } | 771 | } | ||
| 427 | - | if err := s.deps.Traffic.RebuildTraffic(ctx); err != nil { | 772 | + | return true |
| 428 | - | return fmt.Errorf("deployment: rebuild traffic for %q: %w", app, err) | |||
| 429 | - | } | |||
| 430 | - | return nil | |||
| 431 | } | 773 | } | ||
| 432 | 774 | ||||
| 433 | - | // retireContainerID returns the exact container ID to retire after the | 775 | + | // publishedContainer reports whether any service of the ACTIVE record names |
| 434 | - | func retireContainerID(before, after string) string { | 776 | + | // the container. A container that is the published generation is never a |
| 435 | - | if before == "" || before == after { | 777 | + | // leftover, whichever service recorded it. |
| 436 | - | return "" | 778 | + | func publishedContainer(active domain.AppActive, containerID string) bool { |
| 779 | + | if containerID == "" { | |||
| 780 | + | return false | |||
| 781 | + | } | |||
| 782 | + | for _, service := range active.Services { | |||
| 783 | + | if service.Container == containerID { | |||
| 784 | + | return true | |||
| 785 | + | } | |||
| 437 | } | 786 | } | ||
| 438 | - | return before | 787 | + | return false |
| 439 | } | 788 | } | ||
| 440 | 789 | ||||
| 441 | - | // deployInterrupted stops the old instance after preflight, then creates | 790 | + | // deployService replaces one service sequentially and returns its terminal |
| 442 | - | // the replacement. Volume-owning failures never restart the old image: | 791 | + | // result. before is the ACTIVE container being superseded (empty on a first |
| 443 | - | // the replacement may already have written data. | 792 | + | // deployment); beforeGrace is its effective stop grace. |
| 444 | - | func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) { | 793 | + | // |
| 445 | - | // Revalidate the current authorization and source before inhibiting or | 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 | |||
| 446 | // retiring the serving generation. A reload between preflight and this | 802 | // retiring the serving generation. A reload between preflight and this | ||
| 447 | // service step must fail without mutating the existing workload. | 803 | // service step must fail without mutating the existing workload. | ||
| 448 | if _, err := s.resolveServiceBinds(app, p.spec); err != nil { | 804 | if _, err := s.resolveServiceBinds(app, p.spec); err != nil { | ||
| 449 | - | return s.failResult(revision, before, "", err), true | 805 | + | return s.failResult(revision, before, "", err) |
| 450 | - | } | 806 | + | } |
| 451 | - | // A volume-owning replacement may write while the old generation | 807 | + | // Withdraw the service from traffic first and confirm it: a withdrawal |
| 452 | - | // still exists and could be revived by native restart policy. | 808 | + | // that cannot be applied must block every container mutation, so no |
| 453 | - | // Inhibit that generation durably BEFORE the write can happen, so | 809 | + | // unverified generation stays routable. A first deployment has no |
| 454 | - | // boot or periodic recovery can never restart the old writer on | 810 | + | // published generation to withdraw. |
| 455 | - | // top of the new one. Cleared once the safe generation is | 811 | + | if before != "" { |
| 456 | - | // published (publishService) or the operator removes the app. | 812 | + | if err := s.withdrawForRecovery(ctx, app, p.name); err != nil { |
| 457 | - | inhibited := before != "" && singleWriterRequired(p.spec) | 813 | + | return s.failResult(revision, before, "", err) |
| 458 | - | if inhibited { | 814 | + | } |
| 815 | + | } | |||
| 816 | + | // A volume- or bind-owning replacement may write while the old | |||
| 817 | + | // generation still exists and could be revived by native restart policy. | |||
| 818 | + | // Inhibit that generation durably BEFORE the write can happen, so boot or | |||
| 819 | + | // periodic recovery can never restart the old writer on top of the new | |||
| 820 | + | // one. Cleared once the safe generation is published (publishService) or | |||
| 821 | + | // the operator removes the app. | |||
| 822 | + | if before != "" && singleWriterRequired(p.spec) { | |||
| 459 | if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil { | 823 | if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil { | ||
| 460 | - | return s.failResult(revision, before, "", err), true | 824 | + | return s.failResult(revision, before, "", err) |
| 461 | } | 825 | } | ||
| 462 | } | 826 | } | ||
| 463 | if before != "" { | 827 | if before != "" { | ||
| 464 | - | // The superseded writer must be confirmed gone before a new | 828 | + | // The superseded generation must be confirmed gone before a new |
| 465 | - | // writer can touch the same data: a failed retirement aborts the | 829 | + | // generation starts: a failed retirement aborts the replacement |
| 466 | - | // replacement instead of allowing overlapping writers, and the | 830 | + | // instead of allowing overlapping generations, and the generation |
| 467 | - | // generation keeps its inhibition and its claims. | 831 | + | // keeps its inhibition and its claims. |
| 468 | retired := s.retireContainer(ctx, app, retireOptions{ | 832 | retired := s.retireContainer(ctx, app, retireOptions{ | ||
| 469 | Service: p.name, Grace: beforeGrace, | 833 | Service: p.name, Grace: beforeGrace, | ||
| 470 | }, before) | 834 | }, before) | ||
| 471 | if !retired.Gone { | 835 | if !retired.Gone { | ||
| 472 | - | return s.failResult(revision, before, "", fmt.Errorf("deployment: retire superseded container %s: %s", before, cleanupDetail(retired))), true | 836 | + | return s.failResult(revision, before, "", fmt.Errorf("deployment: retire superseded container %s: %s", before, cleanupDetail(retired))) |
| 473 | } | 837 | } | ||
| 474 | } | 838 | } | ||
| 475 | - | created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID) | 839 | + | created, binds, udpBinds, err := s.createAndStart(ctx, app, revision, p, opID, journal) |
| 476 | if err != nil { | 840 | if err != nil { | ||
| 477 | - | return s.failResult(revision, before, "", err), true | 841 | + | result := s.failResult(revision, before, "", err) |
| 478 | - | } | 842 | + | if created != nil { |
| 479 | - | if err := s.waitServiceReady(ctx, app, created.ID, p.spec, binds); err != nil { | 843 | + | // The candidate exists: keep its ID in the terminal result so the |
| 480 | - | // Capture redacted diagnostics while the candidate still exists, | 844 | + | // journal and the operator can still trace it. |
| 481 | - | // then remove only that candidate. | 845 | + | result.After = created.ID |
| 846 | + | } | |||
| 847 | + | return result | |||
| 848 | + | } | |||
| 849 | + | if err := s.waitServiceReady(ctx, app, created.ID, deploymentReadiness(p.spec), binds); err != nil { | |||
| 850 | + | // Capture redacted diagnostics while the failed replacement still | |||
| 851 | + | // exists, then remove only that candidate. The superseded container | |||
| 852 | + | // is already gone and is never recreated. Cleanup is best effort: a | |||
| 853 | + | // canceled context can leave the candidate behind, which is then | |||
| 854 | + | // reported as a leftover warning instead of a false success. | |||
| 482 | tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID)) | 855 | tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID)) | ||
| 483 | cleanup := s.retireCandidate(ctx, app, p.name, created.ID) | 856 | cleanup := s.retireCandidate(ctx, app, p.name, created.ID) | ||
| 484 | return ServiceResult{ | 857 | return ServiceResult{ | ||
| @@ -490,7 +863,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p | |||||
| 490 | Error: err.Error(), | 863 | Error: err.Error(), | ||
| 491 | Diagnostics: tail, | 864 | Diagnostics: tail, | ||
| 492 | CleanupWarnings: cleanup, | 865 | CleanupWarnings: cleanup, | ||
| 493 | - | }, true | 866 | + | } |
| 494 | } | 867 | } | ||
| 495 | return ServiceResult{ | 868 | return ServiceResult{ | ||
| 496 | Result: "deployed", | 869 | Result: "deployed", | ||
| @@ -500,7 +873,35 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p | |||||
| 500 | RestartUnsafe: singleWriterRequired(p.spec), | 873 | RestartUnsafe: singleWriterRequired(p.spec), | ||
| 501 | BackendBinds: binds, | 874 | BackendBinds: binds, | ||
| 502 | UDPBackendBinds: udpBinds, | 875 | UDPBackendBinds: udpBinds, | ||
| 503 | - | }, 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 | |||
| 504 | } | 905 | } | ||
| 505 | 906 | ||||
| 506 | // reserveVolumeOwnership durably records one app-owned volume as | 907 | // reserveVolumeOwnership durably records one app-owned volume as | ||
| @@ -568,7 +969,7 @@ func volumeProvenanceLabels(app, appID, service, revision string) map[string]str | |||||
| 568 | // never container IPs. The runtime keeps native restarts; Gordon | 969 | // never container IPs. The runtime keeps native restarts; Gordon | ||
| 569 | // reconciles intent at boot and in the monitor (accepted decision: | 970 | // reconciles intent at boot and in the monitor (accepted decision: | ||
| 570 | // native runtime restarts plus daemon reconciliation). | 971 | // native runtime restarts plus daemon reconciliation). | ||
| 571 | - | func (s *Service) createAndStart(ctx context.Context, app, revision string, p pinnedService, opID string) (*domain.Container, map[int]int, map[int]int, error) { | 972 | + | func (s *Service) createAndStart(ctx context.Context, app, revision string, p pinnedService, opID string, journal candidateJournal) (*domain.Container, map[int]int, map[int]int, error) { |
| 572 | // Re-resolve binds from the current policy immediately before any | 973 | // Re-resolve binds from the current policy immediately before any | ||
| 573 | // runtime mutation: a bind revoked since preflight must fail here, | 974 | // runtime mutation: a bind revoked since preflight must fail here, | ||
| 574 | // before volume ownership or container creation. | 975 | // before volume ownership or container creation. | ||
| @@ -580,13 +981,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi | |||||
| 580 | if err != nil { | 981 | if err != nil { | ||
| 581 | return nil, nil, nil, err | 982 | return nil, nil, nil, err | ||
| 582 | } | 983 | } | ||
| 583 | - | image := p.runtimeImage | 984 | + | image := runtimeImageRef(p) |
| 584 | - | if image == "" { | |||
| 585 | - | image = p.spec.Image | |||
| 586 | - | if p.digest != "" { | |||
| 587 | - | image = stripImageTag(p.spec.Image) + "@" + p.digest | |||
| 588 | - | } | |||
| 589 | - | } | |||
| 590 | // The incarnation network is verified or created before any | 985 | // The incarnation network is verified or created before any | ||
| 591 | // container exists, so a workload is only ever started on a | 986 | // container exists, so a workload is only ever started on a | ||
| 592 | // Gordon-owned network that no other app shares. | 987 | // Gordon-owned network that no other app shares. | ||
| @@ -646,22 +1041,58 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi | |||||
| 646 | if err != nil { | 1041 | if err != nil { | ||
| 647 | return nil, nil, nil, err | 1042 | return nil, nil, nil, err | ||
| 648 | } | 1043 | } | ||
| 1044 | + | // The candidate ID is durably journaled before the container is started | |||
| 1045 | + | // or joins a shared network, so an interrupted deployment can be cleaned | |||
| 1046 | + | // up instead of leaving an untracked running generation. | |||
| 1047 | + | if err := s.recordCandidate(ctx, app, p.spec.Name, created.ID, journal); err != nil { | |||
| 1048 | + | return nil, nil, nil, err | |||
| 1049 | + | } | |||
| 649 | if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil { | 1050 | if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil { | ||
| 650 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | 1051 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | ||
| 651 | - | return nil, nil, nil, err | 1052 | + | return created, nil, nil, err |
| 652 | } | 1053 | } | ||
| 653 | if err := s.deps.Runtime.StartContainer(ctx, created.ID); err != nil { | 1054 | if err := s.deps.Runtime.StartContainer(ctx, created.ID); err != nil { | ||
| 654 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | 1055 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | ||
| 655 | - | return nil, nil, nil, fmt.Errorf("deployment: start container: %w", err) | 1056 | + | return created, nil, nil, fmt.Errorf("deployment: start container: %w", err) |
| 656 | } | 1057 | } | ||
| 657 | binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec)) | 1058 | binds, udpBinds, err := s.readBackendBinds(ctx, app, p.spec.Name, created.ID, backendPorts(p.spec)) | ||
| 658 | if err != nil { | 1059 | if err != nil { | ||
| 659 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | 1060 | s.retireCandidate(ctx, app, p.spec.Name, created.ID) | ||
| 660 | - | return nil, nil, nil, err | 1061 | + | return created, nil, nil, err |
| 661 | } | 1062 | } | ||
| 662 | return created, binds, udpBinds, nil | 1063 | return created, binds, udpBinds, nil | ||
| 663 | } | 1064 | } | ||
| 664 | 1065 | ||||
| 1066 | + | // runtimeImageRef resolves the exact runtime image reference of one pinned | |||
| 1067 | + | // service: the image preflight resolved, else the manifest reference pinned to | |||
| 1068 | + | // its digest. | |||
| 1069 | + | func runtimeImageRef(p pinnedService) string { | |||
| 1070 | + | if p.runtimeImage != "" { | |||
| 1071 | + | return p.runtimeImage | |||
| 1072 | + | } | |||
| 1073 | + | if p.digest == "" { | |||
| 1074 | + | return p.spec.Image | |||
| 1075 | + | } | |||
| 1076 | + | return stripImageTag(p.spec.Image) + "@" + p.digest | |||
| 1077 | + | } | |||
| 1078 | + | ||||
| 1079 | + | // recordCandidate durably journals a freshly created candidate before it is | |||
| 1080 | + | // started. A candidate whose ID cannot be recorded must not keep running: | |||
| 1081 | + | // recovery could never find it, so it is removed and the failure surfaced. | |||
| 1082 | + | func (s *Service) recordCandidate(ctx context.Context, app, service, containerID string, journal candidateJournal) error { | |||
| 1083 | + | if journal == nil { | |||
| 1084 | + | return nil | |||
| 1085 | + | } | |||
| 1086 | + | if err := journal(ctx, containerID); err != nil { | |||
| 1087 | + | retired := s.retireContainer(ctx, app, retireOptions{Service: service, Force: true}, containerID) | |||
| 1088 | + | if !retired.Gone { | |||
| 1089 | + | return fmt.Errorf("%w (candidate cleanup: %s)", err, cleanupDetail(retired)) | |||
| 1090 | + | } | |||
| 1091 | + | return err | |||
| 1092 | + | } | |||
| 1093 | + | return nil | |||
| 1094 | + | } | |||
| 1095 | + | ||||
| 665 | func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) { | 1096 | func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) { | ||
| 666 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | 1097 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | ||
| 667 | if err == nil { | 1098 | if err == nil { | ||
| @@ -927,7 +1358,8 @@ func sortedEnv(envMap map[string]string) []string { | |||||
| 927 | return env | 1358 | return env | ||
| 928 | } | 1359 | } | ||
| 929 | 1360 | ||||
| 930 | - | // publishService writes the per-service effective record after cutover. | 1361 | + | // publishService writes the per-service effective record once the |
| 1362 | + | // replacement passed readiness. | |||
| 931 | func (s *Service) publishService(ctx context.Context, app, revision string, p pinnedService, result ServiceResult, opID string) error { | 1363 | func (s *Service) publishService(ctx context.Context, app, revision string, p pinnedService, result ServiceResult, opID string) error { | ||
| 932 | if app == "" { | 1364 | if app == "" { | ||
| 933 | return fmt.Errorf("deployment: publish active: empty app: %w", domain.ErrAppStateConflict) | 1365 | return fmt.Errorf("deployment: publish active: empty app: %w", domain.ErrAppStateConflict) | ||
| @@ -982,7 +1414,7 @@ func (s *Service) publishService(ctx context.Context, app, revision string, p pi | |||||
| 982 | return nil | 1414 | return nil | ||
| 983 | } | 1415 | } | ||
| 984 | 1416 | ||||
| 985 | - | // recordOwnership stamps volume/secret/network ownership after cutover. | 1417 | + | // recordOwnership stamps volume/secret/network ownership after publication. |
| 986 | func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedService) error { | 1418 | func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedService) error { | ||
| 987 | ownership, err := s.deps.State.LoadOwnership(ctx, app) | 1419 | ownership, err := s.deps.State.LoadOwnership(ctx, app) | ||
| 988 | if err != nil { | 1420 | if err != nil { | ||
| @@ -1145,26 +1577,39 @@ func tailLines(r io.Reader, n int) ([]string, error) { | |||||
| 1145 | return lines, nil | 1577 | return lines, nil | ||
| 1146 | } | 1578 | } | ||
| 1147 | 1579 | ||||
| 1148 | - | // httpReadiness forces the HTTP path for eligible cutover checks. | 1580 | + | // deploymentReadiness resolves the readiness check a deploy or restart |
| 1149 | - | func httpReadiness(spec domain.AppService) domain.AppService { | 1581 | + | // runs. Readiness never depends on how a service is replaced: a declared |
| 1150 | - | if spec.Readiness.Type == "" || spec.Readiness.Type == domain.AppReadinessNone { | 1582 | + | // http, tcp, or log check is used as declared, and an undeclared check |
| 1151 | - | spec.Readiness.Type = domain.AppReadinessHTTP | 1583 | + | // ("none" or unset) keeps the implicit HTTP probe on a service Gordon can |
| 1152 | - | } | 1584 | + | // probe over the published loopback bind — at least one effective-public |
| 1585 | + | // HTTP interface, no L4 interface, no volume, and no bind. An undeclared | |||
| 1586 | + | // probe on a stateful or L4 workload stays unchecked instead: an HTTP GET on | |||
| 1587 | + | // the published port proves nothing there, so Gordon never guesses one. | |||
| 1588 | + | func deploymentReadiness(spec domain.AppService) domain.AppService { | |||
| 1589 | + | if !implicitHTTPProbe(spec) { | |||
| 1590 | + | return spec | |||
| 1591 | + | } | |||
| 1592 | + | spec.Readiness.Type = domain.AppReadinessHTTP | |||
| 1153 | return spec | 1593 | return spec | ||
| 1154 | } | 1594 | } | ||
| 1155 | 1595 | ||||
| 1156 | - | // drainWithDeadline pauses briefly so in-flight HTTP requests finish. | 1596 | + | // implicitHTTPProbe reports whether a service that declares no readiness |
| 1157 | - | func drainWithDeadline(ctx context.Context, grace time.Duration) { | 1597 | + | // check receives the implicit HTTP probe when a deploy or restart starts it. |
| 1158 | - | if grace <= 0 { | 1598 | + | // Recovery verification of an already running generation keeps the declared |
| 1159 | - | grace = domain.AppDefaultStopGrace | 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 | |||
| 1160 | } | 1603 | } | ||
| 1161 | - | if grace > time.Second { | 1604 | + | if len(spec.HTTP) == 0 || len(spec.TCP) > 0 || len(spec.UDP) > 0 || len(spec.Volumes) > 0 || len(spec.Binds) > 0 { |
| 1162 | - | grace = time.Second | 1605 | + | return false |
| 1163 | } | 1606 | } | ||
| 1164 | - | select { | 1607 | + | for _, h := range spec.HTTP { |
| 1165 | - | case <-time.After(grace): | 1608 | + | if !h.IsPublic() { |
| 1166 | - | case <-ctx.Done(): | 1609 | + | return false |
| 1610 | + | } | |||
| 1167 | } | 1611 | } | ||
| 1612 | + | return true | |||
| 1168 | } | 1613 | } | ||
| 1169 | 1614 | ||||
| 1170 | // appLabels stamps engine ownership on every created container. | 1615 | // appLabels stamps engine ownership on every created container. | ||
D internal/usecase/deployment/gcbarrier_test.go +0 -51
| @@ -1,51 +0,0 @@ | |||||
| 1 | - | package deployment_test | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/zerowrap" | |||
| 8 | - | "github.com/stretchr/testify/mock" | |||
| 9 | - | "github.com/stretchr/testify/require" | |||
| 10 | - | ||||
| 11 | - | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 12 | - | "github.com/bnema/gordon/internal/domain" | |||
| 13 | - | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 14 | - | ) | |||
| 15 | - | ||||
| 16 | - | // TestPreflight_HoldsSharedGCBarrierAcrossResourceSelection proves the | |||
| 17 | - | // shared lease is taken before resource selection and released only | |||
| 18 | - | // when the mutation ends. Prune can therefore never observe a resource | |||
| 19 | - | // that was selected but whose protection is not yet durable. | |||
| 20 | - | func TestPreflight_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) { | |||
| 21 | - | ctx := context.Background() | |||
| 22 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 23 | - | barrier := outmocks.NewMockGCBarrier(t) | |||
| 24 | - | lease := outmocks.NewMockGCLease(t) | |||
| 25 | - | ||||
| 26 | - | held := false | |||
| 27 | - | barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) { | |||
| 28 | - | held = true | |||
| 29 | - | }).Return(lease, nil).Once() | |||
| 30 | - | lease.EXPECT().Release().Run(func() { | |||
| 31 | - | held = false | |||
| 32 | - | }).Once() | |||
| 33 | - | ||||
| 34 | - | rev := testRevision("blog", webService()) | |||
| 35 | - | // Image resolution is the resource-selection step: it must happen | |||
| 36 | - | // inside the shared lease. | |||
| 37 | - | expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "s3cr3t", nil) | |||
| 38 | - | images.ExpectedCalls = nil | |||
| 39 | - | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Run(func(context.Context, string) { | |||
| 40 | - | require.True(t, held, "image resolution must run inside the shared GC lease") | |||
| 41 | - | }).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once() | |||
| 42 | - | ||||
| 43 | - | svc := deployment.NewService(deployment.Deps{ | |||
| 44 | - | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 45 | - | ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}}, | |||
| 46 | - | }, zerowrap.Default()).WithGCBarrier(barrier) | |||
| 47 | - | ||||
| 48 | - | _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 49 | - | require.NoError(t, err) | |||
| 50 | - | require.False(t, held, "the shared lease must be released when the operation ends") | |||
| 51 | - | } | |||
D internal/usecase/deployment/http_cutover_behavior_test.go +0 -143
| @@ -1,143 +0,0 @@ | |||||
| 1 | - | package deployment_test | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "io" | |||
| 6 | - | "net/http" | |||
| 7 | - | "net/http/httptest" | |||
| 8 | - | "sync" | |||
| 9 | - | "testing" | |||
| 10 | - | "time" | |||
| 11 | - | ||||
| 12 | - | "github.com/bnema/zerowrap" | |||
| 13 | - | "github.com/stretchr/testify/assert" | |||
| 14 | - | "github.com/stretchr/testify/mock" | |||
| 15 | - | "github.com/stretchr/testify/require" | |||
| 16 | - | ||||
| 17 | - | "github.com/bnema/gordon/internal/domain" | |||
| 18 | - | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 19 | - | ) | |||
| 20 | - | ||||
| 21 | - | type switchingTraffic struct { | |||
| 22 | - | mu sync.RWMutex | |||
| 23 | - | backend string | |||
| 24 | - | ready <-chan struct{} | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | func (s *switchingTraffic) RebuildTraffic(context.Context) error { | |||
| 28 | - | <-s.ready | |||
| 29 | - | s.mu.Lock() | |||
| 30 | - | s.backend = "new" | |||
| 31 | - | s.mu.Unlock() | |||
| 32 | - | return nil | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | func (s *switchingTraffic) WithdrawService(context.Context, string, string) error { | |||
| 36 | - | s.mu.Lock() | |||
| 37 | - | s.backend = "withdrawn" | |||
| 38 | - | s.mu.Unlock() | |||
| 39 | - | return nil | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | func (s *switchingTraffic) WithdrawServiceState(context.Context, string, string) error { | |||
| 43 | - | s.mu.Lock() | |||
| 44 | - | s.backend = "withdrawn" | |||
| 45 | - | s.mu.Unlock() | |||
| 46 | - | return nil | |||
| 47 | - | } | |||
| 48 | - | ||||
| 49 | - | func (s *switchingTraffic) current() string { | |||
| 50 | - | s.mu.RLock() | |||
| 51 | - | defer s.mu.RUnlock() | |||
| 52 | - | return s.backend | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | // TestDeploy_HTTPZeroDowntimeBehavior exercises the observable cutover contract: | |||
| 56 | - | // requests keep reaching the old backend while candidate readiness is delayed, | |||
| 57 | - | // traffic changes only after readiness, and retirement follows the switch. | |||
| 58 | - | func TestDeploy_HTTPZeroDowntimeBehavior(t *testing.T) { | |||
| 59 | - | ctx := context.Background() | |||
| 60 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 61 | - | rev := mockRevision() | |||
| 62 | - | rev.Spec.Services[0].StopGrace = time.Millisecond | |||
| 63 | - | expectDeployPreflight(state, runtime, images, secrets, rev) | |||
| 64 | - | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 65 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 66 | - | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"}, | |||
| 67 | - | }}, true, nil) | |||
| 68 | - | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 69 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 70 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 71 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 72 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 73 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 74 | - | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"}, | |||
| 75 | - | }}, true, nil) | |||
| 76 | - | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 77 | - | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 78 | - | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | |||
| 79 | - | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | |||
| 80 | - | ||||
| 81 | - | ready := make(chan struct{}) | |||
| 82 | - | traffic := &switchingTraffic{backend: "old", ready: ready} | |||
| 83 | - | retired := make(chan struct{}) | |||
| 84 | - | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error { | |||
| 85 | - | assert.Equal(t, "new", traffic.current(), "old backend retired before traffic switched") | |||
| 86 | - | close(retired) | |||
| 87 | - | return nil | |||
| 88 | - | }).Once() | |||
| 89 | - | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 90 | - | ||||
| 91 | - | front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 92 | - | _, _ = io.WriteString(w, traffic.current()) | |||
| 93 | - | })) | |||
| 94 | - | defer front.Close() | |||
| 95 | - | ||||
| 96 | - | probeStarted := make(chan struct{}) | |||
| 97 | - | svc := deployment.NewService(deployment.Deps{ | |||
| 98 | - | State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic, | |||
| 99 | - | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 100 | - | func(context.Context, string) (int, error) { | |||
| 101 | - | select { | |||
| 102 | - | case <-probeStarted: | |||
| 103 | - | default: | |||
| 104 | - | close(probeStarted) | |||
| 105 | - | } | |||
| 106 | - | <-ready | |||
| 107 | - | return http.StatusOK, nil | |||
| 108 | - | }, | |||
| 109 | - | func(context.Context, string) error { return nil }, | |||
| 110 | - | )) | |||
| 111 | - | ||||
| 112 | - | done := make(chan error, 1) | |||
| 113 | - | go func() { | |||
| 114 | - | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 115 | - | done <- err | |||
| 116 | - | }() | |||
| 117 | - | select { | |||
| 118 | - | case <-probeStarted: | |||
| 119 | - | case err := <-done: | |||
| 120 | - | require.NoError(t, err) | |||
| 121 | - | t.Fatal("deploy completed before readiness probe started") | |||
| 122 | - | case <-time.After(5 * time.Second): | |||
| 123 | - | t.Fatal("timed out waiting for readiness probe") | |||
| 124 | - | } | |||
| 125 | - | for range 20 { | |||
| 126 | - | resp, err := http.Get(front.URL) //nolint:noctx // bounded local test server | |||
| 127 | - | require.NoError(t, err) | |||
| 128 | - | body, readErr := io.ReadAll(resp.Body) | |||
| 129 | - | resp.Body.Close() | |||
| 130 | - | require.NoError(t, readErr) | |||
| 131 | - | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 132 | - | assert.Equal(t, "old", string(body)) | |||
| 133 | - | } | |||
| 134 | - | close(ready) | |||
| 135 | - | require.NoError(t, <-done) | |||
| 136 | - | <-retired | |||
| 137 | - | resp, err := http.Get(front.URL) //nolint:noctx // bounded local test server | |||
| 138 | - | require.NoError(t, err) | |||
| 139 | - | defer resp.Body.Close() | |||
| 140 | - | body, err := io.ReadAll(resp.Body) | |||
| 141 | - | require.NoError(t, err) | |||
| 142 | - | assert.Equal(t, "new", string(body)) | |||
| 143 | - | } | |||
M internal/usecase/deployment/internal_readiness_test.go +2 -2
| @@ -327,7 +327,7 @@ func TestWaitServiceReady_InternalUsesNetworkPath(t *testing.T) { | |||||
| 327 | assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network) | 327 | assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network) | ||
| 328 | return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil | 328 | return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil | ||
| 329 | }, | 329 | }, | ||
| 330 | - | httpGet: func(context.Context, string) (int, error) { | 330 | + | httpGet: func(context.Context, string, string) (int, error) { |
| 331 | loopbackCalls++ | 331 | loopbackCalls++ | ||
| 332 | return 200, nil | 332 | return 200, nil | ||
| 333 | }, | 333 | }, | ||
| @@ -346,7 +346,7 @@ func TestWaitServiceReady_PublicKeepsLoopback(t *testing.T) { | |||||
| 346 | loopbackCalls := 0 | 346 | loopbackCalls := 0 | ||
| 347 | service := NewService(Deps{}, zerowrap.Default()). | 347 | service := NewService(Deps{}, zerowrap.Default()). | ||
| 348 | WithProbeDeps(ProbeDeps{ | 348 | WithProbeDeps(ProbeDeps{ | ||
| 349 | - | httpGet: func(context.Context, string) (int, error) { | 349 | + | httpGet: func(context.Context, string, string) (int, error) { |
| 350 | loopbackCalls++ | 350 | loopbackCalls++ | ||
| 351 | return 200, nil | 351 | return 200, nil | ||
| 352 | }, | 352 | }, | ||
M internal/usecase/deployment/lifecycle.go +123 -209
| @@ -44,6 +44,9 @@ func (s *Service) stopLocked(ctx context.Context, app, opID string) (*LifecycleR | |||||
| 44 | if err := s.deps.State.Recover(ctx); err != nil { | 44 | if err := s.deps.State.Recover(ctx); err != nil { | ||
| 45 | return nil, fmt.Errorf("deployment: recover before stop: %w", err) | 45 | return nil, fmt.Errorf("deployment: recover before stop: %w", err) | ||
| 46 | } | 46 | } | ||
| 47 | + | if err := s.reconcileInterruptedDeploy(ctx, app); err != nil { | |||
| 48 | + | return nil, err | |||
| 49 | + | } | |||
| 47 | op := domain.AppOperation{ | 50 | op := domain.AppOperation{ | ||
| 48 | Kind: "stop", App: app, | 51 | Kind: "stop", App: app, | ||
| 49 | StartedAt: time.Now().UTC(), | 52 | StartedAt: time.Now().UTC(), | ||
| @@ -147,6 +150,9 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle | |||||
| 147 | if err := s.deps.State.Recover(ctx); err != nil { | 150 | if err := s.deps.State.Recover(ctx); err != nil { | ||
| 148 | return nil, fmt.Errorf("deployment: recover before start: %w", err) | 151 | return nil, fmt.Errorf("deployment: recover before start: %w", err) | ||
| 149 | } | 152 | } | ||
| 153 | + | if err := s.reconcileInterruptedDeploy(ctx, app); err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 150 | op := domain.AppOperation{ | 156 | op := domain.AppOperation{ | ||
| 151 | Kind: "start", App: app, | 157 | Kind: "start", App: app, | ||
| 152 | StartedAt: time.Now().UTC(), | 158 | StartedAt: time.Now().UTC(), | ||
| @@ -176,12 +182,12 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle | |||||
| 176 | result := &LifecycleResult{Op: op.Op, App: app, Verb: "start", Services: map[string]ServiceResult{}} | 182 | result := &LifecycleResult{Op: op.Op, App: app, Verb: "start", Services: map[string]ServiceResult{}} | ||
| 177 | for _, name := range sortedServiceNames(active) { | 183 | for _, name := range sortedServiceNames(active) { | ||
| 178 | eff := active.Services[name] | 184 | eff := active.Services[name] | ||
| 179 | - | step := domain.AppOperationStep{ID: "service." + name + ".start", State: domain.AppStepPending, Before: eff.Container} | 185 | + | // The step is journaled before the runtime work, so a candidate |
| 180 | - | if s.ensureServiceRunning(ctx, app, opID, name, eff, &step, result) { | 186 | + | // created by a redeploy is durably recorded as soon as it exists. |
| 181 | - | op.Steps = append(op.Steps, step) | 187 | + | op.Steps = append(op.Steps, domain.AppOperationStep{ |
| 182 | - | continue | 188 | + | ID: "service." + name + ".start", State: domain.AppStepPending, Before: eff.Container, Service: name, |
| 183 | - | } | 189 | + | }) |
| 184 | - | op.Steps = append(op.Steps, step) | 190 | + | s.ensureServiceRunning(ctx, app, opID, name, eff, &op, len(op.Steps)-1, result) |
| 185 | } | 191 | } | ||
| 186 | op.Outcome = ComputeOutcome(result.Services) | 192 | op.Outcome = ComputeOutcome(result.Services) | ||
| 187 | op.Warnings = journalWarnings(collectCleanupWarnings(result.Services)) | 193 | op.Warnings = journalWarnings(collectCleanupWarnings(result.Services)) | ||
| @@ -196,10 +202,10 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle | |||||
| 196 | } | 202 | } | ||
| 197 | 203 | ||||
| 198 | // Restart restarts from pinned digests without re-resolution. Empty | 204 | // Restart restarts from pinned digests without re-resolution. Empty | ||
| 199 | - | // service means all services in sorted order. HTTP-only, volume-less | 205 | + | // service means all services in sorted order. Every service restarts in |
| 200 | - | // services restart with zero downtime: the old container keeps serving | 206 | + | // place: traffic is withdrawn, the same pinned container is restarted, its |
| 201 | - | // until the candidate passes readiness and traffic has moved to it. | 207 | + | // readiness probe is checked, and traffic is republished. No second |
| 202 | - | // HTTP-with-volume, TCP/UDP, and mixed services restart in place. | 208 | + | // container is created and the pinned digest never changes. |
| 203 | func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) { | 209 | func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) { | ||
| 204 | release, err := s.acquireAppContext(ctx, app) | 210 | release, err := s.acquireAppContext(ctx, app) | ||
| 205 | if err != nil { | 211 | if err != nil { | ||
| @@ -214,6 +220,9 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string) | |||||
| 214 | if err := s.deps.State.Recover(ctx); err != nil { | 220 | if err := s.deps.State.Recover(ctx); err != nil { | ||
| 215 | return nil, fmt.Errorf("deployment: recover before restart: %w", err) | 221 | return nil, fmt.Errorf("deployment: recover before restart: %w", err) | ||
| 216 | } | 222 | } | ||
| 223 | + | if err := s.reconcileInterruptedDeploy(ctx, app); err != nil { | |||
| 224 | + | return nil, err | |||
| 225 | + | } | |||
| 217 | active, ok, err := s.deps.State.LoadActive(ctx, app) | 226 | active, ok, err := s.deps.State.LoadActive(ctx, app) | ||
| 218 | if err != nil { | 227 | if err != nil { | ||
| 219 | return nil, fmt.Errorf("deployment: load active: %w", err) | 228 | return nil, fmt.Errorf("deployment: load active: %w", err) | ||
| @@ -244,14 +253,12 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string) | |||||
| 244 | var failures []string | 253 | var failures []string | ||
| 245 | for _, name := range names { | 254 | for _, name := range names { | ||
| 246 | eff := active.Services[name] | 255 | eff := active.Services[name] | ||
| 247 | - | var step domain.AppOperationStep | 256 | + | // The step is journaled before the runtime work, so a candidate |
| 248 | - | var svcResult ServiceResult | 257 | + | // created by a rebuild of a missing generation is durably recorded. |
| 249 | - | if httpEligible(eff.Spec) { | 258 | + | op.Steps = append(op.Steps, domain.AppOperationStep{ |
| 250 | - | step, svcResult = s.restartOneHTTPService(ctx, app, name, eff, op.Op) | 259 | + | ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container, Service: name, |
| 251 | - | } else { | 260 | + | }) |
| 252 | - | step, svcResult = s.restartOneService(ctx, app, name, eff) | 261 | + | svcResult := s.restartOneService(ctx, app, op.Op, name, eff, &op, len(op.Steps)-1) |
| 253 | - | } | |||
| 254 | - | op.Steps = append(op.Steps, step) | |||
| 255 | result.Services[name] = svcResult | 262 | result.Services[name] = svcResult | ||
| 256 | if svcResult.Result != "deployed" { | 263 | if svcResult.Result != "deployed" { | ||
| 257 | failures = append(failures, name) | 264 | failures = append(failures, name) | ||
| @@ -272,141 +279,21 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string) | |||||
| 272 | return result, nil | 279 | return result, nil | ||
| 273 | } | 280 | } | ||
| 274 | 281 | ||||
| 275 | - | // restartOneHTTPService restarts one HTTP-only, volume-less service with | |||
| 276 | - | // zero downtime, gated by the same httpEligible rule as deploy. The old | |||
| 277 | - | // container keeps ACTIVE and serving while a candidate is created and | |||
| 278 | - | // started from the pinned ACTIVE digest and revision, and readiness is | |||
| 279 | - | // awaited against its fresh loopback binds. Only then is ACTIVE narrowly | |||
| 280 | - | // repointed at the candidate (carrying the restart operation identity and | |||
| 281 | - | // the unchanged revision, so convergence metadata stays intact), traffic | |||
| 282 | - | // applied, and the old generation drained and retired. | |||
| 283 | - | // | |||
| 284 | - | // Every failure before the repoint leaves the old generation ACTIVE and | |||
| 285 | - | // serving and removes only the candidate: the restart never trades a | |||
| 286 | - | // working generation for an unverified one. | |||
| 287 | - | func (s *Service) restartOneHTTPService(ctx context.Context, app, name string, eff domain.AppEffectiveService, opID string) (domain.AppOperationStep, ServiceResult) { | |||
| 288 | - | step := domain.AppOperationStep{ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container} | |||
| 289 | - | result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container} | |||
| 290 | - | fail := func(msg string) (domain.AppOperationStep, ServiceResult) { | |||
| 291 | - | step.State = domain.AppStepFailed | |||
| 292 | - | step.Error = msg | |||
| 293 | - | result.Result = "failed" | |||
| 294 | - | result.Error = msg | |||
| 295 | - | return step, result | |||
| 296 | - | } | |||
| 297 | - | if eff.Container == "" { | |||
| 298 | - | result.After = "" | |||
| 299 | - | return fail("no active container") | |||
| 300 | - | } | |||
| 301 | - | // A durably inhibited generation is never revived, not even as the | |||
| 302 | - | // candidate's predecessor: its replacement may already own its data. | |||
| 303 | - | if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil { | |||
| 304 | - | return fail(err.Error()) | |||
| 305 | - | } | |||
| 306 | - | rev, err := s.deps.State.LoadRevision(ctx, app, eff.EffectiveRevision) | |||
| 307 | - | if err != nil { | |||
| 308 | - | return fail(err.Error()) | |||
| 309 | - | } | |||
| 310 | - | runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest) | |||
| 311 | - | if err != nil { | |||
| 312 | - | return fail(err.Error()) | |||
| 313 | - | } | |||
| 314 | - | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | |||
| 315 | - | return fail(err.Error()) | |||
| 316 | - | } | |||
| 317 | - | pinned := pinnedService{ | |||
| 318 | - | name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, | |||
| 319 | - | appEnv: maps.Clone(rev.Spec.Env), | |||
| 320 | - | appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...), | |||
| 321 | - | sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name), | |||
| 322 | - | } | |||
| 323 | - | created, binds, udpBinds, err := s.createAndStart(ctx, app, eff.EffectiveRevision, pinned, opID) | |||
| 324 | - | if err != nil { | |||
| 325 | - | return fail(err.Error()) | |||
| 326 | - | } | |||
| 327 | - | if err := s.waitServiceReady(ctx, app, created.ID, httpReadiness(eff.Spec), binds); err != nil { | |||
| 328 | - | // The old generation is still ACTIVE and serving: capture redacted | |||
| 329 | - | // diagnostics while the failed candidate exists, then remove only | |||
| 330 | - | // that candidate. | |||
| 331 | - | tail := s.redactDiagnostics(ctx, app, pinned, s.logTail(ctx, created.ID)) | |||
| 332 | - | step.State = domain.AppStepFailed | |||
| 333 | - | step.Error = err.Error() | |||
| 334 | - | step.Diagnostics = tail | |||
| 335 | - | result.Error = err.Error() | |||
| 336 | - | result.Diagnostics = tail | |||
| 337 | - | result.CleanupWarnings = s.retireCandidate(ctx, app, name, created.ID) | |||
| 338 | - | return step, result | |||
| 339 | - | } | |||
| 340 | - | result = ServiceResult{ | |||
| 341 | - | Result: "deployed", | |||
| 342 | - | EffectiveRevision: eff.EffectiveRevision, | |||
| 343 | - | Before: eff.Container, | |||
| 344 | - | After: created.ID, | |||
| 345 | - | BackendBinds: binds, | |||
| 346 | - | UDPBackendBinds: udpBinds, | |||
| 347 | - | } | |||
| 348 | - | // Narrow ACTIVE repoint: only this service's effective entry moves. | |||
| 349 | - | // ActivatedBy is the restart operation, ActivatedAt is refreshed, and | |||
| 350 | - | // the pinned revision is unchanged, so ConvergedRevision/Converged | |||
| 351 | - | // stay truthful for the app. | |||
| 352 | - | if err := s.publishService(ctx, app, eff.EffectiveRevision, pinned, result, opID); err != nil { | |||
| 353 | - | // Publication can fail before ACTIVE is written (the candidate was | |||
| 354 | - | // never published, so remove it and keep the old generation | |||
| 355 | - | // serving) or after it is written (a post-save failure such as | |||
| 356 | - | // ownership persistence: ACTIVE now names the candidate, which must | |||
| 357 | - | // keep running). An unprovable publication state keeps the | |||
| 358 | - | // candidate: an unconfirmed leftover is safer than retiring the | |||
| 359 | - | // live generation. | |||
| 360 | - | if s.candidateUnpublished(ctx, app, name, created.ID) { | |||
| 361 | - | result.CleanupWarnings = append(result.CleanupWarnings, s.retireCandidate(ctx, app, name, created.ID)...) | |||
| 362 | - | } | |||
| 363 | - | return fail(err.Error()) | |||
| 364 | - | } | |||
| 365 | - | step.After = created.ID | |||
| 366 | - | // Traffic is applied before the old generation drains: the proxy only | |||
| 367 | - | // switches to the candidate's verified loopback binds at the rebuild, | |||
| 368 | - | // and only a routable candidate may cost the old generation its drain | |||
| 369 | - | // window. A traffic failure retains the old container for the next | |||
| 370 | - | // convergence pass. | |||
| 371 | - | if err := s.refreshTraffic(ctx, app); err != nil { | |||
| 372 | - | if inhibitErr := s.inhibitRecovery(ctx, app, name, eff.Container, domain.AppInhibitRetirementPending, opID); inhibitErr != nil { | |||
| 373 | - | return fail(errors.Join(err, inhibitErr).Error()) | |||
| 374 | - | } | |||
| 375 | - | return fail(err.Error()) | |||
| 376 | - | } | |||
| 377 | - | drainWithDeadline(ctx, eff.Spec.StopGrace) | |||
| 378 | - | retired := s.retireContainer(ctx, app, retireOptions{Service: name, Grace: serviceStopGrace(eff)}, eff.Container) | |||
| 379 | - | result.CleanupWarnings = append(result.CleanupWarnings, retired.Warnings...) | |||
| 380 | - | step.State = domain.AppStepSucceeded | |||
| 381 | - | step.After = created.ID | |||
| 382 | - | return step, result | |||
| 383 | - | } | |||
| 384 | - | ||||
| 385 | - | // candidateUnpublished reports whether a candidate that failed | |||
| 386 | - | // publication is provably not the app's current generation. It is only | |||
| 387 | - | // true when ACTIVE loads and designates some other container for the | |||
| 388 | - | // service: a candidate whose state cannot be read is treated as possibly | |||
| 389 | - | // published and retained, never removed. | |||
| 390 | - | func (s *Service) candidateUnpublished(ctx context.Context, app, service, containerID string) bool { | |||
| 391 | - | active, ok, err := s.deps.State.LoadActive(ctx, app) | |||
| 392 | - | if err != nil || !ok { | |||
| 393 | - | return false | |||
| 394 | - | } | |||
| 395 | - | return active.Services[service].Container != containerID | |||
| 396 | - | } | |||
| 397 | - | ||||
| 398 | // restartOneService withdraws one service, restarts its exact container, | 282 | // restartOneService withdraws one service, restarts its exact container, | ||
| 399 | // re-inspects its binds, and verifies readiness before it may be published | 283 | // re-inspects its binds, and verifies readiness before it may be published | ||
| 400 | // again. Every failure leaves the service withdrawn with its recorded binds | 284 | // again. Every failure leaves the service withdrawn with its recorded binds | ||
| 401 | - | // cleared, so a failed restart is never served. | 285 | + | // cleared, so a failed restart is never served. A generation whose recorded |
| 402 | - | func (s *Service) restartOneService(ctx context.Context, app, name string, eff domain.AppEffectiveService) (domain.AppOperationStep, ServiceResult) { | 286 | + | // container no longer exists is rebuilt from the pinned ACTIVE digest, so a |
| 403 | - | step := domain.AppOperationStep{ID: "service." + name + ".restart", State: domain.AppStepPending, Before: eff.Container} | 287 | + | // failed replacement cannot leave restart as a permanent dead end. |
| 288 | + | func (s *Service) restartOneService(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, op *domain.AppOperation, index int) ServiceResult { | |||
| 289 | + | step := &op.Steps[index] | |||
| 404 | result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container} | 290 | result := ServiceResult{Result: "failed", Before: eff.Container, After: eff.Container} | ||
| 405 | - | fail := func(msg string) (domain.AppOperationStep, ServiceResult) { | 291 | + | fail := func(msg string) ServiceResult { |
| 406 | step.State = domain.AppStepFailed | 292 | step.State = domain.AppStepFailed | ||
| 407 | step.Error = msg | 293 | step.Error = msg | ||
| 294 | + | result.Result = "failed" | |||
| 408 | result.Error = msg | 295 | result.Error = msg | ||
| 409 | - | return step, result | 296 | + | return result |
| 410 | } | 297 | } | ||
| 411 | if eff.Container == "" { | 298 | if eff.Container == "" { | ||
| 412 | result.After = "" | 299 | result.After = "" | ||
| @@ -427,6 +314,30 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d | |||||
| 427 | return fail(err.Error()) | 314 | return fail(err.Error()) | ||
| 428 | } | 315 | } | ||
| 429 | if err := s.deps.Runtime.RestartContainer(ctx, eff.Container, serviceStopGrace(eff)); err != nil { | 316 | if err := s.deps.Runtime.RestartContainer(ctx, eff.Container, serviceStopGrace(eff)); err != nil { | ||
| 317 | + | if errors.Is(err, domain.ErrContainerNotFound) { | |||
| 318 | + | // The recorded generation is gone: rebuild and publish it from the | |||
| 319 | + | // pinned ACTIVE digest instead of leaving the service withdrawn | |||
| 320 | + | // until a deploy. | |||
| 321 | + | svcResult, rebuildErr := s.redeployPinned(ctx, app, opID, name, eff, s.journalCandidate(op, index)) | |||
| 322 | + | if rebuildErr != nil { | |||
| 323 | + | svcResult.Error = rebuildErr.Error() | |||
| 324 | + | step.State = domain.AppStepFailed | |||
| 325 | + | step.Error = rebuildErr.Error() | |||
| 326 | + | step.Diagnostics = svcResult.Diagnostics | |||
| 327 | + | // The recorded container is proven gone: the inhibition the | |||
| 328 | + | // rebuild wrote for it protects nothing, and keeping it would | |||
| 329 | + | // refuse every later start, recovery pass, and restart. | |||
| 330 | + | if clearErr := s.clearRecoveryInhibition(ctx, app, name, eff.Container); clearErr != nil { | |||
| 331 | + | svcResult.CleanupWarnings = append(svcResult.CleanupWarnings, CleanupWarning{ | |||
| 332 | + | Service: name, Leftover: eff.Container, Detail: "clear recovery inhibition: " + clearErr.Error(), | |||
| 333 | + | }) | |||
| 334 | + | } | |||
| 335 | + | return svcResult | |||
| 336 | + | } | |||
| 337 | + | step.State = domain.AppStepSucceeded | |||
| 338 | + | step.After = svcResult.After | |||
| 339 | + | return svcResult | |||
| 340 | + | } | |||
| 430 | s.clearServiceBinds(ctx, app, name) | 341 | s.clearServiceBinds(ctx, app, name) | ||
| 431 | return fail(err.Error()) | 342 | return fail(err.Error()) | ||
| 432 | } | 343 | } | ||
| @@ -438,13 +349,13 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d | |||||
| 438 | s.clearServiceBinds(ctx, app, name) | 349 | s.clearServiceBinds(ctx, app, name) | ||
| 439 | return fail(err.Error()) | 350 | return fail(err.Error()) | ||
| 440 | } | 351 | } | ||
| 441 | - | if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil { | 352 | + | if err := s.waitServiceReady(ctx, app, eff.Container, deploymentReadiness(eff.Spec), binds); err != nil { |
| 442 | s.clearServiceBinds(ctx, app, name) | 353 | s.clearServiceBinds(ctx, app, name) | ||
| 443 | return fail(err.Error()) | 354 | return fail(err.Error()) | ||
| 444 | } | 355 | } | ||
| 445 | step.State = domain.AppStepSucceeded | 356 | step.State = domain.AppStepSucceeded | ||
| 446 | step.After = eff.Container | 357 | step.After = eff.Container | ||
| 447 | - | return step, ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds} | 358 | + | return ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds} |
| 448 | } | 359 | } | ||
| 449 | 360 | ||||
| 450 | // Remove withdraws workloads by exact container ID; volumes, secrets, and | 361 | // Remove withdraws workloads by exact container ID; volumes, secrets, and | ||
| @@ -468,6 +379,9 @@ func (s *Service) removeLocked(ctx context.Context, app, opID string) (*Lifecycl | |||||
| 468 | if err := s.deps.State.Recover(ctx); err != nil { | 379 | if err := s.deps.State.Recover(ctx); err != nil { | ||
| 469 | return nil, fmt.Errorf("deployment: recover before remove: %w", err) | 380 | return nil, fmt.Errorf("deployment: recover before remove: %w", err) | ||
| 470 | } | 381 | } | ||
| 382 | + | if err := s.reconcileInterruptedDeploy(ctx, app); err != nil { | |||
| 383 | + | return nil, err | |||
| 384 | + | } | |||
| 471 | op := domain.AppOperation{ | 385 | op := domain.AppOperation{ | ||
| 472 | Kind: "remove", App: app, | 386 | Kind: "remove", App: app, | ||
| 473 | StartedAt: time.Now().UTC(), | 387 | StartedAt: time.Now().UTC(), | ||
| @@ -643,9 +557,18 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error { | |||||
| 643 | return fmt.Errorf("deployment: load active: %w", err) | 557 | return fmt.Errorf("deployment: load active: %w", err) | ||
| 644 | } | 558 | } | ||
| 645 | if !ok || len(active.Services) == 0 { | 559 | if !ok || len(active.Services) == 0 { | ||
| 646 | - | return 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) | |||
| 647 | } | 565 | } | ||
| 648 | if intent.Stopped { | 566 | if intent.Stopped { | ||
| 567 | + | // A stopped app does not start, so its unfinished operation is | |||
| 568 | + | // reconciled here instead. | |||
| 569 | + | if err := s.reconcileInterruptedDeploy(ctx, app); err != nil { | |||
| 570 | + | return err | |||
| 571 | + | } | |||
| 649 | for _, name := range sortedServiceNames(active) { | 572 | for _, name := range sortedServiceNames(active) { | ||
| 650 | if err := s.convergeStoppedService(ctx, app, name, active.Services[name]); err != nil { | 573 | if err := s.convergeStoppedService(ctx, app, name, active.Services[name]); err != nil { | ||
| 651 | return fmt.Errorf("deployment: boot stopped convergence %q/%q: %w", app, name, err) | 574 | return fmt.Errorf("deployment: boot stopped convergence %q/%q: %w", app, name, err) | ||
| @@ -653,6 +576,8 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error { | |||||
| 653 | } | 576 | } | ||
| 654 | return nil | 577 | return nil | ||
| 655 | } | 578 | } | ||
| 579 | + | // startLocked reconciles the app's unfinished operation before it starts | |||
| 580 | + | // anything, so the boot pass reconciles exactly once. | |||
| 656 | result, err := s.startLocked(ctx, app, "") | 581 | result, err := s.startLocked(ctx, app, "") | ||
| 657 | if err != nil { | 582 | if err != nil { | ||
| 658 | return fmt.Errorf("deployment: boot start %q: %w", app, err) | 583 | return fmt.Errorf("deployment: boot start %q: %w", app, err) | ||
| @@ -670,16 +595,16 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error { | |||||
| 670 | // may shift across any runtime restart (stop/start, daemon absence, | 595 | // may shift across any runtime restart (stop/start, daemon absence, | ||
| 671 | // host reboot), so the recorded binds are re-inspected and persisted | 596 | // host reboot), so the recorded binds are re-inspected and persisted | ||
| 672 | // before the proxy can dial them. Verification failure fails the step; | 597 | // before the proxy can dial them. Verification failure fails the step; | ||
| 673 | - | // a stale recorded bind is never served. It reports whether | 598 | + | // a stale recorded bind is never served. A recorded container that no |
| 674 | - | // the caller should continue with the next service (true) or finish the | 599 | + | // longer exists is rebuilt from the revision pinned in ACTIVE. |
| 675 | - | // step inline (false, reserved for future fallible publishes). | 600 | + | func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, op *domain.AppOperation, index int, result *LifecycleResult) { |
| 676 | - | func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool { | 601 | + | step := &op.Steps[index] |
| 677 | if eff.Container != "" { | 602 | if eff.Container != "" { | ||
| 678 | // Fail closed before any runtime mutation if a bind's policy was | 603 | // Fail closed before any runtime mutation if a bind's policy was | ||
| 679 | // revoked or became invalid since ACTIVE was published. | 604 | // revoked or became invalid since ACTIVE was published. | ||
| 680 | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 605 | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | ||
| 681 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | 606 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | ||
| 682 | - | return true | 607 | + | return |
| 683 | } | 608 | } | ||
| 684 | // Boot recovery refuses a generation whose recovery is durably | 609 | // Boot recovery refuses a generation whose recovery is durably | ||
| 685 | // inhibited: a replacement may already have written to its | 610 | // inhibited: a replacement may already have written to its | ||
| @@ -687,106 +612,96 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri | |||||
| 687 | inhibited, err := s.recoveryInhibited(ctx, app, name, eff.Container) | 612 | inhibited, err := s.recoveryInhibited(ctx, app, name, eff.Container) | ||
| 688 | if err != nil { | 613 | if err != nil { | ||
| 689 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | 614 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | ||
| 690 | - | return true | 615 | + | return |
| 691 | } | 616 | } | ||
| 692 | if inhibited { | 617 | if inhibited { | ||
| 693 | s.failServiceStep(ctx, app, name, eff, step, result, | 618 | s.failServiceStep(ctx, app, name, eff, step, result, | ||
| 694 | fmt.Sprintf("recovery inhibited for container %s", eff.Container)) | 619 | fmt.Sprintf("recovery inhibited for container %s", eff.Container)) | ||
| 695 | - | return true | 620 | + | return |
| 696 | } | 621 | } | ||
| 697 | if ok, err := s.deps.Runtime.IsContainerRunning(ctx, eff.Container); err == nil && ok { | 622 | if ok, err := s.deps.Runtime.IsContainerRunning(ctx, eff.Container); err == nil && ok { | ||
| 698 | - | return s.verifyRunningService(ctx, app, name, eff, step, result) | 623 | + | s.verifyRunningService(ctx, app, name, eff, step, result) |
| 624 | + | return | |||
| 699 | } | 625 | } | ||
| 700 | // Restart by exact container ID when the runtime still has it. | 626 | // Restart by exact container ID when the runtime still has it. | ||
| 701 | if err := s.deps.Runtime.StartContainer(ctx, eff.Container); err == nil { | 627 | if err := s.deps.Runtime.StartContainer(ctx, eff.Container); err == nil { | ||
| 702 | - | return s.verifyRunningService(ctx, app, name, eff, step, result) | 628 | + | s.verifyRunningService(ctx, app, name, eff, step, result) |
| 629 | + | return | |||
| 703 | } | 630 | } | ||
| 704 | } | 631 | } | ||
| 705 | - | // Otherwise redeploy from the pinned active digest. | 632 | + | // Otherwise rebuild from the revision pinned in ACTIVE. |
| 706 | - | rev, err := s.deps.State.LoadRevision(ctx, app, eff.EffectiveRevision) | 633 | + | svcResult, err := s.redeployPinned(ctx, app, opID, name, eff, s.journalCandidate(op, index)) |
| 707 | if err != nil { | 634 | if err != nil { | ||
| 708 | step.State = domain.AppStepFailed | 635 | step.State = domain.AppStepFailed | ||
| 709 | step.Error = err.Error() | 636 | step.Error = err.Error() | ||
| 710 | - | result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()} | 637 | + | step.Diagnostics = svcResult.Diagnostics |
| 711 | - | return true | 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 | |||
| 712 | } | 655 | } | ||
| 713 | runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest) | 656 | runtimeImage, err := s.preflightImage(ctx, eff.Spec.Image, eff.Digest) | ||
| 714 | if err != nil { | 657 | if err != nil { | ||
| 715 | - | step.State = domain.AppStepFailed | 658 | + | return failedServiceResult(eff.Container, err), err |
| 716 | - | step.Error = err.Error() | |||
| 717 | - | result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()} | |||
| 718 | - | return true | |||
| 719 | } | 659 | } | ||
| 720 | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | 660 | if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil { | ||
| 721 | - | step.State = domain.AppStepFailed | 661 | + | return failedServiceResult(eff.Container, err), err |
| 722 | - | step.Error = err.Error() | |||
| 723 | - | result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()} | |||
| 724 | - | return true | |||
| 725 | } | 662 | } | ||
| 726 | pinned := pinnedService{ | 663 | pinned := pinnedService{ | ||
| 727 | name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env), | 664 | name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env), | ||
| 728 | appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...), | 665 | appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...), | ||
| 729 | sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name), | 666 | sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name), | ||
| 730 | } | 667 | } | ||
| 731 | - | svcResult, _ := s.deployService(ctx, app, rev.Revision, pinned, opID, eff.Container, serviceStopGrace(eff)) | 668 | + | svcResult := s.deployService(ctx, app, rev.Revision, pinned, opID, eff.Container, serviceStopGrace(eff), journal) |
| 732 | if svcResult.Result == "failed" { | 669 | if svcResult.Result == "failed" { | ||
| 733 | - | step.State = domain.AppStepFailed | 670 | + | return svcResult, errors.New(svcResult.Error) |
| 734 | - | step.Error = svcResult.Error | |||
| 735 | - | step.Diagnostics = svcResult.Diagnostics | |||
| 736 | - | result.Services[name] = svcResult | |||
| 737 | - | return true | |||
| 738 | } | 671 | } | ||
| 739 | - | result.Services[name] = svcResult | 672 | + | // The step may only be recorded as succeeded once the effective state is |
| 740 | - | // The step is recorded as succeeded only after the effective state is | 673 | + | // published: a journaled success is never ahead of what the proxy reaches. |
| 741 | - | // published: a journaled success is never ahead of what the proxy can | |||
| 742 | - | // reach. | |||
| 743 | if err := s.publishService(ctx, app, rev.Revision, pinned, svcResult, opID); err != nil { | 674 | if err := s.publishService(ctx, app, rev.Revision, pinned, svcResult, opID); err != nil { | ||
| 744 | - | step.State = domain.AppStepFailed | 675 | + | svcResult.Result = "failed" |
| 745 | - | step.Error = err.Error() | 676 | + | svcResult.Error = err.Error() |
| 746 | - | result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()} | 677 | + | return svcResult, err |
| 747 | - | return true | |||
| 748 | - | } | |||
| 749 | - | // The replaced container is only retired once the replacement is | |||
| 750 | - | // published: it must not be removed while a crash could leave the | |||
| 751 | - | // service with no routable generation at all. | |||
| 752 | - | if svcResult.Retire != "" { | |||
| 753 | - | s.retireAfterPublish(ctx, app, pinned, &svcResult, result) | |||
| 754 | } | 678 | } | ||
| 755 | - | step.State = domain.AppStepSucceeded | 679 | + | return svcResult, nil |
| 756 | - | step.After = svcResult.After | |||
| 757 | - | return true | |||
| 758 | } | 680 | } | ||
| 759 | 681 | ||||
| 760 | - | // retireAfterPublish retires the container a published replacement | 682 | + | // failedServiceResult builds the terminal result of a service step that |
| 761 | - | // superseded. The replaced generation keeps its inhibition: the new | 683 | + | // failed before any container existed. |
| 762 | - | // generation's publication already cleared it, and a still-unpublished | 684 | + | func failedServiceResult(before string, err error) ServiceResult { |
| 763 | - | // replacement never reaches here. | 685 | + | return ServiceResult{Result: "failed", Before: before, Error: err.Error()} |
| 764 | - | func (s *Service) retireAfterPublish(ctx context.Context, app string, p pinnedService, svcResult *ServiceResult, result *LifecycleResult) { | |||
| 765 | - | retired := s.retireContainer(ctx, app, retireOptions{ | |||
| 766 | - | Service: p.name, Grace: svcResult.RetireGrace, | |||
| 767 | - | }, svcResult.Retire) | |||
| 768 | - | service := result.Services[p.name] | |||
| 769 | - | service.CleanupWarnings = append(service.CleanupWarnings, retired.Warnings...) | |||
| 770 | - | result.Services[p.name] = service | |||
| 771 | } | 686 | } | ||
| 772 | 687 | ||||
| 773 | // verifyRunningService withdraws a running or freshly started generation, | 688 | // verifyRunningService withdraws a running or freshly started generation, | ||
| 774 | // re-inspects its loopback binds, and verifies readiness before the service | 689 | // re-inspects its loopback binds, and verifies readiness before the service | ||
| 775 | // may be published again. Any failure leaves the service withdrawn with its | 690 | // may be published again. Any failure leaves the service withdrawn with its | ||
| 776 | // recorded binds cleared, so a not-ready backend is never projected. | 691 | // recorded binds cleared, so a not-ready backend is never projected. | ||
| 777 | - | func (s *Service) verifyRunningService(ctx context.Context, app, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool { | 692 | + | func (s *Service) verifyRunningService(ctx context.Context, app, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) { |
| 778 | if err := s.withdrawForRecovery(ctx, app, name); err != nil { | 693 | if err := s.withdrawForRecovery(ctx, app, name); err != nil { | ||
| 779 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | 694 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | ||
| 780 | - | return true | 695 | + | return |
| 781 | } | 696 | } | ||
| 782 | binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil) | 697 | binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil) | ||
| 783 | if err != nil { | 698 | if err != nil { | ||
| 784 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | 699 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | ||
| 785 | - | return true | 700 | + | return |
| 786 | } | 701 | } | ||
| 787 | if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil { | 702 | if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil { | ||
| 788 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | 703 | s.failServiceStep(ctx, app, name, eff, step, result, err.Error()) | ||
| 789 | - | return true | 704 | + | return |
| 790 | } | 705 | } | ||
| 791 | // The service is running and its re-inspected loopback binds are | 706 | // The service is running and its re-inspected loopback binds are | ||
| 792 | // persisted, so the step may succeed: the graph apply for this app | 707 | // persisted, so the step may succeed: the graph apply for this app | ||
| @@ -794,7 +709,6 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef | |||||
| 794 | step.State = domain.AppStepSucceeded | 709 | step.State = domain.AppStepSucceeded | ||
| 795 | step.After = eff.Container | 710 | step.After = eff.Container | ||
| 796 | result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds} | 711 | result.Services[name] = ServiceResult{Result: "deployed", Before: eff.Container, After: eff.Container, BackendBinds: binds, UDPBackendBinds: udpBinds} | ||
| 797 | - | return true | |||
| 798 | } | 712 | } | ||
| 799 | 713 | ||||
| 800 | // refreshBackendBinds re-inspects the loopback publishes of a restarted | 714 | // refreshBackendBinds re-inspects the loopback publishes of a restarted | ||
M internal/usecase/deployment/networks_internal_test.go +5 -5
| @@ -54,7 +54,7 @@ func TestCreateAndStart_UsesIncarnationPrivateNetworkAndLimits(t *testing.T) { | |||||
| 54 | ResourceLimits{MemoryBytes: 1 << 30, NanoCPUs: 2_000_000_000, PidsLimit: 256}, | 54 | ResourceLimits{MemoryBytes: 1 << 30, NanoCPUs: 2_000_000_000, PidsLimit: 256}, | ||
| 55 | ) | 55 | ) | ||
| 56 | created, binds, udp, err := svc.createAndStart(ctx, "blog", "rev-1", | 56 | created, binds, udp, err := svc.createAndStart(ctx, "blog", "rev-1", | ||
| 57 | - | pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1") | 57 | + | pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil) |
| 58 | require.NoError(t, err) | 58 | require.NoError(t, err) | ||
| 59 | require.NotNil(t, created) | 59 | require.NotNil(t, created) | ||
| 60 | assert.Empty(t, binds) | 60 | assert.Empty(t, binds) | ||
| @@ -80,7 +80,7 @@ func TestCreateAndStart_ReusesOwnedNetwork(t *testing.T) { | |||||
| 80 | 80 | ||||
| 81 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | 81 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | ||
| 82 | _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1", | 82 | _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1", | ||
| 83 | - | pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1") | 83 | + | pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil) |
| 84 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| @@ -100,7 +100,7 @@ func TestCreateAndStart_RefusesForeignNetwork(t *testing.T) { | |||||
| 100 | 100 | ||||
| 101 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | 101 | svc := networkTestService(t, state, runtime, NetworkConfig{Prefix: "gordon"}, ResourceLimits{}) | ||
| 102 | _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1", | 102 | _, _, _, err := svc.createAndStart(ctx, "blog", "rev-1", | ||
| 103 | - | pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1") | 103 | + | pinnedService{name: "web", spec: domain.AppService{Name: "web"}}, "op-1", nil) |
| 104 | require.ErrorIs(t, err, domain.ErrAppStateConflict) | 104 | require.ErrorIs(t, err, domain.ErrAppStateConflict) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| @@ -130,7 +130,7 @@ func TestCreateAndStart_JoinsDeclaredSharedNetworks(t *testing.T) { | |||||
| 130 | name: "web", | 130 | name: "web", | ||
| 131 | spec: domain.AppService{Name: "web"}, | 131 | spec: domain.AppService{Name: "web"}, | ||
| 132 | sharedNetworks: []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}}}, | 132 | sharedNetworks: []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}}}, | ||
| 133 | - | }, "op-1") | 133 | + | }, "op-1", nil) |
| 134 | require.NoError(t, err) | 134 | require.NoError(t, err) | ||
| 135 | } | 135 | } | ||
| 136 | 136 | ||||
| @@ -163,7 +163,7 @@ func TestCreateAndStart_RoutesDeclaredReadOnlyVolumeToReadOnlyMount(t *testing.T | |||||
| 163 | Name: "web", | 163 | Name: "web", | ||
| 164 | Volumes: []domain.AppVolume{{Name: "config", Path: "/config", ReadOnly: true}}, | 164 | Volumes: []domain.AppVolume{{Name: "config", Path: "/config", ReadOnly: true}}, | ||
| 165 | }, | 165 | }, | ||
| 166 | - | }, "op-1") | 166 | + | }, "op-1", nil) |
| 167 | require.NoError(t, err) | 167 | require.NoError(t, err) | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
A internal/usecase/deployment/preflight_gate_test.go +289 -0
| @@ -0,0 +1,289 @@ | |||||
| 1 | + | package deployment_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // recordSavedOperations captures every journal write so a test can assert the | |||
| 19 | + | // terminal preflight outcome and that no effect ran before the gate failed. | |||
| 20 | + | func recordSavedOperations(state *outmocks.MockAppState) *[]domain.AppOperation { | |||
| 21 | + | saved := &[]domain.AppOperation{} | |||
| 22 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn( | |||
| 23 | + | func(_ context.Context, op domain.AppOperation) error { | |||
| 24 | + | *saved = append(*saved, op) | |||
| 25 | + | return nil | |||
| 26 | + | }) | |||
| 27 | + | return saved | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // assertNoDeployMutation proves a gate that failed before execution never | |||
| 31 | + | // touched the workload: no container is created, started, stopped, or | |||
| 32 | + | // removed, no volume is created, and ACTIVE is never republished. | |||
| 33 | + | func assertNoDeployMutation(t *testing.T, runtime *outmocks.MockContainerRuntime, state *outmocks.MockAppState) { | |||
| 34 | + | t.Helper() | |||
| 35 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 36 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 37 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 38 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 39 | + | runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything, mock.Anything) | |||
| 40 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // assertPreflightGateJournal proves a failed preflight gate left a terminal | |||
| 44 | + | // failed journal whose preflight step carries the failure. | |||
| 45 | + | func assertPreflightGateJournal(t *testing.T, saved []domain.AppOperation) { | |||
| 46 | + | t.Helper() | |||
| 47 | + | require.NotEmpty(t, saved, "a failed gate must journal the claim and its failure") | |||
| 48 | + | last := saved[len(saved)-1] | |||
| 49 | + | require.True(t, last.Terminal(), "a failed preflight gate must persist a terminal journal") | |||
| 50 | + | assert.Equal(t, domain.AppOutcomeFailed, last.Outcome) | |||
| 51 | + | step, ok := opStep(last, "preflight") | |||
| 52 | + | require.True(t, ok) | |||
| 53 | + | assert.Equal(t, domain.AppStepFailed, step.State) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // TestDeploy_MissingRequiredSecretFailsClosed proves a deploy whose required | |||
| 57 | + | // secret cannot be resolved fails closed with ErrAppSecretMissing before any | |||
| 58 | + | // workload mutation, and journals the terminal failure. | |||
| 59 | + | func TestDeploy_MissingRequiredSecretFailsClosed(t *testing.T) { | |||
| 60 | + | ctx := context.Background() | |||
| 61 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 62 | + | rev := mockRevision() | |||
| 63 | + | ||||
| 64 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 65 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 66 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 67 | + | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once() | |||
| 68 | + | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("", assert.AnError).Once() | |||
| 69 | + | saved := recordSavedOperations(state) | |||
| 70 | + | ||||
| 71 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 72 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 73 | + | ||||
| 74 | + | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 75 | + | assertNoDeployMutation(t, runtime, state) | |||
| 76 | + | assertPreflightGateJournal(t, *saved) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // TestDeploy_EnvSecretCollisionFailsClosed proves a stored revision whose | |||
| 80 | + | // public env collides with one of the service's secret keys is refused | |||
| 81 | + | // before any image resolution or workload mutation. | |||
| 82 | + | func TestDeploy_EnvSecretCollisionFailsClosed(t *testing.T) { | |||
| 83 | + | ctx := context.Background() | |||
| 84 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 85 | + | rev := mockRevision() | |||
| 86 | + | rev.Spec.Env = map[string]string{"DATABASE_URL": "public-value"} | |||
| 87 | + | ||||
| 88 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 89 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 90 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 91 | + | saved := recordSavedOperations(state) | |||
| 92 | + | ||||
| 93 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 94 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 95 | + | ||||
| 96 | + | require.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 97 | + | images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything) | |||
| 98 | + | secrets.AssertNotCalled(t, "GetSecret", mock.Anything, mock.Anything) | |||
| 99 | + | assertNoDeployMutation(t, runtime, state) | |||
| 100 | + | assertPreflightGateJournal(t, *saved) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // TestDeploy_UnmanagedImageVolumeRejected proves an image that declares a | |||
| 104 | + | // VOLUME with no operator mapping is refused before any workload mutation, | |||
| 105 | + | // and journals the terminal failure. | |||
| 106 | + | func TestDeploy_UnmanagedImageVolumeRejected(t *testing.T) { | |||
| 107 | + | ctx := context.Background() | |||
| 108 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 109 | + | rev := mockRevision() | |||
| 110 | + | ||||
| 111 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 112 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 113 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 114 | + | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(restartTestDigest, nil).Once() | |||
| 115 | + | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once() | |||
| 116 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return([]string{"/data"}, nil).Once() | |||
| 117 | + | saved := recordSavedOperations(state) | |||
| 118 | + | ||||
| 119 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 120 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 121 | + | ||||
| 122 | + | require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume) | |||
| 123 | + | assertNoDeployMutation(t, runtime, state) | |||
| 124 | + | assertPreflightGateJournal(t, *saved) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // TestDeploy_RefusesUnownedExistingVolume proves R2: a generated runtime | |||
| 128 | + | // volume that already exists but is NOT recorded in the app's ownership is | |||
| 129 | + | // refused fail-closed — the deploy must never implicitly adopt (and modify) | |||
| 130 | + | // foreign data under a reused name. | |||
| 131 | + | func TestDeploy_RefusesUnownedExistingVolume(t *testing.T) { | |||
| 132 | + | ctx := context.Background() | |||
| 133 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 134 | + | svcSpec := webService() | |||
| 135 | + | svcSpec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | |||
| 136 | + | rev := testRevision("blog", svcSpec) | |||
| 137 | + | runtimeName := domain.RuntimeVolumeName("blog", "web", "data") | |||
| 138 | + | ||||
| 139 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 140 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 141 | + | // Ownership records no volume, so the runtime one is foreign. | |||
| 142 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 143 | + | images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil).Once() | |||
| 144 | + | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil).Once() | |||
| 145 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil).Once() | |||
| 146 | + | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | |||
| 147 | + | runtime.EXPECT().VolumeExists(mock.Anything, runtimeName).Return(true, nil).Once() | |||
| 148 | + | saved := recordSavedOperations(state) | |||
| 149 | + | ||||
| 150 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 151 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 152 | + | ||||
| 153 | + | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 154 | + | require.Contains(t, err.Error(), "not owned by app") | |||
| 155 | + | assertNoDeployMutation(t, runtime, state) | |||
| 156 | + | assertPreflightGateJournal(t, *saved) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // TestStartDeploy_TargetedDiffPolicy proves the targeted-deploy convergence | |||
| 160 | + | // rules live in the claim phase: a requested-service image change is allowed | |||
| 161 | + | // and claims the operation, while app-wide or other-service divergence is | |||
| 162 | + | // refused with ErrAppStateConflict before any claim is written and without | |||
| 163 | + | // resolving an image or touching the runtime. | |||
| 164 | + | func TestStartDeploy_TargetedDiffPolicy(t *testing.T) { | |||
| 165 | + | baseService := webService() | |||
| 166 | + | worker := webService() | |||
| 167 | + | worker.Name = "worker" | |||
| 168 | + | worker.HTTP = []domain.AppHTTPInterface{{Host: "worker.example.com", Port: 8080, TLS: domain.AppTLSAuto}} | |||
| 169 | + | base := domain.AppActive{ | |||
| 170 | + | App: "blog", ConvergedRevision: "rev-1", Converged: true, | |||
| 171 | + | Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}}, | |||
| 172 | + | Services: map[string]domain.AppEffectiveService{ | |||
| 173 | + | "web": {Spec: baseService}, | |||
| 174 | + | "worker": {Spec: worker}, | |||
| 175 | + | }, | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | tests := []struct { | |||
| 179 | + | name string | |||
| 180 | + | mutate func(*domain.AppSpec) | |||
| 181 | + | wantErr bool | |||
| 182 | + | }{ | |||
| 183 | + | {name: "requested service image", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }}, | |||
| 184 | + | {name: "other service", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }}, | |||
| 185 | + | {name: "environment", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }}, | |||
| 186 | + | {name: "network", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }}, | |||
| 187 | + | {name: "service added", wantErr: true, mutate: func(spec *domain.AppSpec) { | |||
| 188 | + | spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}}) | |||
| 189 | + | }}, | |||
| 190 | + | } | |||
| 191 | + | for _, tc := range tests { | |||
| 192 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 193 | + | ctx := context.Background() | |||
| 194 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 195 | + | spec := domain.AppSpec{ | |||
| 196 | + | Name: "blog", | |||
| 197 | + | Networks: append([]domain.AppSharedNetwork(nil), base.Networks...), | |||
| 198 | + | Services: []domain.AppService{baseService, worker}, | |||
| 199 | + | } | |||
| 200 | + | spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...) | |||
| 201 | + | tc.mutate(&spec) | |||
| 202 | + | rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec} | |||
| 203 | + | ||||
| 204 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 205 | + | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once() | |||
| 206 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once() | |||
| 207 | + | if !tc.wantErr { | |||
| 208 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 212 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"}) | |||
| 213 | + | ||||
| 214 | + | if tc.wantErr { | |||
| 215 | + | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 216 | + | state.AssertNotCalled(t, "SaveOperation", mock.Anything, mock.Anything) | |||
| 217 | + | } else { | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | require.True(t, started.Owned) | |||
| 220 | + | } | |||
| 221 | + | images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything) | |||
| 222 | + | assertNoDeployMutation(t, runtime, state) | |||
| 223 | + | }) | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection proves the shared | |||
| 228 | + | // GC lease is taken by the execution phase before resource selection and | |||
| 229 | + | // released only when the mutation ends. Prune can therefore never observe a | |||
| 230 | + | // resource that was selected but whose protection is not yet durable. | |||
| 231 | + | func TestDeploy_HoldsSharedGCBarrierAcrossResourceSelection(t *testing.T) { | |||
| 232 | + | ctx := context.Background() | |||
| 233 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 234 | + | svcSpec := webService() | |||
| 235 | + | svcSpec.HTTP = nil | |||
| 236 | + | svcSpec.Readiness = domain.AppReadiness{} | |||
| 237 | + | rev := testRevision("blog", svcSpec) | |||
| 238 | + | ||||
| 239 | + | barrier := outmocks.NewMockGCBarrier(t) | |||
| 240 | + | lease := outmocks.NewMockGCLease(t) | |||
| 241 | + | held := false | |||
| 242 | + | barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) { | |||
| 243 | + | held = true | |||
| 244 | + | }).Return(lease, nil).Once() | |||
| 245 | + | lease.EXPECT().Release().Run(func() { | |||
| 246 | + | held = false | |||
| 247 | + | }).Once() | |||
| 248 | + | ||||
| 249 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 250 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 251 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 252 | + | // Image resolution and image-volume inspection are the resource-selection | |||
| 253 | + | // steps: they must happen inside the shared lease. | |||
| 254 | + | images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Run(func(context.Context, string) { | |||
| 255 | + | require.True(t, held, "image resolution must run inside the shared GC lease") | |||
| 256 | + | }).Return(restartTestDigest, nil).Once() | |||
| 257 | + | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil) | |||
| 258 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Run(func(context.Context, string) { | |||
| 259 | + | require.True(t, held, "image-volume inspection must run inside the shared GC lease") | |||
| 260 | + | }).Return(nil, nil).Once() | |||
| 261 | + | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil) | |||
| 262 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |||
| 263 | + | App: "blog", Services: map[string]domain.AppEffectiveService{}, | |||
| 264 | + | }, true, nil) | |||
| 265 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 266 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 267 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 268 | + | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | |||
| 269 | + | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |||
| 270 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 271 | + | ||||
| 272 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 273 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 274 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 275 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 276 | + | func(context.Context, string) error { return nil }, | |||
| 277 | + | )).WithGCBarrier(barrier) | |||
| 278 | + | ||||
| 279 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 280 | + | require.NoError(t, err) | |||
| 281 | + | require.True(t, started.Owned) | |||
| 282 | + | require.False(t, held, "the claim phase takes no shared GC lease") | |||
| 283 | + | ||||
| 284 | + | result, err := svc.ExecuteDeploy(ctx, started.Claim) | |||
| 285 | + | require.NoError(t, err) | |||
| 286 | + | require.NotNil(t, result) | |||
| 287 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 288 | + | assert.False(t, held, "the shared lease must be released when the operation ends") | |||
| 289 | + | } | |||
M internal/usecase/deployment/probes.go +9 -3
| @@ -22,12 +22,18 @@ var probeHTTPClient = &http.Client{ | |||||
| 22 | Timeout: 5 * time.Second, | 22 | Timeout: 5 * time.Second, | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | // httpGetOnce performs one HTTP GET without following redirects. | 25 | + | // httpGetOnce performs one HTTP GET without following redirects. It dials |
| 26 | - | func httpGetOnce(ctx context.Context, url string) (int, error) { | 26 | + | // probeURL's authority (the loopback published bind) while sending |
| 27 | - | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | 27 | + | // hostAuthority as the Host header, so a workload that validates the Host |
| 28 | + | // port of its declared container port accepts the probe. | |||
| 29 | + | func httpGetOnce(ctx context.Context, probeURL, hostAuthority string) (int, error) { | |||
| 30 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil) | |||
| 28 | if err != nil { | 31 | if err != nil { | ||
| 29 | return 0, err | 32 | return 0, err | ||
| 30 | } | 33 | } | ||
| 34 | + | if hostAuthority != "" { | |||
| 35 | + | req.Host = hostAuthority | |||
| 36 | + | } | |||
| 31 | resp, err := probeHTTPClient.Do(req) | 37 | resp, err := probeHTTPClient.Do(req) | ||
| 32 | if err != nil { | 38 | if err != nil { | ||
| 33 | return 0, err | 39 | return 0, err | ||
M internal/usecase/deployment/publication_internal_test.go +4 -4
| @@ -46,13 +46,13 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) { | |||||
| 46 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once() | 46 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once() | ||
| 47 | svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()). | 47 | svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()). | ||
| 48 | WithProbeDeps(NewTestProbeDeps(runtime, | 48 | WithProbeDeps(NewTestProbeDeps(runtime, | ||
| 49 | - | func(context.Context, string) (int, error) { return 500, nil }, | 49 | + | func(context.Context, string, string) (int, error) { return 500, nil }, |
| 50 | func(context.Context, string) error { return nil }, | 50 | func(context.Context, string) error { return nil }, | ||
| 51 | )) | 51 | )) | ||
| 52 | 52 | ||||
| 53 | step := domain.AppOperationStep{ID: "service.web.start"} | 53 | step := domain.AppOperationStep{ID: "service.web.start"} | ||
| 54 | result := &LifecycleResult{Services: map[string]ServiceResult{}} | 54 | result := &LifecycleResult{Services: map[string]ServiceResult{}} | ||
| 55 | - | require.True(t, svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)) | 55 | + | svc.verifyRunningService(ctx, "blog", "web", eff, &step, result) |
| 56 | 56 | ||||
| 57 | assert.Equal(t, domain.AppStepFailed, step.State) | 57 | assert.Equal(t, domain.AppStepFailed, step.State) | ||
| 58 | assert.Equal(t, "failed", result.Services["web"].Result) | 58 | assert.Equal(t, "failed", result.Services["web"].Result) | ||
| @@ -81,13 +81,13 @@ func TestVerifyRunningService_ReadyPublishesBinds(t *testing.T) { | |||||
| 81 | 81 | ||||
| 82 | svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()). | 82 | svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()). | ||
| 83 | WithProbeDeps(NewTestProbeDeps(runtime, | 83 | WithProbeDeps(NewTestProbeDeps(runtime, | ||
| 84 | - | func(context.Context, string) (int, error) { return 200, nil }, | 84 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 85 | func(context.Context, string) error { return nil }, | 85 | func(context.Context, string) error { return nil }, | ||
| 86 | )) | 86 | )) | ||
| 87 | 87 | ||||
| 88 | step := domain.AppOperationStep{ID: "service.web.start"} | 88 | step := domain.AppOperationStep{ID: "service.web.start"} | ||
| 89 | result := &LifecycleResult{Services: map[string]ServiceResult{}} | 89 | result := &LifecycleResult{Services: map[string]ServiceResult{}} | ||
| 90 | - | require.True(t, svc.verifyRunningService(ctx, "blog", "web", eff, &step, result)) | 90 | + | svc.verifyRunningService(ctx, "blog", "web", eff, &step, result) |
| 91 | 91 | ||||
| 92 | assert.Equal(t, domain.AppStepSucceeded, step.State) | 92 | assert.Equal(t, domain.AppStepSucceeded, step.State) | ||
| 93 | assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds) | 93 | assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds) | ||
M internal/usecase/deployment/readiness.go +9 -3
| @@ -20,7 +20,7 @@ type ProbeDeps struct { | |||||
| 20 | networkInfo func(ctx context.Context, containerID string) (string, int, error) | 20 | networkInfo func(ctx context.Context, containerID string) (string, int, error) | ||
| 21 | containerStart func(ctx context.Context, containerID string) (time.Time, error) | 21 | containerStart func(ctx context.Context, containerID string) (time.Time, error) | ||
| 22 | logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error) | 22 | logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error) | ||
| 23 | - | httpGet func(ctx context.Context, url string) (int, error) | 23 | + | httpGet func(ctx context.Context, url, hostAuthority string) (int, error) |
| 24 | tcpDial func(ctx context.Context, addr string) error | 24 | tcpDial func(ctx context.Context, addr string) error | ||
| 25 | // networkProbe runs one bounded session against an internal port that | 25 | // networkProbe runs one bounded session against an internal port that | ||
| 26 | // has no host publication. Nil disables the internal path: it must | 26 | // has no host publication. Nil disables the internal path: it must | ||
| @@ -67,7 +67,7 @@ func NewProbeDeps(runtime ProbeRuntime) ProbeDeps { | |||||
| 67 | // NewTestProbeDeps builds ProbeDeps with injectable HTTP/TCP probers. | 67 | // NewTestProbeDeps builds ProbeDeps with injectable HTTP/TCP probers. | ||
| 68 | // Tests control L4 outcomes without dialing; logStream still reads the | 68 | // Tests control L4 outcomes without dialing; logStream still reads the | ||
| 69 | // runtime mock. | 69 | // runtime mock. | ||
| 70 | - | func NewTestProbeDeps(runtime ProbeRuntime, httpGet func(ctx context.Context, url string) (int, error), tcpDial func(ctx context.Context, addr string) error) ProbeDeps { | 70 | + | func NewTestProbeDeps(runtime ProbeRuntime, httpGet func(ctx context.Context, url, hostAuthority string) (int, error), tcpDial func(ctx context.Context, addr string) error) ProbeDeps { |
| 71 | deps := NewProbeDeps(runtime) | 71 | deps := NewProbeDeps(runtime) | ||
| 72 | deps.httpGet = httpGet | 72 | deps.httpGet = httpGet | ||
| 73 | deps.tcpDial = tcpDial | 73 | deps.tcpDial = tcpDial | ||
| @@ -287,6 +287,7 @@ func waitServiceReadyWithDeps(ctx context.Context, deps ProbeDeps, containerID s | |||||
| 287 | // waitHTTPReadyWithDeps polls GET path until 2xx/3xx or timeout. | 287 | // waitHTTPReadyWithDeps polls GET path until 2xx/3xx or timeout. | ||
| 288 | func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppService, binds map[int]int) error { | 288 | func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppService, binds map[int]int) error { | ||
| 289 | timeout := readinessTimeout(spec) | 289 | timeout := readinessTimeout(spec) | ||
| 290 | + | containerPort := readinessContainerPort(spec) | |||
| 290 | port, err := dialPort(spec, binds) | 291 | port, err := dialPort(spec, binds) | ||
| 291 | if err != nil { | 292 | if err != nil { | ||
| 292 | return err | 293 | return err | ||
| @@ -302,6 +303,11 @@ func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppS | |||||
| 302 | Path: pathPart, | 303 | Path: pathPart, | ||
| 303 | RawQuery: rawQuery, | 304 | RawQuery: rawQuery, | ||
| 304 | }).String() | 305 | }).String() | ||
| 306 | + | // The probe dials the random loopback publication but must address the | |||
| 307 | + | // workload at its declared container port: a workload that validates the | |||
| 308 | + | // Host port (e.g. qBittorrent) rejects a published-port authority with | |||
| 309 | + | // 401 "Invalid Host header, port mismatch". | |||
| 310 | + | hostAuthority := net.JoinHostPort("127.0.0.1", itoa(containerPort)) | |||
| 305 | deadline := time.Now().Add(timeout) | 311 | deadline := time.Now().Add(timeout) | ||
| 306 | var lastAttempt string | 312 | var lastAttempt string | ||
| 307 | for { | 313 | for { | ||
| @@ -309,7 +315,7 @@ func waitHTTPReadyWithDeps(ctx context.Context, deps ProbeDeps, spec domain.AppS | |||||
| 309 | return err | 315 | return err | ||
| 310 | } | 316 | } | ||
| 311 | attemptCtx, cancel := context.WithTimeout(ctx, min(2*time.Second, time.Until(deadline))) | 317 | attemptCtx, cancel := context.WithTimeout(ctx, min(2*time.Second, time.Until(deadline))) | ||
| 312 | - | status, err := deps.httpGet(attemptCtx, probeURL) | 318 | + | status, err := deps.httpGet(attemptCtx, probeURL, hostAuthority) |
| 313 | cancel() | 319 | cancel() | ||
| 314 | if err == nil && status >= 200 && status < 400 { | 320 | if err == nil && status >= 200 && status < 400 { | ||
| 315 | return nil | 321 | return nil | ||
M internal/usecase/deployment/readiness_internal_test.go +36 -6
| @@ -2,6 +2,9 @@ package deployment | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "net" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 5 | "testing" | 8 | "testing" | ||
| 6 | "time" | 9 | "time" | ||
| 7 | 10 | ||||
| @@ -13,11 +16,12 @@ import ( | |||||
| 13 | 16 | ||||
| 14 | // TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority proves the probe URL is | 17 | // TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority proves the probe URL is | ||
| 15 | // built from an immutable loopback host and an escaped path, so no declared | 18 | // built from an immutable loopback host and an escaped path, so no declared | ||
| 16 | - | // path can redirect the request to a different authority. | 19 | + | // path can redirect the request to a different authority. The Host header |
| 20 | + | // must name the declared container port, not the random published port. | |||
| 17 | func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) { | 21 | func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) { | ||
| 18 | - | var got string | 22 | + | var gotURL, gotHost string |
| 19 | - | deps := ProbeDeps{httpGet: func(_ context.Context, url string) (int, error) { | 23 | + | deps := ProbeDeps{httpGet: func(_ context.Context, url, hostAuthority string) (int, error) { |
| 20 | - | got = url | 24 | + | gotURL, gotHost = url, hostAuthority |
| 21 | return 200, nil | 25 | return 200, nil | ||
| 22 | }} | 26 | }} | ||
| 23 | spec := domain.AppService{ | 27 | spec := domain.AppService{ | ||
| @@ -26,13 +30,39 @@ func TestWaitHTTPReady_ProbeURLKeepsLoopbackAuthority(t *testing.T) { | |||||
| 26 | Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz?probe=1", Timeout: time.Second}, | 30 | Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz?probe=1", Timeout: time.Second}, | ||
| 27 | } | 31 | } | ||
| 28 | require.NoError(t, waitHTTPReadyWithDeps(context.Background(), deps, spec, map[int]int{8080: 18080})) | 32 | require.NoError(t, waitHTTPReadyWithDeps(context.Background(), deps, spec, map[int]int{8080: 18080})) | ||
| 29 | - | assert.Equal(t, "http://127.0.0.1:18080/healthz?probe=1", got) | 33 | + | assert.Equal(t, "http://127.0.0.1:18080/healthz?probe=1", gotURL) |
| 34 | + | assert.Equal(t, "127.0.0.1:8080", gotHost, "the Host header names the declared container port, not the published port") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // TestWaitHTTPReady_SendsContainerPortAsHostAuthority is the qBittorrent | |||
| 38 | + | // regression: readiness dials the random loopback publication, but the | |||
| 39 | + | // workload rejects a Host whose port is not its declared container port | |||
| 40 | + | // with 401 "Invalid Host header, port mismatch". The probe must dial the | |||
| 41 | + | // published port while sending the container port as the Host authority. | |||
| 42 | + | func TestWaitHTTPReady_SendsContainerPortAsHostAuthority(t *testing.T) { | |||
| 43 | + | const containerPort = 8080 | |||
| 44 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 45 | + | if r.Host != "127.0.0.1:8080" { | |||
| 46 | + | http.Error(w, "Invalid Host header, port mismatch", http.StatusUnauthorized) | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | w.WriteHeader(http.StatusOK) | |||
| 50 | + | })) | |||
| 51 | + | defer server.Close() | |||
| 52 | + | publishedPort := server.Listener.Addr().(*net.TCPAddr).Port | |||
| 53 | + | ||||
| 54 | + | spec := domain.AppService{ | |||
| 55 | + | Name: "qbittorrent", | |||
| 56 | + | HTTP: []domain.AppHTTPInterface{{Host: "qbit.example.com", Port: containerPort}}, | |||
| 57 | + | Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/api/v2/app/version", Timeout: 2 * time.Second}, | |||
| 58 | + | } | |||
| 59 | + | require.NoError(t, waitHTTPReadyWithDeps(context.Background(), ProbeDeps{httpGet: httpGetOnce}, spec, map[int]int{containerPort: publishedPort})) | |||
| 30 | } | 60 | } | ||
| 31 | 61 | ||||
| 32 | // TestWaitHTTPReady_HangingProbeRespectsDeadline proves a workload that | 62 | // TestWaitHTTPReady_HangingProbeRespectsDeadline proves a workload that | ||
| 33 | // never answers cannot hold the readiness operation open past its bound. | 63 | // never answers cannot hold the readiness operation open past its bound. | ||
| 34 | func TestWaitHTTPReady_HangingProbeRespectsDeadline(t *testing.T) { | 64 | func TestWaitHTTPReady_HangingProbeRespectsDeadline(t *testing.T) { | ||
| 35 | - | deps := ProbeDeps{httpGet: func(ctx context.Context, _ string) (int, error) { | 65 | + | deps := ProbeDeps{httpGet: func(ctx context.Context, _, _ string) (int, error) { |
| 36 | <-ctx.Done() | 66 | <-ctx.Done() | ||
| 37 | return 0, ctx.Err() | 67 | return 0, ctx.Err() | ||
| 38 | }} | 68 | }} | ||
M internal/usecase/deployment/reconcile_mock_test.go +4 -4
| @@ -86,7 +86,7 @@ func recoveringService( | |||||
| 86 | state *outmocks.MockAppState, | 86 | state *outmocks.MockAppState, | ||
| 87 | runtime *outmocks.MockContainerRuntime, | 87 | runtime *outmocks.MockContainerRuntime, | ||
| 88 | traffic *recordingTraffic, | 88 | traffic *recordingTraffic, | ||
| 89 | - | httpGet func(context.Context, string) (int, error), | 89 | + | httpGet func(context.Context, string, string) (int, error), |
| 90 | ) *deployment.Service { | 90 | ) *deployment.Service { | ||
| 91 | t.Helper() | 91 | t.Helper() | ||
| 92 | return deployment.NewService(deployment.Deps{ | 92 | return deployment.NewService(deployment.Deps{ | ||
| @@ -195,7 +195,7 @@ func TestReconcileRunning_StartsExitedExactIDThenVerifiesAndPublishes(t *testing | |||||
| 195 | })).Return(nil).Once() | 195 | })).Return(nil).Once() | ||
| 196 | 196 | ||||
| 197 | var probedURL string | 197 | var probedURL string | ||
| 198 | - | svc := recoveringService(t, state, runtime, traffic, func(_ context.Context, url string) (int, error) { | 198 | + | svc := recoveringService(t, state, runtime, traffic, func(_ context.Context, url, _ string) (int, error) { |
| 199 | probedURL = url | 199 | probedURL = url | ||
| 200 | return 200, nil | 200 | return 200, nil | ||
| 201 | }) | 201 | }) | ||
| @@ -599,7 +599,7 @@ func TestReconcileRunning_NoHealthcheckNeverRestartsOnReadiness(t *testing.T) { | |||||
| 599 | // An execution unseen by this daemon is verified immediately because it | 599 | // An execution unseen by this daemon is verified immediately because it | ||
| 600 | // may have restarted while Gordon was absent. Readiness failure keeps it | 600 | // may have restarted while Gordon was absent. Readiness failure keeps it | ||
| 601 | // withdrawn, but never restarts an otherwise running workload. | 601 | // withdrawn, but never restarts an otherwise running workload. | ||
| 602 | - | svc := recoveringService(t, state, runtime, traffic, func(context.Context, string) (int, error) { | 602 | + | svc := recoveringService(t, state, runtime, traffic, func(context.Context, string, string) (int, error) { |
| 603 | return 500, nil | 603 | return 500, nil | ||
| 604 | }) | 604 | }) | ||
| 605 | require.Error(t, svc.ReconcileRunning(ctx), "the unseen execution fails readiness and stays withdrawn") | 605 | require.Error(t, svc.ReconcileRunning(ctx), "the unseen execution fails readiness and stays withdrawn") | ||
| @@ -796,7 +796,7 @@ func TestReconcileRunning_FailedReadinessPersistsNoBinds(t *testing.T) { | |||||
| 796 | Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | 796 | Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | ||
| 797 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | 797 | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | ||
| 798 | 798 | ||||
| 799 | - | svc := recoveringService(t, state, runtime, traffic, func(context.Context, string) (int, error) { | 799 | + | svc := recoveringService(t, state, runtime, traffic, func(context.Context, string, string) (int, error) { |
| 800 | return 500, nil | 800 | return 500, nil | ||
| 801 | }) | 801 | }) | ||
| 802 | require.Error(t, svc.ReconcileRunning(ctx)) | 802 | require.Error(t, svc.ReconcileRunning(ctx)) | ||
M internal/usecase/deployment/recovery_integration_test.go +3 -3
| @@ -96,11 +96,11 @@ func seedDesired(t *testing.T, ctx context.Context, store *appstate.Store, rev d | |||||
| 96 | require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID)) | 96 | require.NoError(t, store.MaterializeApply(ctx, rev.App, intentID)) | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | - | // TestInterruptedVolumeFailureLeavesOldGenerationInhibited proves the | 99 | + | // TestVolumeReplacementFailureLeavesOldGenerationInhibited proves the |
| 100 | // marker is written BEFORE a volume-owning replacement can write and is | 100 | // marker is written BEFORE a volume-owning replacement can write and is | ||
| 101 | // not cleared when that replacement fails: the old generation must never | 101 | // not cleared when that replacement fails: the old generation must never | ||
| 102 | // be restarted on top of possibly-newer data. | 102 | // be restarted on top of possibly-newer data. | ||
| 103 | - | func TestInterruptedVolumeFailureLeavesOldGenerationInhibited(t *testing.T) { | 103 | + | func TestVolumeReplacementFailureLeavesOldGenerationInhibited(t *testing.T) { |
| 104 | ctx := context.Background() | 104 | ctx := context.Background() | ||
| 105 | store := newTestStore(t) | 105 | store := newTestStore(t) | ||
| 106 | runtime := outmocks.NewMockContainerRuntime(t) | 106 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| @@ -144,7 +144,7 @@ func TestInterruptedVolumeFailureLeavesOldGenerationInhibited(t *testing.T) { | |||||
| 144 | State: store, Runtime: runtime, Images: images, Secrets: secrets, | 144 | State: store, Runtime: runtime, Images: images, Secrets: secrets, | ||
| 145 | ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}}, | 145 | ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}}, | ||
| 146 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 146 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 147 | - | func(context.Context, string) (int, error) { return 500, nil }, | 147 | + | func(context.Context, string, string) (int, error) { return 500, nil }, |
| 148 | func(context.Context, string) error { return assert.AnError }, | 148 | func(context.Context, string) error { return assert.AnError }, | ||
| 149 | )) | 149 | )) | ||
| 150 | 150 | ||||
D internal/usecase/deployment/restart_http_test.go +0 -314
| @@ -1,314 +0,0 @@ | |||||
| 1 | - | package deployment_test | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "context" | |||
| 5 | - | "errors" | |||
| 6 | - | "sync" | |||
| 7 | - | "testing" | |||
| 8 | - | "time" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/zerowrap" | |||
| 11 | - | "github.com/stretchr/testify/assert" | |||
| 12 | - | "github.com/stretchr/testify/mock" | |||
| 13 | - | "github.com/stretchr/testify/require" | |||
| 14 | - | ||||
| 15 | - | "github.com/bnema/gordon/internal/domain" | |||
| 16 | - | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | type restartTrafficRecorder struct { | |||
| 20 | - | mu sync.Mutex | |||
| 21 | - | events *[]string | |||
| 22 | - | failRebuild bool | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | func (r *restartTrafficRecorder) record(event string) { | |||
| 26 | - | r.mu.Lock() | |||
| 27 | - | defer r.mu.Unlock() | |||
| 28 | - | if r.events != nil { | |||
| 29 | - | *r.events = append(*r.events, event) | |||
| 30 | - | } | |||
| 31 | - | } | |||
| 32 | - | func (r *restartTrafficRecorder) RebuildTraffic(context.Context) error { | |||
| 33 | - | r.record("traffic") | |||
| 34 | - | if r.failRebuild { | |||
| 35 | - | return errors.New("traffic rebuild failed") | |||
| 36 | - | } | |||
| 37 | - | return nil | |||
| 38 | - | } | |||
| 39 | - | func (r *restartTrafficRecorder) WithdrawService(context.Context, string, string) error { | |||
| 40 | - | r.record("withdraw") | |||
| 41 | - | return nil | |||
| 42 | - | } | |||
| 43 | - | func (r *restartTrafficRecorder) WithdrawServiceState(context.Context, string, string) error { | |||
| 44 | - | r.record("withdraw-state") | |||
| 45 | - | return nil | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | - | const restartTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | |||
| 49 | - | ||||
| 50 | - | func restartActive(t *testing.T, spec domain.AppService) domain.AppActive { | |||
| 51 | - | t.Helper() | |||
| 52 | - | return domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true, Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", ActivatedBy: "op-before", ActivatedAt: time.Now().Add(-time.Hour).UTC(), Image: spec.Image, Digest: restartTestDigest, Container: "c-old", Spec: spec, BackendBinds: map[int]int{8080: 32770}}}} | |||
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func restartRevision(spec domain.AppService) domain.AppDesiredRevision { | |||
| 56 | - | return domain.AppDesiredRevision{Revision: "rev-1", App: "blog", Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{spec}}} | |||
| 57 | - | } | |||
| 58 | - | ||||
| 59 | - | // cloneActive returns a copy of an ACTIVE record with its own service map, | |||
| 60 | - | // so a mocked LoadActive never hands the same mutable map to the code | |||
| 61 | - | // under test twice. | |||
| 62 | - | func cloneActive(a domain.AppActive) domain.AppActive { | |||
| 63 | - | cloned := a | |||
| 64 | - | cloned.Services = make(map[string]domain.AppEffectiveService, len(a.Services)) | |||
| 65 | - | for name, svc := range a.Services { | |||
| 66 | - | cloned.Services[name] = svc | |||
| 67 | - | } | |||
| 68 | - | return cloned | |||
| 69 | - | } | |||
| 70 | - | ||||
| 71 | - | func TestRestart_HTTPZeroDowntimeRepointsTrafficBeforeRetiringOld(t *testing.T) { | |||
| 72 | - | ctx := context.Background() | |||
| 73 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 74 | - | spec := webService() | |||
| 75 | - | spec.Secrets = map[string]string{} | |||
| 76 | - | spec.StopGrace = 10 * time.Millisecond | |||
| 77 | - | spec.Readiness.Timeout = time.Second | |||
| 78 | - | active := restartActive(t, spec) | |||
| 79 | - | rev := restartRevision(spec) | |||
| 80 | - | oldActivatedAt := active.Services["web"].ActivatedAt | |||
| 81 | - | var order []string | |||
| 82 | - | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 83 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |||
| 84 | - | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 85 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once() | |||
| 86 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 87 | - | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 88 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 89 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 90 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 91 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 92 | - | var saved domain.AppActive | |||
| 93 | - | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Run(func(_ context.Context, a domain.AppActive) { saved = a; order = append(order, "active") }).Return(nil).Once() | |||
| 94 | - | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 95 | - | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | |||
| 96 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 97 | - | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Run(func(context.Context, string, time.Duration) { order = append(order, "stop-old") }).Return(nil).Once() | |||
| 98 | - | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 99 | - | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | |||
| 100 | - | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &order}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil })) | |||
| 101 | - | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 102 | - | require.NoError(t, err) | |||
| 103 | - | assert.Equal(t, []string{"active", "traffic", "stop-old", "traffic"}, order) | |||
| 104 | - | assert.Equal(t, "c-new", saved.Services["web"].Container) | |||
| 105 | - | assert.Equal(t, result.Op, saved.Services["web"].ActivatedBy) | |||
| 106 | - | assert.True(t, saved.Services["web"].ActivatedAt.After(oldActivatedAt)) | |||
| 107 | - | assert.Equal(t, "rev-1", saved.ConvergedRevision) | |||
| 108 | - | assert.True(t, saved.Converged) | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | func TestRestart_HTTPReadinessFailureKeepsOldServing(t *testing.T) { | |||
| 112 | - | ctx := context.Background() | |||
| 113 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 114 | - | spec := webService() | |||
| 115 | - | spec.Secrets = map[string]string{} | |||
| 116 | - | spec.Readiness.Timeout = 50 * time.Millisecond | |||
| 117 | - | active := restartActive(t, spec) | |||
| 118 | - | rev := restartRevision(spec) | |||
| 119 | - | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 120 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil).Once() | |||
| 121 | - | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 122 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once() | |||
| 123 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 124 | - | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 125 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new"}, nil).Once() | |||
| 126 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 127 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 128 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 129 | - | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once() | |||
| 130 | - | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once() | |||
| 131 | - | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once() | |||
| 132 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 133 | - | var events []string | |||
| 134 | - | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 503, nil }, func(context.Context, string) error { return nil })) | |||
| 135 | - | _, err := svc.Restart(ctx, "blog", "web", "") | |||
| 136 | - | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 137 | - | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 138 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything) | |||
| 139 | - | assert.NotContains(t, events, "withdraw") | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func TestRestart_HTTPWithVolumeStaysInPlace(t *testing.T) { | |||
| 143 | - | ctx := context.Background() | |||
| 144 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 145 | - | spec := webService() | |||
| 146 | - | spec.Secrets = map[string]string{} | |||
| 147 | - | spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | |||
| 148 | - | active := restartActive(t, spec) | |||
| 149 | - | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 150 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil).Once() | |||
| 151 | - | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 152 | - | runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 153 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32770, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 154 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 155 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 156 | - | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil })) | |||
| 157 | - | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 158 | - | require.NoError(t, err) | |||
| 159 | - | assert.Equal(t, "c-old", result.Services["web"].After) | |||
| 160 | - | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | func TestRestart_HTTPPublicationFailureReportsFailedAndRetiresCandidate(t *testing.T) { | |||
| 164 | - | ctx := context.Background() | |||
| 165 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 166 | - | spec := webService() | |||
| 167 | - | spec.Secrets = map[string]string{} | |||
| 168 | - | spec.StopGrace = 10 * time.Millisecond | |||
| 169 | - | spec.Readiness.Timeout = time.Second | |||
| 170 | - | active := restartActive(t, spec) | |||
| 171 | - | rev := restartRevision(spec) | |||
| 172 | - | publishErr := errors.New("publish failed") | |||
| 173 | - | ||||
| 174 | - | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 175 | - | state.EXPECT().LoadActive(mock.Anything, "blog").RunAndReturn(func(context.Context, string) (domain.AppActive, bool, error) { | |||
| 176 | - | return cloneActive(active), true, nil | |||
| 177 | - | }) | |||
| 178 | - | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 179 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once() | |||
| 180 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 181 | - | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 182 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 183 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 184 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 185 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 186 | - | // ACTIVE is never written: the candidate was not published. | |||
| 187 | - | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(publishErr).Once() | |||
| 188 | - | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once() | |||
| 189 | - | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once() | |||
| 190 | - | var ops []domain.AppOperation | |||
| 191 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error { | |||
| 192 | - | ops = append(ops, op) | |||
| 193 | - | return nil | |||
| 194 | - | }) | |||
| 195 | - | var events []string | |||
| 196 | - | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil })) | |||
| 197 | - | ||||
| 198 | - | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 199 | - | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 200 | - | require.NotNil(t, result) | |||
| 201 | - | assert.Equal(t, "failed", result.Services["web"].Result, "a failed publication is not a deployed restart") | |||
| 202 | - | require.NotEmpty(t, ops) | |||
| 203 | - | last := ops[len(ops)-1] | |||
| 204 | - | assert.Equal(t, domain.AppOutcomeFailed, last.Outcome, "a failed publication must not be journaled as success") | |||
| 205 | - | step, ok := opStep(last, "service.web.restart") | |||
| 206 | - | require.True(t, ok) | |||
| 207 | - | assert.Equal(t, domain.AppStepFailed, step.State, "the restart step must not claim success before publication") | |||
| 208 | - | // The old generation keeps serving and a never-published candidate is | |||
| 209 | - | // removed without a grace period; it is never stopped or served. | |||
| 210 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything) | |||
| 211 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything) | |||
| 212 | - | assert.NotContains(t, events, "withdraw") | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | func TestRestart_HTTPPostSavePublicationFailureKeepsPublishedCandidate(t *testing.T) { | |||
| 216 | - | ctx := context.Background() | |||
| 217 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 218 | - | spec := webService() | |||
| 219 | - | spec.Secrets = map[string]string{} | |||
| 220 | - | spec.StopGrace = 10 * time.Millisecond | |||
| 221 | - | spec.Readiness.Timeout = time.Second | |||
| 222 | - | active := restartActive(t, spec) | |||
| 223 | - | rev := restartRevision(spec) | |||
| 224 | - | published := active | |||
| 225 | - | published.Services = map[string]domain.AppEffectiveService{ | |||
| 226 | - | "web": {EffectiveRevision: "rev-1", Container: "c-new", Spec: spec}, | |||
| 227 | - | } | |||
| 228 | - | loads := 0 | |||
| 229 | - | ||||
| 230 | - | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 231 | - | state.EXPECT().LoadActive(mock.Anything, "blog").RunAndReturn(func(context.Context, string) (domain.AppActive, bool, error) { | |||
| 232 | - | loads++ | |||
| 233 | - | if loads == 1 { | |||
| 234 | - | return cloneActive(active), true, nil | |||
| 235 | - | } | |||
| 236 | - | return cloneActive(published), true, nil | |||
| 237 | - | }) | |||
| 238 | - | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 239 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once() | |||
| 240 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 241 | - | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 242 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 243 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 244 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 245 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 246 | - | // ACTIVE is written, then ownership persistence fails: the candidate is | |||
| 247 | - | // the published generation and must keep running. | |||
| 248 | - | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 249 | - | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(errors.New("ownership failed")).Once() | |||
| 250 | - | var ops []domain.AppOperation | |||
| 251 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error { | |||
| 252 | - | ops = append(ops, op) | |||
| 253 | - | return nil | |||
| 254 | - | }) | |||
| 255 | - | var events []string | |||
| 256 | - | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil })) | |||
| 257 | - | ||||
| 258 | - | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 259 | - | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 260 | - | require.NotNil(t, result) | |||
| 261 | - | assert.Equal(t, "failed", result.Services["web"].Result, "a post-save publication failure is not a deployed restart") | |||
| 262 | - | require.NotEmpty(t, ops) | |||
| 263 | - | assert.Equal(t, domain.AppOutcomeFailed, ops[len(ops)-1].Outcome) | |||
| 264 | - | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything) | |||
| 265 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything) | |||
| 266 | - | } | |||
| 267 | - | ||||
| 268 | - | func TestRestart_HTTPTrafficFailureReportsFailedAndKeepsPublishedCandidate(t *testing.T) { | |||
| 269 | - | ctx := context.Background() | |||
| 270 | - | state, runtime, images, secrets := mockDeps(t) | |||
| 271 | - | spec := webService() | |||
| 272 | - | spec.Secrets = map[string]string{} | |||
| 273 | - | spec.StopGrace = 10 * time.Millisecond | |||
| 274 | - | spec.Readiness.Timeout = time.Second | |||
| 275 | - | active := restartActive(t, spec) | |||
| 276 | - | rev := restartRevision(spec) | |||
| 277 | - | ||||
| 278 | - | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 279 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |||
| 280 | - | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 281 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-1").Return(rev, nil).Once() | |||
| 282 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 283 | - | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 284 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 285 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 286 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 287 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 288 | - | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 289 | - | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 290 | - | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | |||
| 291 | - | state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool { | |||
| 292 | - | return inhibition.Service == "web" && inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitRetirementPending | |||
| 293 | - | })).Return(nil).Once() | |||
| 294 | - | var ops []domain.AppOperation | |||
| 295 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error { | |||
| 296 | - | ops = append(ops, op) | |||
| 297 | - | return nil | |||
| 298 | - | }) | |||
| 299 | - | var events []string | |||
| 300 | - | svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: &restartTrafficRecorder{events: &events, failRebuild: true}}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, func(context.Context, string) (int, error) { return 200, nil }, func(context.Context, string) error { return nil })) | |||
| 301 | - | ||||
| 302 | - | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 303 | - | require.ErrorContains(t, err, "traffic rebuild failed") | |||
| 304 | - | require.NotNil(t, result) | |||
| 305 | - | assert.Equal(t, "failed", result.Services["web"].Result, "a published but unroutable candidate is not a deployed restart") | |||
| 306 | - | require.NotEmpty(t, ops) | |||
| 307 | - | assert.Equal(t, domain.AppOutcomeFailed, ops[len(ops)-1].Outcome, "an unroutable publication must not be journaled as success") | |||
| 308 | - | assert.Equal(t, []string{"traffic", "traffic"}, events, "the graph apply is retried once per restart phase and neither retry may claim success") | |||
| 309 | - | // ACTIVE names the candidate: neither generation may be retired while | |||
| 310 | - | // routing is unproven. | |||
| 311 | - | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything) | |||
| 312 | - | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", mock.Anything) | |||
| 313 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything) | |||
| 314 | - | } | |||
A internal/usecase/deployment/restart_inplace_test.go +163 -0
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package deployment_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // restartTrafficRecorder records the traffic boundary calls of a restart. | |||
| 20 | + | type restartTrafficRecorder struct { | |||
| 21 | + | mu sync.Mutex | |||
| 22 | + | events *[]string | |||
| 23 | + | failRebuild bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (r *restartTrafficRecorder) record(event string) { | |||
| 27 | + | r.mu.Lock() | |||
| 28 | + | defer r.mu.Unlock() | |||
| 29 | + | if r.events != nil { | |||
| 30 | + | *r.events = append(*r.events, event) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | func (r *restartTrafficRecorder) RebuildTraffic(context.Context) error { | |||
| 34 | + | r.record("traffic") | |||
| 35 | + | if r.failRebuild { | |||
| 36 | + | return errors.New("traffic rebuild failed") | |||
| 37 | + | } | |||
| 38 | + | return nil | |||
| 39 | + | } | |||
| 40 | + | func (r *restartTrafficRecorder) WithdrawService(context.Context, string, string) error { | |||
| 41 | + | r.record("withdraw") | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | func (r *restartTrafficRecorder) WithdrawServiceState(context.Context, string, string) error { | |||
| 45 | + | r.record("withdraw-state") | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | const restartTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" | |||
| 50 | + | ||||
| 51 | + | func restartActive(t *testing.T, spec domain.AppService) domain.AppActive { | |||
| 52 | + | t.Helper() | |||
| 53 | + | return domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true, Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", ActivatedBy: "op-before", ActivatedAt: time.Now().Add(-time.Hour).UTC(), Image: spec.Image, Digest: restartTestDigest, Container: "c-old", Spec: spec, BackendBinds: map[int]int{8080: 32770}}}} | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes proves the | |||
| 57 | + | // restart contract: the service is withdrawn from traffic, the SAME pinned | |||
| 58 | + | // container is restarted, readiness is verified against its refreshed | |||
| 59 | + | // loopback bind, and traffic is republished. No second container is created | |||
| 60 | + | // and no container is stopped or removed. | |||
| 61 | + | func TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes(t *testing.T) { | |||
| 62 | + | ctx := context.Background() | |||
| 63 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 64 | + | spec := webService() | |||
| 65 | + | spec.Secrets = map[string]string{} | |||
| 66 | + | spec.StopGrace = 10 * time.Millisecond | |||
| 67 | + | // No declared readiness probe: the implicit HTTP check still applies to a | |||
| 68 | + | // stateless public HTTP service on restart, exactly as it does on deploy. | |||
| 69 | + | spec.Readiness = domain.AppReadiness{Timeout: time.Second} | |||
| 70 | + | active := restartActive(t, spec) | |||
| 71 | + | ||||
| 72 | + | var order []string | |||
| 73 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 74 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |||
| 75 | + | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 76 | + | runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn( | |||
| 77 | + | func(context.Context, string, time.Duration) error { | |||
| 78 | + | recordEvent(&order, "restart") | |||
| 79 | + | return nil | |||
| 80 | + | }).Once() | |||
| 81 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return( | |||
| 82 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 83 | + | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 84 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |||
| 85 | + | var saved domain.AppActive | |||
| 86 | + | state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn( | |||
| 87 | + | func(_ context.Context, refreshed domain.AppActive) error { | |||
| 88 | + | saved = refreshed | |||
| 89 | + | recordEvent(&order, "persist-binds") | |||
| 90 | + | return nil | |||
| 91 | + | }).Once() | |||
| 92 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 93 | + | ||||
| 94 | + | var probedURL string | |||
| 95 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 96 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 97 | + | Traffic: &restartTrafficRecorder{events: &order}, | |||
| 98 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 99 | + | func(_ context.Context, url, _ string) (int, error) { | |||
| 100 | + | probedURL = url | |||
| 101 | + | recordEvent(&order, "ready") | |||
| 102 | + | return 200, nil | |||
| 103 | + | }, | |||
| 104 | + | func(context.Context, string) error { return nil }, | |||
| 105 | + | )) | |||
| 106 | + | ||||
| 107 | + | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 108 | + | require.NoError(t, err) | |||
| 109 | + | require.NotNil(t, result) | |||
| 110 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 111 | + | assert.Equal(t, "c-old", result.Services["web"].After, "the same pinned container is restarted in place") | |||
| 112 | + | assert.Contains(t, probedURL, "127.0.0.1:32771", "readiness dials the refreshed loopback bind") | |||
| 113 | + | assert.Equal(t, []string{"withdraw", "restart", "persist-binds", "ready", "traffic"}, order) | |||
| 114 | + | assert.Equal(t, map[int]int{8080: 32771}, saved.Services["web"].BackendBinds) | |||
| 115 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 116 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 117 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn proves a | |||
| 121 | + | // restart is never served unverified: the failure leaves the same container | |||
| 122 | + | // in place but withdraws its recorded binds, and the graph is republished | |||
| 123 | + | // fail-closed. Nothing is created, stopped, or removed. | |||
| 124 | + | func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing.T) { | |||
| 125 | + | ctx := context.Background() | |||
| 126 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 127 | + | spec := webService() | |||
| 128 | + | spec.Secrets = map[string]string{} | |||
| 129 | + | spec.StopGrace = 10 * time.Millisecond | |||
| 130 | + | spec.Readiness = domain.AppReadiness{Timeout: 50 * time.Millisecond} | |||
| 131 | + | active := restartActive(t, spec) | |||
| 132 | + | ||||
| 133 | + | var order []string | |||
| 134 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 135 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |||
| 136 | + | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once() | |||
| 137 | + | runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 138 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return( | |||
| 139 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 140 | + | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 141 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil) | |||
| 142 | + | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 143 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 144 | + | ||||
| 145 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 146 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 147 | + | Traffic: &restartTrafficRecorder{events: &order}, | |||
| 148 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 149 | + | func(context.Context, string, string) (int, error) { return 503, nil }, | |||
| 150 | + | func(context.Context, string) error { return assert.AnError }, | |||
| 151 | + | )) | |||
| 152 | + | ||||
| 153 | + | result, err := svc.Restart(ctx, "blog", "web", "") | |||
| 154 | + | require.Error(t, err) | |||
| 155 | + | require.NotNil(t, result) | |||
| 156 | + | assert.Equal(t, "failed", result.Services["web"].Result) | |||
| 157 | + | assert.Contains(t, result.Services["web"].Error, "readiness") | |||
| 158 | + | assert.Contains(t, order, "withdraw-state", "the unverified generation is withdrawn from state") | |||
| 159 | + | assert.Contains(t, order, "traffic", "the graph is republished fail-closed") | |||
| 160 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 161 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 162 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 163 | + | } | |||
M internal/usecase/deployment/retirement_test.go +26 -27
| @@ -133,14 +133,14 @@ func TestRemove_FailedRetirementKeepsClaimsAndActiveState(t *testing.T) { | |||||
| 133 | state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything) | 133 | state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything) | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | - | // TestDeploy_RetirementFailureIsSurfacedAsAWarning proves a leftover of a | 136 | + | // TestDeploy_RetirementFailureBlocksReplacement proves the superseded |
| 137 | - | // successful deploy is journaled as a bounded warning instead of being | 137 | + | // generation must be confirmed gone before a replacement starts: a failed |
| 138 | - | // dropped, and that the deploy itself still reports success. | 138 | + | // retirement aborts the service without creating a candidate, and the |
| 139 | - | func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) { | 139 | + | // failure is journaled instead of being downgraded to a warning. |
| 140 | + | func TestDeploy_RetirementFailureBlocksReplacement(t *testing.T) { | |||
| 140 | ctx := context.Background() | 141 | ctx := context.Background() | ||
| 141 | state, runtime, images, secrets := mockDeps(t) | 142 | state, runtime, images, secrets := mockDeps(t) | ||
| 142 | rev := mockRevision() | 143 | rev := mockRevision() | ||
| 143 | - | rev.Spec.Services[0].StopGrace = time.Millisecond | |||
| 144 | 144 | ||||
| 145 | oldActive := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | 145 | oldActive := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | ||
| 146 | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old", Spec: graceSpec(time.Second)}, | 146 | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old", Spec: graceSpec(time.Second)}, | ||
| @@ -155,18 +155,9 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) { | |||||
| 155 | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | 155 | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | ||
| 156 | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | 156 | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | ||
| 157 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(oldActive, true, nil) | 157 | state.EXPECT().LoadActive(mock.Anything, "blog").Return(oldActive, true, nil) | ||
| 158 | - | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | 158 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) |
| 159 | - | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | 159 | + | // The superseded container is retired with its own effective grace, and |
| 160 | - | expectNetworkProvision(runtime, "app-blog", 1) | 160 | + | // its removal fails: the replacement must not start. |
| 161 | - | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 162 | - | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 163 | - | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |||
| 164 | - | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 165 | - | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 166 | - | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | |||
| 167 | - | // The replaced container is retired with its own effective grace, and | |||
| 168 | - | // its removal fails: the deploy is still successful, the leftover is | |||
| 169 | - | // a warning. | |||
| 170 | runtime.EXPECT().StopContainer(mock.Anything, "c-old", time.Second).Return(nil).Once() | 161 | runtime.EXPECT().StopContainer(mock.Anything, "c-old", time.Second).Return(nil).Once() | ||
| 171 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(assert.AnError).Once() | 162 | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(assert.AnError).Once() | ||
| 172 | 163 | ||||
| @@ -180,17 +171,27 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) { | |||||
| 180 | svc := deployment.NewService(deployment.Deps{ | 171 | svc := deployment.NewService(deployment.Deps{ | ||
| 181 | State: state, Runtime: runtime, Images: images, Secrets: secrets, | 172 | State: state, Runtime: runtime, Images: images, Secrets: secrets, | ||
| 182 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 173 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 183 | - | func(context.Context, string) (int, error) { return 200, nil }, | 174 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 184 | func(context.Context, string) error { return nil }, | 175 | func(context.Context, string) error { return nil }, | ||
| 185 | )) | 176 | )) | ||
| 186 | 177 | ||||
| 187 | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | 178 | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | ||
| 188 | - | require.NoError(t, err) | 179 | + | require.Error(t, err) |
| 189 | require.NotNil(t, result) | 180 | require.NotNil(t, result) | ||
| 190 | - | assert.Equal(t, "deployed", result.Services["web"].Result) | 181 | + | assert.Equal(t, "failed", result.Services["web"].Result) |
| 191 | - | require.Len(t, result.CleanupWarnings, 1) | 182 | + | assert.Contains(t, result.Services["web"].Error, "retire superseded container", |
| 192 | - | assert.Equal(t, "c-old", result.CleanupWarnings[0].Leftover) | 183 | + | "the unconfirmed retirement is the reported failure") |
| 193 | - | assert.NotContains(t, result.CleanupWarnings[0].Detail, "unexpected", "warnings carry stable, log-free detail") | 184 | + | |
| 185 | + | // No workload is created, started, or published while the superseded | |||
| 186 | + | // generation is not confirmed gone, and no claim or inhibition of that | |||
| 187 | + | // generation is released. | |||
| 188 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 189 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 190 | + | runtime.AssertNotCalled(t, "ListNetworks", mock.Anything) | |||
| 191 | + | runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything) | |||
| 192 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 193 | + | state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, mock.Anything, mock.Anything) | |||
| 194 | + | state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything) | |||
| 194 | 195 | ||||
| 195 | require.NotEmpty(t, saved) | 196 | require.NotEmpty(t, saved) | ||
| 196 | last := saved[len(saved)-1] | 197 | last := saved[len(saved)-1] | ||
| @@ -198,8 +199,6 @@ func TestDeploy_RetirementFailureIsSurfacedAsAWarning(t *testing.T) { | |||||
| 198 | assert.Equal(t, "preflight", last.Steps[0].ID) | 199 | assert.Equal(t, "preflight", last.Steps[0].ID) | ||
| 199 | assert.Equal(t, domain.AppStepSucceeded, last.Steps[0].State) | 200 | assert.Equal(t, domain.AppStepSucceeded, last.Steps[0].State) | ||
| 200 | assert.Equal(t, "service.web.replace", last.Steps[1].ID) | 201 | assert.Equal(t, "service.web.replace", last.Steps[1].ID) | ||
| 201 | - | assert.Equal(t, domain.AppStepSucceeded, last.Steps[1].State) | 202 | + | assert.Equal(t, domain.AppStepFailed, last.Steps[1].State) |
| 202 | - | require.Len(t, last.Warnings, 1) | 203 | + | assert.Equal(t, domain.AppOutcomeFailed, last.Outcome) |
| 203 | - | assert.Equal(t, "web", last.Warnings[0].Service) | |||
| 204 | - | assert.Equal(t, "c-old", last.Warnings[0].Leftover) | |||
| 205 | } | 204 | } | ||
A internal/usecase/deployment/sequential_replacement_test.go +745 -0
| @@ -0,0 +1,745 @@ | |||||
| 1 | + | package deployment_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ||||
| 15 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 17 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // orderedTraffic records traffic boundary calls into a shared event log and | |||
| 21 | + | // can fail one withdrawal or one graph apply. | |||
| 22 | + | type orderedTraffic struct { | |||
| 23 | + | mu sync.Mutex | |||
| 24 | + | events *[]string | |||
| 25 | + | failWithdraw bool | |||
| 26 | + | failRebuild bool | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (t *orderedTraffic) record(event string) { | |||
| 30 | + | t.mu.Lock() | |||
| 31 | + | defer t.mu.Unlock() | |||
| 32 | + | if t.events != nil { | |||
| 33 | + | *t.events = append(*t.events, event) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (t *orderedTraffic) RebuildTraffic(context.Context) error { | |||
| 38 | + | t.record("traffic") | |||
| 39 | + | if t.failRebuild { | |||
| 40 | + | return assert.AnError | |||
| 41 | + | } | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (t *orderedTraffic) WithdrawService(context.Context, string, string) error { | |||
| 46 | + | t.record("withdraw") | |||
| 47 | + | if t.failWithdraw { | |||
| 48 | + | return assert.AnError | |||
| 49 | + | } | |||
| 50 | + | return nil | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (t *orderedTraffic) WithdrawServiceState(context.Context, string, string) error { | |||
| 54 | + | t.record("withdraw-state") | |||
| 55 | + | if t.failWithdraw { | |||
| 56 | + | return assert.AnError | |||
| 57 | + | } | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // replaceableActive is one ACTIVE record whose web service is superseded. | |||
| 62 | + | func replaceableActive() domain.AppActive { | |||
| 63 | + | return domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{ | |||
| 64 | + | "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"}, | |||
| 65 | + | }} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // recordEvent appends one step to a shared event log. Test doubles and mock | |||
| 69 | + | // hooks share the log, so the sequence a deployment performs is observable. | |||
| 70 | + | func recordEvent(events *[]string, event string) { | |||
| 71 | + | if events != nil { | |||
| 72 | + | *events = append(*events, event) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // expectReplacementPreflight wires the preflight calls of one replacement | |||
| 77 | + | // without pre-registering an operation-journal write, so a test can observe | |||
| 78 | + | // the terminal journal write itself. | |||
| 79 | + | func expectReplacementPreflight( | |||
| 80 | + | state *outmocks.MockAppState, | |||
| 81 | + | runtime *outmocks.MockContainerRuntime, | |||
| 82 | + | images *outmocks.MockImageResolver, | |||
| 83 | + | secrets *outmocks.MockSecretProvider, | |||
| 84 | + | rev domain.AppDesiredRevision, | |||
| 85 | + | ) { | |||
| 86 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 87 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 88 | + | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return( | |||
| 89 | + | "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil) | |||
| 90 | + | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil) | |||
| 91 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil) | |||
| 92 | + | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil) | |||
| 93 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // expectSequentialCreate wires one replacement's candidate creation and loopback | |||
| 97 | + | // publication. It records "create-new" and "start-new" in order. | |||
| 98 | + | func expectSequentialCreate( | |||
| 99 | + | state *outmocks.MockAppState, | |||
| 100 | + | runtime *outmocks.MockContainerRuntime, | |||
| 101 | + | events *[]string, | |||
| 102 | + | containerID string, | |||
| 103 | + | hostPort int, | |||
| 104 | + | ) { | |||
| 105 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn( | |||
| 106 | + | func(context.Context, *domain.ContainerConfig) (*domain.Container, error) { | |||
| 107 | + | recordEvent(events, "create-new") | |||
| 108 | + | return &domain.Container{ID: containerID, Name: "web"}, nil | |||
| 109 | + | }).Once() | |||
| 110 | + | runtime.EXPECT().StartContainer(mock.Anything, containerID).RunAndReturn( | |||
| 111 | + | func(context.Context, string) error { | |||
| 112 | + | recordEvent(events, "start-new") | |||
| 113 | + | return nil | |||
| 114 | + | }).Once() | |||
| 115 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, containerID, mock.Anything).Return( | |||
| 116 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: hostPort, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 117 | + | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // expectRetireOld wires the confirmed retirement of the superseded container. | |||
| 121 | + | func expectRetireOld( | |||
| 122 | + | state *outmocks.MockAppState, | |||
| 123 | + | runtime *outmocks.MockContainerRuntime, | |||
| 124 | + | events *[]string, | |||
| 125 | + | containerID string, | |||
| 126 | + | ) { | |||
| 127 | + | runtime.EXPECT().StopContainer(mock.Anything, containerID, mock.Anything).RunAndReturn( | |||
| 128 | + | func(context.Context, string, time.Duration) error { | |||
| 129 | + | recordEvent(events, "stop-old") | |||
| 130 | + | return nil | |||
| 131 | + | }).Once() | |||
| 132 | + | runtime.EXPECT().RemoveContainer(mock.Anything, containerID, false).RunAndReturn( | |||
| 133 | + | func(context.Context, string, bool) error { | |||
| 134 | + | recordEvent(events, "remove-old") | |||
| 135 | + | return nil | |||
| 136 | + | }).Once() | |||
| 137 | + | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", containerID).Return(nil).Once() | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // TestDeploy_SequentialReplacementNeverOverlapsGenerations proves the | |||
| 141 | + | // replacement order for a public HTTP service without volumes: withdrawal is | |||
| 142 | + | // confirmed first, the superseded container is stopped and removed before the | |||
| 143 | + | // replacement is created, readiness runs before ACTIVE is published, and the | |||
| 144 | + | // graph is applied last. | |||
| 145 | + | func TestDeploy_SequentialReplacementNeverOverlapsGenerations(t *testing.T) { | |||
| 146 | + | ctx := context.Background() | |||
| 147 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 148 | + | rev := mockRevision() | |||
| 149 | + | ||||
| 150 | + | expectDeployPreflight(state, runtime, images, secrets, rev) | |||
| 151 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 152 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil) | |||
| 153 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 154 | + | ||||
| 155 | + | var order []string | |||
| 156 | + | expectRetireOld(state, runtime, &order, "c-old") | |||
| 157 | + | expectSequentialCreate(state, runtime, &order, "c-new", 18080) | |||
| 158 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil) | |||
| 159 | + | state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn( | |||
| 160 | + | func(context.Context, domain.AppActive) error { | |||
| 161 | + | recordEvent(&order, "publish-active") | |||
| 162 | + | return nil | |||
| 163 | + | }).Once() | |||
| 164 | + | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |||
| 165 | + | state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once() | |||
| 166 | + | ||||
| 167 | + | var probedURL string | |||
| 168 | + | traffic := &orderedTraffic{events: &order} | |||
| 169 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 170 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic, | |||
| 171 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 172 | + | func(_ context.Context, url, _ string) (int, error) { | |||
| 173 | + | probedURL = url | |||
| 174 | + | recordEvent(&order, "ready") | |||
| 175 | + | return 200, nil | |||
| 176 | + | }, | |||
| 177 | + | func(context.Context, string) error { return nil }, | |||
| 178 | + | )) | |||
| 179 | + | ||||
| 180 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | require.NotNil(t, result) | |||
| 183 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 184 | + | assert.Contains(t, probedURL, "127.0.0.1:18080", "readiness dials the replacement's fresh loopback bind") | |||
| 185 | + | assert.Equal(t, | |||
| 186 | + | []string{"withdraw", "stop-old", "remove-old", "create-new", "start-new", "ready", "publish-active", "traffic"}, | |||
| 187 | + | order) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // TestDeploy_PreflightFailureLeavesTheWorkloadUntouched proves preflight runs | |||
| 191 | + | // to completion before anything is disrupted: an unresolvable image fails the | |||
| 192 | + | // deploy without withdrawing traffic or stopping, removing, creating, or | |||
| 193 | + | // starting any container. | |||
| 194 | + | func TestDeploy_PreflightFailureLeavesTheWorkloadUntouched(t *testing.T) { | |||
| 195 | + | ctx := context.Background() | |||
| 196 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 197 | + | rev := mockRevision() | |||
| 198 | + | ||||
| 199 | + | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 200 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once() | |||
| 201 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 202 | + | images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("", assert.AnError).Once() | |||
| 203 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 204 | + | ||||
| 205 | + | var events []string | |||
| 206 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 207 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 208 | + | Traffic: &orderedTraffic{events: &events}, | |||
| 209 | + | }, zerowrap.Default()) | |||
| 210 | + | ||||
| 211 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 212 | + | require.ErrorIs(t, err, domain.ErrAppImageUnresolvable) | |||
| 213 | + | assert.Empty(t, events, "preflight must not touch traffic before it succeeds") | |||
| 214 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 215 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 216 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 217 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 218 | + | runtime.AssertNotCalled(t, "ListNetworks", mock.Anything) | |||
| 219 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // TestDeploy_WithdrawalFailureBlocksMutation proves a traffic withdrawal that | |||
| 223 | + | // cannot be applied leaves the running generation untouched: no container is | |||
| 224 | + | // stopped, removed, created, or started, and no ACTIVE state is published. | |||
| 225 | + | func TestDeploy_WithdrawalFailureBlocksMutation(t *testing.T) { | |||
| 226 | + | ctx := context.Background() | |||
| 227 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 228 | + | rev := mockRevision() | |||
| 229 | + | ||||
| 230 | + | expectDeployPreflight(state, runtime, images, secrets, rev) | |||
| 231 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil) | |||
| 232 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 233 | + | ||||
| 234 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 235 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 236 | + | Traffic: &orderedTraffic{failWithdraw: true}, | |||
| 237 | + | }, zerowrap.Default()) | |||
| 238 | + | ||||
| 239 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 240 | + | require.Error(t, err) | |||
| 241 | + | require.NotNil(t, result) | |||
| 242 | + | assert.Equal(t, "failed", result.Services["web"].Result) | |||
| 243 | + | assert.Contains(t, result.Services["web"].Error, "withdraw") | |||
| 244 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 245 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 246 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 247 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 248 | + | runtime.AssertNotCalled(t, "CreateVolume", mock.Anything, mock.Anything, mock.Anything) | |||
| 249 | + | runtime.AssertNotCalled(t, "ListNetworks", mock.Anything) | |||
| 250 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 251 | + | state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, mock.Anything, mock.Anything) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // TestDeploy_FirstDeploymentRetiresNothing proves a service with no previous | |||
| 255 | + | // ACTIVE generation is created, verified, and published without any | |||
| 256 | + | // withdrawal or retirement step. | |||
| 257 | + | func TestDeploy_FirstDeploymentRetiresNothing(t *testing.T) { | |||
| 258 | + | ctx := context.Background() | |||
| 259 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 260 | + | rev := mockRevision() | |||
| 261 | + | ||||
| 262 | + | expectDeployPreflight(state, runtime, images, secrets, rev) | |||
| 263 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 264 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |||
| 265 | + | App: "blog", Services: map[string]domain.AppEffectiveService{}, | |||
| 266 | + | }, true, nil) | |||
| 267 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 268 | + | ||||
| 269 | + | var order []string | |||
| 270 | + | expectSequentialCreate(state, runtime, &order, "c-new", 18080) | |||
| 271 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |||
| 272 | + | App: "blog", Services: map[string]domain.AppEffectiveService{}, | |||
| 273 | + | }, true, nil) | |||
| 274 | + | state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn( | |||
| 275 | + | func(context.Context, domain.AppActive) error { | |||
| 276 | + | recordEvent(&order, "publish-active") | |||
| 277 | + | return nil | |||
| 278 | + | }).Once() | |||
| 279 | + | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |||
| 280 | + | ||||
| 281 | + | traffic := &orderedTraffic{events: &order} | |||
| 282 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 283 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic, | |||
| 284 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 285 | + | func(context.Context, string, string) (int, error) { | |||
| 286 | + | recordEvent(&order, "ready") | |||
| 287 | + | return 200, nil | |||
| 288 | + | }, | |||
| 289 | + | func(context.Context, string) error { return nil }, | |||
| 290 | + | )) | |||
| 291 | + | ||||
| 292 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | require.NotNil(t, result) | |||
| 295 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 296 | + | assert.Equal(t, | |||
| 297 | + | []string{"create-new", "start-new", "ready", "publish-active", "traffic"}, | |||
| 298 | + | order, | |||
| 299 | + | "a first deployment withdraws nothing and retires nothing") | |||
| 300 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 301 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // TestDeploy_ReadinessFailureJournalsFailureAndRemovesCandidate proves a | |||
| 305 | + | // replacement that is not ready is journaled as failed, that ACTIVE is never | |||
| 306 | + | // published for it, and that the unready candidate is removed. The superseded | |||
| 307 | + | // container is already gone and is never recreated. | |||
| 308 | + | func TestDeploy_ReadinessFailureJournalsFailureAndRemovesCandidate(t *testing.T) { | |||
| 309 | + | ctx := context.Background() | |||
| 310 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 311 | + | rev := mockRevision() | |||
| 312 | + | rev.Spec.Services[0].Readiness.Timeout = 50 * time.Millisecond | |||
| 313 | + | ||||
| 314 | + | expectReplacementPreflight(state, runtime, images, secrets, rev) | |||
| 315 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 316 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil) | |||
| 317 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 318 | + | ||||
| 319 | + | var order []string | |||
| 320 | + | expectRetireOld(state, runtime, &order, "c-old") | |||
| 321 | + | expectSequentialCreate(state, runtime, &order, "c-new", 18080) | |||
| 322 | + | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once() | |||
| 323 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).RunAndReturn( | |||
| 324 | + | func(context.Context, string, bool) error { | |||
| 325 | + | recordEvent(&order, "remove-candidate") | |||
| 326 | + | return nil | |||
| 327 | + | }).Once() | |||
| 328 | + | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once() | |||
| 329 | + | ||||
| 330 | + | var saved []domain.AppOperation | |||
| 331 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn( | |||
| 332 | + | func(_ context.Context, op domain.AppOperation) error { | |||
| 333 | + | saved = append(saved, op) | |||
| 334 | + | return nil | |||
| 335 | + | }) | |||
| 336 | + | ||||
| 337 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 338 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 339 | + | Traffic: &orderedTraffic{events: &order}, | |||
| 340 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 341 | + | func(context.Context, string, string) (int, error) { return 503, nil }, | |||
| 342 | + | func(context.Context, string) error { return assert.AnError }, | |||
| 343 | + | )) | |||
| 344 | + | ||||
| 345 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 346 | + | require.Error(t, err) | |||
| 347 | + | require.NotNil(t, result) | |||
| 348 | + | assert.Equal(t, "failed", result.Services["web"].Result) | |||
| 349 | + | assert.Contains(t, result.Services["web"].Error, "readiness", "the failure names the readiness probe") | |||
| 350 | + | assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error") | |||
| 351 | + | assert.Equal(t, "c-new", result.Services["web"].After) | |||
| 352 | + | ||||
| 353 | + | // ACTIVE was never repointed and the replaced generation's inhibition was | |||
| 354 | + | // never cleared: nothing claims the old workload is available again. | |||
| 355 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 356 | + | state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything) | |||
| 357 | + | ||||
| 358 | + | require.NotEmpty(t, saved) | |||
| 359 | + | last := saved[len(saved)-1] | |||
| 360 | + | assert.Equal(t, domain.AppOutcomeFailed, last.Outcome, "a failed replacement must not be journaled as success") | |||
| 361 | + | step, ok := opStep(last, "service.web.replace") | |||
| 362 | + | require.True(t, ok) | |||
| 363 | + | assert.Equal(t, domain.AppStepFailed, step.State) | |||
| 364 | + | ||||
| 365 | + | assert.Equal(t, "remove-candidate", order[len(order)-1], "the unready candidate is the last thing removed") | |||
| 366 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything) | |||
| 367 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-old", true) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // TestDeploy_CandidateCleanupFailureStaysVisible proves a candidate that | |||
| 371 | + | // cannot be removed does not turn a failed replacement into a success: the | |||
| 372 | + | // leftover is reported as a cleanup warning on the failed service. | |||
| 373 | + | func TestDeploy_CandidateCleanupFailureStaysVisible(t *testing.T) { | |||
| 374 | + | ctx := context.Background() | |||
| 375 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 376 | + | rev := mockRevision() | |||
| 377 | + | rev.Spec.Services[0].Readiness.Timeout = 50 * time.Millisecond | |||
| 378 | + | ||||
| 379 | + | expectDeployPreflight(state, runtime, images, secrets, rev) | |||
| 380 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 381 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil) | |||
| 382 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 383 | + | ||||
| 384 | + | expectRetireOld(state, runtime, nil, "c-old") | |||
| 385 | + | expectSequentialCreate(state, runtime, nil, "c-new", 18080) | |||
| 386 | + | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once() | |||
| 387 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(assert.AnError).Once() | |||
| 388 | + | ||||
| 389 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 390 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 391 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 392 | + | func(context.Context, string, string) (int, error) { return 503, nil }, | |||
| 393 | + | func(context.Context, string) error { return assert.AnError }, | |||
| 394 | + | )) | |||
| 395 | + | ||||
| 396 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 397 | + | require.Error(t, err) | |||
| 398 | + | require.NotNil(t, result) | |||
| 399 | + | svcResult := result.Services["web"] | |||
| 400 | + | assert.Equal(t, "failed", svcResult.Result) | |||
| 401 | + | require.Len(t, svcResult.CleanupWarnings, 1, "an unremovable candidate is reported, never hidden") | |||
| 402 | + | assert.Equal(t, "web", svcResult.CleanupWarnings[0].Service) | |||
| 403 | + | assert.Equal(t, "c-new", svcResult.CleanupWarnings[0].Leftover) | |||
| 404 | + | assert.Contains(t, svcResult.CleanupWarnings[0].Detail, "remove") | |||
| 405 | + | state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, "blog", "c-new") | |||
| 406 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | // TestDeploy_CancellationDuringReadinessIsNotReportedAsSuccess proves a | |||
| 410 | + | // cancellation during the readiness wait never becomes a success: ACTIVE is | |||
| 411 | + | // not published, no terminal outcome is written, and the candidate cleanup | |||
| 412 | + | // that the canceled context could not complete is reported as a leftover. | |||
| 413 | + | // The durable store and the runtime refuse work on a canceled context, so | |||
| 414 | + | // this test models both. | |||
| 415 | + | func TestDeploy_CancellationDuringReadinessIsNotReportedAsSuccess(t *testing.T) { | |||
| 416 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 417 | + | defer cancel() | |||
| 418 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 419 | + | rev := mockRevision() | |||
| 420 | + | rev.Spec.Services[0].Readiness.Timeout = 30 * time.Second | |||
| 421 | + | ||||
| 422 | + | expectReplacementPreflight(state, runtime, images, secrets, rev) | |||
| 423 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 424 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(replaceableActive(), true, nil) | |||
| 425 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 426 | + | ||||
| 427 | + | expectRetireOld(state, runtime, nil, "c-old") | |||
| 428 | + | expectSequentialCreate(state, runtime, nil, "c-new", 18080) | |||
| 429 | + | // The runtime refuses to remove the candidate with a canceled context. | |||
| 430 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(context.Canceled).Once() | |||
| 431 | + | ||||
| 432 | + | var saved []domain.AppOperation | |||
| 433 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn( | |||
| 434 | + | func(opCtx context.Context, op domain.AppOperation) error { | |||
| 435 | + | if opCtx.Err() != nil { | |||
| 436 | + | // The durable store refuses writes on a canceled context, so | |||
| 437 | + | // the operation stays in flight instead of becoming terminal. | |||
| 438 | + | return opCtx.Err() | |||
| 439 | + | } | |||
| 440 | + | saved = append(saved, op) | |||
| 441 | + | return nil | |||
| 442 | + | }) | |||
| 443 | + | ||||
| 444 | + | probeStarted := make(chan struct{}) | |||
| 445 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 446 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 447 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 448 | + | func(probeCtx context.Context, _, _ string) (int, error) { | |||
| 449 | + | select { | |||
| 450 | + | case <-probeStarted: | |||
| 451 | + | default: | |||
| 452 | + | close(probeStarted) | |||
| 453 | + | } | |||
| 454 | + | <-probeCtx.Done() | |||
| 455 | + | return 0, probeCtx.Err() | |||
| 456 | + | }, | |||
| 457 | + | func(context.Context, string) error { return nil }, | |||
| 458 | + | )) | |||
| 459 | + | ||||
| 460 | + | done := make(chan error, 1) | |||
| 461 | + | var deployResult *deployment.DeployResult | |||
| 462 | + | go func() { | |||
| 463 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 464 | + | deployResult = result | |||
| 465 | + | done <- err | |||
| 466 | + | }() | |||
| 467 | + | select { | |||
| 468 | + | case <-probeStarted: | |||
| 469 | + | case <-time.After(5 * time.Second): | |||
| 470 | + | t.Fatal("timed out waiting for the readiness probe") | |||
| 471 | + | } | |||
| 472 | + | cancel() | |||
| 473 | + | err := <-done | |||
| 474 | + | require.Error(t, err) | |||
| 475 | + | assert.Contains(t, err.Error(), context.Canceled.Error(), "the canceled probe is reported") | |||
| 476 | + | ||||
| 477 | + | // No success is claimed anywhere: ACTIVE is untouched, and no journaled | |||
| 478 | + | // outcome records the replacement as succeeded. The durable store refuses | |||
| 479 | + | // the terminal write on a canceled context, so the operation stays in | |||
| 480 | + | // flight rather than becoming a false success. | |||
| 481 | + | state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything) | |||
| 482 | + | require.NotEmpty(t, saved) | |||
| 483 | + | for _, op := range saved { | |||
| 484 | + | assert.NotContains(t, []string{domain.AppOutcomeSuccess, domain.AppOutcomePartial}, op.Outcome, | |||
| 485 | + | "a canceled replacement is never journaled as a success") | |||
| 486 | + | if step, ok := opStep(op, "service.web.replace"); ok { | |||
| 487 | + | assert.NotEqual(t, domain.AppStepSucceeded, step.State, | |||
| 488 | + | "a canceled replacement step is never recorded as succeeded") | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | // The canceled cleanup is best effort, and what it could not finish is | |||
| 493 | + | // reported instead of being hidden. | |||
| 494 | + | require.NotNil(t, deployResult) | |||
| 495 | + | require.Len(t, deployResult.Services["web"].CleanupWarnings, 1, | |||
| 496 | + | "a candidate the canceled cleanup could not remove is reported") | |||
| 497 | + | assert.Equal(t, "c-new", deployResult.Services["web"].CleanupWarnings[0].Leftover) | |||
| 498 | + | runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-new", true) | |||
| 499 | + | state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, "blog", "c-new") | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | // TestDeploy_MultiServiceStopsAtFirstFailure proves services are deployed in | |||
| 503 | + | // sorted order and the first failure stops the run: the failing service is | |||
| 504 | + | // cleaned up, later services are untouched, and the services already deployed | |||
| 505 | + | // are kept without any rollback. | |||
| 506 | + | func TestDeploy_MultiServiceStopsAtFirstFailure(t *testing.T) { | |||
| 507 | + | ctx := context.Background() | |||
| 508 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 509 | + | ||||
| 510 | + | httpService := func(name, host, image string, timeout time.Duration) domain.AppService { | |||
| 511 | + | return domain.AppService{ | |||
| 512 | + | Name: name, Image: image, | |||
| 513 | + | Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: timeout}, | |||
| 514 | + | HTTP: []domain.AppHTTPInterface{{Host: host, Port: 8080, TLS: domain.AppTLSAuto}}, | |||
| 515 | + | } | |||
| 516 | + | } | |||
| 517 | + | rev := testRevision("blog", | |||
| 518 | + | httpService("alpha", "alpha.example.com", "docker.io/example/alpha:1.4.2", time.Second), | |||
| 519 | + | httpService("beta", "beta.example.com", "docker.io/example/beta:1.4.2", 50*time.Millisecond), | |||
| 520 | + | httpService("gamma", "gamma.example.com", "docker.io/example/gamma:1.4.2", time.Second), | |||
| 521 | + | ) | |||
| 522 | + | ||||
| 523 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 524 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 525 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/alpha:1.4.2").Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once() | |||
| 526 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/beta:1.4.2").Return("sha256:"+strings.Repeat("b", 64), nil).Once() | |||
| 527 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/gamma:1.4.2").Return("sha256:"+strings.Repeat("c", 64), nil).Once() | |||
| 528 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/alpha:1.4.2").Return(nil, nil).Once() | |||
| 529 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/beta:1.4.2").Return(nil, nil).Once() | |||
| 530 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/gamma:1.4.2").Return(nil, nil).Once() | |||
| 531 | + | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil) | |||
| 532 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 533 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |||
| 534 | + | App: "blog", Services: map[string]domain.AppEffectiveService{}, | |||
| 535 | + | }, true, nil) | |||
| 536 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 537 | + | expectNetworkProvision(runtime, "app-blog", 2) | |||
| 538 | + | ||||
| 539 | + | expectSequentialCreate(state, runtime, nil, "c-alpha", 18080) | |||
| 540 | + | state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(active domain.AppActive) bool { | |||
| 541 | + | return active.Services["alpha"].Container == "c-alpha" | |||
| 542 | + | })).Return(nil).Once() | |||
| 543 | + | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |||
| 544 | + | ||||
| 545 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-beta", mock.Anything).Return( | |||
| 546 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 547 | + | state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once() | |||
| 548 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-beta", Name: "beta"}, nil).Once() | |||
| 549 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-beta").Return(nil).Once() | |||
| 550 | + | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-beta", false).Return(nil, assert.AnError).Once() | |||
| 551 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-beta", true).Return(nil).Once() | |||
| 552 | + | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-beta").Return(nil).Once() | |||
| 553 | + | ||||
| 554 | + | var saved []domain.AppOperation | |||
| 555 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn( | |||
| 556 | + | func(_ context.Context, op domain.AppOperation) error { | |||
| 557 | + | saved = append(saved, op) | |||
| 558 | + | return nil | |||
| 559 | + | }) | |||
| 560 | + | ||||
| 561 | + | traffic := &orderedTraffic{} | |||
| 562 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 563 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic, | |||
| 564 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 565 | + | func(_ context.Context, url, _ string) (int, error) { | |||
| 566 | + | if strings.Contains(url, "18081") { | |||
| 567 | + | return 503, nil | |||
| 568 | + | } | |||
| 569 | + | return 200, nil | |||
| 570 | + | }, | |||
| 571 | + | func(context.Context, string) error { return nil }, | |||
| 572 | + | )) | |||
| 573 | + | ||||
| 574 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 575 | + | require.Error(t, err) | |||
| 576 | + | require.NotNil(t, result) | |||
| 577 | + | assert.Equal(t, "deployed", result.Services["alpha"].Result, "an already deployed service is kept") | |||
| 578 | + | assert.Equal(t, "failed", result.Services["beta"].Result) | |||
| 579 | + | assert.NotContains(t, result.Services, "gamma", "a later service is never attempted after a failure") | |||
| 580 | + | ||||
| 581 | + | runtime.AssertNumberOfCalls(t, "CreateContainer", 2) | |||
| 582 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 583 | + | return cfg.Name == "gamma" | |||
| 584 | + | })) | |||
| 585 | + | // No rollback of the already deployed service. | |||
| 586 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 587 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-alpha", mock.Anything) | |||
| 588 | + | state.AssertNumberOfCalls(t, "SaveActive", 1) | |||
| 589 | + | ||||
| 590 | + | require.NotEmpty(t, saved) | |||
| 591 | + | last := saved[len(saved)-1] | |||
| 592 | + | assert.Equal(t, domain.AppOutcomePartial, last.Outcome, | |||
| 593 | + | "the run stops at the first failure; the earlier success is kept, not rolled back") | |||
| 594 | + | } | |||
| 595 | + | ||||
| 596 | + | // TestDeploy_VolumeReplacementPreventsOverlappingWriters proves a stateful | |||
| 597 | + | // replacement durably inhibits the superseded generation before it is | |||
| 598 | + | // retired, stops and removes it before the candidate starts, and keeps the | |||
| 599 | + | // inhibition while the candidate's removal cannot be confirmed. Exclusion | |||
| 600 | + | // holds because the previous process is gone before the replacement can write. | |||
| 601 | + | func TestDeploy_VolumeReplacementPreventsOverlappingWriters(t *testing.T) { | |||
| 602 | + | ctx := context.Background() | |||
| 603 | + | store := newTestStore(t) | |||
| 604 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 605 | + | images := outmocks.NewMockImageResolver(t) | |||
| 606 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 607 | + | ||||
| 608 | + | image := "registry.example.com/blog/web:1.4.2" | |||
| 609 | + | spec := domain.AppService{ | |||
| 610 | + | Name: "web", | |||
| 611 | + | Image: image, | |||
| 612 | + | Secrets: map[string]string{}, | |||
| 613 | + | Volumes: []domain.AppVolume{{Name: "data", Path: "/data"}}, | |||
| 614 | + | HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: domain.AppTLSAuto}}, | |||
| 615 | + | Readiness: domain.AppReadiness{ | |||
| 616 | + | Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 50 * time.Millisecond, | |||
| 617 | + | }, | |||
| 618 | + | } | |||
| 619 | + | rev := testRevision("blog", spec) | |||
| 620 | + | ||||
| 621 | + | require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog"})) | |||
| 622 | + | require.NoError(t, store.SaveActive(ctx, domain.AppActive{ | |||
| 623 | + | App: "blog", | |||
| 624 | + | Services: map[string]domain.AppEffectiveService{ | |||
| 625 | + | "web": { | |||
| 626 | + | Container: "c-old", EffectiveRevision: "rev-0", Spec: spec, | |||
| 627 | + | BackendBinds: map[int]int{8080: 18080}, | |||
| 628 | + | }, | |||
| 629 | + | }, | |||
| 630 | + | })) | |||
| 631 | + | seedDesired(t, ctx, store, rev) | |||
| 632 | + | // The app keeps one incarnation: the private network every service joins | |||
| 633 | + | // is derived from it. | |||
| 634 | + | require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog", ID: "app-blog"})) | |||
| 635 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 636 | + | ||||
| 637 | + | images.EXPECT().ResolveDigest(mock.Anything, image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once() | |||
| 638 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, image).Return(nil, nil).Once() | |||
| 639 | + | runtime.EXPECT().VolumeExists(mock.Anything, mock.Anything).Return(false, nil).Once() | |||
| 640 | + | ||||
| 641 | + | var order []string | |||
| 642 | + | inhibitedBeforeStop := false | |||
| 643 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn( | |||
| 644 | + | func(context.Context, string, time.Duration) error { | |||
| 645 | + | recordEvent(&order, "stop-old") | |||
| 646 | + | // The durable marker must already exist before the superseded | |||
| 647 | + | // writer is stopped: recovery must never race the destructive step. | |||
| 648 | + | inhibitions, loadErr := store.LoadRecoveryInhibitions(context.Background(), "blog") | |||
| 649 | + | require.NoError(t, loadErr) | |||
| 650 | + | inhibitedBeforeStop = len(inhibitions) == 1 && | |||
| 651 | + | inhibitions[0].ContainerID == "c-old" && | |||
| 652 | + | inhibitions[0].Reason == domain.AppInhibitReplacementPending | |||
| 653 | + | return nil | |||
| 654 | + | }).Once() | |||
| 655 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).RunAndReturn( | |||
| 656 | + | func(context.Context, string, bool) error { | |||
| 657 | + | recordEvent(&order, "remove-old") | |||
| 658 | + | return nil | |||
| 659 | + | }).Once() | |||
| 660 | + | runtime.EXPECT().CreateVolume(mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 661 | + | func(context.Context, string, map[string]string) error { | |||
| 662 | + | recordEvent(&order, "create-volume") | |||
| 663 | + | return nil | |||
| 664 | + | }).Once() | |||
| 665 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn( | |||
| 666 | + | func(context.Context, *domain.ContainerConfig) (*domain.Container, error) { | |||
| 667 | + | recordEvent(&order, "create-new") | |||
| 668 | + | return &domain.Container{ID: "c-new", Name: "web"}, nil | |||
| 669 | + | }).Once() | |||
| 670 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-new").RunAndReturn( | |||
| 671 | + | func(context.Context, string) error { | |||
| 672 | + | recordEvent(&order, "start-new") | |||
| 673 | + | return nil | |||
| 674 | + | }).Once() | |||
| 675 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |||
| 676 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 677 | + | runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(nil, assert.AnError).Once() | |||
| 678 | + | // The failed replacement removes its candidate once. Boot reconciliation | |||
| 679 | + | // retries it because a terminal failed operation cannot know the earlier | |||
| 680 | + | // removal succeeded without re-checking the runtime; that retry fails, so | |||
| 681 | + | // recovery fails closed and never revives the superseded writer whose | |||
| 682 | + | // volume the orphan may still touch. | |||
| 683 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once() | |||
| 684 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(assert.AnError).Once() | |||
| 685 | + | ||||
| 686 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 687 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 688 | + | ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}}, | |||
| 689 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 690 | + | func(context.Context, string, string) (int, error) { return 503, nil }, | |||
| 691 | + | func(context.Context, string) error { return assert.AnError }, | |||
| 692 | + | )) | |||
| 693 | + | ||||
| 694 | + | _, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 695 | + | require.Error(t, err) | |||
| 696 | + | ||||
| 697 | + | assert.Equal(t, []string{"stop-old", "remove-old", "create-volume", "create-new", "start-new"}, order, | |||
| 698 | + | "the superseded writer is gone before the replacement can write") | |||
| 699 | + | assert.True(t, inhibitedBeforeStop, | |||
| 700 | + | "the durable recovery inhibition exists before the superseded writer is stopped") | |||
| 701 | + | ||||
| 702 | + | // The failed replacement never cleared the durable inhibition: a later | |||
| 703 | + | // boot must not revive the old writer on top of data the candidate may | |||
| 704 | + | // already have touched. | |||
| 705 | + | inhibitions, loadErr := store.LoadRecoveryInhibitions(ctx, "blog") | |||
| 706 | + | require.NoError(t, loadErr) | |||
| 707 | + | require.Len(t, inhibitions, 1) | |||
| 708 | + | assert.Equal(t, "c-old", inhibitions[0].ContainerID) | |||
| 709 | + | assert.Equal(t, domain.AppInhibitReplacementPending, inhibitions[0].Reason) | |||
| 710 | + | ||||
| 711 | + | // ACTIVE still names the superseded generation: the failure is explicit | |||
| 712 | + | // and no availability was restored on its behalf. | |||
| 713 | + | active, ok, loadErr := store.LoadActive(ctx, "blog") | |||
| 714 | + | require.NoError(t, loadErr) | |||
| 715 | + | require.True(t, ok) | |||
| 716 | + | assert.Equal(t, "c-old", active.Services["web"].Container) | |||
| 717 | + | ||||
| 718 | + | // The failed replacement keeps its candidate in the journal: a leftover | |||
| 719 | + | // must stay traceable so reconciliation can converge it. | |||
| 720 | + | failedOp, hasOp, loadErr := store.LoadLatestOperation(ctx, "blog") | |||
| 721 | + | require.NoError(t, loadErr) | |||
| 722 | + | require.True(t, hasOp) | |||
| 723 | + | failedStep, found := opStep(failedOp, "service.web.replace") | |||
| 724 | + | require.True(t, found) | |||
| 725 | + | assert.Equal(t, domain.AppStepFailed, failedStep.State) | |||
| 726 | + | assert.Equal(t, "c-new", failedStep.After, "the created candidate stays recorded") | |||
| 727 | + | ||||
| 728 | + | rebooted := deployment.NewService(deployment.Deps{ | |||
| 729 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 730 | + | }, zerowrap.Default()) | |||
| 731 | + | bootErr := rebooted.ReconcileBoot(ctx) | |||
| 732 | + | require.Error(t, bootErr) | |||
| 733 | + | assert.Contains(t, bootErr.Error(), "could not be removed") | |||
| 734 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, "c-old") | |||
| 735 | + | runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, "c-old", mock.Anything) | |||
| 736 | + | ||||
| 737 | + | // The orphan that could not be removed keeps the inhibition: the | |||
| 738 | + | // superseded writer is never revived on top of data the candidate may | |||
| 739 | + | // already have touched. | |||
| 740 | + | keptInhibitions, keptErr := store.LoadRecoveryInhibitions(ctx, "blog") | |||
| 741 | + | require.NoError(t, keptErr) | |||
| 742 | + | require.Len(t, keptInhibitions, 1) | |||
| 743 | + | assert.Equal(t, "c-old", keptInhibitions[0].ContainerID) | |||
| 744 | + | assert.Equal(t, domain.AppInhibitReplacementPending, keptInhibitions[0].Reason) | |||
| 745 | + | } | |||
M internal/usecase/deployment/service.go +121 -47
| @@ -103,6 +103,14 @@ type Service struct { | |||||
| 103 | // the lock. | 103 | // the lock. | ||
| 104 | bindMu sync.RWMutex | 104 | bindMu sync.RWMutex | ||
| 105 | bindPolicies map[string]domain.AppBindPolicy | 105 | bindPolicies map[string]domain.AppBindPolicy | ||
| 106 | + | // liveMu guards liveOps. liveOps holds the operations this process | |||
| 107 | + | // owns between the StartDeploy claim and the end of ExecuteDeploy. It | |||
| 108 | + | // is in-memory only: a fresh Service (boot) starts empty and may | |||
| 109 | + | // reconcile every persisted non-terminal operation, while a | |||
| 110 | + | // foreground mutation must never finalize an operation a live | |||
| 111 | + | // goroutine still owns. | |||
| 112 | + | liveMu sync.Mutex | |||
| 113 | + | liveOps map[string]struct{} | |||
| 106 | } | 114 | } | ||
| 107 | 115 | ||||
| 108 | // NewService creates the deployment engine. All deps are required; | 116 | // NewService creates the deployment engine. All deps are required; | ||
| @@ -119,6 +127,43 @@ func NewService(deps Deps, log zerowrap.Logger) *Service { | |||||
| 119 | } | 127 | } | ||
| 120 | } | 128 | } | ||
| 121 | 129 | ||||
| 130 | + | // markOperationLive records that this process owns one operation from the | |||
| 131 | + | // StartDeploy claim until ExecuteDeploy finishes. A live operation is the | |||
| 132 | + | // single owner's in-flight work: a foreground reconciliation must not | |||
| 133 | + | // finalize it, or the claim would be settled before the owner can execute it. | |||
| 134 | + | func (s *Service) markOperationLive(app, op string) { | |||
| 135 | + | if app == "" || op == "" { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | s.liveMu.Lock() | |||
| 139 | + | defer s.liveMu.Unlock() | |||
| 140 | + | if s.liveOps == nil { | |||
| 141 | + | s.liveOps = make(map[string]struct{}) | |||
| 142 | + | } | |||
| 143 | + | s.liveOps[liveOperationKey(app, op)] = struct{}{} | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // clearOperationLive drops the live marker. It is called once ExecuteDeploy | |||
| 147 | + | // has recorded its outcome or left the claim recoverable, so a later | |||
| 148 | + | // reconciliation can converge the operation instead of leaving it in flight. | |||
| 149 | + | func (s *Service) clearOperationLive(app, op string) { | |||
| 150 | + | s.liveMu.Lock() | |||
| 151 | + | defer s.liveMu.Unlock() | |||
| 152 | + | delete(s.liveOps, liveOperationKey(app, op)) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // operationLive reports whether this process still owns the operation. | |||
| 156 | + | func (s *Service) operationLive(app, op string) bool { | |||
| 157 | + | s.liveMu.Lock() | |||
| 158 | + | defer s.liveMu.Unlock() | |||
| 159 | + | _, ok := s.liveOps[liveOperationKey(app, op)] | |||
| 160 | + | return ok | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func liveOperationKey(app, op string) string { | |||
| 164 | + | return app + "\x00" + op | |||
| 165 | + | } | |||
| 166 | + | ||||
| 122 | // WithGCBarrier wires the process-wide GC barrier. Every workload | 167 | // WithGCBarrier wires the process-wide GC barrier. Every workload | ||
| 123 | // mutation holds a shared lease from resource selection through the | 168 | // mutation holds a shared lease from resource selection through the | ||
| 124 | // durable publication of the resource's protection, so prune can never | 169 | // durable publication of the resource's protection, so prune can never | ||
| @@ -258,7 +303,6 @@ type DeployResult struct { | |||||
| 258 | Outcome string | 303 | Outcome string | ||
| 259 | Services map[string]ServiceResult | 304 | Services map[string]ServiceResult | ||
| 260 | CleanupWarnings []CleanupWarning | 305 | CleanupWarnings []CleanupWarning | ||
| 261 | - | Interrupted []string | |||
| 262 | // Removed names services retired because the new revision no longer | 306 | // Removed names services retired because the new revision no longer | ||
| 263 | // declares them. | 307 | // declares them. | ||
| 264 | Removed []string | 308 | Removed []string | ||
| @@ -279,12 +323,6 @@ type ServiceResult struct { | |||||
| 279 | // publishes for the active record. Nil when the service declares | 323 | // publishes for the active record. Nil when the service declares | ||
| 280 | // no UDP interface. | 324 | // no UDP interface. | ||
| 281 | UDPBackendBinds map[int]int | 325 | UDPBackendBinds map[int]int | ||
| 282 | - | // Retire is the exact container ID to stop+remove AFTER the new | |||
| 283 | - | // effective state is published. Empty when nothing retires. | |||
| 284 | - | Retire string | |||
| 285 | - | // RetireGrace is the effective stop grace of the container named by | |||
| 286 | - | // Retire: the generation being stopped, never its replacement. | |||
| 287 | - | RetireGrace time.Duration | |||
| 288 | // CleanupWarnings are bounded leftovers of this service's terminal | 326 | // CleanupWarnings are bounded leftovers of this service's terminal | ||
| 289 | // path: a candidate that could not be removed, or a backend claim | 327 | // path: a candidate that could not be removed, or a backend claim | ||
| 290 | // that could not be released. | 328 | // that could not be released. | ||
| @@ -330,38 +368,21 @@ func newOpID() string { | |||||
| 330 | return "op-" + string(buf[:]) | 368 | return "op-" + string(buf[:]) | ||
| 331 | } | 369 | } | ||
| 332 | 370 | ||||
| 333 | - | // Preflight resolves a captured revision without any workload mutation: | 371 | + | // claimDeploymentLocked is the first phase of a deploy: it resolves the |
| 334 | - | // image digests, secret presence, image-volume mapping, reservation | 372 | + | // requested revision, enforces the targeted-deploy convergence rules, and |
| 335 | - | // recheck, and resource preconditions. It records the pinned digest | 373 | + | // claims the request key, persisting the non-terminal journal. It performs |
| 336 | - | // table into the journal BEFORE any effect. | 374 | + | // no image pull and no workload mutation, so a crash after it leaves a |
| 337 | - | func (s *Service) Preflight(ctx context.Context, input DeployInput) ([]pinnedService, *domain.AppOperation, error) { | 375 | + | // durable claim that reconciliation can converge. owned is true only for the |
| 338 | - | release, err := s.acquireAppContext(ctx, input.App) | 376 | + | // single claimer of the key; a replay returns the stored journal instead. |
| 339 | - | if err != nil { | 377 | + | // The caller holds the app lock and has already recovered the store. |
| 340 | - | return nil, nil, err | 378 | + | func (s *Service) claimDeploymentLocked(ctx context.Context, input DeployInput) (domain.AppDesiredRevision, domain.AppOperation, bool, error) { |
| 341 | - | } | |||
| 342 | - | defer release() | |||
| 343 | - | pinned, op, _, err := s.preflightLocked(ctx, input) | |||
| 344 | - | return pinned, op, err | |||
| 345 | - | } | |||
| 346 | - | ||||
| 347 | - | func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pinnedService, *domain.AppOperation, bool, error) { | |||
| 348 | - | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 349 | - | zerowrap.FieldLayer: "usecase", | |||
| 350 | - | zerowrap.FieldUseCase: "Preflight", | |||
| 351 | - | "app": input.App, | |||
| 352 | - | }) | |||
| 353 | - | log := zerowrap.FromCtx(ctx) | |||
| 354 | - | ||||
| 355 | - | if err := s.deps.State.Recover(ctx); err != nil { | |||
| 356 | - | return nil, nil, false, fmt.Errorf("deployment: recover before preflight: %w", err) | |||
| 357 | - | } | |||
| 358 | rev, err := s.resolveRevision(ctx, input) | 379 | rev, err := s.resolveRevision(ctx, input) | ||
| 359 | if err != nil { | 380 | if err != nil { | ||
| 360 | - | return nil, nil, false, err | 381 | + | return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err |
| 361 | } | 382 | } | ||
| 362 | if input.Service != "" { | 383 | if input.Service != "" { | ||
| 363 | if err := s.checkConverged(ctx, input.App, input.Service, rev); err != nil { | 384 | if err := s.checkConverged(ctx, input.App, input.Service, rev); err != nil { | ||
| 364 | - | return nil, nil, false, err | 385 | + | return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err |
| 365 | } | 386 | } | ||
| 366 | } | 387 | } | ||
| 367 | 388 | ||||
| @@ -374,26 +395,42 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin | |||||
| 374 | } | 395 | } | ||
| 375 | op, owned, err := s.claimOperation(ctx, input.Op, op, []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}) | 396 | op, owned, err := s.claimOperation(ctx, input.Op, op, []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}) | ||
| 376 | if err != nil { | 397 | if err != nil { | ||
| 377 | - | return nil, nil, false, err | 398 | + | return domain.AppDesiredRevision{}, domain.AppOperation{}, false, err |
| 378 | - | } | |||
| 379 | - | if !owned { | |||
| 380 | - | // The key already answered this request: its journal is the | |||
| 381 | - | // result, and no effect may run again. | |||
| 382 | - | return nil, &op, true, replayError(op) | |||
| 383 | } | 399 | } | ||
| 400 | + | return rev, op, owned, nil | |||
| 401 | + | } | |||
| 384 | 402 | ||||
| 385 | - | pinned, err := s.preflightServices(ctx, input.App, rev, input.Service) | 403 | + | // pinPreflightLocked is the second preflight phase: it runs the image |
| 404 | + | // pull/pin and resource gates of an already claimed operation and records the | |||
| 405 | + | // pinned table (or the terminal preflight failure) in the journal. The | |||
| 406 | + | // caller holds the app lock and owns the claimed, non-terminal operation. | |||
| 407 | + | func (s *Service) pinPreflightLocked(ctx context.Context, app, onlyService string, rev domain.AppDesiredRevision, op *domain.AppOperation) ([]pinnedService, error) { | |||
| 408 | + | log := zerowrap.FromCtx(ctx) | |||
| 409 | + | ||||
| 410 | + | pinned, err := s.preflightServices(ctx, app, rev, onlyService) | |||
| 386 | if err != nil { | 411 | if err != nil { | ||
| 387 | op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepFailed, Error: err.Error()} | 412 | op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepFailed, Error: err.Error()} | ||
| 388 | op.Outcome = domain.AppOutcomeFailed | 413 | op.Outcome = domain.AppOutcomeFailed | ||
| 389 | - | if saveErr := s.deps.State.SaveOperation(ctx, op); saveErr != nil { | 414 | + | if saveErr := s.deps.State.SaveOperation(ctx, *op); saveErr != nil { |
| 390 | log.Warn().Err(saveErr).Msg("deployment: failed to record preflight failure") | 415 | log.Warn().Err(saveErr).Msg("deployment: failed to record preflight failure") | ||
| 391 | } | 416 | } | ||
| 392 | - | return nil, &op, false, err | 417 | + | return nil, err |
| 393 | } | 418 | } | ||
| 394 | op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepSucceeded} | 419 | op.Steps[0] = domain.AppOperationStep{ID: "preflight", State: domain.AppStepSucceeded} | ||
| 420 | + | // A resumed claim can carry the service-step plan of an earlier attempt, | |||
| 421 | + | // including candidates it recorded. Converge those candidates before | |||
| 422 | + | // replacing the plan: an unreconciled leftover may still be running, and | |||
| 423 | + | // the replacement this execution creates must never overlap it. | |||
| 424 | + | if err := s.convergeResumedCandidates(ctx, app, op, log); err != nil { | |||
| 425 | + | return nil, err | |||
| 426 | + | } | |||
| 427 | + | // Replace the persisted plan instead of appending: the preflight step plus | |||
| 428 | + | // one step per pinned service, so runServiceStep's index contract matches a | |||
| 429 | + | // normalized layout and no earlier step is overwritten or left pending. | |||
| 430 | + | plan := make([]domain.AppOperationStep, 1, len(pinned)+1) | |||
| 431 | + | plan[0] = op.Steps[0] | |||
| 395 | for _, p := range pinned { | 432 | for _, p := range pinned { | ||
| 396 | - | op.Steps = append(op.Steps, domain.AppOperationStep{ | 433 | + | plan = append(plan, domain.AppOperationStep{ |
| 397 | ID: "service." + p.name + ".replace", | 434 | ID: "service." + p.name + ".replace", | ||
| 398 | State: domain.AppStepPending, | 435 | State: domain.AppStepPending, | ||
| 399 | Service: p.name, | 436 | Service: p.name, | ||
| @@ -401,11 +438,37 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin | |||||
| 401 | Image: p.runtimeImage, | 438 | Image: p.runtimeImage, | ||
| 402 | }) | 439 | }) | ||
| 403 | } | 440 | } | ||
| 404 | - | if err := s.deps.State.SaveOperation(ctx, op); err != nil { | 441 | + | op.Steps = plan |
| 405 | - | return nil, nil, false, fmt.Errorf("deployment: persist pinned table: %w", err) | 442 | + | if err := s.deps.State.SaveOperation(ctx, *op); err != nil { |
| 443 | + | return nil, fmt.Errorf("deployment: persist pinned table: %w", err) | |||
| 406 | } | 444 | } | ||
| 407 | log.Info().Str("op", op.Op).Str("revision", rev.Revision).Int("services", len(pinned)).Msg("deployment: preflight passed") | 445 | log.Info().Str("op", op.Op).Str("revision", rev.Revision).Int("services", len(pinned)).Msg("deployment: preflight passed") | ||
| 408 | - | return pinned, &op, false, 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 | |||
| 409 | } | 472 | } | ||
| 410 | 473 | ||||
| 411 | // claimOperation is the single claim point before any mutation effect. | 474 | // claimOperation is the single claim point before any mutation effect. | ||
| @@ -418,6 +481,17 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin | |||||
| 418 | // journal under a generated id. | 481 | // journal under a generated id. | ||
| 419 | func (s *Service) claimOperation(ctx context.Context, key string, op domain.AppOperation, steps []domain.AppOperationStep) (domain.AppOperation, bool, error) { | 482 | func (s *Service) claimOperation(ctx context.Context, key string, op domain.AppOperation, steps []domain.AppOperationStep) (domain.AppOperation, bool, error) { | ||
| 420 | op.Steps = steps | 483 | op.Steps = steps | ||
| 484 | + | // A journal is never opened while a different operation of this app is | |||
| 485 | + | // still unfinished: that predecessor must be reconciled first, or this | |||
| 486 | + | // claim would mask it and its leftover generation could stay untracked. | |||
| 487 | + | latest, found, err := s.deps.State.LoadLatestOperation(ctx, op.App) | |||
| 488 | + | if err != nil { | |||
| 489 | + | return domain.AppOperation{}, false, fmt.Errorf("deployment: load latest operation: %w", err) | |||
| 490 | + | } | |||
| 491 | + | if found && !latest.Terminal() && latest.Op != key { | |||
| 492 | + | return domain.AppOperation{}, false, fmt.Errorf( | |||
| 493 | + | "deployment: app %q has unfinished operation %s: %w", op.App, latest.Op, domain.ErrAppStateConflict) | |||
| 494 | + | } | |||
| 421 | if key == "" { | 495 | if key == "" { | ||
| 422 | op.Op = newOpID() | 496 | op.Op = newOpID() | ||
| 423 | if err := s.deps.State.SaveOperation(ctx, op); err != nil { | 497 | if err := s.deps.State.SaveOperation(ctx, op); err != nil { | ||
M internal/usecase/deployment/service_test.go +7 -341
| @@ -2,7 +2,6 @@ package deployment_test | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "strings" | |||
| 6 | "testing" | 5 | "testing" | ||
| 7 | "time" | 6 | "time" | ||
| 8 | 7 | ||||
| @@ -48,333 +47,19 @@ func preflightService( | |||||
| 48 | secrets *outmocks.MockSecretProvider, | 47 | secrets *outmocks.MockSecretProvider, | ||
| 49 | ) *deployment.Service { | 48 | ) *deployment.Service { | ||
| 50 | t.Helper() | 49 | t.Helper() | ||
| 50 | + | // Every mutation claims a journal only when no other operation of the | |||
| 51 | + | // app is unfinished. These tests start clean. | |||
| 52 | + | state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything). | |||
| 53 | + | Return(domain.AppOperation{}, false, nil).Maybe() | |||
| 51 | return deployment.NewService(deployment.Deps{ | 54 | return deployment.NewService(deployment.Deps{ | ||
| 52 | State: state, Runtime: runtime, Images: images, Secrets: secrets, | 55 | State: state, Runtime: runtime, Images: images, Secrets: secrets, | ||
| 53 | ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}}, | 56 | ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}}, | ||
| 54 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 57 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 55 | - | func(context.Context, string) (int, error) { return 200, nil }, | 58 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 56 | func(context.Context, string) error { return nil }, | 59 | func(context.Context, string) error { return nil }, | ||
| 57 | )) | 60 | )) | ||
| 58 | } | 61 | } | ||
| 59 | 62 | ||||
| 60 | - | // expectFullPreflight wires every preflight gate on mocks. | |||
| 61 | - | func expectFullPreflight( | |||
| 62 | - | state *outmocks.MockAppState, | |||
| 63 | - | runtime *outmocks.MockContainerRuntime, | |||
| 64 | - | images *outmocks.MockImageResolver, | |||
| 65 | - | secrets *outmocks.MockSecretProvider, | |||
| 66 | - | rev domain.AppDesiredRevision, | |||
| 67 | - | imageVolumes []string, | |||
| 68 | - | digest string, | |||
| 69 | - | secretValue string, | |||
| 70 | - | secretErr error, | |||
| 71 | - | ) { | |||
| 72 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 73 | - | state.EXPECT().LoadDesired(mock.Anything, rev.App).Return(rev, true, nil).Once() | |||
| 74 | - | state.EXPECT().LoadOwnership(mock.Anything, rev.App).Return(domain.AppOwnership{App: rev.App}, nil) | |||
| 75 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 76 | - | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(digest, nil).Once() | |||
| 77 | - | if secretErr != nil { | |||
| 78 | - | secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("", secretErr).Once() | |||
| 79 | - | return | |||
| 80 | - | } | |||
| 81 | - | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/blog/web/database-url").Return(secretValue, nil).Once() | |||
| 82 | - | runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(imageVolumes, nil).Once() | |||
| 83 | - | if secretErr == nil && imageVolumes == nil { | |||
| 84 | - | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | |||
| 85 | - | } | |||
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestPreflight_PassesAndPins(t *testing.T) { | |||
| 89 | - | ctx := context.Background() | |||
| 90 | - | state := outmocks.NewMockAppState(t) | |||
| 91 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 92 | - | images := outmocks.NewMockImageResolver(t) | |||
| 93 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 94 | - | rev := testRevision("blog", webService()) | |||
| 95 | - | expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "s3cr3t", nil) | |||
| 96 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 97 | - | ||||
| 98 | - | pinned, op, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 99 | - | require.NoError(t, err) | |||
| 100 | - | require.NotNil(t, pinned) | |||
| 101 | - | require.NotNil(t, op) | |||
| 102 | - | assert.Equal(t, "rev-1", op.InputRevision) | |||
| 103 | - | require.Len(t, op.Steps, 2) | |||
| 104 | - | assert.Equal(t, "succeeded", op.Steps[0].State) | |||
| 105 | - | assert.Contains(t, op.Steps[1].Digest, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") | |||
| 106 | - | assert.Equal(t, "web", op.Steps[1].Service) | |||
| 107 | - | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 108 | - | } | |||
| 109 | - | ||||
| 110 | - | func TestPreflight_ImageFailureActivatesNothing(t *testing.T) { | |||
| 111 | - | ctx := context.Background() | |||
| 112 | - | state := outmocks.NewMockAppState(t) | |||
| 113 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 114 | - | images := outmocks.NewMockImageResolver(t) | |||
| 115 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 116 | - | rev := testRevision("blog", webService()) | |||
| 117 | - | ||||
| 118 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 119 | - | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once() | |||
| 120 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil) | |||
| 121 | - | images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("", assert.AnError).Once() | |||
| 122 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 123 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 124 | - | ||||
| 125 | - | _, op, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 126 | - | require.ErrorIs(t, err, domain.ErrAppImageUnresolvable) | |||
| 127 | - | require.NotNil(t, op) | |||
| 128 | - | assert.Equal(t, "failed", op.Outcome) | |||
| 129 | - | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | func TestPreflight_SecretMissingFailsClosed(t *testing.T) { | |||
| 133 | - | ctx := context.Background() | |||
| 134 | - | state := outmocks.NewMockAppState(t) | |||
| 135 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 136 | - | images := outmocks.NewMockImageResolver(t) | |||
| 137 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 138 | - | rev := testRevision("blog", webService()) | |||
| 139 | - | expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "", assert.AnError) | |||
| 140 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 141 | - | ||||
| 142 | - | _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 143 | - | require.ErrorIs(t, err, domain.ErrAppSecretMissing) | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func TestPreflight_UnmanagedImageVolumeRejected(t *testing.T) { | |||
| 147 | - | ctx := context.Background() | |||
| 148 | - | state := outmocks.NewMockAppState(t) | |||
| 149 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 150 | - | images := outmocks.NewMockImageResolver(t) | |||
| 151 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 152 | - | rev := testRevision("blog", webService()) | |||
| 153 | - | expectFullPreflight(state, runtime, images, secrets, rev, []string{"/data"}, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "x", nil) | |||
| 154 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 155 | - | ||||
| 156 | - | _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 157 | - | require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume) | |||
| 158 | - | } | |||
| 159 | - | ||||
| 160 | - | // TestPreflight_RefusesUnownedExistingVolume proves R2: a generated | |||
| 161 | - | // runtime volume that already exists but is NOT recorded in the app's | |||
| 162 | - | // ownership is refused fail-closed — the deploy must never implicitly | |||
| 163 | - | // adopt (and modify) foreign data under a reused name. | |||
| 164 | - | func TestPreflight_RefusesUnownedExistingVolume(t *testing.T) { | |||
| 165 | - | ctx := context.Background() | |||
| 166 | - | state := outmocks.NewMockAppState(t) | |||
| 167 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 168 | - | images := outmocks.NewMockImageResolver(t) | |||
| 169 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 170 | - | svc := webService() | |||
| 171 | - | svc.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | |||
| 172 | - | rev := testRevision("blog", svc) | |||
| 173 | - | expectFullPreflight(state, runtime, images, secrets, rev, nil, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "x", nil) | |||
| 174 | - | // Ownership knows no volumes; the runtime one is foreign. | |||
| 175 | - | runtime.EXPECT().VolumeExists(mock.Anything, "gordon-blog--web--vol--data").Return(true, nil).Once() | |||
| 176 | - | svcSvc := preflightService(t, state, runtime, images, secrets) | |||
| 177 | - | ||||
| 178 | - | _, _, err := svcSvc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 179 | - | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 180 | - | require.Contains(t, err.Error(), "not owned by app") | |||
| 181 | - | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | // TestPreflight_AcceptsOwnedExistingVolume proves R2 does not break the | |||
| 185 | - | // legitimate reuse path: a volume created by this app's own earlier | |||
| 186 | - | // deploy (recorded in ownership) is accepted. | |||
| 187 | - | func TestPreflight_AcceptsOwnedExistingVolume(t *testing.T) { | |||
| 188 | - | ctx := context.Background() | |||
| 189 | - | state := outmocks.NewMockAppState(t) | |||
| 190 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 191 | - | images := outmocks.NewMockImageResolver(t) | |||
| 192 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 193 | - | svc := webService() | |||
| 194 | - | svc.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}} | |||
| 195 | - | rev := testRevision("blog", svc) | |||
| 196 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 197 | - | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once() | |||
| 198 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{ | |||
| 199 | - | App: "blog", | |||
| 200 | - | Volumes: []domain.AppOwnedVolume{{ | |||
| 201 | - | Name: "data", Service: "web", | |||
| 202 | - | RuntimeName: "gordon-blog--web--vol--data", | |||
| 203 | - | State: domain.AppResourceAttached, | |||
| 204 | - | }}, | |||
| 205 | - | }, nil) | |||
| 206 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 207 | - | images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once() | |||
| 208 | - | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/blog/web/database-url").Return("x", nil).Once() | |||
| 209 | - | runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 210 | - | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | |||
| 211 | - | runtime.EXPECT().VolumeExists(mock.Anything, "gordon-blog--web--vol--data").Return(true, nil).Once() | |||
| 212 | - | svcSvc := preflightService(t, state, runtime, images, secrets) | |||
| 213 | - | ||||
| 214 | - | pinned, _, err := svcSvc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 215 | - | require.NoError(t, err) | |||
| 216 | - | require.Len(t, pinned, 1) | |||
| 217 | - | } | |||
| 218 | - | ||||
| 219 | - | func TestPreflight_TargetedRefusesDivergence(t *testing.T) { | |||
| 220 | - | ctx := context.Background() | |||
| 221 | - | state := outmocks.NewMockAppState(t) | |||
| 222 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 223 | - | images := outmocks.NewMockImageResolver(t) | |||
| 224 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 225 | - | rev := testRevision("blog", webService()) | |||
| 226 | - | rev.Revision = "rev-2" | |||
| 227 | - | ||||
| 228 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 229 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once() | |||
| 230 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true}, true, nil).Once() | |||
| 231 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 232 | - | ||||
| 233 | - | _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog", Revision: "rev-2", Service: "web"}) | |||
| 234 | - | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | func TestPreflight_TargetedDiffPolicy(t *testing.T) { | |||
| 238 | - | baseService := webService() | |||
| 239 | - | worker := webService() | |||
| 240 | - | worker.Name = "worker" | |||
| 241 | - | worker.HTTP[0].Host = "worker.example.com" | |||
| 242 | - | base := domain.AppActive{ | |||
| 243 | - | App: "blog", ConvergedRevision: "rev-1", Converged: true, | |||
| 244 | - | Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}}, | |||
| 245 | - | Services: map[string]domain.AppEffectiveService{ | |||
| 246 | - | "web": {Spec: baseService}, | |||
| 247 | - | "worker": {Spec: worker}, | |||
| 248 | - | }, | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | tests := []struct { | |||
| 252 | - | name string | |||
| 253 | - | mutate func(*domain.AppSpec) | |||
| 254 | - | service string | |||
| 255 | - | wantErr bool | |||
| 256 | - | }{ | |||
| 257 | - | {name: "requested service image", service: "web", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }}, | |||
| 258 | - | {name: "other service", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }}, | |||
| 259 | - | {name: "environment", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }}, | |||
| 260 | - | {name: "network", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }}, | |||
| 261 | - | {name: "service added", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { | |||
| 262 | - | spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}}) | |||
| 263 | - | }}, | |||
| 264 | - | } | |||
| 265 | - | for _, tc := range tests { | |||
| 266 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 267 | - | spec := domain.AppSpec{Name: "blog", Networks: append([]domain.AppSharedNetwork(nil), base.Networks...), Services: []domain.AppService{baseService, worker}} | |||
| 268 | - | spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...) | |||
| 269 | - | tc.mutate(&spec) | |||
| 270 | - | state := outmocks.NewMockAppState(t) | |||
| 271 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 272 | - | images := outmocks.NewMockImageResolver(t) | |||
| 273 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 274 | - | rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec} | |||
| 275 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 276 | - | state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once() | |||
| 277 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once() | |||
| 278 | - | if !tc.wantErr { | |||
| 279 | - | images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:"+strings.Repeat("a", 64), nil).Once() | |||
| 280 | - | secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("x", nil).Once() | |||
| 281 | - | runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 282 | - | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | |||
| 283 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil).Once() | |||
| 284 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Times(2) | |||
| 285 | - | } | |||
| 286 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 287 | - | _, _, err := svc.Preflight(context.Background(), deployment.DeployInput{App: "blog", Revision: "rev-2", Service: tc.service}) | |||
| 288 | - | if tc.wantErr { | |||
| 289 | - | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 290 | - | } else { | |||
| 291 | - | require.NoError(t, err) | |||
| 292 | - | } | |||
| 293 | - | }) | |||
| 294 | - | } | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | func TestPreflight_TargetedConvergedPasses(t *testing.T) { | |||
| 298 | - | ctx := context.Background() | |||
| 299 | - | state := outmocks.NewMockAppState(t) | |||
| 300 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 301 | - | images := outmocks.NewMockImageResolver(t) | |||
| 302 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 303 | - | rev := testRevision("blog", webService()) | |||
| 304 | - | ||||
| 305 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 306 | - | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once() | |||
| 307 | - | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true}, true, nil).Once() | |||
| 308 | - | images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once() | |||
| 309 | - | secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("x", nil).Once() | |||
| 310 | - | runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once() | |||
| 311 | - | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once() | |||
| 312 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil) | |||
| 313 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Times(2) | |||
| 314 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 315 | - | ||||
| 316 | - | _, op, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog", Service: "web"}) | |||
| 317 | - | require.NoError(t, err) | |||
| 318 | - | require.NotNil(t, op) | |||
| 319 | - | } | |||
| 320 | - | ||||
| 321 | - | func TestPreflight_MutableTagReresolved(t *testing.T) { | |||
| 322 | - | ctx := context.Background() | |||
| 323 | - | newDeps := func() (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) { | |||
| 324 | - | return outmocks.NewMockAppState(t), outmocks.NewMockContainerRuntime(t), outmocks.NewMockImageResolver(t), outmocks.NewMockSecretProvider(t) | |||
| 325 | - | } | |||
| 326 | - | svcSpec := webService() | |||
| 327 | - | svcSpec.Image = "registry.example.com/blog/web:latest" | |||
| 328 | - | rev := testRevision("blog", svcSpec) | |||
| 329 | - | ||||
| 330 | - | firstDigest := "sha256:" + strings.Repeat("a", 64) | |||
| 331 | - | secondDigest := "sha256:" + strings.Repeat("b", 64) | |||
| 332 | - | state, runtime, images, secrets := newDeps() | |||
| 333 | - | expectFullPreflight(state, runtime, images, secrets, rev, nil, firstDigest, "x", nil) | |||
| 334 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 335 | - | _, op1, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 336 | - | require.NoError(t, err) | |||
| 337 | - | assert.Contains(t, op1.Steps[1].Digest, firstDigest) | |||
| 338 | - | ||||
| 339 | - | state2, runtime2, images2, secrets2 := newDeps() | |||
| 340 | - | expectFullPreflight(state2, runtime2, images2, secrets2, rev, nil, secondDigest, "x", nil) | |||
| 341 | - | svc2 := preflightService(t, state2, runtime2, images2, secrets2) | |||
| 342 | - | _, op2, err := svc2.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 343 | - | require.NoError(t, err) | |||
| 344 | - | assert.Contains(t, op2.Steps[1].Digest, secondDigest) | |||
| 345 | - | assert.NotEqual(t, op1.Op, op2.Op) | |||
| 346 | - | } | |||
| 347 | - | ||||
| 348 | - | // TestPreflight_RejectsCrossAppReservationConflict proves preflight | |||
| 349 | - | // re-validates the candidate against the live global table with the | |||
| 350 | - | // same wildcard-aware policy as apply. | |||
| 351 | - | func TestPreflight_RejectsCrossAppReservationConflict(t *testing.T) { | |||
| 352 | - | ctx := context.Background() | |||
| 353 | - | state := outmocks.NewMockAppState(t) | |||
| 354 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 355 | - | images := outmocks.NewMockImageResolver(t) | |||
| 356 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 357 | - | svcSpec := webService() | |||
| 358 | - | svcSpec.HTTP = nil | |||
| 359 | - | svcSpec.TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "127.0.0.1:19090"}} | |||
| 360 | - | rev := testRevision("blog", svcSpec) | |||
| 361 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 362 | - | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once() | |||
| 363 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil) | |||
| 364 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 365 | - | images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once() | |||
| 366 | - | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/blog/web/database-url").Return("x", nil).Once() | |||
| 367 | - | runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil).Once() | |||
| 368 | - | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{ | |||
| 369 | - | Reservations: []domain.AppListenerReservation{{Proto: "tcp", IP: "dual", Port: 19090, Service: "s", App: "other"}}, | |||
| 370 | - | }, nil).Once() | |||
| 371 | - | svc := preflightService(t, state, runtime, images, secrets) | |||
| 372 | - | ||||
| 373 | - | _, _, err := svc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 374 | - | require.ErrorIs(t, err, domain.ErrAppReservationConflict) | |||
| 375 | - | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | func TestComputeOutcome_TerminalResults(t *testing.T) { | 63 | func TestComputeOutcome_TerminalResults(t *testing.T) { | ||
| 379 | deployed := deployment.ServiceResult{Result: "deployed"} | 64 | deployed := deployment.ServiceResult{Result: "deployed"} | ||
| 380 | failed := deployment.ServiceResult{Result: "failed", Error: "boom"} | 65 | failed := deployment.ServiceResult{Result: "failed", Error: "boom"} | ||
| @@ -412,6 +97,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) { | |||||
| 412 | // bad app: Start fails bind verification, binds withdrawn. | 97 | // bad app: Start fails bind verification, binds withdrawn. | ||
| 413 | state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once() | 98 | state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once() | ||
| 414 | state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil) | 99 | state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil) | ||
| 100 | + | state.EXPECT().LoadLatestOperation(mock.Anything, "bad").Return(domain.AppOperation{}, false, nil).Maybe() | |||
| 415 | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | 101 | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | ||
| 416 | state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil) | 102 | state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil) | ||
| 417 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once() | 103 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once() | ||
| @@ -423,6 +109,7 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) { | |||||
| 423 | state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once() | 109 | state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once() | ||
| 424 | // Start load + bind-refresh reload. | 110 | // Start load + bind-refresh reload. | ||
| 425 | state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil) | 111 | state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil) | ||
| 112 | + | state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe() | |||
| 426 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once() | 113 | state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once() | ||
| 427 | runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once() | 114 | runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once() | ||
| 428 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | 115 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | ||
| @@ -434,24 +121,3 @@ func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) { | |||||
| 434 | require.Error(t, err) | 121 | require.Error(t, err) | ||
| 435 | assert.Contains(t, err.Error(), `"bad"`) | 122 | assert.Contains(t, err.Error(), `"bad"`) | ||
| 436 | } | 123 | } | ||
| 437 | - | ||||
| 438 | - | func TestPreflight_EnvCollisionFailsClosed(t *testing.T) { | |||
| 439 | - | ctx := context.Background() | |||
| 440 | - | state := outmocks.NewMockAppState(t) | |||
| 441 | - | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 442 | - | images := outmocks.NewMockImageResolver(t) | |||
| 443 | - | secrets := outmocks.NewMockSecretProvider(t) | |||
| 444 | - | svc := webService() | |||
| 445 | - | rev := testRevision("blog", svc) | |||
| 446 | - | rev.Spec.Env = map[string]string{"DATABASE_URL": "public-value"} | |||
| 447 | - | ||||
| 448 | - | state.EXPECT().Recover(mock.Anything).Return(nil).Once() | |||
| 449 | - | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once() | |||
| 450 | - | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil).Once() | |||
| 451 | - | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil) | |||
| 452 | - | svcSvc := preflightService(t, state, runtime, images, secrets) | |||
| 453 | - | ||||
| 454 | - | _, _, err := svcSvc.Preflight(ctx, deployment.DeployInput{App: "blog"}) | |||
| 455 | - | require.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 456 | - | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 457 | - | } | |||
M internal/usecase/deployment/traffic_failure_test.go +10 -4
| @@ -50,6 +50,11 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) { | |||||
| 50 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | 50 | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | ||
| 51 | 51 | ||||
| 52 | expectNetworkProvision(runtime, "app-blog", 1) | 52 | expectNetworkProvision(runtime, "app-blog", 1) | ||
| 53 | + | // The superseded generation is confirmed gone before the replacement is | |||
| 54 | + | // created: a deploy never overlaps two generations of one service. | |||
| 55 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 56 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 57 | + | state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once() | |||
| 53 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | 58 | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | ||
| 54 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | 59 | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | ||
| 55 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | 60 | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | ||
| @@ -68,7 +73,7 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) { | |||||
| 68 | svc := deployment.NewService(deployment.Deps{ | 73 | svc := deployment.NewService(deployment.Deps{ | ||
| 69 | State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic, | 74 | State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic, | ||
| 70 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | 75 | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | ||
| 71 | - | func(context.Context, string) (int, error) { return 200, nil }, | 76 | + | func(context.Context, string, string) (int, error) { return 200, nil }, |
| 72 | func(context.Context, string) error { return nil }, | 77 | func(context.Context, string) error { return nil }, | ||
| 73 | )) | 78 | )) | ||
| 74 | 79 | ||||
| @@ -83,9 +88,10 @@ func TestDeploy_TrafficFailureRecordsFailedServiceStep(t *testing.T) { | |||||
| 83 | step, ok := opStep(last, "service.web.replace") | 88 | step, ok := opStep(last, "service.web.replace") | ||
| 84 | require.True(t, ok) | 89 | require.True(t, ok) | ||
| 85 | assert.Equal(t, domain.AppStepFailed, step.State, "the service step must not claim success before traffic applied") | 90 | assert.Equal(t, domain.AppStepFailed, step.State, "the service step must not claim success before traffic applied") | ||
| 86 | - | // The replaced container is retained: ACTIVE is published but the graph | 91 | + | // The superseded container is gone: a rejected graph is reported as a |
| 87 | - | // never accepted the new routing. | 92 | + | // failure of the new generation, never as a return to the old one. |
| 88 | - | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-old", mock.Anything) | 93 | + | runtime.AssertCalled(t, "RemoveContainer", mock.Anything, "c-old", false) |
| 94 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, "c-new", mock.Anything) | |||
| 89 | } | 95 | } | ||
| 90 | 96 | ||||
| 91 | // TestStop_TrafficFailureRecordsTerminalFailure proves a rejected graph | 97 | // TestStop_TrafficFailureRecordsTerminalFailure proves a rejected graph | ||
A internal/usecase/deployment/two_phase_deploy_test.go +459 -0
| @@ -0,0 +1,459 @@ | |||||
| 1 | + | package deployment_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | "github.com/bnema/gordon/internal/usecase/deployment" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // TestStartDeploy_PersistsClaimBeforeAnyExecution proves the first phase | |||
| 19 | + | // durably claims the key with a non-terminal journal and touches no runtime | |||
| 20 | + | // resource: no image resolution, no pull, no workload mutation. A crash right | |||
| 21 | + | // after StartDeploy therefore leaves a claim reconciliation can converge. | |||
| 22 | + | func TestStartDeploy_PersistsClaimBeforeAnyExecution(t *testing.T) { | |||
| 23 | + | t.Run("keyed", func(t *testing.T) { | |||
| 24 | + | ctx := context.Background() | |||
| 25 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 26 | + | rev := mockRevision() | |||
| 27 | + | ||||
| 28 | + | state.EXPECT().Recover(mock.Anything).Return(nil).Maybe() | |||
| 29 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 30 | + | state.EXPECT().ClaimOperation(mock.Anything, mock.MatchedBy(func(op domain.AppOperation) bool { | |||
| 31 | + | return op.Op == "k1" && !op.Terminal() | |||
| 32 | + | })).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) { | |||
| 33 | + | return op, true, nil | |||
| 34 | + | }).Once() | |||
| 35 | + | ||||
| 36 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 37 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"}) | |||
| 38 | + | ||||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | require.True(t, started.Owned) | |||
| 41 | + | require.Equal(t, "k1", started.Claim.Op) | |||
| 42 | + | require.False(t, started.Claim.Journal.Terminal(), "the claim is non-terminal before execution") | |||
| 43 | + | require.Len(t, started.Claim.Journal.Steps, 1) | |||
| 44 | + | assert.Equal(t, domain.AppStepPending, started.Claim.Journal.Steps[0].State) | |||
| 45 | + | ||||
| 46 | + | state.AssertNotCalled(t, "LoadOwnership", mock.Anything, mock.Anything) | |||
| 47 | + | images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything) | |||
| 48 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 49 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 50 | + | runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 51 | + | }) | |||
| 52 | + | ||||
| 53 | + | t.Run("unkeyed", func(t *testing.T) { | |||
| 54 | + | ctx := context.Background() | |||
| 55 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 56 | + | rev := mockRevision() | |||
| 57 | + | ||||
| 58 | + | var saved []domain.AppOperation | |||
| 59 | + | state.EXPECT().Recover(mock.Anything).Return(nil).Maybe() | |||
| 60 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error { | |||
| 61 | + | saved = append(saved, op) | |||
| 62 | + | return nil | |||
| 63 | + | }).Maybe() | |||
| 64 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 65 | + | ||||
| 66 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 67 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 68 | + | ||||
| 69 | + | require.NoError(t, err) | |||
| 70 | + | require.True(t, started.Owned) | |||
| 71 | + | require.Len(t, saved, 1, "the claim journal is persisted before any effect") | |||
| 72 | + | require.False(t, saved[0].Terminal()) | |||
| 73 | + | assert.Equal(t, domain.AppStepPending, saved[0].Steps[0].State) | |||
| 74 | + | images.AssertNotCalled(t, "ResolveDigest", mock.Anything, mock.Anything) | |||
| 75 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 76 | + | }) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // TestStartDeploy_SameKeyHandsOneOwner proves the second phase is only ever | |||
| 80 | + | // handed to the single claimer: a repeated key reports Owned false, carries | |||
| 81 | + | // the stored journal, and replays with an explicit conflict instead of a | |||
| 82 | + | // second execution. | |||
| 83 | + | func TestStartDeploy_SameKeyHandsOneOwner(t *testing.T) { | |||
| 84 | + | ctx := context.Background() | |||
| 85 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 86 | + | rev := mockRevision() | |||
| 87 | + | ||||
| 88 | + | stored := domain.AppOperation{ | |||
| 89 | + | Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 90 | + | Request: domain.AppOperationRequestFor("deploy", "blog", "", ""), | |||
| 91 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}}, | |||
| 92 | + | } | |||
| 93 | + | state.EXPECT().Recover(mock.Anything).Return(nil).Maybe() | |||
| 94 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 95 | + | state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(domain.AppOperation{}, true, nil).Once() | |||
| 96 | + | state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).Return(stored, false, nil).Once() | |||
| 97 | + | ||||
| 98 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 99 | + | first, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"}) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | require.True(t, first.Owned) | |||
| 102 | + | ||||
| 103 | + | second, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"}) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.False(t, second.Owned, "the key is never handed to a second owner") | |||
| 106 | + | require.Equal(t, "k1", second.Claim.Journal.Op) | |||
| 107 | + | require.ErrorIs(t, second.ReplayError(), domain.ErrAppStateConflict) | |||
| 108 | + | ||||
| 109 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // expectHeadlessDeploy wires one full sequential replacement of a headless | |||
| 113 | + | // service whose only effect is one container replacement, and records every | |||
| 114 | + | // journal write so the caller can assert claim-before-execution ordering. | |||
| 115 | + | func expectHeadlessDeploy( | |||
| 116 | + | state *outmocks.MockAppState, | |||
| 117 | + | runtime *outmocks.MockContainerRuntime, | |||
| 118 | + | images *outmocks.MockImageResolver, | |||
| 119 | + | secrets *outmocks.MockSecretProvider, | |||
| 120 | + | ) (domain.AppDesiredRevision, *[]domain.AppOperation) { | |||
| 121 | + | svcSpec := webService() | |||
| 122 | + | svcSpec.HTTP = nil | |||
| 123 | + | svcSpec.Readiness = domain.AppReadiness{} | |||
| 124 | + | rev := domain.AppDesiredRevision{ | |||
| 125 | + | Revision: "rev-1", App: "blog", | |||
| 126 | + | Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svcSpec}}, | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | saved := &[]domain.AppOperation{} | |||
| 130 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error { | |||
| 131 | + | *saved = append(*saved, op) | |||
| 132 | + | return nil | |||
| 133 | + | }).Maybe() | |||
| 134 | + | state.EXPECT().Recover(mock.Anything).Return(nil) | |||
| 135 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 136 | + | images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return(restartTestDigest, nil) | |||
| 137 | + | secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil) | |||
| 138 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil) | |||
| 139 | + | state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil) | |||
| 140 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 141 | + | state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{ | |||
| 142 | + | App: "blog", Services: map[string]domain.AppEffectiveService{}, | |||
| 143 | + | }, true, nil) | |||
| 144 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 145 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil) | |||
| 146 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil) | |||
| 147 | + | state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil) | |||
| 148 | + | state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil) | |||
| 149 | + | return rev, saved | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // TestExecuteDeploy_RecordsTerminalOutcome proves the second phase executes | |||
| 153 | + | // the already claimed operation and records its terminal success, while the | |||
| 154 | + | // claim itself stayed non-terminal up to execution. | |||
| 155 | + | func TestExecuteDeploy_RecordsTerminalOutcome(t *testing.T) { | |||
| 156 | + | ctx := context.Background() | |||
| 157 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 158 | + | _, saved := expectHeadlessDeploy(state, runtime, images, secrets) | |||
| 159 | + | ||||
| 160 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 161 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 162 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 163 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 164 | + | func(context.Context, string) error { return nil }, | |||
| 165 | + | )) | |||
| 166 | + | ||||
| 167 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | require.True(t, started.Owned) | |||
| 170 | + | require.Len(t, *saved, 1) | |||
| 171 | + | require.False(t, (*saved)[0].Terminal(), "the claim is non-terminal before execution") | |||
| 172 | + | ||||
| 173 | + | result, err := svc.ExecuteDeploy(ctx, started.Claim) | |||
| 174 | + | ||||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | require.NotNil(t, result) | |||
| 177 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 178 | + | last := (*saved)[len(*saved)-1] | |||
| 179 | + | require.True(t, last.Terminal(), "the second phase records a terminal outcome") | |||
| 180 | + | assert.Equal(t, domain.AppOutcomeSuccess, last.Outcome) | |||
| 181 | + | runtime.AssertNumberOfCalls(t, "CreateContainer", 1) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // TestExecuteDeploy_RecordsTerminalFailure proves a preflight that fails | |||
| 185 | + | // during the second phase records the terminal failure in the claimed | |||
| 186 | + | // journal and runs no workload. | |||
| 187 | + | func TestExecuteDeploy_RecordsTerminalFailure(t *testing.T) { | |||
| 188 | + | ctx := context.Background() | |||
| 189 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 190 | + | rev := mockRevision() | |||
| 191 | + | ||||
| 192 | + | var saved []domain.AppOperation | |||
| 193 | + | state.EXPECT().Recover(mock.Anything).Return(nil).Maybe() | |||
| 194 | + | state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) error { | |||
| 195 | + | saved = append(saved, op) | |||
| 196 | + | return nil | |||
| 197 | + | }).Maybe() | |||
| 198 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 199 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil) | |||
| 200 | + | images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError) | |||
| 201 | + | ||||
| 202 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 203 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | require.True(t, started.Owned) | |||
| 206 | + | ||||
| 207 | + | result, err := svc.ExecuteDeploy(ctx, started.Claim) | |||
| 208 | + | ||||
| 209 | + | require.ErrorIs(t, err, domain.ErrAppImageUnresolvable) | |||
| 210 | + | require.NotNil(t, result) | |||
| 211 | + | require.NotEmpty(t, saved) | |||
| 212 | + | last := saved[len(saved)-1] | |||
| 213 | + | require.True(t, last.Terminal()) | |||
| 214 | + | assert.Equal(t, domain.AppOutcomeFailed, last.Outcome) | |||
| 215 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | // TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim proves the store is | |||
| 219 | + | // authoritative: a claim whose durable record was terminalized by another | |||
| 220 | + | // actor between the phases replays that outcome and never executes, even | |||
| 221 | + | // though the in-process claim still carries a non-terminal journal. | |||
| 222 | + | func TestExecuteDeploy_TerminalStoreRecordBeatsStaleClaim(t *testing.T) { | |||
| 223 | + | ctx := context.Background() | |||
| 224 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 225 | + | rev := mockRevision() | |||
| 226 | + | ||||
| 227 | + | state.EXPECT().Recover(mock.Anything).Return(nil).Maybe() | |||
| 228 | + | state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil) | |||
| 229 | + | state.EXPECT().ClaimOperation(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, op domain.AppOperation) (domain.AppOperation, bool, error) { | |||
| 230 | + | return op, true, nil | |||
| 231 | + | }).Once() | |||
| 232 | + | ||||
| 233 | + | svc := keyedService(t, state, runtime, images, secrets) | |||
| 234 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "k1"}) | |||
| 235 | + | require.NoError(t, err) | |||
| 236 | + | require.True(t, started.Owned) | |||
| 237 | + | require.False(t, started.Claim.Journal.Terminal(), "the in-process claim is still non-terminal") | |||
| 238 | + | ||||
| 239 | + | // Another actor settled the key in the store: the stale in-memory copy | |||
| 240 | + | // must not drive execution. | |||
| 241 | + | expectStoredOperation(state, domain.AppOperation{ | |||
| 242 | + | Op: "k1", Kind: "deploy", App: "blog", InputRevision: "rev-1", | |||
| 243 | + | Outcome: domain.AppOutcomeSuccess, | |||
| 244 | + | Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepSucceeded}}, | |||
| 245 | + | }) | |||
| 246 | + | ||||
| 247 | + | result, err := svc.ExecuteDeploy(ctx, started.Claim) | |||
| 248 | + | ||||
| 249 | + | require.NoError(t, err) | |||
| 250 | + | require.NotNil(t, result) | |||
| 251 | + | assert.Equal(t, domain.AppOutcomeSuccess, result.Outcome) | |||
| 252 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 253 | + | runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything) | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | // TestExecuteDeploy_CancellationLeavesRecoverableClaim proves cancellation or | |||
| 257 | + | // shutdown during the second phase leaves the durable claim non-terminal, and | |||
| 258 | + | // the existing reconciliation converges it on the next mutation instead of | |||
| 259 | + | // ever re-executing it. | |||
| 260 | + | func TestExecuteDeploy_CancellationLeavesRecoverableClaim(t *testing.T) { | |||
| 261 | + | ctx := context.Background() | |||
| 262 | + | store := newTestStore(t) | |||
| 263 | + | _, desired := seedReplaceableApp(t, ctx, store) | |||
| 264 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 265 | + | ||||
| 266 | + | barrier := outmocks.NewMockGCBarrier(t) | |||
| 267 | + | lease := outmocks.NewMockGCLease(t) | |||
| 268 | + | barrier.EXPECT().AcquireShared(mock.Anything).Return(nil, context.Canceled).Once() | |||
| 269 | + | barrier.EXPECT().AcquireShared(mock.Anything).Return(lease, nil).Once() | |||
| 270 | + | lease.EXPECT().Release().Return().Once() | |||
| 271 | + | ||||
| 272 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 273 | + | State: store, Runtime: runtime, | |||
| 274 | + | Images: outmocks.NewMockImageResolver(t), Secrets: outmocks.NewMockSecretProvider(t), | |||
| 275 | + | }, zerowrap.Default()).WithGCBarrier(barrier) | |||
| 276 | + | ||||
| 277 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-cancel", Revision: desired.Revision}) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | require.True(t, started.Owned) | |||
| 280 | + | ||||
| 281 | + | claimed, err := store.LoadOperation(ctx, "blog", "op-cancel") | |||
| 282 | + | require.NoError(t, err) | |||
| 283 | + | require.False(t, claimed.Terminal(), "the claim is durable and non-terminal before execution") | |||
| 284 | + | ||||
| 285 | + | cancelled, cancel := context.WithCancel(ctx) | |||
| 286 | + | cancel() | |||
| 287 | + | _, err = svc.ExecuteDeploy(cancelled, started.Claim) | |||
| 288 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 289 | + | ||||
| 290 | + | interrupted, err := store.LoadOperation(ctx, "blog", "op-cancel") | |||
| 291 | + | require.NoError(t, err) | |||
| 292 | + | require.False(t, interrupted.Terminal(), "cancellation leaves the claim non-terminal for recovery") | |||
| 293 | + | ||||
| 294 | + | // The next mutation reconciles the interrupted claim before claiming its | |||
| 295 | + | // own key: the operation is finalized and never executes twice. | |||
| 296 | + | _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision}) | |||
| 297 | + | require.NoError(t, err) | |||
| 298 | + | ||||
| 299 | + | reconciled, err := store.LoadOperation(ctx, "blog", "op-cancel") | |||
| 300 | + | require.NoError(t, err) | |||
| 301 | + | require.True(t, reconciled.Terminal()) | |||
| 302 | + | assert.Equal(t, domain.AppOutcomeFailed, reconciled.Outcome) | |||
| 303 | + | ||||
| 304 | + | _, err = svc.ExecuteDeploy(ctx, deployment.DeployClaim{App: "blog", Op: "op-cancel"}) | |||
| 305 | + | require.ErrorIs(t, err, domain.ErrAppStateConflict, "a settled key never re-executes") | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // TestDeploy_IsStartDeployThenExecuteDeploy proves the synchronous entry point | |||
| 309 | + | // is still the composition of both phases: it journals a non-terminal claim | |||
| 310 | + | // before any effect and a terminal outcome after the replacement. | |||
| 311 | + | func TestDeploy_IsStartDeployThenExecuteDeploy(t *testing.T) { | |||
| 312 | + | ctx := context.Background() | |||
| 313 | + | state, runtime, images, secrets := mockDeps(t) | |||
| 314 | + | _, saved := expectHeadlessDeploy(state, runtime, images, secrets) | |||
| 315 | + | ||||
| 316 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 317 | + | State: state, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 318 | + | }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 319 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 320 | + | func(context.Context, string) error { return nil }, | |||
| 321 | + | )) | |||
| 322 | + | ||||
| 323 | + | result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"}) | |||
| 324 | + | ||||
| 325 | + | require.NoError(t, err) | |||
| 326 | + | require.NotNil(t, result) | |||
| 327 | + | assert.Equal(t, "deployed", result.Services["web"].Result) | |||
| 328 | + | require.NotEmpty(t, *saved) | |||
| 329 | + | require.False(t, (*saved)[0].Terminal(), "Deploy journals the claim before execution") | |||
| 330 | + | require.True(t, (*saved)[len(*saved)-1].Terminal(), "Deploy records the terminal outcome") | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | // TestStartDeploy_LiveClaimSurvivesForegroundReconciliation pins the | |||
| 334 | + | // StartDeploy/ExecuteDeploy hand-off window: while an owner is between the | |||
| 335 | + | // claim and the coordinator (blocked before its GC lease), a same-key and a | |||
| 336 | + | // different-key foreground mutation must not reconcile, terminalize, or | |||
| 337 | + | // re-claim the live operation. The single owner then executes exactly the | |||
| 338 | + | // revision it claimed. | |||
| 339 | + | func TestStartDeploy_LiveClaimSurvivesForegroundReconciliation(t *testing.T) { | |||
| 340 | + | ctx := context.Background() | |||
| 341 | + | store := newTestStore(t) | |||
| 342 | + | _, desired := seedReplaceableApp(t, ctx, store) | |||
| 343 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 344 | + | images := outmocks.NewMockImageResolver(t) | |||
| 345 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 346 | + | ||||
| 347 | + | // One full headless replacement, the only effects the live owner may run. | |||
| 348 | + | images.EXPECT().ResolveDigest(mock.Anything, "docker.io/example/web:1.4.2").Return(restartTestDigest, nil).Once() | |||
| 349 | + | runtime.EXPECT().InspectImageVolumes(mock.Anything, "docker.io/example/web:1.4.2").Return(nil, nil).Once() | |||
| 350 | + | expectNetworkProvision(runtime, "app-blog", 1) | |||
| 351 | + | runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once() | |||
| 352 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once() | |||
| 353 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once() | |||
| 354 | + | runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once() | |||
| 355 | + | runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return( | |||
| 356 | + | []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18081, Protocol: domain.NetworkProtocolTCP}}, nil).Once() | |||
| 357 | + | ||||
| 358 | + | // Block ExecuteDeploy on its GC lease, before it takes the coordinator. | |||
| 359 | + | entered := make(chan struct{}) | |||
| 360 | + | release := make(chan struct{}) | |||
| 361 | + | barrier := outmocks.NewMockGCBarrier(t) | |||
| 362 | + | lease := outmocks.NewMockGCLease(t) | |||
| 363 | + | barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) { | |||
| 364 | + | close(entered) | |||
| 365 | + | <-release | |||
| 366 | + | }).Return(lease, nil).Once() | |||
| 367 | + | lease.EXPECT().Release().Return().Once() | |||
| 368 | + | ||||
| 369 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 370 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 371 | + | }, zerowrap.Default()).WithGCBarrier(barrier).WithProbeDeps(deployment.NewTestProbeDeps(runtime, | |||
| 372 | + | func(context.Context, string, string) (int, error) { return 200, nil }, | |||
| 373 | + | func(context.Context, string) error { return nil }, | |||
| 374 | + | )) | |||
| 375 | + | ||||
| 376 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision}) | |||
| 377 | + | require.NoError(t, err) | |||
| 378 | + | require.True(t, started.Owned, "the first claim owns the operation") | |||
| 379 | + | ||||
| 380 | + | done := make(chan error, 1) | |||
| 381 | + | go func() { | |||
| 382 | + | _, execErr := svc.ExecuteDeploy(ctx, started.Claim) | |||
| 383 | + | done <- execErr | |||
| 384 | + | }() | |||
| 385 | + | select { | |||
| 386 | + | case <-entered: | |||
| 387 | + | case <-time.After(5 * time.Second): | |||
| 388 | + | t.Fatal("timed out waiting for ExecuteDeploy to block before the coordinator") | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // Same key while the owner is live: replay, never a second owner. | |||
| 392 | + | same, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-live", Revision: desired.Revision}) | |||
| 393 | + | require.NoError(t, err) | |||
| 394 | + | require.False(t, same.Owned, "a same-key replay never owns the operation twice") | |||
| 395 | + | require.ErrorIs(t, same.ReplayError(), domain.ErrAppStateConflict, "the live claim was not terminalized") | |||
| 396 | + | ||||
| 397 | + | // Different key while the owner is live: the unfinished-operation guard | |||
| 398 | + | // refuses, so no second claim is written. | |||
| 399 | + | _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-other", Revision: "rev-0"}) | |||
| 400 | + | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 401 | + | ||||
| 402 | + | live, err := store.LoadOperation(ctx, "blog", "op-live") | |||
| 403 | + | require.NoError(t, err) | |||
| 404 | + | require.False(t, live.Terminal(), "a live operation is never terminalized by a foreground mutation") | |||
| 405 | + | _, err = store.LoadOperation(ctx, "blog", "op-other") | |||
| 406 | + | require.ErrorIs(t, err, domain.ErrAppOperationNotFound, "no second claim was written") | |||
| 407 | + | ||||
| 408 | + | close(release) | |||
| 409 | + | require.NoError(t, <-done) | |||
| 410 | + | ||||
| 411 | + | final, err := store.LoadOperation(ctx, "blog", "op-live") | |||
| 412 | + | require.NoError(t, err) | |||
| 413 | + | require.True(t, final.Terminal()) | |||
| 414 | + | assert.Equal(t, domain.AppOutcomeSuccess, final.Outcome) | |||
| 415 | + | assert.Equal(t, desired.Revision, final.InputRevision, "the owner executed the revision it claimed, not the stale one") | |||
| 416 | + | runtime.AssertNumberOfCalls(t, "CreateContainer", 1) | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | // TestAbandonDeploy_SettlesClaimWithoutStarting proves a claimed but never | |||
| 420 | + | // scheduled operation can be settled safely: the journal becomes terminal, the | |||
| 421 | + | // in-process live marker is released, and a later claim succeeds instead of | |||
| 422 | + | // being refused as a permanently unfinished operation. No runtime effect is | |||
| 423 | + | // touched. | |||
| 424 | + | func TestAbandonDeploy_SettlesClaimWithoutStarting(t *testing.T) { | |||
| 425 | + | ctx := context.Background() | |||
| 426 | + | store := newTestStore(t) | |||
| 427 | + | _, desired := seedReplaceableApp(t, ctx, store) | |||
| 428 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 429 | + | images := outmocks.NewMockImageResolver(t) | |||
| 430 | + | secrets := outmocks.NewMockSecretProvider(t) | |||
| 431 | + | ||||
| 432 | + | svc := deployment.NewService(deployment.Deps{ | |||
| 433 | + | State: store, Runtime: runtime, Images: images, Secrets: secrets, | |||
| 434 | + | }, zerowrap.Default()) | |||
| 435 | + | ||||
| 436 | + | started, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-abandon", Revision: desired.Revision}) | |||
| 437 | + | require.NoError(t, err) | |||
| 438 | + | require.True(t, started.Owned) | |||
| 439 | + | ||||
| 440 | + | // While the claim is live, a different key is refused as unfinished. | |||
| 441 | + | _, err = svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-blocked", Revision: desired.Revision}) | |||
| 442 | + | require.ErrorIs(t, err, domain.ErrAppStateConflict) | |||
| 443 | + | ||||
| 444 | + | require.NoError(t, svc.AbandonDeploy(ctx, started.Claim)) | |||
| 445 | + | ||||
| 446 | + | settled, err := store.LoadOperation(ctx, "blog", "op-abandon") | |||
| 447 | + | require.NoError(t, err) | |||
| 448 | + | require.True(t, settled.Terminal(), "a settled claim must not remain in flight") | |||
| 449 | + | assert.Equal(t, domain.AppOutcomeFailed, settled.Outcome) | |||
| 450 | + | ||||
| 451 | + | // The live marker is gone and the journal terminal, so the next claim is | |||
| 452 | + | // accepted cleanly instead of hanging on a false live operation. | |||
| 453 | + | next, err := svc.StartDeploy(ctx, deployment.DeployInput{App: "blog", Op: "op-next", Revision: desired.Revision}) | |||
| 454 | + | require.NoError(t, err) | |||
| 455 | + | require.True(t, next.Owned, "a settled claim never blocks the next owner") | |||
| 456 | + | require.NoError(t, svc.AbandonDeploy(ctx, next.Claim)) | |||
| 457 | + | ||||
| 458 | + | runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything) | |||
| 459 | + | } | |||
M wiki/tutorials/first-deploy.md +1 -1
| @@ -155,7 +155,7 @@ docker tag my-first-app registry.mydomain.com/my-first-app:latest | |||||
| 155 | docker push registry.mydomain.com/my-first-app:latest | 155 | docker push registry.mydomain.com/my-first-app:latest | ||
| 156 | ``` | 156 | ``` | ||
| 157 | 157 | ||||
| 158 | - | Gordon automatically deploys the update with zero downtime. | 158 | + | Gordon replaces the running container: it withdraws traffic, stops and removes the old container, starts the new one, waits for it to pass its readiness probe, and routes traffic to it. Expect a short interruption at that point. |
| 159 | 159 | ||||
| 160 | ## Next Steps | 160 | ## Next Steps | ||
| 161 | 161 | ||||