detached a6eb480

feat(apps): fix migration friction and add internal HTTP interfaces (#282)

* fix(apps): reduce migration friction

* feat(apps): add internal HTTP interfaces

* fix(apps): harden internal deployment lifecycle

* fix(apps): preserve targeted deployment state (#283)

* fix(apps): preserve networks in active diffs

* fix(apps): allow safe targeted deployments

* fix(apps): preserve non-targeted services

* fix(apps): parse shared network tables
commit
a6eb48093ffda254f367ceeb0268a5f54fd6cbc4
author
bnema · · 2026-09-13 11:00 UTC
committer
GitHub · · 2026-09-13 11:00 UTC
parent
25270e4
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
94 files · +6830 -275
M .goreleaser.yaml +1 -1
@@ -23,7 +23,7 @@ builds:
2323 env:
2424 - CGO_ENABLED=0
2525 ldflags:
26- - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
26+ - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.dirty={{.IsGitDirty}}
2727
2828 # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup)
2929 # dockers:
M Dockerfile +7 -1
@@ -5,6 +5,11 @@ ARG BUILDPLATFORM
55 ARG VERSION=dev
66 ARG COMMIT=unknown
77 ARG BUILD_DATE=unknown
8+# Whether the source checkout had uncommitted changes. The Makefile and the
9+# release tooling pass an explicit true/false value. Because .git is excluded
10+# from the build context (see .dockerignore), an ad-hoc build that passes no
11+# build-arg reports "unknown" rather than falsely claiming a clean checkout.
12+ARG DIRTY=unknown
813
914 FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder
1015
@@ -13,6 +18,7 @@ ARG TARGETARCH=amd64
1318 ARG VERSION
1419 ARG COMMIT
1520 ARG BUILD_DATE
21+ARG DIRTY
1622
1723 RUN apk add --no-cache git ca-certificates tzdata
1824
@@ -25,7 +31,7 @@ COPY . .
2531
2632 RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \
2733 -trimpath \
28- -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
34+ -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X main.dirty=${DIRTY}" \
2935 -o /gordon ./main.go
3036
3137 FROM alpine:3.22
M Makefile +8 -2
@@ -10,12 +10,17 @@ ENGINE := podman
1010 VERSION := $(shell git describe --tags --always --dirty)
1111 COMMIT := $(shell git rev-parse --short HEAD)
1212 BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')
13+# Probe the working tree directly (tracked edits and untracked files) instead
14+# of parsing VERSION, so an overridden VERSION cannot mask a dirty checkout.
15+# `git describe --dirty`, which VERSION uses, only reflects tracked edits.
16+DIRTY := $(if $(shell git status --porcelain --untracked-files=normal 2>/dev/null),true,false)
1317
1418 # Build flags
1519 LDFLAGS := -s -w \
1620 -X main.version=$(VERSION) \
1721 -X main.commit=$(COMMIT) \
18- -X main.date=$(BUILD_DATE)
22+ -X main.date=$(BUILD_DATE) \
23+ -X main.dirty=$(DIRTY)
1924
2025 # Architectures
2126 ARCHS := amd64 arm64
@@ -83,7 +88,7 @@ build: ## Build binaries for linux (amd64 and arm64)
8388 @echo "Building Go binaries..."
8489 @mkdir -p $(DIST_DIR)
8590 @rm -f $(DIST_DIR)/*
86- @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE)"
91+ @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE), dirty $(DIRTY)"
8792 @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
8893 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
8994 @echo "Go binaries built successfully"
@@ -105,6 +110,7 @@ build-push: ## Build and push Docker images
105110 --build-arg VERSION="$(VERSION)" \
106111 --build-arg COMMIT="$(COMMIT)" \
107112 --build-arg BUILD_DATE="$(BUILD_DATE)" \
113+ --build-arg DIRTY="$(DIRTY)" \
108114 -t $(REPO):$(TAG)-$$arch .; \
109115 $(ENGINE) push $(REPO):$(TAG)-$$arch; \
110116 done
M docs/cli/apps.md +94 -5
@@ -29,6 +29,7 @@ See [Local-only Mode](../config/auth.md#local-only-mode).
2929 | Subcommand | Description |
3030 |------------|-------------|
3131 | `apply` | Validate and persist an app manifest |
32+| `operations` | Inspect app operation journals |
3233 | `list` | List applications |
3334 | `show` | Show desired and active state for an app |
3435 | `diff` | Show the normalized desired-vs-active diff |
@@ -81,6 +82,26 @@ stopped intent, and the last operation with its outcome.
8182
8283 ---
8384
85+## gordon apps operations show
86+
87+```bash
88+gordon apps operations show APP --key KEY [--json]
89+```
90+
91+Recovers one operation journal entry by its client-generated request key.
92+`--key` is required. Use it to re-query an ambiguous mutation outcome before
93+retrying: repeating the same key replays the recorded result, while a key
94+reused for a different request is refused.
95+
96+### Flags
97+
98+| Flag | Description |
99+|------|-------------|
100+| `--key` | Request key (required) |
101+| `--json` | Output as JSON |
102+
103+---
104+
84105 ## gordon apps diff
85106
86107 ```bash
@@ -93,16 +114,84 @@ Shows the normalized desired-vs-active diff (`added`, `removed`, `changed`).
93114
94115 ## gordon apps secrets
95116
96-Values are accepted via `KEY=VALUE` arguments (discouraged: shell history),
97-`--stdin` (preferred), or an interactive prompt. Names must already exist in
98-desired or active state. Only key names are ever echoed back — never values.
117+Values are accepted via `KEY=VALUE` arguments (discouraged: shell history) or
118+stdin. Names must already exist in desired or active state. Only key names are
119+ever echoed back — never values. Secrets are service-scoped: `--service` is
120+required for `set` and `delete`, and optional for `list` where it filters to
121+one service.
122+
123+### gordon apps secrets list
124+
125+```bash
126+gordon apps secrets list APP [--service SVC] [--json]
127+```
128+
129+Lists registration metadata for an app's secrets — service, key, name, source,
130+and presence — never secret values. `--service` filters to one service.
131+
132+#### Flags
133+
134+| Flag | Description |
135+|------|-------------|
136+| `--service` | Filter by service |
137+| `--json` | Output as JSON |
138+
139+#### JSON Output
140+
141+```json
142+[
143+ {"service": "web", "key": "DATABASE_URL", "name": "app_database_url", "source": "desired", "presence": "unknown"}
144+]
145+```
146+
147+### gordon apps secrets set
148+
149+```bash
150+gordon apps secrets set APP --service SVC KEY=VALUE… [--json]
151+gordon apps secrets set APP --service SVC --stdin [--json]
152+gordon apps secrets set APP --service SVC --stdin --key KEY [--json]
153+```
154+
155+Reads values in one of three ways:
156+
157+- `KEY=VALUE` arguments: the value must be a single line of 1–65536 bytes.
158+- `--stdin`: reads `KEY=VALUE` lines; blank and whitespace-only lines are
159+ ignored and every non-blank value byte is preserved.
160+- `--stdin --key KEY`: reads one raw value for `KEY`; a single trailing newline
161+ is stripped and the value must still be a single non-empty line.
162+
163+`--key` requires `--stdin` and cannot be combined with `KEY=VALUE` arguments.
164+An empty value is rejected, so `KEY=` is invalid.
165+
166+#### Flags
167+
168+| Flag | Description |
169+|------|-------------|
170+| `--service` | Service the secrets belong to (required) |
171+| `--stdin` | Read `KEY=VALUE` lines, or one raw value with `--key` |
172+| `--key` | Secret key for single-value stdin mode |
173+| `--json` | Output as JSON |
174+
175+#### JSON Output
176+
177+```json
178+{"app": "blog", "service": "web", "keys": ["DATABASE_URL"]}
179+```
180+
181+### gordon apps secrets delete
99182
100183 ```bash
101-gordon apps secrets set APP --service SVC KEY=VALUE… [--stdin] [--json]
102184 gordon apps secrets delete APP KEY --service SVC [--json]
103185 ```
104186
105-`--service` is required: secrets are service-scoped.
187+Deletes the registered value for `KEY`.
188+
189+#### Flags
190+
191+| Flag | Description |
192+|------|-------------|
193+| `--service` | Service the secret belongs to (required) |
194+| `--json` | Output as JSON |
106195
107196 ---
108197
M docs/cli/backup.md +4 -0
@@ -32,6 +32,10 @@ volume = ["data"]
3232 A declared database or volume that the service's backup declaration does not
3333 reference is not a backup target.
3434
35+Administrative bind mounts are never backup targets. `[service.backup]` accepts
36+only declared databases and volumes, and Gordon never archives an
37+operator-owned host path exposed through a bind.
38+
3539 ## gordon backups
3640
3741 Database backups are logical PostgreSQL backups made with `pg_dump`.
M docs/cli/config.md +46 -0
@@ -12,6 +12,7 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1212 | Subcommand | Description |
1313 |------------|-------------|
1414 | `show` | Show server configuration |
15+| `validate` | Statically validate a local configuration file |
1516
1617 ---
1718
@@ -59,6 +60,51 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
5960
6061 External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
6162
63+---
64+
65+## gordon config validate
66+
67+Statically validates a candidate configuration file before it is installed.
68+This command is local-only: `--remote` is rejected. Validation is static —
69+runtime, ACTIVE-state, secret, pull, and listener checks are not performed.
70+A file that fails validation exits non-zero; `--json` is still written first.
71+
72+```bash
73+gordon config validate --file ./gordon.toml
74+gordon config validate --file ./gordon.toml --json
75+```
76+
77+### Flags
78+
79+| Flag | Description |
80+|------|-------------|
81+| `--file` | Local candidate configuration file (required) |
82+| `--json` | Output as JSON |
83+
84+### JSON Output
85+
86+On success:
87+
88+```json
89+{
90+ "valid": true,
91+ "diagnostics": [],
92+ "scope": "static"
93+}
94+```
95+
96+On failure, `valid` is `false` and `diagnostics` carries the failure:
97+
98+```json
99+{
100+ "valid": false,
101+ "diagnostics": [
102+ {"code": "config-invalid", "key": "", "message": "configuration failed static validation"}
103+ ],
104+ "scope": "static"
105+}
106+```
107+
62108 ## Related
63109
64110 - [CLI Overview](./index.md)
M docs/cli/index.md +5 -2
@@ -24,9 +24,9 @@ Management commands use the authenticated daemon API. By default they connect th
2424
2525 | Command | Description | Documentation |
2626 |---------|-------------|---------------|
27-| `gordon apps` | Manage applications (apply, deploy, lifecycle) | [apps](./apps.md) |
27+| `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
2828 | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |
29-| `gordon config show` | Show server configuration | [config](./config.md) |
29+| `gordon config` | Show and validate server configuration | [config](./config.md) |
3030 | `gordon images` | List and prune images | [images](./images.md) |
3131 | `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) |
3232 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
@@ -67,6 +67,9 @@ gordon apps restart blog
6767 gordon apps stop blog
6868 gordon apps start blog
6969 gordon apps remove blog
70+gordon apps operations show blog --key <operation-key>
71+gordon apps secrets list blog
72+gordon config validate --file /path/to/gordon.toml
7073
7174 # Push an image (OCI transfer only; deploy separately)
7275 gordon push myapp --build --remote prod
M docs/config/apps.md +43 -3
@@ -30,7 +30,7 @@ APP_ENV = "production"
3030 name = "web"
3131 image = "gordon.mydomain.com/blog:1.4.2"
3232 command = ["node", "server.js"] # optional override
33-stop_grace = "10s" # optional, default 10s
33+stop_grace = "30s" # optional, default 30s
3434
3535 [service.readiness] # optional explicit readiness
3636 type = "http"
@@ -43,6 +43,10 @@ host = "blog.mydomain.com"
4343 port = 3000
4444 tls = "auto" # auto | always | never
4545
46+[[service.http]] # optional private interface
47+visibility = "internal" # public (default) | internal
48+port = 8080 # required; no host or tls
49+
4650 [service.secrets] # ENV name -> secret name (values in pass)
4751 DATABASE_URL = "database-url"
4852
@@ -51,6 +55,11 @@ name = "web-data"
5155 path = "/data"
5256 readonly = false
5357
58+[[service.bind]] # 0..n; name references [app_mounts.<name>]
59+name = "app-logs" # policy name, never a host path
60+path = "/var/log/app" # absolute container destination
61+readonly = true
62+
5463 [[service.database]] # explicit database declarations
5564 name = "main"
5665 type = "postgres"
@@ -69,6 +78,7 @@ services = ["web"]
6978
7079 - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness.
7180 - Service name: `[a-z0-9_.-]`, max 63, unique within the app.
81+- Bind name: `[a-z0-9_.-]`, max 63, must not contain `--`, unique within its service.
7282 - Removing an app ends its incarnation: the name is freed, volumes and secrets are archived as retained under the old internal UUID, desired/active/intent state is cleared, and the next apply allocates a new UUID. A new app reusing the name never adopts the old secrets or volumes.
7383
7484 ## Services
@@ -101,17 +111,45 @@ Image registry names and digest syntax are validated during manifest apply, reso
101111
102112 ## Volumes and Databases
103113
104-- Named volumes only; no bind mounts, no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible.
114+- Named volumes only, and no service-shared volumes. Replacement reuses volumes; removed services leave volumes retained and visible.
115+- Manifests never carry host paths. A `[[service.bind]]` references an `[app_mounts.<name>]` policy the operator declares in `gordon.toml`; a bind whose name has no matching policy is rejected at apply time. See [Volumes](./volumes.md) and [Security Hardening](./security-hardening.md).
116+- Named volumes are Gordon-owned app data: created, labeled, retained, backed up, and pruned by Gordon. Administrative binds are operator-owned host locations: Gordon mounts them and never creates, deletes, owns, backs up, or prunes them.
117+- `[[service.bind]]` requires an absolute, normalized container `path` and an optional `readonly`. The policy's `read_only` and the bind's `readonly` force read-only together: either side wins and a manifest can never weaken its policy. Reserved destinations (`/`, `/proc`, `/sys`, `/dev`, `/boot` and their children) and paths colliding with a declared volume or another bind are rejected at apply time.
118+- A service with any bind runs as a single writer, like a volume-backed service: replacements never serve two generations at once.
105119 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
106120 - `[[service.database]]` declares databases explicitly (no image inference). Only PostgreSQL is supported, and each database declares its own backup `schedule` (`hourly`, `daily`, `weekly`, or `monthly`).
107121 - `[service.backup]` lists the declared databases (`postgres`) and volumes (`volume`) that are backup targets. A declared database or volume that is not referenced here is never backed up. Schedules follow the declaration through deploys; stored backups are never deleted when declarations change.
108122
123+## HTTP Interfaces
124+
125+`[[service.http]]` declares 0..n HTTP interfaces per service. `visibility` is optional and defaults to `public` when the key is absent or empty.
126+
127+```toml
128+[[service.http]] # public: proxied by host
129+host = "blog.mydomain.com"
130+port = 3000
131+tls = "auto" # auto | always | never
132+
133+[[service.http]] # internal: not published (app private network only)
134+visibility = "internal"
135+port = 8080
136+```
137+
138+- `public`: `host` is required and must be a valid public hostname, and `tls` is `auto` (default when absent), `always`, or `never`. A public interface gets a proxy route, a global host reservation, a certificate target when TLS applies, and a `127.0.0.1` loopback backend publication.
139+- `internal`: creates no proxy route, no host reservation, no certificate target, and no host port publication, so it is never reachable through the host or proxy plane. `port` is required, `host` must be absent, and `tls` must be absent — any declared TLS value is rejected. It is still a declared TCP-capable container port for readiness metadata. Reachability follows network membership, not visibility: any container attached to a network this service joins can reach the port — sibling services on the app's own private network, and peers on any `[[network.shared]]` network the service is enrolled in.
140+
141+There is no `.internal` pseudo-domain: internal interfaces carry no hostname at all.
142+
143+A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected at apply time, because publication is socket-level. Duplicate internal HTTP ports within a service are rejected the same way.
144+
109145 ## Readiness
110146
111-`type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation.
147+`type = "http"` requires an origin-form `path` beginning with a single `/`. Absolute URLs, authority forms such as `@host:port`, scheme-relative paths, and control characters are rejected at apply time. The probe always dials the declared loopback backend, never follows redirects, ignores environment proxy settings, and is bounded per request and for the whole operation. A public or otherwise published interface keeps this loopback probe. An internal HTTP port is instead probed over the app private network by one bounded, short-lived helper, using HTTP or TCP according to the interface's `[service.readiness]` type; the internal port is never temporarily published to the host to probe it.
112148
113149 ## TLS
114150
151+TLS applies to public interfaces only; an internal interface never declares `tls`.
152+
115153 - `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled.
116154 - `always` is enforced: a plaintext request to an `always` host is redirected whenever an HTTPS endpoint exists, and refused with `421 Misdirected Request` when none does. The backend is never reached over plaintext.
117155 - `never` stays plaintext and is never issued an app certificate.
@@ -120,6 +158,8 @@ Image registry names and digest syntax are validated during manifest apply, reso
120158
121159 Each app gets a private network automatically. `[[network.shared]]` adds services to named shared networks, created/reused only within verified Gordon ownership. Deploy adds AND removes memberships without disconnecting unrelated services.
122160
161+Services of the same app communicate over that private network and resolve each other by service alias. Different apps are isolated by default; cross-app traffic requires both services to declare the same `[[network.shared]]` membership. See [Network Isolation](./network-isolation.md).
162+
123163 ## Strictness
124164
125165 Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions, unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence.
M docs/config/backups.md +1 -1
@@ -3,7 +3,7 @@
33 Gordon has two backup flows:
44
55 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.
6-- **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
6+- **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3. Administrative bind mounts are operator-owned host paths and are never archived.
77
88 Backups are identified by app, service, and the declared database or volume
99 (see [Apps](../config/apps.md)). Domains are routing addresses and are never a
M docs/config/network-isolation.md +8 -0
@@ -21,6 +21,14 @@ internal = false
2121
2222 Per-app isolation is declared in app files, not here: each app gets a private network automatically, and services can join named shared networks with `[[network.shared]]` (see [App Manifest](./apps.md)). Deploy adds AND removes memberships without disconnecting unrelated services. Shared networks are created/reused only within verified Gordon ownership.
2323
24+## Service-to-Service Networking
25+
26+Every app container joins an incarnation-owned private network. Services of the same app communicate over that network and can resolve each other by service alias.
27+
28+Different apps are isolated by default: a container on one app network cannot reach another app's network. Cross-app communication happens only when both services declare the same `[[network.shared]]` membership, which attaches the explicitly enrolled containers to a shared network.
29+
30+Readiness helpers never join shared networks and exist only on the target app's private network.
31+
2432 ## Inspecting Networks
2533
2634 View Gordon-managed networks:
M docs/config/reference.md +33 -0
@@ -150,6 +150,21 @@ auto_create = true # Auto-create volumes from Dockerfi
150150 prefix = "gordon" # Volume name prefix
151151 preserve = true # Keep volumes when containers are removed
152152
153+# =============================================================================
154+# ADMINISTRATIVE APP BIND MOUNTS
155+# =============================================================================
156+# A named policy is the only way an app manifest may reference a host path.
157+# App manifests declare [[service.bind]] with name = "<mount>"; direct host
158+# paths are always rejected, and the policy's allowlists are exact and
159+# non-empty. read_only on the policy and readonly on the bind both force
160+# read-only: either side wins and a bind never weakens its policy.
161+# [app_mounts.app-logs]
162+# source = "/srv/gordon/host-logs" # Required; absolute, normalized
163+# read_only = true # Force read-only for this mount
164+# allowed_apps = ["metrics-agent"] # Required; exact, non-empty
165+# allowed_services = ["web"] # Required; exact, non-empty
166+# root = "/srv/gordon" # Optional; default: parent of source
167+
153168 # REMOVED in v2.50: [routes], [attachments], [network_groups],
154169 # [[services]]-as-apps, [service_routes], [auto_route], [previews].
155170 # Declare apps in standalone files (see ./apps.md).
@@ -286,6 +301,11 @@ keep_last = 3 # Keep N newest tags per repository
286301 | `volumes.auto_create` | `true` | Auto-create volumes |
287302 | `volumes.prefix` | `"gordon"` | Volume prefix |
288303 | `volumes.preserve` | `true` | Keep volumes |
304+| `app_mounts.<name>.source` | none | Required host source of a named administrative bind; absolute and normalized. The only host path that mount may come from |
305+| `app_mounts.<name>.allowed_apps` | none | Required exact, non-empty app allowlist for this mount (no wildcard or empty-means-all form) |
306+| `app_mounts.<name>.allowed_services` | none | Required exact, non-empty service allowlist for this mount |
307+| `app_mounts.<name>.read_only` | `false` | Force every bind resolved under this policy read-only; a manifest bind can never weaken it |
308+| `app_mounts.<name>.root` | parent of `source` | Optional administrative boundary the resolved source must stay under |
289309 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
290310 | `services[].image` | none | Container image for enabled standalone services |
291311 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |
@@ -322,6 +342,19 @@ keep_last = 3 # Keep N newest tags per repository
322342
323343 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
324344
345+## App Manifest HTTP Interfaces
346+
347+`[[service.http]]` declares HTTP interfaces in a standalone app manifest (see [App Manifest](./apps.md)).
348+
349+| Key | Values | Default | Description |
350+|-----|--------|---------|-------------|
351+| `service.http[].visibility` | `"public"`, `"internal"` | `"public"` | `public` is proxied by host and may terminate TLS; `internal` is reachable only from the app's private network |
352+| `service.http[].host` | hostname | none | Required for public interfaces; must be absent for `visibility = "internal"` |
353+| `service.http[].port` | integer | none | Required container port for every interface |
354+| `service.http[].tls` | `"auto"`, `"always"`, `"never"` | `"auto"` | Public interfaces only; `visibility = "internal"` rejects any declared value |
355+
356+`visibility = "internal"` creates no proxy route, host reservation, certificate target, or host port publication. A container port declared by both an internal HTTP interface and an externally backed interface (public HTTP or TCP) is rejected, as are duplicate internal HTTP ports.
357+
325358 ## Environment Variables
326359
327360 All configuration options can be set via environment variables using the pattern:
M docs/config/security-hardening.md +35 -0
@@ -40,6 +40,29 @@ Use dedicated admin scopes:
4040 - `admin:volumes:read` for listing volumes.
4141 - `admin:volumes:write` for prune operations.
4242
43+## Administrative app bind mounts
44+
45+App manifests cannot name host paths. A host bind exists only when the operator declares a named policy in `gordon.toml`:
46+
47+```toml
48+[app_mounts.app-logs]
49+source = "/srv/gordon/host-logs"
50+read_only = true
51+allowed_apps = ["metrics-agent"] # exact, non-empty
52+allowed_services = ["web"] # exact, non-empty
53+root = "/srv/gordon" # optional boundary
54+```
55+
56+The manifest references only the policy name: `[[service.bind]] name = "app-logs"` with an absolute container `path`.
57+
58+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists, so least privilege is enforced by construction. There is no wildcard, prefix, or empty-means-all form.
59+- `source` is resolved through symlinks and must be a regular file or directory under `root`. When `root` is omitted, the source's parent directory is the boundary. Devices, sockets, FIFOs, and escaping symlinks are refused.
60+- Read-only precedence: `read_only` on the policy or `readonly` on the manifest bind forces the mount read-only; a manifest never weakens its policy.
61+- Destinations must be absolute, normalized, and outside reserved container paths (`/`, `/proc`, `/sys`, `/dev`, `/boot`, and their children).
62+- The policy is re-resolved immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed.
63+- `gordon serve` reload republishes validated policies atomically. An edit that fails validation is rejected and the previous policies stay live.
64+- Gordon never creates, deletes, chowns, backs up, or prunes the host path; ownership, permissions, and backup remain the operator's responsibility.
65+
4366 ## Pass import plaintext handling
4467
4568 With the `pass` backend, Gordon imports eligible plaintext `.env` files at startup. It removes a source file only after every entry is stored successfully. If a destination entry already exists or an import fails, Gordon fails closed and leaves the plaintext source in place for operator review.
@@ -102,6 +125,18 @@ internal = true
102125
103126 Every app container joins an incarnation-owned private network derived from the app's internal UUID; shared memberships come only from explicit `[[network.shared]]` declarations, and memory/CPU/PID limits apply on every create and recovery path.
104127
128+## Readiness helper containers
129+
130+Probing an internal HTTP port uses one bounded helper container per readiness attempt, removed immediately after. Gordon force-removes the helper under an independent cleanup context, including on failure and timeout. There is no operator configuration for the helper; its image and limits are fixed.
131+
132+- The helper image is digest-pinned and multi-arch: `alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc`. The image must be pre-provisioned and available offline on the target host, because the probe never pulls.
133+- The helper attaches only to the target app's private network. It never joins a shared network or a host network.
134+- It never publishes a host port and has no mounts, volumes, secrets, environment, or runtime socket.
135+- It runs non-root (uid/gid `65534`) with a read-only root filesystem, all capabilities dropped, `no-new-privileges`, and bounded CPU, memory, and PIDs.
136+- It targets the exact inspected container IP on that network, never a service alias, and revalidates the container's execution start before trusting the result; a restarted generation is discarded.
137+- A target that listens only on its own loopback fails readiness.
138+- Helpers left behind by an abrupt daemon or host stop are reclaimed by the next probe once they are older than ten minutes; a live session is never touched.
139+
105140 ## Registry exposure
106141
107142 - With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.
M docs/config/volumes.md +74 -4
@@ -12,9 +12,58 @@ name = "database-data"
1212 path = "/var/lib/postgresql/data"
1313 ```
1414
15-A volume name is unique within its service. Gordon creates an incarnation-owned runtime volume, records the app, app UUID, service, and logical volume ownership, and reuses it across replacement and restart. App manifests do not support bind mounts or sharing one volume between services.
15+A volume name is unique within its service. Gordon creates an incarnation-owned runtime volume, records the app, app UUID, service, and logical volume ownership, and reuses it across replacement and restart. Manifests never carry host paths, and sharing one volume between services is not supported. When an app must read an operator-approved host location, use a named administrative bind instead.
1616
17-Every Dockerfile `VOLUME` path must have a matching `[[service.volume]]` declaration. Deployment fails closed when an image declares an unmanaged volume.
17+## Administrative bind mounts
18+
19+Manifests cannot name host paths directly. The operator declares a named policy in `gordon.toml`; the manifest only references the policy name:
20+
21+```toml
22+# gordon.toml
23+[app_mounts.app-logs]
24+source = "/srv/gordon/host-logs" # required; the only host path this mount may come from
25+read_only = true
26+allowed_apps = ["metrics-agent"] # required; exact, non-empty
27+allowed_services = ["web"] # required; exact, non-empty
28+# root = "/srv/gordon" # optional administrative boundary
29+```
30+
31+```toml
32+# app manifest
33+[[service.bind]]
34+name = "app-logs" # must match [app_mounts.<name>]
35+path = "/var/lib/collector/host-logs" # container destination
36+readonly = true
37+```
38+
39+- `source` must be absolute, normalized, and resolve through symlinks to a regular file or directory that stays under `root`. When `root` is omitted, the source's parent directory is the boundary, so a symlinked source may resolve within it but never escape it.
40+- `allowed_apps` and `allowed_services` are exact, non-empty allowlists; there is no wildcard, prefix, or empty-means-all form.
41+- Read-only precedence: `read_only` on the policy or `readonly` on the bind forces the resolved mount read-only. A bind never weakens its policy.
42+- Gordon re-resolves the policy immediately before every container create, restart, and recovery. Removing a policy or an allowlist entry blocks future deploys; running containers keep their current mounts until redeployed. `gordon serve` reload republishes validated policies atomically, and a failing edit keeps the previous policies live.
43+- Gordon never creates, copies, deletes, chowns, backs up, or prunes the host path; ownership and permissions stay with the operator.
44+
45+A read-only log collector is the typical use. Its own state is a named volume; the host logs are exposed through a bind:
46+
47+```toml
48+name = "metrics-agent"
49+
50+[[service]]
51+name = "collector"
52+image = "registry.example.com/metrics/collector:2.1.0"
53+
54+[[service.volume]]
55+name = "collector-state"
56+path = "/var/lib/collector"
57+
58+[[service.bind]]
59+name = "app-logs"
60+path = "/var/lib/collector/host-logs"
61+readonly = true
62+```
63+
64+Collection is one-way: the collector reads operator-owned logs and cannot write back through the bind.
65+
66+Every Dockerfile `VOLUME` path must be mapped explicitly. A matching `[[service.volume]]` declaration maps it to a Gordon-owned volume; a `[[service.bind]]` whose container `path` equals the `VOLUME` path also satisfies the mapping, because the bind mounts over that image-declared volume. Any `VOLUME` path with neither mapping fails closed at deploy with an unmanaged-volume error. A bind destination may not collide with a declared volume path.
1867
1968 Runtime volume names are implementation details. Use `gordon volumes list` and ownership labels to inspect them; do not derive ownership from a name, rename volumes, or edit Gordon's ownership records.
2069
@@ -25,10 +74,31 @@ Ordinary app operations retain data:
2574 - deploy and restart reuse the service's volumes;
2675 - removing a service retains its volumes;
2776 - `gordon apps remove` retains volumes under the removed app's internal UUID;
28-- a new app that reuses the public name does not adopt retained volumes.
77+- a new app that reuses the public name does not adopt retained volumes;
78+- administrative binds are unaffected by app operations: their host paths are never created, adopted, or retained by Gordon.
2979
3080 Gordon does not automatically delete retained app volumes. Back up persistent data before any manual deletion, and use database-native backup and restore procedures for databases.
3181
82+## Manual migration
83+
84+Gordon does not copy data between volumes. Stop every container that can write to the source, verify a backup, create an empty target volume, and perform the copy through the runtime so rootless UID/GID mappings are preserved. For Podman, a disposable helper container is the preferred generic method:
85+
86+```bash
87+podman run --rm --pull=never --network=none \
88+ --volume "<source-volume>:/source:ro" \
89+ --volume "<target-volume>:/target" \
90+ docker.io/library/alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \
91+ sh -c 'cp -a /source/. /target/'
92+```
93+
94+Replace the literal `<...>` placeholders before running; they are quoted above so a shell cannot mistake them for redirection. The approved helper image is digest-pinned, must already be available locally, and runs without network access. If direct access to storage paths is unavoidable with rootless Podman, enter its user namespace:
95+
96+```bash
97+podman unshare cp -a "<source-path>"/. "<target-path>"/
98+```
99+
100+Do not use a plain root `cp` as the default procedure: host ownership IDs may not match the container's user namespace. Applications that rely on ACLs, extended attributes, sparse files, or database consistency need an application-specific export/restore or copy tool. Validate ownership and application behavior against the target, and retain the source volume until the migration is accepted.
101+
32102 ## Pruning
33103
34104 Use Gordon's ownership-aware command:
@@ -44,7 +114,7 @@ A volume is eligible only when all of these requirements hold:
44114 2. Runtime labels agree with the record's app, app incarnation UUID, and service.
45115 3. No container mounts the volume.
46116
47-Retained, attached, unknown, contradictory, and unrelated volumes survive. A `gordon.managed=true` label or a matching name is not sufficient deletion authority. With current lifecycle metadata, no operation marks app volumes `released`, so prune normally succeeds with no deletions.
117+Retained, attached, unknown, contradictory, and unrelated volumes survive. A `gordon.managed=true` label or a matching name is not sufficient deletion authority. Administrative binds are out of scope entirely: they are operator-owned host paths, no ownership record exists for them, and they are never candidates. With current lifecycle metadata, no operation marks app volumes `released`, so prune normally succeeds with no deletions.
48118
49119 > **Warning:** Do not use `docker volume prune`, `podman volume prune`, or equivalent runtime cleanup for Gordon data. Those commands bypass Gordon's ownership and retention checks and can delete an unmounted retained volume.
50120
M gordon.toml.example +14 -0
@@ -117,6 +117,20 @@ max_age = 28
117117 exclude_health_checks = true
118118 syslog_identifier = "gordon-access"
119119
120+# =============================================================================
121+# ADMINISTRATIVE APP BIND MOUNTS
122+# =============================================================================
123+# A named policy is the only way an app manifest may reference a host path.
124+# The manifest declares [[service.bind]] name = "<mount>"; direct host paths
125+# are always rejected. allowed_apps/allowed_services are exact and non-empty,
126+# and read_only/readonly force read-only: either side wins.
127+# [app_mounts.app-logs]
128+# source = "/srv/gordon/host-logs" # Required; absolute, normalized
129+# read_only = true # Either side forcing read-only wins
130+# allowed_apps = ["metrics-agent"] # Required; exact, non-empty
131+# allowed_services = ["web"] # Required; exact, non-empty
132+# root = "/srv/gordon" # Optional; defaults to the source's parent
133+
120134 [telemetry]
121135 enabled = false
122136 # endpoint = "http://localhost:4318"
M install.sh +3 -1
@@ -311,8 +311,10 @@ install_next() {
311311 NEXT_VERSION="next-${COMMIT}"
312312 BINARY="$TMP_DIR/gordon"
313313 echo "Building ${NEXT_VERSION} for ${OS}/${ARCH}..."
314+ # The source is a pristine tarball of the pinned commit, so it can
315+ # never be a dirty checkout: dirty is reported as false.
314316 if ! (cd "$SOURCE_DIR" && CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" GOTOOLCHAIN=local \
315- go build -trimpath -ldflags "-s -w -X main.version=${NEXT_VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" \
317+ go build -trimpath -ldflags "-s -w -X main.version=${NEXT_VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE} -X main.dirty=false" \
316318 -o "$BINARY" .); then
317319 echo "Error: Failed to build Gordon from source"
318320 exit 1
M internal/adapters/dto/admin_apps.go +9 -0
@@ -94,6 +94,15 @@ type AppRetainedDTO struct {
9494 Images []string `json:"images"`
9595 }
9696
97+// AppSecretMetadataDTO reports registration metadata without secret values.
98+type AppSecretMetadataDTO struct {
99+ Service string `json:"service"`
100+ Key string `json:"key"`
101+ Name string `json:"name"`
102+ Source string `json:"source"`
103+ Presence string `json:"presence"`
104+}
105+
97106 // AppDeployResponse reports deploy outcome with terminal results.
98107 // Effective and Retained are filled from current app state when the app
99108 // still exists; they are omitted when it does not.
M internal/adapters/in/appmanifest/parse.go +74 -16
@@ -7,6 +7,7 @@ package appmanifest
77
88 import (
99 "bytes"
10+ "errors"
1011 "fmt"
1112 "strings"
1213 "time"
@@ -18,10 +19,15 @@ import (
1819
1920 // rawManifest mirrors the frozen TOML schema for strict decoding.
2021 type rawManifest struct {
21- Name string `toml:"name"`
22- Env map[string]string `toml:"env"`
23- Services []rawService `toml:"service"`
24- Networks []rawSharedNetwork `toml:"network.shared"`
22+ Name string `toml:"name"`
23+ Env map[string]string `toml:"env"`
24+ Services []rawService `toml:"service"`
25+ Network rawNetwork `toml:"network"`
26+}
27+
28+// rawNetwork mirrors [network] and its [[network.shared]] children.
29+type rawNetwork struct {
30+ Shared []rawSharedNetwork `toml:"shared"`
2531 }
2632
2733 // rawService mirrors one [[service]] table.
@@ -37,6 +43,7 @@ type rawService struct {
3743 UDP []rawUDP `toml:"udp"`
3844 Secrets map[string]string `toml:"secrets"`
3945 Volumes []rawVolume `toml:"volume"`
46+ Binds []rawBind `toml:"bind"`
4047 Databases []rawDatabase `toml:"database"`
4148 Backup rawBackup `toml:"backup"`
4249 }
@@ -54,7 +61,11 @@ type rawReadiness struct {
5461 type rawHTTP struct {
5562 Host string `toml:"host"`
5663 Port int `toml:"port"`
57- TLS string `toml:"tls"`
64+ // TLS uses presence tracking because internal interfaces reject every
65+ // explicit value, including an empty string.
66+ TLS *string `toml:"tls"`
67+ // Visibility is empty when unset; the parser normalizes it to public.
68+ Visibility string `toml:"visibility"`
5869 }
5970
6071 // rawTCP mirrors [[service.tcp]].
@@ -78,6 +89,13 @@ type rawVolume struct {
7889 ReadOnly bool `toml:"readonly"`
7990 }
8091
92+// rawBind mirrors [[service.bind]].
93+type rawBind struct {
94+ Name string `toml:"name"`
95+ Path string `toml:"path"`
96+ ReadOnly bool `toml:"readonly"`
97+}
98+
8199 // rawDatabase mirrors [[service.database]].
82100 type rawDatabase struct {
83101 Name string `toml:"name"`
@@ -104,7 +122,7 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
104122 var raw rawManifest
105123 decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
106124 if err := decoder.Decode(&raw); err != nil {
107- return domain.AppSpec{}, nil, fmt.Errorf("%w: %v", domain.ErrInvalidAppSpec, err)
125+ return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err))
108126 }
109127 spec, err := toDomain(raw)
110128 if err != nil {
@@ -120,13 +138,31 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
120138 return spec, warnings, nil
121139 }
122140
141+func formatDecodeError(err error) string {
142+ var strictErr *toml.StrictMissingError
143+ if !errors.As(err, &strictErr) {
144+ return err.Error()
145+ }
146+ unknown := make([]string, 0, len(strictErr.Errors))
147+ for i := range strictErr.Errors {
148+ key := strictErr.Errors[i].Key()
149+ if len(key) > 0 {
150+ unknown = append(unknown, strings.Join(key, "."))
151+ }
152+ }
153+ if len(unknown) == 0 {
154+ return strictErr.Error()
155+ }
156+ return "unknown TOML fields or tables: " + strings.Join(unknown, ", ")
157+}
158+
123159 // toDomain maps raw TOML onto domain types with normalization and defaults.
124160 func toDomain(raw rawManifest) (domain.AppSpec, error) {
125161 spec := domain.AppSpec{
126162 Name: raw.Name,
127163 Env: map[string]string{},
128164 Services: make([]domain.AppService, 0, len(raw.Services)),
129- Networks: make([]domain.AppSharedNetwork, 0, len(raw.Networks)),
165+ Networks: make([]domain.AppSharedNetwork, 0, len(raw.Network.Shared)),
130166 }
131167 for key, value := range raw.Env {
132168 spec.Env[key] = value
@@ -138,7 +174,7 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
138174 }
139175 spec.Services = append(spec.Services, svc)
140176 }
141- for _, net := range raw.Networks {
177+ for _, net := range raw.Network.Shared {
142178 spec.Networks = append(spec.Networks, domain.AppSharedNetwork{
143179 Network: net.Network,
144180 Services: append([]string(nil), net.Services...),
@@ -148,6 +184,25 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
148184 return spec, nil
149185 }
150186
187+func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) {
188+ visibility := h.Visibility
189+ if visibility == "" {
190+ visibility = domain.AppVisibilityPublic
191+ }
192+ if visibility == domain.AppVisibilityInternal && h.TLS != nil {
193+ return domain.AppHTTPInterface{}, fmt.Errorf("%w: service %q internal http port %d must not declare tls", domain.ErrInvalidAppSpec, service, h.Port)
194+ }
195+ tls := ""
196+ if h.TLS != nil {
197+ tls = *h.TLS
198+ } else if visibility == domain.AppVisibilityPublic {
199+ tls = domain.AppTLSAuto
200+ }
201+ return domain.AppHTTPInterface{
202+ Host: domain.CanonicalHTTPHost(h.Host), Port: h.Port, TLS: tls, Visibility: visibility,
203+ }, nil
204+}
205+
151206 // toDomainService maps one raw service with defaults.
152207 func toDomainService(raw rawService) (domain.AppService, error) {
153208 svc := domain.AppService{
@@ -175,15 +230,11 @@ func toDomainService(raw rawService) (domain.AppService, error) {
175230 }
176231 svc.Readiness = readiness
177232 for _, h := range raw.HTTP {
178- tls := h.TLS
179- if tls == "" {
180- tls = domain.AppTLSAuto
233+ iface, err := toDomainHTTP(raw.Name, h)
234+ if err != nil {
235+ return domain.AppService{}, err
181236 }
182- svc.HTTP = append(svc.HTTP, domain.AppHTTPInterface{
183- Host: domain.CanonicalHTTPHost(h.Host),
184- Port: h.Port,
185- TLS: tls,
186- })
237+ svc.HTTP = append(svc.HTTP, iface)
187238 }
188239 for _, t := range raw.TCP {
189240 svc.TCP = append(svc.TCP, domain.AppTCPInterface{
@@ -209,6 +260,13 @@ func toDomainService(raw rawService) (domain.AppService, error) {
209260 ReadOnly: v.ReadOnly,
210261 })
211262 }
263+ for _, b := range raw.Binds {
264+ svc.Binds = append(svc.Binds, domain.AppBind{
265+ Name: b.Name,
266+ Path: b.Path,
267+ ReadOnly: b.ReadOnly,
268+ })
269+ }
212270 for _, db := range raw.Databases {
213271 svc.Databases = append(svc.Databases, domain.AppDatabase{
214272 Name: db.Name,
M internal/adapters/in/appmanifest/parse_test.go +60 -2
@@ -8,6 +8,7 @@ import (
88 "github.com/stretchr/testify/require"
99
1010 "github.com/bnema/gordon/internal/adapters/in/appmanifest"
11+ "github.com/bnema/gordon/internal/domain"
1112 )
1213
1314 const validWeb = `
@@ -46,6 +47,27 @@ func TestParse_ValidWeb(t *testing.T) {
4647 assert.Equal(t, "http", svc.Readiness.Type)
4748 }
4849
50+func TestParse_SharedNetwork(t *testing.T) {
51+ doc := `
52+name = "media"
53+[[service]]
54+name = "web"
55+image = "registry.example.com/media/web:1"
56+[[network.shared]]
57+network = "backend"
58+services = ["web"]
59+aliases = ["media-web"]
60+`
61+
62+ spec, warnings, err := appmanifest.Parse([]byte(doc), "media.toml")
63+ require.NoError(t, err)
64+ assert.Empty(t, warnings)
65+ require.Len(t, spec.Networks, 1)
66+ assert.Equal(t, domain.AppSharedNetwork{
67+ Network: "backend", Services: []string{"web"}, Aliases: []string{"media-web"},
68+ }, spec.Networks[0])
69+}
70+
4971 func TestParse_FileNameMismatchIsWarning(t *testing.T) {
5072 _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml")
5173 require.NoError(t, err)
@@ -112,7 +134,7 @@ func TestParse_Names(t *testing.T) {
112134 {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"},
113135 {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"},
114136 {"normalized collision", "name = \"blog\"\n[[service]]\nname = \"a.b\"\nimage = \"img:1\"\n[[service]]\nname = \"a-b\"\nimage = \"img:2\"\n", "same runtime identifier"},
115- {"replicas rejected", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nreplicas = 2\n", "missing in the target struct"},
137+ {"replicas rejected", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: service.replicas"},
116138 {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},
117139 {"shared volume", "name = \"blog\"\n[[service]]\nname = \"a\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"d\"\npath = \"/data\"\n[[service]]\nname = \"b\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"},
118140 {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[service.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"},
@@ -156,7 +178,7 @@ func TestParse_Interfaces(t *testing.T) {
156178 }{
157179 {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},
158180 {"publish hostname", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"},
159- {"rcon table rejected", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "missing in the target struct"},
181+ {"rcon table rejected", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "unknown TOML fields or tables: service.rcon"},
160182 {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},
161183 }
162184 for _, tc := range cases {
@@ -252,3 +274,39 @@ func TestParse_NoFileEnvSecretReads(t *testing.T) {
252274 require.NoError(t, err)
253275 assert.Equal(t, "blog", spec.Name)
254276 }
277+
278+func TestParse_Binds(t *testing.T) {
279+ doc := `
280+name = "blog"
281+[[service]]
282+name = "web"
283+image = "registry.example.com/blog/web:1.4.2"
284+[[service.bind]]
285+name = "config"
286+path = "/etc/app.conf"
287+readonly = true
288+[[service.bind]]
289+name = "data"
290+path = "/var/lib/app"
291+`
292+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
293+ require.NoError(t, err)
294+ require.Len(t, spec.Services[0].Binds, 2)
295+ assert.Equal(t, domain.AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: true}, spec.Services[0].Binds[0])
296+ assert.Equal(t, domain.AppBind{Name: "data", Path: "/var/lib/app"}, spec.Services[0].Binds[1])
297+}
298+
299+func TestParse_BindDestinationRejected(t *testing.T) {
300+ doc := `
301+name = "blog"
302+[[service]]
303+name = "web"
304+image = "registry.example.com/blog/web:1.4.2"
305+[[service.bind]]
306+name = "dev"
307+path = "/dev"
308+`
309+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
310+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
311+ assert.Contains(t, err.Error(), "sensitive container path")
312+}
A internal/adapters/in/appmanifest/parse_visibility_test.go +157 -0
@@ -0,0 +1,157 @@
1+package appmanifest_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/adapters/in/appmanifest"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// TestParse_VisibilityNormalization proves absent visibility becomes
14+// explicit public, an internal interface keeps its TLS absent, and a
15+// public interface still defaults an absent TLS to auto.
16+func TestParse_VisibilityNormalization(t *testing.T) {
17+ t.Run("absent visibility stays public with auto tls", func(t *testing.T) {
18+ doc := `
19+name = "blog"
20+[[service]]
21+name = "web"
22+image = "registry.example.com/blog/web:1.0.0"
23+[[service.http]]
24+host = "blog.example.com"
25+port = 8080
26+`
27+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
28+ require.NoError(t, err)
29+ require.Len(t, spec.Services[0].HTTP, 1)
30+ iface := spec.Services[0].HTTP[0]
31+ assert.Equal(t, domain.AppVisibilityPublic, iface.Visibility)
32+ assert.Equal(t, domain.AppTLSAuto, iface.TLS)
33+ })
34+
35+ t.Run("explicit internal keeps tls absent and has no host", func(t *testing.T) {
36+ doc := `
37+name = "blog"
38+[[service]]
39+name = "api"
40+image = "registry.example.com/blog/api:1.0.0"
41+[service.readiness]
42+type = "http"
43+path = "/healthz"
44+[[service.http]]
45+visibility = "internal"
46+port = 8080
47+`
48+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
49+ require.NoError(t, err)
50+ require.Len(t, spec.Services[0].HTTP, 1)
51+ iface := spec.Services[0].HTTP[0]
52+ assert.Equal(t, domain.AppVisibilityInternal, iface.Visibility)
53+ assert.Empty(t, iface.Host)
54+ assert.Empty(t, iface.TLS, "an internal interface must not inherit the public auto tls default")
55+ assert.True(t, spec.Services[0].InternallyOnlyPort(8080))
56+ })
57+
58+ t.Run("explicit public normalizes to auto tls", func(t *testing.T) {
59+ doc := `
60+name = "blog"
61+[[service]]
62+name = "web"
63+image = "registry.example.com/blog/web:1.0.0"
64+[[service.http]]
65+host = "blog.example.com"
66+port = 8080
67+visibility = "public"
68+`
69+ spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
70+ require.NoError(t, err)
71+ assert.Equal(t, domain.AppVisibilityPublic, spec.Services[0].HTTP[0].Visibility)
72+ assert.Equal(t, domain.AppTLSAuto, spec.Services[0].HTTP[0].TLS)
73+ })
74+}
75+
76+// TestParse_VisibilityRejections proves invalid combinations fail at
77+// validation with the sentinel error.
78+func TestParse_VisibilityRejections(t *testing.T) {
79+ tests := map[string]string{
80+ "internal forbids host": `
81+name = "blog"
82+[[service]]
83+name = "api"
84+image = "registry.example.com/blog/api:1.0.0"
85+[[service.http]]
86+host = "blog.example.com"
87+port = 8080
88+visibility = "internal"
89+`,
90+ "internal forbids tls": `
91+name = "blog"
92+[[service]]
93+name = "api"
94+image = "registry.example.com/blog/api:1.0.0"
95+[[service.http]]
96+port = 8080
97+visibility = "internal"
98+tls = "auto"
99+`,
100+ "internal forbids explicitly empty tls": `
101+name = "blog"
102+[[service]]
103+name = "api"
104+image = "registry.example.com/blog/api:1.0.0"
105+[[service.http]]
106+port = 8080
107+visibility = "internal"
108+tls = ""
109+`,
110+ "unknown visibility value": `
111+name = "blog"
112+[[service]]
113+name = "web"
114+image = "registry.example.com/blog/web:1.0.0"
115+[[service.http]]
116+host = "blog.example.com"
117+port = 8080
118+visibility = "vpn"
119+`,
120+ "internal port also published as tcp": `
121+name = "blog"
122+[[service]]
123+name = "api"
124+image = "registry.example.com/blog/api:1.0.0"
125+[[service.http]]
126+port = 8080
127+visibility = "internal"
128+[[service.tcp]]
129+port = 8080
130+`,
131+ }
132+ for name, doc := range tests {
133+ t.Run(name, func(t *testing.T) {
134+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
135+ require.Error(t, err)
136+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
137+ })
138+ }
139+}
140+
141+// TestParse_VisibilityUnknownFieldStillRejected proves the parser stays
142+// strict: a near-miss key is still a decode error.
143+func TestParse_VisibilityUnknownFieldStillRejected(t *testing.T) {
144+ doc := `
145+name = "blog"
146+[[service]]
147+name = "web"
148+image = "registry.example.com/blog/web:1.0.0"
149+[[service.http]]
150+host = "blog.example.com"
151+port = 8080
152+visibilities = "internal"
153+`
154+ _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
155+ require.Error(t, err)
156+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
157+}
M internal/adapters/in/cli/apps.go +144 -18
@@ -19,6 +19,7 @@ import (
1919
2020 "github.com/bnema/gordon/internal/adapters/dto"
2121 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
22+ "github.com/bnema/gordon/internal/domain"
2223 )
2324
2425 // appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2).
@@ -70,8 +71,8 @@ func appMutationError(op, app, key string, err error) error {
7071 var unknown *remote.OutcomeUnknownError
7172 if errors.As(err, &unknown) {
7273 return fmt.Errorf(
73- "outcome-unknown: %s %s may have executed; query "+
74- "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w",
74+ "outcome-unknown: %s %s may have executed; run the by-key lookup "+
75+ "gordon apps operations show %s --key %s before retrying with the same key: %w",
7576 op, app, app, key, err)
7677 }
7778 return err
@@ -95,8 +96,8 @@ func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut
9596 return rerr, true
9697 }
9798 return fmt.Errorf(
98- "%s of %s did not succeed (outcome %s, op %s); journal rendered above; query "+
99- "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w",
99+ "%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",
100101 op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true
101102 }
102103
@@ -118,6 +119,7 @@ Examples:
118119 }
119120 cmd.AddCommand(
120121 newAppsApplyCmd(),
122+ newAppsOperationsCmd(),
121123 newAppsListCmd(),
122124 newAppsShowCmd(),
123125 newAppsDiffCmd(),
@@ -470,16 +472,55 @@ func newAppsSecretsCmd() *cobra.Command {
470472 Use: "secrets",
471473 Short: "Manage app secret values",
472474 Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),
473---stdin (preferred), or an interactive prompt. Names must already exist in
474-desired or active state. Only key names are ever echoed back — never values.`,
475+--stdin with KEY=VALUE lines, or --stdin --key KEY for one raw value. Names
476+must already exist in desired or active state. Only key names are ever echoed
477+back — never values.`,
475478 }
476- cmd.AddCommand(newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
479+ cmd.AddCommand(newAppsSecretsListCmd(), newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
477480 return cmd
478481 }
479482
483+func newAppsSecretsListCmd() *cobra.Command {
484+ var service string
485+ var jsonOut bool
486+ cmd := &cobra.Command{Use: "list APP", Short: "List app secret registration metadata", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
487+ handle, err := resolveAppPlane()
488+ if err != nil {
489+ return err
490+ }
491+ defer handle.close()
492+ return runAppsSecretsList(cmd.Context(), handle.plane, cmd.OutOrStdout(), args[0], service, jsonOut)
493+ }}
494+ cmd.Flags().StringVar(&service, "service", "", "Filter by service")
495+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
496+ return cmd
497+}
498+
499+// runAppsSecretsList renders metadata-only registrations for one app,
500+// optionally filtered to one service. Secret values never appear.
501+func runAppsSecretsList(ctx context.Context, plane ControlPlane, out io.Writer, app, service string, jsonOut bool) error {
502+ entries, err := plane.ListAppSecrets(ctx, app, service)
503+ if err != nil {
504+ return fmt.Errorf("list app secrets for %s: %w", app, err)
505+ }
506+ if jsonOut {
507+ return writeJSON(out, entries)
508+ }
509+ for _, entry := range entries {
510+ if err := cliWriteLine(out, cliRenderMeta(entry.Service+"/"+entry.Key+":", entry.Name+" "+entry.Source+" "+entry.Presence)); err != nil {
511+ return err
512+ }
513+ }
514+ if len(entries) == 0 {
515+ return cliWriteLine(out, cliRenderMuted("No registered secrets"))
516+ }
517+ return nil
518+}
519+
480520 // newAppsSecretsSetCmd creates `apps secrets set`.
481521 func newAppsSecretsSetCmd() *cobra.Command {
482522 var service string
523+ var key string
483524 var fromStdin bool
484525 var jsonOut bool
485526 cmd := &cobra.Command{
@@ -496,23 +537,37 @@ func newAppsSecretsSetCmd() *cobra.Command {
496537 if len(args) == 0 {
497538 return fmt.Errorf("missing APP argument")
498539 }
499- return runAppsSecretsSet(cmd.Context(), plane, os.Stdin, cmd.OutOrStdout(), args[0], args[1:], service, fromStdin, jsonOut)
540+ return runAppsSecretsSetMode(cmd.Context(), plane, cmd.InOrStdin(), cmd.OutOrStdout(), args[0], args[1:], service, key, fromStdin, jsonOut)
500541 },
501542 }
502543 cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)")
503- cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines from stdin")
544+ cmd.Flags().StringVar(&key, "key", "", "Secret key for single-value stdin mode")
545+ cmd.Flags().BoolVar(&fromStdin, "stdin", false, "Read KEY=VALUE lines, or one raw value with --key")
504546 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
505547 return cmd
506548 }
507549
508550 func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error {
551+ return runAppsSecretsSetMode(ctx, plane, stdin, out, app, pairs, service, "", fromStdin, jsonOut)
552+}
553+
554+func runAppsSecretsSetMode(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service, key string, fromStdin, jsonOut bool) error {
509555 if app == "" {
510556 return fmt.Errorf("missing APP argument")
511557 }
512558 if service == "" {
513559 return fmt.Errorf("missing required flag --service: secrets are service-scoped")
514560 }
515- if fromStdin {
561+ if key != "" {
562+ if !fromStdin || len(pairs) != 0 {
563+ return fmt.Errorf("--key requires --stdin and forbids KEY=VALUE arguments")
564+ }
565+ value, err := readRawSecretStdin(stdin)
566+ if err != nil {
567+ return err
568+ }
569+ pairs = []string{key + "=" + value}
570+ } else if fromStdin {
516571 stdinPairs, err := readSecretStdin(stdin)
517572 if err != nil {
518573 return err
@@ -579,30 +634,68 @@ func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer
579634 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))
580635 }
581636
582-// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty;
583-// values may be empty (explicit empty secret).
637+// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty and
638+// each value must pass validateSecretValue (1-MaxAppEnvValueLen bytes on a
639+// single line); an empty value is rejected.
584640 func parseSecretPairs(pairs []string) (map[string]string, error) {
585641 values := make(map[string]string, len(pairs))
586- for _, pair := range pairs {
642+ for index, pair := range pairs {
587643 key, value, ok := strings.Cut(pair, "=")
588644 if !ok || key == "" {
589- return nil, fmt.Errorf("invalid secret %q: expected KEY=VALUE", pair)
645+ return nil, fmt.Errorf("invalid secret input %d: expected KEY=VALUE", index+1)
646+ }
647+ if err := validateSecretValue(value); err != nil {
648+ return nil, fmt.Errorf("invalid secret input %d: %w", index+1, err)
590649 }
591650 values[key] = value
592651 }
593652 return values, nil
594653 }
595654
596-// readSecretStdin reads KEY=VALUE lines (blank lines ignored).
655+func validateSecretValue(value string) error {
656+ if value == "" || len(value) > domain.MaxAppEnvValueLen {
657+ return fmt.Errorf("value must be 1-%d bytes", domain.MaxAppEnvValueLen)
658+ }
659+ if strings.ContainsAny(value, "\r\n") {
660+ return fmt.Errorf("value must be a single line")
661+ }
662+ return nil
663+}
664+
665+func readRawSecretStdin(in io.Reader) (string, error) {
666+ data, err := io.ReadAll(io.LimitReader(in, domain.MaxAppEnvValueLen+3))
667+ if err != nil {
668+ return "", fmt.Errorf("failed to read secret from stdin: %w", err)
669+ }
670+ if strings.HasSuffix(string(data), "\r\n") {
671+ data = data[:len(data)-2]
672+ } else if strings.HasSuffix(string(data), "\n") {
673+ data = data[:len(data)-1]
674+ }
675+ value := string(data)
676+ if err := validateSecretValue(value); err != nil {
677+ return "", fmt.Errorf("invalid stdin secret: %w", err)
678+ }
679+ return value, nil
680+}
681+
682+const maxSecretStdinBytes = 1 << 20
683+
684+// readSecretStdin reads bounded KEY=VALUE lines. Whitespace-only lines are
685+// ignored, while every byte in nonblank values is preserved.
597686 func readSecretStdin(in io.Reader) ([]string, error) {
598- data, err := io.ReadAll(in)
687+ data, err := io.ReadAll(io.LimitReader(in, maxSecretStdinBytes+1))
599688 if err != nil {
600689 return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)
601690 }
691+ if len(data) > maxSecretStdinBytes {
692+ return nil, fmt.Errorf("secret input exceeds %d bytes", maxSecretStdinBytes)
693+ }
602694 var pairs []string
603695 for _, line := range strings.Split(string(data), "\n") {
604- if trimmed := strings.TrimSpace(line); trimmed != "" {
605- pairs = append(pairs, trimmed)
696+ line = strings.TrimSuffix(line, "\r")
697+ if strings.TrimSpace(line) != "" {
698+ pairs = append(pairs, line)
606699 }
607700 }
608701 return pairs, nil
@@ -776,9 +869,42 @@ func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error {
776869 if err := renderDeployServices(out, resp); err != nil {
777870 return err
778871 }
872+ if err := renderDeploySteps(out, resp.Steps); err != nil {
873+ return err
874+ }
779875 return renderDeploySummaries(out, resp)
780876 }
781877
878+func renderDeploySteps(out io.Writer, steps []dto.AppStepDTO) error {
879+ for _, step := range steps {
880+ detail := step.State
881+ if step.Detail != "" {
882+ detail += ": " + sanitizeTerminalText(step.Detail)
883+ }
884+ if step.Error != "" {
885+ detail += ": " + sanitizeTerminalText(step.Error)
886+ }
887+ if err := cliWriteLine(out, cliRenderMeta(" step "+step.ID+":", detail)); err != nil {
888+ return err
889+ }
890+ for _, diagnostic := range step.Diagnostics {
891+ if err := cliWriteLine(out, cliRenderMuted(" "+sanitizeTerminalText(diagnostic))); err != nil {
892+ return err
893+ }
894+ }
895+ }
896+ return nil
897+}
898+
899+func sanitizeTerminalText(value string) string {
900+ return strings.Map(func(r rune) rune {
901+ if r == '\n' || r == '\t' || r >= ' ' && r != '\x7f' {
902+ return r
903+ }
904+ return '�'
905+ }, value)
906+}
907+
782908 func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {
783909 headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)
784910 if resp.Outcome == "success" {
A internal/adapters/in/cli/apps_operations.go +51 -0
@@ -0,0 +1,51 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+)
10+
11+func newAppsOperationsCmd() *cobra.Command {
12+ cmd := &cobra.Command{Use: "operations", Short: "Inspect app operation journals"}
13+ cmd.AddCommand(newAppsOperationsShowCmd())
14+ return cmd
15+}
16+
17+func newAppsOperationsShowCmd() *cobra.Command {
18+ var key string
19+ var jsonOut bool
20+ cmd := &cobra.Command{
21+ Use: "show APP",
22+ Short: "Show an app operation by request key",
23+ Args: cobra.ExactArgs(1),
24+ RunE: func(cmd *cobra.Command, args []string) error {
25+ handle, err := resolveAppPlane()
26+ if err != nil {
27+ return err
28+ }
29+ defer handle.close()
30+ return runAppsOperationsShow(cmd.Context(), handle.plane, cmd.OutOrStdout(), args[0], key, jsonOut)
31+ },
32+ }
33+ cmd.Flags().StringVar(&key, "key", "", "Request key (required)")
34+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
35+ _ = cmd.MarkFlagRequired("key")
36+ return cmd
37+}
38+
39+func runAppsOperationsShow(ctx context.Context, plane ControlPlane, out io.Writer, app, key string, jsonOut bool) error {
40+ if app == "" || key == "" {
41+ return fmt.Errorf("APP and --key are required")
42+ }
43+ operation, err := plane.OperationByKey(ctx, app, key)
44+ if err != nil {
45+ return fmt.Errorf("lookup operation for app %s: %w", app, err)
46+ }
47+ if jsonOut {
48+ return writeJSON(out, operation)
49+ }
50+ return renderAppDeployResponse(out, operation)
51+}
M internal/adapters/in/cli/apps_test.go +20 -2
@@ -185,12 +185,12 @@ func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) {
185185 plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).
186186 Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()
187187 var out bytes.Buffer
188- stdin := strings.NewReader("from_stdin=stdin-value\n\nempty_val=\n")
188+ stdin := strings.NewReader("from_stdin=stdin-value\n\nspaced= preserved \n")
189189 require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,
190190 &out, "blog", []string{"from_flag=flag-value"}, "web", true, false))
191191 assert.Equal(t, map[string]string{
192192 "from_stdin": "stdin-value",
193- "empty_val": "",
193+ "spaced": " preserved ",
194194 "from_flag": "flag-value",
195195 }, gotReq.Secrets)
196196
@@ -529,3 +529,21 @@ func TestRunAppLogs_TextJSONFollow(t *testing.T) {
529529 require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))
530530 assert.Equal(t, "s1\n", out.String())
531531 }
532+
533+func TestRunAppsSecretsList_RendersMetadataAndWrapsErrors(t *testing.T) {
534+ plane := appPlane(t)
535+ plane.EXPECT().ListAppSecrets(mock.Anything, "blog", "web").Return([]dto.AppSecretMetadataDTO{
536+ {Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
537+ }, nil).Once()
538+ var out bytes.Buffer
539+ require.NoError(t, runAppsSecretsList(context.Background(), plane, &out, "blog", "web", false))
540+ assert.Contains(t, out.String(), "web/DATABASE_URL")
541+ assert.Contains(t, out.String(), "desired")
542+
543+ errPlane := appPlane(t)
544+ errPlane.EXPECT().ListAppSecrets(mock.Anything, "blog", "").Return(nil, errors.New("boom")).Once()
545+ err := runAppsSecretsList(context.Background(), errPlane, &bytes.Buffer{}, "blog", "", false)
546+ require.Error(t, err)
547+ assert.Contains(t, err.Error(), "list app secrets for blog")
548+ assert.Contains(t, err.Error(), "boom")
549+}
M internal/adapters/in/cli/config.go +1 -1
@@ -17,7 +17,7 @@ func newConfigCmd() *cobra.Command {
1717 Short: "Inspect Gordon configuration",
1818 }
1919
20- cmd.AddCommand(newConfigShowCmd())
20+ cmd.AddCommand(newConfigShowCmd(), newConfigValidateCmd())
2121
2222 return cmd
2323 }
A internal/adapters/in/cli/config_validate.go +60 -0
@@ -0,0 +1,60 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "io"
6+
7+ "github.com/spf13/cobra"
8+
9+ "github.com/bnema/gordon/internal/app"
10+)
11+
12+type configDiagnostic struct {
13+ Code string `json:"code"`
14+ Key string `json:"key"`
15+ Message string `json:"message"`
16+}
17+
18+type configValidationResult struct {
19+ Valid bool `json:"valid"`
20+ Diagnostics []configDiagnostic `json:"diagnostics"`
21+ Scope string `json:"scope"`
22+}
23+
24+func newConfigValidateCmd() *cobra.Command {
25+ var file string
26+ var jsonOut bool
27+ cmd := &cobra.Command{
28+ Use: "validate",
29+ Short: "Statically validate a local configuration file",
30+ Args: cobra.NoArgs,
31+ RunE: func(cmd *cobra.Command, _ []string) error {
32+ if remoteFlag != "" {
33+ return fmt.Errorf("config validate is local-only; --remote is not supported")
34+ }
35+ return runConfigValidate(cmd.OutOrStdout(), file, jsonOut)
36+ },
37+ }
38+ cmd.Flags().StringVar(&file, "file", "", "Local candidate configuration file (required)")
39+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
40+ _ = cmd.MarkFlagRequired("file")
41+ return cmd
42+}
43+
44+func runConfigValidate(out io.Writer, file string, jsonOut bool) error {
45+ result := configValidationResult{Valid: true, Diagnostics: []configDiagnostic{}, Scope: "static"}
46+ if err := app.ValidateConfigFile(file); err != nil {
47+ result.Valid = false
48+ result.Diagnostics = []configDiagnostic{{Code: "config-invalid", Message: "configuration failed static validation"}}
49+ if jsonOut {
50+ if writeErr := writeJSON(out, result); writeErr != nil {
51+ return writeErr
52+ }
53+ }
54+ return fmt.Errorf("configuration failed static validation: %w", err)
55+ }
56+ if jsonOut {
57+ return writeJSON(out, result)
58+ }
59+ return cliWriteLine(out, cliRenderSuccess("Configuration is statically valid; runtime, ACTIVE-state, secret, pull, and listener checks were not performed."))
60+}
M internal/adapters/in/cli/controlplane.go +1 -0
@@ -23,6 +23,7 @@ type ControlPlane interface {
2323 RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error)
2424 RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
2525 OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
26+ ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)
2627 SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
2728 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
2829
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -60,6 +60,10 @@ func (r *remoteControlPlane) OperationByKey(ctx context.Context, app, key string
6060 return r.client.OperationByKey(ctx, app, key)
6161 }
6262
63+func (r *remoteControlPlane) ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error) {
64+ return r.client.ListAppSecrets(ctx, app, service)
65+}
66+
6367 func (r *remoteControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
6468 return r.client.SetAppSecrets(ctx, app, req)
6569 }
M internal/adapters/in/cli/mocks/mock_control_plane.go +74 -0
@@ -760,6 +760,80 @@ func (_c *MockControlPlane_GetTrafficStatus_Call) RunAndReturn(run func(ctx cont
760760 return _c
761761 }
762762
763+// ListAppSecrets provides a mock function for the type MockControlPlane
764+func (_mock *MockControlPlane) ListAppSecrets(ctx context.Context, app string, service string) ([]dto.AppSecretMetadataDTO, error) {
765+ ret := _mock.Called(ctx, app, service)
766+
767+ if len(ret) == 0 {
768+ panic("no return value specified for ListAppSecrets")
769+ }
770+
771+ var r0 []dto.AppSecretMetadataDTO
772+ var r1 error
773+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]dto.AppSecretMetadataDTO, error)); ok {
774+ return returnFunc(ctx, app, service)
775+ }
776+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []dto.AppSecretMetadataDTO); ok {
777+ r0 = returnFunc(ctx, app, service)
778+ } else {
779+ if ret.Get(0) != nil {
780+ r0 = ret.Get(0).([]dto.AppSecretMetadataDTO)
781+ }
782+ }
783+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
784+ r1 = returnFunc(ctx, app, service)
785+ } else {
786+ r1 = ret.Error(1)
787+ }
788+ return r0, r1
789+}
790+
791+// MockControlPlane_ListAppSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListAppSecrets'
792+type MockControlPlane_ListAppSecrets_Call struct {
793+ *mock.Call
794+}
795+
796+// ListAppSecrets is a helper method to define mock.On call
797+// - ctx context.Context
798+// - app string
799+// - service string
800+func (_e *MockControlPlane_Expecter) ListAppSecrets(ctx any, app any, service any) *MockControlPlane_ListAppSecrets_Call {
801+ return &MockControlPlane_ListAppSecrets_Call{Call: _e.mock.On("ListAppSecrets", ctx, app, service)}
802+}
803+
804+func (_c *MockControlPlane_ListAppSecrets_Call) Run(run func(ctx context.Context, app string, service string)) *MockControlPlane_ListAppSecrets_Call {
805+ _c.Call.Run(func(args mock.Arguments) {
806+ var arg0 context.Context
807+ if args[0] != nil {
808+ arg0 = args[0].(context.Context)
809+ }
810+ var arg1 string
811+ if args[1] != nil {
812+ arg1 = args[1].(string)
813+ }
814+ var arg2 string
815+ if args[2] != nil {
816+ arg2 = args[2].(string)
817+ }
818+ run(
819+ arg0,
820+ arg1,
821+ arg2,
822+ )
823+ })
824+ return _c
825+}
826+
827+func (_c *MockControlPlane_ListAppSecrets_Call) Return(appSecretMetadataDTOs []dto.AppSecretMetadataDTO, err error) *MockControlPlane_ListAppSecrets_Call {
828+ _c.Call.Return(appSecretMetadataDTOs, err)
829+ return _c
830+}
831+
832+func (_c *MockControlPlane_ListAppSecrets_Call) RunAndReturn(run func(ctx context.Context, app string, service string) ([]dto.AppSecretMetadataDTO, error)) *MockControlPlane_ListAppSecrets_Call {
833+ _c.Call.Return(run)
834+ return _c
835+}
836+
763837 // ListApps provides a mock function for the type MockControlPlane
764838 func (_mock *MockControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
765839 ret := _mock.Called(ctx)
M internal/adapters/in/cli/remote/apps.go +17 -0
@@ -260,6 +260,23 @@ func (c *Client) OperationByKey(ctx context.Context, app, key string) (*dto.AppD
260260 return &result, nil
261261 }
262262
263+// ListAppSecrets returns metadata-only secret registrations.
264+func (c *Client) ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error) {
265+ path := "/apps/" + url.PathEscape(app) + "/secrets"
266+ if service != "" {
267+ path += "?service=" + url.QueryEscape(service)
268+ }
269+ resp, err := c.request(ctx, http.MethodGet, path, nil)
270+ if err != nil {
271+ return nil, fmt.Errorf("list app secrets %s: %w", app, err)
272+ }
273+ var result []dto.AppSecretMetadataDTO
274+ if err := parseResponse(resp, &result); err != nil {
275+ return nil, fmt.Errorf("list app secrets %s: %w", app, err)
276+ }
277+ return result, nil
278+}
279+
263280 // SetAppSecrets writes secret values for pre-registered names.
264281 func (c *Client) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
265282 resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/set", newIdempotencyKey(), req)
M internal/adapters/in/cli/remote/apps_test.go +28 -0
@@ -171,3 +171,31 @@ func TestClientDeployApp_ConflictWithoutJournalFallsBackToHTTPError(t *testing.T
171171 require.ErrorAs(t, err, &httpErr)
172172 assert.Equal(t, http.StatusConflict, httpErr.StatusCode)
173173 }
174+
175+func TestClientListAppSecrets_ServiceFilterAndErrorContext(t *testing.T) {
176+ var gotService string
177+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178+ require.Equal(t, "/admin/apps/blog/secrets", r.URL.Path)
179+ gotService = r.URL.Query().Get("service")
180+ w.Header().Set("Content-Type", "application/json")
181+ _ = json.NewEncoder(w).Encode([]dto.AppSecretMetadataDTO{
182+ {Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
183+ })
184+ }))
185+ defer srv.Close()
186+
187+ entries, err := NewClient(srv.URL).ListAppSecrets(context.Background(), "blog", "web")
188+ require.NoError(t, err)
189+ require.Len(t, entries, 1)
190+ assert.Equal(t, "web", gotService)
191+
192+ errSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
193+ w.WriteHeader(http.StatusInternalServerError)
194+ _, _ = w.Write([]byte(`{"error":"internal"}`))
195+ }))
196+ defer errSrv.Close()
197+
198+ _, err = NewClient(errSrv.URL).ListAppSecrets(context.Background(), "blog", "")
199+ require.Error(t, err)
200+ assert.Contains(t, err.Error(), "list app secrets blog")
201+}
M internal/adapters/in/cli/remote/client.go +8 -5
@@ -373,14 +373,17 @@ func parseErrorResponse(resp *http.Response, body []byte) error {
373373 msg := string(body)
374374 structured := false
375375 var errResp struct {
376- Error string `json:"error"`
377- Cause string `json:"cause"`
378- Hint string `json:"hint"`
379- Logs []string `json:"logs"`
376+ Error string `json:"error"`
377+ Message string `json:"message"`
378+ Cause string `json:"cause"`
379+ Hint string `json:"hint"`
380+ Logs []string `json:"logs"`
380381 }
381382 if err := json.Unmarshal(body, &errResp); err == nil {
382383 structured = errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0
383- if errResp.Error != "" {
384+ if errResp.Message != "" {
385+ msg = errResp.Message
386+ } else if errResp.Error != "" {
384387 msg = errResp.Error
385388 }
386389 }
M internal/adapters/in/cli/remote/client_test.go +10 -0
@@ -379,6 +379,16 @@ func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
379379 assert.Equal(t, "insufficient scope", httpErr.Body)
380380 }
381381
382+func TestParseErrorResponse_AppErrorMessage(t *testing.T) {
383+ resp := &http.Response{StatusCode: 400, Status: "400 Bad Request"}
384+ err := parseErrorResponse(resp, []byte(`{"error":"invalid-manifest","message":"invalid app manifest: service \"metrics\" readiness.port 9090 matches no declared container port"}`))
385+
386+ var httpErr *HTTPError
387+ require.True(t, errors.As(err, &httpErr))
388+ assert.Equal(t, `invalid app manifest: service "metrics" readiness.port 9090 matches no declared container port`, httpErr.Body)
389+ assert.Contains(t, err.Error(), `service "metrics" readiness.port 9090`)
390+}
391+
382392 func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
383393 resp := &http.Response{
384394 StatusCode: 500,
M internal/adapters/in/cli/root.go +6 -1
@@ -20,6 +20,7 @@ var (
2020 Version = "dev"
2121 Commit = "unknown"
2222 BuildDate = "unknown"
23+ Dirty = "unknown"
2324
2425 // Global flags for remote targeting
2526 remoteFlag string
@@ -229,6 +230,9 @@ func newVersionCmd() *cobra.Command {
229230 if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
230231 return err
231232 }
233+ if err := cliWriteLine(out, cliRenderMeta("Dirty:", Dirty)); err != nil {
234+ return err
235+ }
232236 return nil
233237 },
234238 }
@@ -312,8 +316,9 @@ func runProcessLogs(ctx context.Context, cp ControlPlane, follow bool, lines int
312316 }
313317
314318 // SetVersionInfo sets the version information for the CLI.
315-func SetVersionInfo(version, commit, date string) {
319+func SetVersionInfo(version, commit, date, dirty string) {
316320 Version = version
317321 Commit = commit
318322 BuildDate = date
323+ Dirty = dirty
319324 }
M internal/adapters/in/http/admin/handler_apps.go +30 -1
@@ -67,7 +67,11 @@ func (h *Handler) dispatchAppSubroute(w http.ResponseWriter, r *http.Request, pa
6767 return
6868 }
6969 if len(parts) == 2 {
70- h.dispatchAppVerb(w, r, app, parts[1])
70+ if parts[1] == "secrets" && r.Method == http.MethodGet {
71+ h.handleAppSecretsList(w, r, app)
72+ } else {
73+ h.dispatchAppVerb(w, r, app, parts[1])
74+ }
7175 return
7276 }
7377 if len(parts) == 3 && parts[1] == "secrets" {
@@ -416,6 +420,28 @@ func (h *Handler) handleAppOpLookup(w http.ResponseWriter, r *http.Request, app,
416420 h.sendJSON(w, http.StatusOK, toAppDeployResponse(app, op, includeDiagnostics))
417421 }
418422
423+func (h *Handler) handleAppSecretsList(w http.ResponseWriter, r *http.Request, app string) {
424+ ctx := r.Context()
425+ if !HasAccess(ctx, domain.AdminResourceApps, domain.AdminActionRead) {
426+ h.sendError(w, http.StatusForbidden, "insufficient permissions for apps:read")
427+ return
428+ }
429+ svc, ok := h.appService(w)
430+ if !ok {
431+ return
432+ }
433+ entries, err := svc.ListSecrets(ctx, app, r.URL.Query().Get("service"))
434+ if err != nil {
435+ h.sendAppOpError(w, err)
436+ return
437+ }
438+ result := make([]dto.AppSecretMetadataDTO, 0, len(entries))
439+ for _, entry := range entries {
440+ result = append(result, dto.AppSecretMetadataDTO{Service: entry.Service, Key: entry.Key, Name: entry.Name, Source: entry.Source, Presence: entry.Presence})
441+ }
442+ h.sendJSON(w, http.StatusOK, result)
443+}
444+
419445 // handleAppSecretsSet writes secret values for pre-registered names.
420446 func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, app string) {
421447 ctx := r.Context()
@@ -478,6 +504,7 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool {
478504 }
479505 }
480506 return errors.Is(err, domain.ErrInvalidAppSpec) ||
507+ errors.Is(err, domain.ErrBindPolicy) ||
481508 errors.Is(err, domain.ErrAppNotFound) ||
482509 errors.Is(err, domain.ErrAppReservationConflict) ||
483510 errors.Is(err, domain.ErrAppImageUnresolvable) ||
@@ -494,6 +521,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
494521 switch {
495522 case errors.Is(err, domain.ErrInvalidAppSpec):
496523 h.sendAppError(w, http.StatusBadRequest, "invalid-manifest", err.Error(), "", "")
524+ case errors.Is(err, domain.ErrBindPolicy):
525+ h.sendAppError(w, http.StatusBadRequest, "bind-policy-violation", err.Error(), "", "check the administrative mount authorization")
497526 case errors.Is(err, domain.ErrAppReservationConflict):
498527 h.sendAppError(w, http.StatusConflict, "reservation-conflict", err.Error(), "", "")
499528 case errors.Is(err, domain.ErrAppImageUnresolvable):
M internal/adapters/in/http/admin/handler_apps_test.go +14 -0
@@ -377,3 +377,17 @@ func TestHandler_AppApply_DryRunOmitsRevision(t *testing.T) {
377377 assert.Empty(t, resp.ResultingRevision)
378378 assert.NotContains(t, rec.Body.String(), "resulting_revision")
379379 }
380+
381+func TestHandler_AppSecretsList_MissingAppIs404(t *testing.T) {
382+ appSvc := inmocks.NewMockAppService(t)
383+ handler := appsTestHandler(t, appSvc)
384+
385+ appSvc.EXPECT().ListSecrets(mock.Anything, "ghost", "").Return(
386+ nil,
387+ fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound),
388+ ).Once()
389+
390+ rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost/secrets", nil, "admin:apps:read")
391+ require.Equal(t, http.StatusNotFound, rec.Code)
392+ assert.Contains(t, rec.Body.String(), "app-not-found")
393+}
M internal/adapters/in/http/admin/local_authority.go +1 -0
@@ -116,6 +116,7 @@ func localAppPathAllowed(method string, parts []string) bool {
116116 "{app}/stop": http.MethodPost,
117117 "{app}/start": http.MethodPost,
118118 "{app}/remove": http.MethodPost,
119+ "{app}/secrets": http.MethodGet,
119120 "{app}/secrets/set": http.MethodPost,
120121 "{app}/secrets/delete": http.MethodPost,
121122 "{app}/operations/by-key/{key}": http.MethodGet,
M internal/adapters/in/traffic/manager.go +8 -0
@@ -3,6 +3,7 @@ package traffic
33
44 import (
55 "context"
6+ "errors"
67 "fmt"
78 "maps"
89 "net"
@@ -10,6 +11,7 @@ import (
1011 "strings"
1112 "sync"
1213 "sync/atomic"
14+ "syscall"
1315 "time"
1416
1517 "github.com/bnema/zerowrap"
@@ -337,6 +339,9 @@ func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint dom
337339 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
338340 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
339341 if err != nil {
342+ if errors.Is(err, syscall.EADDRINUSE) {
343+ return nil, fmt.Errorf("bind tcp entrypoint %q on %s: inspect host-level TCP listeners, including other users or Gordon instances: %w", entryPoint.Name, entryPoint.Address, err)
344+ }
340345 return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
341346 }
342347 trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
@@ -405,6 +410,9 @@ func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint
405410 func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {
406411 packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)
407412 if err != nil {
413+ if errors.Is(err, syscall.EADDRINUSE) {
414+ return nil, fmt.Errorf("bind udp entrypoint %q on %s: inspect host-level UDP listeners, including other users or Gordon instances: %w", entryPoint.Name, entryPoint.Address, err)
415+ }
408416 return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
409417 }
410418 trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
A internal/adapters/out/docker/live_network_probe_test.go +255 -0
@@ -0,0 +1,255 @@
1+//go:build live
2+
3+// Live bounded-network-readiness verification against a real Docker
4+// daemon. These tests are opt-in: build with `-tags live` and run with the
5+// daemon reachable. They create and remove their own networks and
6+// containers, and assert no helper is ever left behind.
7+//
8+// go test -tags live ./internal/adapters/out/docker/ -run TestLiveNetworkProbe -count=1 -v
9+
10+package docker
11+
12+import (
13+ "context"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "github.com/bnema/gordon/internal/domain"
21+)
22+
23+// liveProbeNetwork creates an owned private network for one probe scenario.
24+func liveProbeNetwork(t *testing.T, runtime *Runtime, ctx context.Context) string {
25+ t.Helper()
26+ name := liveName("gordon-live-probe")
27+ require.NoError(t, runtime.CreateNetwork(ctx, name, domain.NetworkConfig{
28+ Driver: "bridge", Labels: domain.AppPrivateNetworkLabels("live", name),
29+ }))
30+ t.Cleanup(func() {
31+ cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
32+ defer cancel()
33+ _ = runtime.RemoveNetwork(cleanup, name)
34+ })
35+ return name
36+}
37+
38+// liveProbeHelpers counts leftover bounded-readiness helpers.
39+func liveProbeHelpers(t *testing.T, runtime *Runtime, ctx context.Context) int {
40+ t.Helper()
41+ containers, err := runtime.ListContainers(ctx, true)
42+ require.NoError(t, err)
43+ count := 0
44+ for _, c := range containers {
45+ if c != nil && c.Labels[domain.LabelPurpose] == domain.PurposeNetworkProbe {
46+ count++
47+ }
48+ }
49+ return count
50+}
51+
52+// liveProbeTarget starts one listener container on the network.
53+func liveProbeTarget(t *testing.T, runtime *Runtime, ctx context.Context, network, listenScript string) *domain.Container {
54+ t.Helper()
55+ return liveCreate(t, runtime, ctx, &domain.ContainerConfig{
56+ Image: liveImage,
57+ Name: liveName("live-probe-target"),
58+ NetworkMode: network,
59+ Entrypoint: []string{"sh", "-c", listenScript},
60+ AutoRemove: false,
61+ })
62+}
63+
64+// TestLiveNetworkProbe_PeerFacingReadyAndCleanedUp proves a published-free
65+// internal listener answers a bounded probe, the helper is removed, and the
66+// target exposes no host port.
67+func TestLiveNetworkProbe_PeerFacingReadyAndCleanedUp(t *testing.T) {
68+ runtime, ctx := liveRuntime(t)
69+ network := liveProbeNetwork(t, runtime, ctx)
70+ target := liveProbeTarget(t, runtime, ctx, network,
71+ `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done`)
72+
73+ before := liveProbeHelpers(t, runtime, ctx)
74+ inspect, err := runtime.InspectContainer(ctx, target.ID)
75+ require.NoError(t, err)
76+ require.False(t, inspect.StartedAt.IsZero(), "the target must report an execution start")
77+ assert.Empty(t, inspect.Ports, "an internal listener must not publish a host port")
78+
79+ result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
80+ TargetContainerID: target.ID,
81+ ExpectedStartedAt: inspect.StartedAt,
82+ Network: network,
83+ Protocol: domain.ProbeProtocolHTTP,
84+ Port: 8080,
85+ Path: "/healthz",
86+ Timeout: 10 * time.Second,
87+ })
88+ require.NoError(t, err)
89+ assert.True(t, result.Ready)
90+ assert.Equal(t, 200, result.Status)
91+
92+ assert.Equal(t, before, liveProbeHelpers(t, runtime, ctx), "the helper must always be removed")
93+}
94+
95+// TestLiveNetworkProbe_LoopbackOnlyListenerFails proves a target listening
96+// only on its own loopback never reports ready from the network.
97+func TestLiveNetworkProbe_LoopbackOnlyListenerFails(t *testing.T) {
98+ runtime, ctx := liveRuntime(t)
99+ network := liveProbeNetwork(t, runtime, ctx)
100+ target := liveProbeTarget(t, runtime, ctx, network,
101+ `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8081 -s 127.0.0.1; done`)
102+
103+ inspect, err := runtime.InspectContainer(ctx, target.ID)
104+ require.NoError(t, err)
105+
106+ result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
107+ TargetContainerID: target.ID,
108+ ExpectedStartedAt: inspect.StartedAt,
109+ Network: network,
110+ Protocol: domain.ProbeProtocolHTTP,
111+ Port: 8081,
112+ Path: "/",
113+ Timeout: 5 * time.Second,
114+ })
115+ require.NoError(t, err)
116+ assert.False(t, result.Ready, "a loopback-only listener must never be reachable over the private network")
117+ assert.NotEmpty(t, result.Diagnostic)
118+}
119+
120+// TestLiveNetworkProbe_TCPReady proves a TCP readiness session accepts an
121+// open port and rejects a closed one.
122+func TestLiveNetworkProbe_TCPReady(t *testing.T) {
123+ runtime, ctx := liveRuntime(t)
124+ network := liveProbeNetwork(t, runtime, ctx)
125+ target := liveProbeTarget(t, runtime, ctx, network,
126+ `while true; do nc -l -p 5432 >/dev/null 2>&1; done`)
127+
128+ inspect, err := runtime.InspectContainer(ctx, target.ID)
129+ require.NoError(t, err)
130+
131+ ready, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
132+ TargetContainerID: target.ID,
133+ ExpectedStartedAt: inspect.StartedAt,
134+ Network: network,
135+ Protocol: domain.ProbeProtocolTCP,
136+ Port: 5432,
137+ Timeout: 5 * time.Second,
138+ })
139+ require.NoError(t, err)
140+ assert.True(t, ready.Ready)
141+
142+ closed, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
143+ TargetContainerID: target.ID,
144+ ExpectedStartedAt: inspect.StartedAt,
145+ Network: network,
146+ Protocol: domain.ProbeProtocolTCP,
147+ Port: 5499,
148+ Timeout: 2 * time.Second,
149+ })
150+ require.NoError(t, err)
151+ assert.False(t, closed.Ready)
152+}
153+
154+// TestLiveNetworkProbe_RejectsStaleGeneration proves a probe whose expected
155+// execution start no longer matches the target fails instead of reporting
156+// readiness for a generation that restarted.
157+func TestLiveNetworkProbe_RejectsStaleGeneration(t *testing.T) {
158+ runtime, ctx := liveRuntime(t)
159+ network := liveProbeNetwork(t, runtime, ctx)
160+ target := liveProbeTarget(t, runtime, ctx, network,
161+ `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done`)
162+
163+ _, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
164+ TargetContainerID: target.ID,
165+ ExpectedStartedAt: time.Now().Add(-time.Hour).UTC(),
166+ Network: network,
167+ Protocol: domain.ProbeProtocolHTTP,
168+ Port: 8080,
169+ Path: "/",
170+ Timeout: 5 * time.Second,
171+ })
172+ require.Error(t, err)
173+ assert.ErrorIs(t, err, domain.ErrAppStateConflict)
174+}
175+
176+// TestLiveNetworkProbe_NonHTTPGreetingIsNotReady proves a listener whose
177+// first line merely starts with 2 or 3 (a non-HTTP service on a
178+// misdirected port) is never reported ready.
179+func TestLiveNetworkProbe_NonHTTPGreetingIsNotReady(t *testing.T) {
180+ runtime, ctx := liveRuntime(t)
181+ network := liveProbeNetwork(t, runtime, ctx)
182+ target := liveProbeTarget(t, runtime, ctx, network,
183+ `while true; do printf '300 ready\r\n' | nc -l -p 8083; done`)
184+
185+ inspect, err := runtime.InspectContainer(ctx, target.ID)
186+ require.NoError(t, err)
187+
188+ result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
189+ TargetContainerID: target.ID,
190+ ExpectedStartedAt: inspect.StartedAt,
191+ Network: network,
192+ Protocol: domain.ProbeProtocolHTTP,
193+ Port: 8083,
194+ Path: "/",
195+ Timeout: 5 * time.Second,
196+ })
197+ require.NoError(t, err)
198+ assert.False(t, result.Ready, "a non-HTTP greeting must never satisfy HTTP readiness")
199+}
200+
201+// TestLiveNetworkProbe_HungTargetIsUnhealthyNotInfrastructure proves a
202+// target that accepts the connection but never answers yields an unhealthy
203+// result rather than an infrastructure error: the retry loop must keep
204+// polling instead of aborting the wait.
205+func TestLiveNetworkProbe_HungTargetIsUnhealthyNotInfrastructure(t *testing.T) {
206+ runtime, ctx := liveRuntime(t)
207+ network := liveProbeNetwork(t, runtime, ctx)
208+ target := liveProbeTarget(t, runtime, ctx, network,
209+ `while true; do sleep 3600 | nc -l -p 8084; done`)
210+
211+ inspect, err := runtime.InspectContainer(ctx, target.ID)
212+ require.NoError(t, err)
213+
214+ result, err := runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
215+ TargetContainerID: target.ID,
216+ ExpectedStartedAt: inspect.StartedAt,
217+ Network: network,
218+ Protocol: domain.ProbeProtocolHTTP,
219+ Port: 8084,
220+ Path: "/",
221+ Timeout: 4 * time.Second,
222+ })
223+ require.NoError(t, err, "a hanging target is a readiness failure, not an infrastructure error")
224+ assert.False(t, result.Ready)
225+ assert.NotEmpty(t, result.Diagnostic)
226+}
227+
228+// TestLiveNetworkProbe_RejectsForeignNetwork proves the helper is bound to
229+// exactly one network: a target attached elsewhere is never probed across
230+// networks.
231+func TestLiveNetworkProbe_RejectsForeignNetwork(t *testing.T) {
232+ runtime, ctx := liveRuntime(t)
233+ ownerNetwork := liveProbeNetwork(t, runtime, ctx)
234+ foreignNetwork := liveProbeNetwork(t, runtime, ctx)
235+ target := liveProbeTarget(t, runtime, ctx, ownerNetwork,
236+ `while true; do printf "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok" | nc -l -p 8080; done`)
237+
238+ before := liveProbeHelpers(t, runtime, ctx)
239+ inspect, err := runtime.InspectContainer(ctx, target.ID)
240+ require.NoError(t, err)
241+
242+ _, err = runtime.ProbeContainerNetwork(ctx, domain.ContainerNetworkProbeRequest{
243+ TargetContainerID: target.ID,
244+ ExpectedStartedAt: inspect.StartedAt,
245+ Network: foreignNetwork,
246+ Protocol: domain.ProbeProtocolHTTP,
247+ Port: 8080,
248+ Path: "/",
249+ Timeout: 5 * time.Second,
250+ })
251+ require.Error(t, err)
252+ assert.ErrorIs(t, err, domain.ErrAppStateConflict)
253+
254+ assert.Equal(t, before, liveProbeHelpers(t, runtime, ctx), "no helper may survive a rejected probe")
255+}
M internal/adapters/out/docker/live_runtime_test.go +4 -2
@@ -49,9 +49,11 @@ func liveCreate(t *testing.T, runtime *Runtime, ctx context.Context, config *dom
4949 created, err := runtime.CreateContainer(ctx, config)
5050 require.NoError(t, err)
5151 t.Cleanup(func() {
52- cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
52+ cleanup, cancel := context.WithTimeout(context.Background(), 60*time.Second)
5353 defer cancel()
54- _ = runtime.StopContainer(cleanup, created.ID, domain.AppDefaultStopGrace)
54+ // Force-remove in one step. A separate StopContainer would wait the
55+ // full stop grace (30s) and then consume the cleanup budget, leaving
56+ // the container behind and exhausting the daemon's address pool.
5557 _ = runtime.RemoveContainer(cleanup, created.ID, true)
5658 })
5759 require.NoError(t, runtime.StartContainer(ctx, created.ID))
A internal/adapters/out/docker/live_shared_network_test.go +65 -0
@@ -0,0 +1,65 @@
1+//go:build live
2+
3+package docker
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+// TestLiveRuntime_SharedMembershipAndDetachment proves cross-app traffic is
17+// possible only for services explicitly enrolled in the same shared
18+// network, and that unrelated services stay detached.
19+func TestLiveRuntime_SharedMembershipAndDetachment(t *testing.T) {
20+ runtime, ctx := liveRuntime(t)
21+ netA := liveProbeNetwork(t, runtime, ctx)
22+ netB := liveProbeNetwork(t, runtime, ctx)
23+
24+ sharedName := liveName("gordon-live-shared")
25+ require.NoError(t, runtime.CreateNetwork(ctx, sharedName, domain.NetworkConfig{
26+ Driver: "bridge", Labels: domain.AppSharedNetworkLabels("live-shared"),
27+ }))
28+ t.Cleanup(func() {
29+ cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
30+ defer cancel()
31+ _ = runtime.RemoveNetwork(cleanup, sharedName)
32+ })
33+
34+ listenerName := liveName("live-shared-listener")
35+ listener := liveCreate(t, runtime, ctx, &domain.ContainerConfig{
36+ Image: liveImage, Name: listenerName, NetworkMode: netA,
37+ Entrypoint: []string{"sh", "-c", "while true; do nc -l -p 9100 >/dev/null 2>&1; done"},
38+ AutoRemove: false,
39+ })
40+ require.NoError(t, runtime.ConnectContainerToNetwork(ctx, listener.ID, sharedName))
41+
42+ // Enrolled on the shared network from another app network.
43+ enrolled := liveCreate(t, runtime, ctx, &domain.ContainerConfig{
44+ Image: liveImage, Name: liveName("live-shared-enrolled"), NetworkMode: netB,
45+ Entrypoint: []string{"sleep", "180"}, AutoRemove: false,
46+ })
47+ require.NoError(t, runtime.ConnectContainerToNetwork(ctx, enrolled.ID, sharedName))
48+
49+ // An unrelated service on the same app network but NOT enrolled.
50+ outsider := liveCreate(t, runtime, ctx, &domain.ContainerConfig{
51+ Image: liveImage, Name: liveName("live-shared-outsider"), NetworkMode: netB,
52+ Entrypoint: []string{"sleep", "180"}, AutoRemove: false,
53+ })
54+
55+ probe := []string{"sh", "-c", "nc -z -w 3 " + listenerName + " 9100"}
56+
57+ require.Eventually(t, func() bool {
58+ result, err := runtime.ExecInContainer(ctx, enrolled.ID, probe)
59+ return err == nil && result.ExitCode == 0
60+ }, 60*time.Second, 2*time.Second, "an enrolled service must reach the peer through the shared network")
61+
62+ result, err := runtime.ExecInContainer(ctx, outsider.ID, probe)
63+ require.NoError(t, err, "the exec itself must run; a transport error is not proof of isolation")
64+ assert.NotZero(t, result.ExitCode, "an unrelated service must not reach a peer it is not enrolled with")
65+}
A internal/adapters/out/docker/network_probe.go +439 -0
@@ -0,0 +1,439 @@
1+package docker
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+ cerrdefs "github.com/containerd/errdefs"
16+ "github.com/moby/moby/api/pkg/stdcopy"
17+ "github.com/moby/moby/api/types/container"
18+ "github.com/moby/moby/client"
19+
20+ "github.com/bnema/gordon/internal/domain"
21+)
22+
23+// DefaultNetworkProbeImage is the digest-pinned, multi-arch helper image
24+// one bounded internal readiness session runs. It provides only a POSIX
25+// shell with busybox nc/printf. Pin by digest, never by tag: the helper
26+// must be byte-identical across hosts and engines. The image must be
27+// pre-provisioned and available offline on the target host, because a
28+// probe never pulls.
29+const DefaultNetworkProbeImage = "alpine@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"
30+
31+// Bounded resources and windows for the probe helper. They exist only to
32+// bound a misbehaving helper, not to size a workload.
33+const (
34+ probeHelperMemory = 32 << 20
35+ probeHelperNanoCPUs = 500_000_000
36+ probeHelperPIDs = 32
37+ // probeHelperCleanup bounds a force-remove of one helper.
38+ probeHelperCleanup = 30 * time.Second
39+ probeMaxSeconds = 30
40+ // probeHelperOrphanAge is how old a leftover probe helper must be
41+ // before a later probe reclaims it. Live helpers last seconds, so this
42+ // never touches an in-flight session, including a concurrent one.
43+ probeHelperOrphanAge = 10 * time.Minute
44+)
45+
46+// networkProbeArgv is the helper argv: a shell script plus positional
47+// arguments, so the target address never appears in a shell string.
48+type networkProbeArgv struct {
49+ script string
50+ args []string
51+}
52+
53+// httpProbeScript sends one HTTP/1.0 request over a raw connection and
54+// accepts only a 2xx/3xx status line. It never follows redirects and never
55+// consults a proxy. The first line must be an HTTP status line: a
56+// non-HTTP greeting that merely starts with 2 or 3 is never readiness. It
57+// prints the observed status (000 when none) for diagnostics.
58+const httpProbeScript = `path="$1"; tmo="$2"; ip="$3"; port="$4"
59+line=$(printf 'GET %s HTTP/1.0\r\nHost: readiness\r\nConnection: close\r\n\r\n' "$path" | nc -w "$tmo" "$ip" "$port" | head -c 128 | head -n1)
60+code=$(printf '%s\n' "$line" | awk '$1 ~ /^HTTP\/[0-9]+\.[0-9]+$/ && $2 ~ /^[0-9][0-9][0-9]$/ { print $2 }')
61+case "$code" in
62+ 2??|3??) printf '%s\n' "$code"; exit 0;;
63+ [0-9][0-9][0-9]) printf '%s\n' "$code"; exit 1;;
64+ *) printf '%s\n' 000; exit 1;;
65+esac`
66+
67+// tcpProbeScript accepts only an accepted connection.
68+const tcpProbeScript = `nc -z -w "$1" "$2" "$3"`
69+
70+// ProbeContainerNetwork runs one bounded readiness session against one
71+// declared internal container port. The helper joins only the exact
72+// network in the request, targets the exact inspected endpoint IP (never
73+// a service alias), and is always force-removed under an independent
74+// cleanup context. An infrastructure failure returns an error and must
75+// never be retried as an unhealthy attempt; an unhealthy target returns a
76+// result with Ready=false.
77+func (r *Runtime) ProbeContainerNetwork(ctx context.Context, request domain.ContainerNetworkProbeRequest) (result domain.ContainerNetworkProbeResult, retErr error) {
78+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
79+ zerowrap.FieldLayer: "adapter",
80+ zerowrap.FieldAdapter: "docker",
81+ zerowrap.FieldAction: "ProbeContainerNetwork",
82+ "network": request.Network,
83+ })
84+ log := zerowrap.FromCtx(ctx)
85+
86+ if err := request.Validate(); err != nil {
87+ return domain.ContainerNetworkProbeResult{}, err
88+ }
89+ sessionCtx, sessionCancel := context.WithTimeout(ctx, request.Timeout)
90+ defer sessionCancel()
91+ targetIP, err := r.inspectProbeTarget(sessionCtx, request)
92+ if err != nil {
93+ return domain.ContainerNetworkProbeResult{}, err
94+ }
95+
96+ helperID, cleanup, err := r.createProbeHelper(sessionCtx, request, targetIP)
97+ if err != nil {
98+ return domain.ContainerNetworkProbeResult{}, err
99+ }
100+ defer func() {
101+ result, retErr = probeCleanupResult(log, cleanup, result, retErr)
102+ }()
103+
104+ if _, err := r.client.ContainerStart(sessionCtx, helperID, client.ContainerStartOptions{}); err != nil {
105+ return domain.ContainerNetworkProbeResult{}, log.WrapErr(err, "failed to start network probe helper")
106+ }
107+
108+ timedOut, err := r.waitForProbeHelper(sessionCtx, helperID)
109+ if err != nil {
110+ return domain.ContainerNetworkProbeResult{}, err
111+ }
112+ // A bound expiry is a readiness failure, not an infrastructure
113+ // failure. Return it before any post-session step, and never classify
114+ // it through a context that just expired. Caller cancellation stays an
115+ // error so the deployment stops immediately.
116+ if timedOut {
117+ return probeWaitTimeoutResult(ctx)
118+ }
119+
120+ exitCode, stdout, err := r.probeHelperOutcome(sessionCtx, helperID)
121+ if result, handled := classifyProbeStepError(sessionCtx, ctx, err); handled {
122+ return result, err
123+ }
124+ // Revalidate identity before trusting the outcome: a target that
125+ // restarted during the session must not report readiness for a
126+ // generation that no longer exists. The caller may retry this as a
127+ // candidate that is simply not ready yet.
128+ finalIP, err := r.inspectProbeTarget(sessionCtx, request)
129+ if result, handled := classifyProbeStepError(sessionCtx, ctx, err); handled {
130+ return result, err
131+ }
132+ if finalIP != targetIP {
133+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("deployment: probe target endpoint changed during readiness: %w", domain.ErrAppStateConflict)
134+ }
135+ if err := ctx.Err(); err != nil {
136+ return domain.ContainerNetworkProbeResult{}, err
137+ }
138+
139+ result = domain.ContainerNetworkProbeResult{Ready: exitCode == 0}
140+ if request.Protocol == domain.ProbeProtocolHTTP {
141+ result.Status = parseProbeStatus(stdout)
142+ result.Ready = result.Ready && result.Status >= 200 && result.Status < 400
143+ }
144+ if !result.Ready {
145+ result.Diagnostic = probeDiagnostic(request, result.Status)
146+ }
147+ return result, nil
148+}
149+
150+func probeWaitTimeoutResult(parentCtx context.Context) (domain.ContainerNetworkProbeResult, error) {
151+ if err := parentCtx.Err(); err != nil {
152+ return domain.ContainerNetworkProbeResult{}, err
153+ }
154+ return probeTimeoutFailure(), nil
155+}
156+
157+func classifyProbeStepError(sessionCtx, parentCtx context.Context, err error) (domain.ContainerNetworkProbeResult, bool) {
158+ if err == nil {
159+ return domain.ContainerNetworkProbeResult{}, false
160+ }
161+ if probeBoundExpired(sessionCtx, parentCtx) {
162+ return probeTimeoutFailure(), true
163+ }
164+ return domain.ContainerNetworkProbeResult{}, true
165+}
166+
167+// probeTimeoutFailure is the bounded readiness failure of a session whose
168+// own bound expired. It is not an infrastructure error: the target may
169+// simply be slow, so the caller may retry it as an unhealthy attempt.
170+func probeTimeoutFailure() domain.ContainerNetworkProbeResult {
171+ return domain.ContainerNetworkProbeResult{Diagnostic: "probe did not complete within its bound"}
172+}
173+
174+// probeBoundExpired reports whether a post-wait probe step failed because
175+// the session's own bound expired. A caller cancellation is never a bound
176+// expiry: it must stay an error so the deployment stops immediately.
177+func probeBoundExpired(sessionCtx, parentCtx context.Context) bool {
178+ return parentCtx.Err() == nil && errors.Is(sessionCtx.Err(), context.DeadlineExceeded)
179+}
180+
181+func probeCleanupResult(log zerowrap.Logger, cleanup func() error, result domain.ContainerNetworkProbeResult, retErr error) (domain.ContainerNetworkProbeResult, error) {
182+ cleanErr := cleanup()
183+ if cleanErr == nil {
184+ return result, retErr
185+ }
186+ log.Warn().Err(cleanErr).Msg("failed to remove network probe helper")
187+ if retErr != nil {
188+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("%w: %v; prior probe error: %w", domain.ErrNetworkProbeCleanup, cleanErr, retErr)
189+ }
190+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("%w: %v", domain.ErrNetworkProbeCleanup, cleanErr)
191+}
192+
193+// inspectProbeTarget resolves the exact endpoint IP of the target on the
194+// requested network and enforces the expected execution start. It fails
195+// closed, wrapping domain.ErrContainerNotFound or domain.ErrAppStateConflict
196+// when the target is gone, not running, not attached to that network, or
197+// has restarted, so the caller can tell "not ready yet" from a broken
198+// helper.
199+func (r *Runtime) inspectProbeTarget(ctx context.Context, request domain.ContainerNetworkProbeRequest) (string, error) {
200+ inspectResult, err := r.client.ContainerInspect(ctx, request.TargetContainerID, client.ContainerInspectOptions{})
201+ if err != nil {
202+ if cerrdefs.IsNotFound(err) {
203+ return "", fmt.Errorf("%w: %s", domain.ErrContainerNotFound, request.TargetContainerID)
204+ }
205+ return "", fmt.Errorf("deployment: inspect probe target: %w", err)
206+ }
207+ observed := inspectResult.Container
208+ if observed.State == nil || observed.State.Status != container.StateRunning {
209+ return "", fmt.Errorf("deployment: probe target %s is not running: %w", request.TargetContainerID, domain.ErrAppStateConflict)
210+ }
211+ startedAt, _ := time.Parse(time.RFC3339Nano, observed.State.StartedAt)
212+ if startedAt.Year() <= 1 {
213+ startedAt = time.Time{}
214+ }
215+ if !startedAt.Equal(request.ExpectedStartedAt) {
216+ return "", fmt.Errorf("deployment: probe target %s execution changed during readiness: %w", request.TargetContainerID, domain.ErrAppStateConflict)
217+ }
218+ if observed.NetworkSettings == nil {
219+ return "", fmt.Errorf("deployment: probe target %s has no network settings: %w", request.TargetContainerID, domain.ErrAppStateConflict)
220+ }
221+ endpoint, ok := observed.NetworkSettings.Networks[request.Network]
222+ if !ok || endpoint == nil || !endpoint.IPAddress.IsValid() {
223+ return "", fmt.Errorf("deployment: probe target %s is not attached to network %q: %w", request.TargetContainerID, request.Network, domain.ErrAppStateConflict)
224+ }
225+ return endpoint.IPAddress.String(), nil
226+}
227+
228+// createProbeHelper creates the hardened helper container on only the
229+// requested network. It has no host ports, mounts, volumes, environment,
230+// or shared networks, runs non-root on a read-only rootfs with all
231+// capabilities dropped, and never gains new privileges.
232+func (r *Runtime) createProbeHelper(ctx context.Context, request domain.ContainerNetworkProbeRequest, targetIP string) (string, func() error, error) {
233+ log := zerowrap.FromCtx(ctx)
234+ // Reclaim helpers orphaned by a previous crash before adding another:
235+ // in-process cleanup cannot run after SIGKILL, so a later probe is the
236+ // only owner that can bound that accumulation.
237+ r.sweepOrphanedProbeHelpers(ctx)
238+
239+ command := buildNetworkProbeCommand(request, targetIP)
240+ entrypoint := append([]string{"sh", "-c", command.script, "sh"}, command.args...)
241+
242+ created, err := r.client.ContainerCreate(ctx, client.ContainerCreateOptions{
243+ Config: &container.Config{
244+ Image: DefaultNetworkProbeImage,
245+ Entrypoint: entrypoint,
246+ User: "65534:65534",
247+ AttachStdout: true,
248+ AttachStderr: true,
249+ Labels: map[string]string{
250+ domain.LabelPurpose: domain.PurposeNetworkProbe,
251+ },
252+ },
253+ HostConfig: probeHelperHostConfig(request.Network),
254+ Name: fmt.Sprintf("gordon-network-probe-%d", time.Now().UTC().UnixNano()),
255+ })
256+ if err != nil {
257+ if cerrdefs.IsNotFound(err) {
258+ return "", nil, log.WrapErr(fmt.Errorf(
259+ "network probe helper image %s is not available on this host: pre-provision the pinned image, the probe never pulls: %w",
260+ DefaultNetworkProbeImage, err), "failed to create network probe helper")
261+ }
262+ return "", nil, log.WrapErr(err, "failed to create network probe helper")
263+ }
264+
265+ var cleanupErr error
266+ var cleaned bool
267+ cleanup := func() error {
268+ if cleaned {
269+ return cleanupErr
270+ }
271+ cleaned = true
272+ // Independent bounded context: the session context may already be
273+ // expired, but the helper must still be force-removed.
274+ removeCtx, cancel := context.WithTimeout(context.Background(), probeHelperCleanup)
275+ defer cancel()
276+ cleanupErr = r.RemoveContainer(removeCtx, created.ID, true)
277+ return cleanupErr
278+ }
279+ return created.ID, cleanup, nil
280+}
281+
282+// sweepOrphanedProbeHelpers best-effort removes probe helpers older than
283+// probeHelperOrphanAge. It is bounded to the probe purpose label, so it
284+// can never touch a workload, a volume archive helper, or an in-flight
285+// session. A sweep failure never fails the probe.
286+func (r *Runtime) sweepOrphanedProbeHelpers(ctx context.Context) {
287+ log := zerowrap.FromCtx(ctx)
288+ list, err := r.client.ContainerList(ctx, client.ContainerListOptions{
289+ All: true,
290+ Filters: client.Filters{}.Add("label", domain.LabelPurpose+"="+domain.PurposeNetworkProbe),
291+ })
292+ if err != nil {
293+ log.Debug().Err(err).Msg("network probe orphan sweep: list failed")
294+ return
295+ }
296+ cutoff := time.Now().Add(-probeHelperOrphanAge)
297+ for _, orphan := range list.Items {
298+ if time.Unix(orphan.Created, 0).After(cutoff) {
299+ continue // live session, leave it alone
300+ }
301+ if err := ctx.Err(); err != nil {
302+ return
303+ }
304+ removeCtx, cancel := context.WithTimeout(ctx, probeHelperCleanup)
305+ removeErr := r.RemoveContainer(removeCtx, orphan.ID, true)
306+ cancel()
307+ if removeErr != nil {
308+ log.Debug().Err(removeErr).Msg("network probe orphan sweep: remove failed")
309+ continue
310+ }
311+ log.Info().Str("container", orphan.ID).Msg("reclaimed orphaned network probe helper")
312+ }
313+}
314+
315+// probeHelperHostConfig is the smallest host configuration able to reach
316+// the target's private network and run a shell probe. Every field that
317+// would widen the helper's reach is left at its zero value.
318+func probeHelperHostConfig(network string) *container.HostConfig {
319+ pids := int64(probeHelperPIDs)
320+ return &container.HostConfig{
321+ AutoRemove: false,
322+ NetworkMode: container.NetworkMode(network),
323+ ReadonlyRootfs: true,
324+ SecurityOpt: []string{"no-new-privileges:true"},
325+ CapDrop: []string{"ALL"},
326+ CapAdd: []string{},
327+ Resources: container.Resources{
328+ Memory: probeHelperMemory,
329+ NanoCPUs: probeHelperNanoCPUs,
330+ PidsLimit: &pids,
331+ },
332+ }
333+}
334+
335+// waitForProbeHelper blocks until the helper exits or the bound expires.
336+// A bound expiry is reported as a non-fatal timeout: the target may be
337+// hanging, which is a readiness failure, not an infrastructure failure.
338+func (r *Runtime) waitForProbeHelper(ctx context.Context, helperID string) (bool, error) {
339+ waitResult := r.client.ContainerWait(ctx, helperID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
340+ select {
341+ case err := <-waitResult.Error:
342+ if err != nil {
343+ if ctx.Err() != nil {
344+ return true, nil
345+ }
346+ return false, fmt.Errorf("deployment: wait for network probe helper: %w", err)
347+ }
348+ return false, nil
349+ case <-waitResult.Result:
350+ return false, nil
351+ case <-ctx.Done():
352+ return true, nil
353+ }
354+}
355+
356+// probeHelperOutcome reads the helper's exit code and bounded stdout.
357+func (r *Runtime) probeHelperOutcome(ctx context.Context, helperID string) (int, string, error) {
358+ log := zerowrap.FromCtx(ctx)
359+ logs, err := r.client.ContainerLogs(ctx, helperID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
360+ if err != nil {
361+ return 0, "", log.WrapErr(err, "failed to read network probe helper logs")
362+ }
363+ defer func() { _ = logs.Close() }()
364+
365+ var stdout cappedProbeBuffer
366+ if _, err := stdcopy.StdCopy(&stdout, io.Discard, logs); err != nil {
367+ return 0, "", log.WrapErr(err, "failed to demux network probe helper logs")
368+ }
369+
370+ inspectResult, err := r.client.ContainerInspect(ctx, helperID, client.ContainerInspectOptions{})
371+ if err != nil {
372+ return 0, "", fmt.Errorf("deployment: inspect network probe helper: %w", err)
373+ }
374+ if inspectResult.Container.State == nil {
375+ return 0, "", fmt.Errorf("deployment: network probe helper has no state: %w", domain.ErrAppStateConflict)
376+ }
377+ return inspectResult.Container.State.ExitCode, stdout.String(), nil
378+}
379+
380+type cappedProbeBuffer struct {
381+ bytes.Buffer
382+}
383+
384+func (b *cappedProbeBuffer) Write(p []byte) (int, error) {
385+ const limit = 64
386+ remaining := limit - b.Len()
387+ if remaining > 0 {
388+ _, _ = b.Buffer.Write(p[:min(len(p), remaining)])
389+ }
390+ return len(p), nil
391+}
392+
393+// buildNetworkProbeCommand builds the helper argv for the requested
394+// protocol. The nc bound is kept strictly shorter than the attempt bound,
395+// so the helper exits while the caller context is still alive; it is
396+// rounded to whole seconds because nc -w takes seconds, floored at one,
397+// and clamped so one attempt can never outlive its bound.
398+func buildNetworkProbeCommand(request domain.ContainerNetworkProbeRequest, targetIP string) networkProbeArgv {
399+ seconds := int(math.Ceil(request.Timeout.Seconds())) - 1
400+ if seconds < 1 {
401+ seconds = 1
402+ }
403+ if seconds > probeMaxSeconds {
404+ seconds = probeMaxSeconds
405+ }
406+ port := strconv.Itoa(request.Port)
407+ if request.Protocol == domain.ProbeProtocolHTTP {
408+ return networkProbeArgv{
409+ script: httpProbeScript,
410+ args: []string{request.Path, strconv.Itoa(seconds), targetIP, port},
411+ }
412+ }
413+ return networkProbeArgv{
414+ script: tcpProbeScript,
415+ args: []string{strconv.Itoa(seconds), targetIP, port},
416+ }
417+}
418+
419+// parseProbeStatus extracts the HTTP status the helper observed; 0 when
420+// none was read.
421+func parseProbeStatus(stdout string) int {
422+ status, err := strconv.Atoi(strings.TrimSpace(stdout))
423+ if err != nil || status < 0 || status > 999 {
424+ return 0
425+ }
426+ return status
427+}
428+
429+// probeDiagnostic describes an unhealthy attempt without leaking addresses
430+// or command output.
431+func probeDiagnostic(request domain.ContainerNetworkProbeRequest, status int) string {
432+ if request.Protocol == domain.ProbeProtocolHTTP {
433+ if status > 0 {
434+ return fmt.Sprintf("http status %d", status)
435+ }
436+ return "no http response"
437+ }
438+ return "tcp connection refused or timed out"
439+}
A internal/adapters/out/docker/network_probe_test.go +174 -0
@@ -0,0 +1,174 @@
1+package docker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strconv"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/moby/moby/api/types/container"
17+
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+// TestBuildNetworkProbeCommand proves the helper argv carries the exact
22+// target and clamps the timeout to whole seconds within its bound.
23+func TestBuildNetworkProbeCommand(t *testing.T) {
24+ t.Run("http carries path, whole-second timeout, and exact endpoint", func(t *testing.T) {
25+ command := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
26+ Protocol: domain.ProbeProtocolHTTP,
27+ Path: "/healthz",
28+ Port: 8080,
29+ Timeout: 1500 * time.Millisecond,
30+ }, "172.18.0.5")
31+ assert.Equal(t, httpProbeScript, command.script)
32+ assert.Equal(t, []string{"/healthz", "1", "172.18.0.5", "8080"}, command.args)
33+ })
34+
35+ t.Run("tcp carries no path", func(t *testing.T) {
36+ command := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
37+ Protocol: domain.ProbeProtocolTCP,
38+ Port: 5432,
39+ Timeout: time.Second,
40+ }, "172.18.0.6")
41+ assert.Equal(t, tcpProbeScript, command.script)
42+ assert.Equal(t, []string{"1", "172.18.0.6", "5432"}, command.args)
43+ })
44+
45+ t.Run("nc bound stays strictly under the attempt bound", func(t *testing.T) {
46+ for _, timeout := range []time.Duration{1500 * time.Millisecond, 5 * time.Second, 30 * time.Second} {
47+ command := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
48+ Protocol: domain.ProbeProtocolTCP, Port: 1, Timeout: timeout,
49+ }, "10.0.0.1")
50+ seconds, err := strconv.Atoi(command.args[0])
51+ require.NoError(t, err)
52+ assert.Less(t, float64(seconds), timeout.Seconds(),
53+ "the helper must exit before the caller context expires for %s", timeout)
54+ }
55+ })
56+
57+ t.Run("timeout is clamped into the bounded window", func(t *testing.T) {
58+ tooSmall := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
59+ Protocol: domain.ProbeProtocolTCP, Port: 1, Timeout: time.Millisecond,
60+ }, "10.0.0.1")
61+ assert.Equal(t, "1", tooSmall.args[0])
62+
63+ tooLarge := buildNetworkProbeCommand(domain.ContainerNetworkProbeRequest{
64+ Protocol: domain.ProbeProtocolTCP, Port: 1, Timeout: time.Hour,
65+ }, "10.0.0.1")
66+ assert.Equal(t, "30", tooLarge.args[0])
67+ })
68+}
69+
70+// TestProbeHelperHostConfig_IsHardened proves the helper can reach its
71+// network and nothing else: no ports, mounts, volumes, or extra capability.
72+func TestProbeHelperHostConfig_IsHardened(t *testing.T) {
73+ config := probeHelperHostConfig("gordon--blog--net")
74+ require.NotNil(t, config)
75+ assert.Equal(t, "gordon--blog--net", string(config.NetworkMode))
76+ assert.True(t, config.ReadonlyRootfs)
77+ assert.Equal(t, []string{"ALL"}, config.CapDrop)
78+ assert.Empty(t, config.CapAdd)
79+ assert.Equal(t, []string{"no-new-privileges:true"}, config.SecurityOpt)
80+ assert.False(t, config.Privileged)
81+
82+ assert.NotZero(t, config.Memory)
83+ require.NotNil(t, config.PidsLimit)
84+ assert.Positive(t, *config.PidsLimit)
85+ assert.NotZero(t, config.NanoCPUs)
86+
87+ assert.Empty(t, config.PortBindings)
88+ assert.Empty(t, config.Binds)
89+ assert.Empty(t, config.Mounts)
90+ assert.Empty(t, config.VolumesFrom)
91+ assert.Empty(t, config.Devices)
92+ assert.NotEqual(t, container.NetworkMode("none"), config.NetworkMode, "the helper must reach its target network")
93+}
94+
95+// TestProbeHelperConfig_NoSharedNetworks proves the helper is created with
96+// exactly one network and no aliases.
97+func TestProbeHelperConfig_NoSharedNetworks(t *testing.T) {
98+ config := probeHelperHostConfig("gordon--blog--net")
99+ assert.Empty(t, config.ExtraHosts)
100+ assert.Empty(t, config.DNS)
101+ assert.Empty(t, config.Links)
102+}
103+
104+func TestParseProbeStatus(t *testing.T) {
105+ assert.Equal(t, 200, parseProbeStatus("200\n"))
106+ assert.Equal(t, 404, parseProbeStatus("404"))
107+ assert.Equal(t, 0, parseProbeStatus(""))
108+ assert.Equal(t, 0, parseProbeStatus("not-a-status"))
109+ assert.Equal(t, 0, parseProbeStatus("1234"))
110+}
111+
112+func TestCappedProbeBuffer(t *testing.T) {
113+ var buffer cappedProbeBuffer
114+ input := strings.Repeat("x", 1024)
115+ written, err := buffer.Write([]byte(input))
116+ require.NoError(t, err)
117+ assert.Equal(t, len(input), written)
118+ assert.Len(t, buffer.String(), 64)
119+}
120+
121+func TestHTTPProbeScript_RequiresThreeDigitStatus(t *testing.T) {
122+ assert.Contains(t, httpProbeScript, "$2 ~ /^[0-9][0-9][0-9]$/")
123+ assert.NotContains(t, httpProbeScript, "2*|3*")
124+ assert.Contains(t, httpProbeScript, "head -c 128")
125+}
126+
127+func TestProbeCleanupResult_PriorErrorNeverHidesCleanupFailure(t *testing.T) {
128+ cleanupErr := errors.New("remove failed")
129+ priorErr := fmt.Errorf("stale: %w", domain.ErrAppStateConflict)
130+ result, err := probeCleanupResult(zerowrap.Default(), func() error { return cleanupErr }, domain.ContainerNetworkProbeResult{Ready: true}, priorErr)
131+ assert.False(t, result.Ready)
132+ require.ErrorIs(t, err, domain.ErrNetworkProbeCleanup)
133+ require.ErrorIs(t, err, priorErr)
134+}
135+
136+func TestProbeBoundExpired_OnlySessionDeadline(t *testing.T) {
137+ parent, cancel := context.WithCancel(context.Background())
138+ session, sessionCancel := context.WithDeadline(parent, time.Now().Add(-time.Second))
139+ defer sessionCancel()
140+ <-session.Done()
141+
142+ assert.True(t, probeBoundExpired(session, parent),
143+ "the session's own bound expiring is a readiness timeout")
144+
145+ // Caller cancellation must never be reclassified as a readiness
146+ // timeout: it has to stay an error so the deployment stops.
147+ cancel()
148+ <-session.Done()
149+ assert.False(t, probeBoundExpired(session, parent),
150+ "caller cancellation must stay an error")
151+
152+ assert.False(t, probeBoundExpired(context.Background(), context.Background()),
153+ "a live session has not expired")
154+}
155+
156+func TestProbeTimeoutFailure_IsUnhealthyNotError(t *testing.T) {
157+ result := probeTimeoutFailure()
158+ assert.False(t, result.Ready, "a bound expiry must not report readiness")
159+ assert.NotEmpty(t, result.Diagnostic, "a bound expiry must carry a bounded diagnostic")
160+}
161+
162+func TestProbeDiagnostic(t *testing.T) {
163+ http := domain.ContainerNetworkProbeRequest{Protocol: domain.ProbeProtocolHTTP}
164+ assert.Equal(t, "http status 503", probeDiagnostic(http, 503))
165+ assert.Equal(t, "no http response", probeDiagnostic(http, 0))
166+
167+ tcp := domain.ContainerNetworkProbeRequest{Protocol: domain.ProbeProtocolTCP}
168+ assert.Equal(t, "tcp connection refused or timed out", probeDiagnostic(tcp, 0))
169+}
170+
171+func TestDefaultNetworkProbeImage_IsDigestPinned(t *testing.T) {
172+ assert.Contains(t, DefaultNetworkProbeImage, "@sha256:", "the helper image must be pinned by digest")
173+ assert.True(t, strings.HasPrefix(DefaultNetworkProbeImage, "alpine@"), "the helper needs only busybox shell tools")
174+}
M internal/adapters/out/docker/runtime.go +129 -10
@@ -13,7 +13,10 @@ import (
1313 "io"
1414 "maps"
1515 "math"
16+ "net"
17+ "net/http"
1618 "net/netip"
19+ "net/url"
1720 "os"
1821 "path/filepath"
1922 "sort"
@@ -39,7 +42,8 @@ import (
3942
4043 // Runtime implements the ContainerRuntime interface using Docker API.
4144 type Runtime struct {
42- client *client.Client
45+ client *client.Client
46+ runtimeName string
4347 }
4448
4549 var _ out.ContainerRuntime = (*Runtime)(nil)
@@ -114,7 +118,7 @@ func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
114118 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
115119 }
116120
117- return &Runtime{client: cli}, nil
121+ return &Runtime{client: cli, runtimeName: guessRuntimeName(socketPath)}, nil
118122 }
119123
120124 // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
@@ -140,6 +144,10 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
140144 return nil, err
141145 }
142146 binds := buildVolumeBinds(config, log)
147+ mounts, err := buildBindMounts(config)
148+ if err != nil {
149+ return nil, err
150+ }
143151
144152 // Create container configuration
145153 containerConfig := &container.Config{
@@ -176,6 +184,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
176184 PortBindings: portBindings,
177185 AutoRemove: config.AutoRemove,
178186 Binds: binds,
187+ Mounts: mounts,
179188 NetworkMode: container.NetworkMode(config.NetworkMode),
180189 Resources: resources,
181190 SecurityOpt: []string{"no-new-privileges:true"},
@@ -288,6 +297,43 @@ func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []str
288297 return binds
289298 }
290299
300+// buildBindMounts validates ephemeral host binds defensively and translates
301+// them into Docker bind mounts. The result is sorted by destination so the
302+// create request is deterministic. Host source paths are never logged or
303+// echoed in errors.
304+func buildBindMounts(config *domain.ContainerConfig) ([]mount.Mount, error) {
305+ if len(config.Binds) == 0 {
306+ return nil, nil
307+ }
308+ mounts := make([]mount.Mount, 0, len(config.Binds))
309+ seen := make(map[string]string, len(config.Binds))
310+ for _, bind := range config.Binds {
311+ if bind.Source == "" || !filepath.IsAbs(bind.Source) || filepath.Clean(bind.Source) != bind.Source {
312+ return nil, fmt.Errorf("invalid bind %q: source must be an absolute clean path", bind.Name)
313+ }
314+ if err := domain.ValidateBindDestination(bind.Destination); err != nil {
315+ return nil, fmt.Errorf("invalid bind %q: %w", bind.Name, err)
316+ }
317+ if prev, ok := seen[bind.Destination]; ok {
318+ return nil, fmt.Errorf("invalid bind %q: destination %q already used by bind %q", bind.Name, bind.Destination, prev)
319+ }
320+ seen[bind.Destination] = bind.Name
321+ mounts = append(mounts, mount.Mount{
322+ Type: mount.TypeBind,
323+ Source: bind.Source,
324+ Target: bind.Destination,
325+ ReadOnly: bind.ReadOnly,
326+ })
327+ }
328+ sort.SliceStable(mounts, func(i, j int) bool {
329+ if mounts[i].Target != mounts[j].Target {
330+ return mounts[i].Target < mounts[j].Target
331+ }
332+ return mounts[i].Source < mounts[j].Source
333+ })
334+ return mounts, nil
335+}
336+
291337 // StartContainer starts a container.
292338 func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
293339 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -628,6 +674,70 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
628674 return logs, nil
629675 }
630676
677+func (r *Runtime) PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error {
678+ if request.Transport == domain.ImagePullTransportHTTP {
679+ if r.runtimeName == "podman" || strings.Contains(strings.ToLower(r.client.DaemonHost()), "podman") {
680+ return r.pullPodmanHTTP(ctx, request)
681+ }
682+ // Docker has no per-pull HTTP switch. Its daemon honors HTTP only for
683+ // endpoints configured in insecure-registries; retain the compatible
684+ // pull API and let a missing daemon policy fail explicitly.
685+ if request.Username != "" || request.Password != "" {
686+ return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password)
687+ }
688+ return r.PullImage(ctx, request.Reference)
689+ }
690+ if request.Username != "" || request.Password != "" {
691+ return r.PullImageWithAuth(ctx, request.Reference, request.Username, request.Password)
692+ }
693+ return r.PullImage(ctx, request.Reference)
694+}
695+
696+func (r *Runtime) pullPodmanHTTP(ctx context.Context, request domain.ImagePullRequest) error {
697+ host := r.client.DaemonHost()
698+ if !strings.HasPrefix(host, "unix://") {
699+ return fmt.Errorf("unsupported Podman endpoint %q for HTTP pull", host)
700+ }
701+ httpClient := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
702+ return (&net.Dialer{}).DialContext(ctx, "unix", strings.TrimPrefix(host, "unix://"))
703+ }}}
704+ query := url.Values{"reference": {request.Reference}, "tlsVerify": {"false"}, "quiet": {"false"}}
705+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://podman/v5.0.0/libpod/images/pull?"+query.Encode(), nil)
706+ if err != nil {
707+ return err
708+ }
709+ if request.Username != "" || request.Password != "" {
710+ auth, err := json.Marshal(registry.AuthConfig{Username: request.Username, Password: request.Password})
711+ if err != nil {
712+ return err
713+ }
714+ req.Header.Set(registry.AuthHeader, base64.StdEncoding.EncodeToString(auth))
715+ }
716+ resp, err := httpClient.Do(req)
717+ if err != nil {
718+ return fmt.Errorf("podman HTTP image pull: %w", err)
719+ }
720+ defer resp.Body.Close()
721+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
722+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
723+ return fmt.Errorf("podman HTTP image pull returned %s: %s: %w", resp.Status, strings.TrimSpace(string(body)), domain.ErrImagePullFailed)
724+ }
725+ decoder := json.NewDecoder(resp.Body)
726+ for {
727+ var event struct {
728+ Error string `json:"error"`
729+ }
730+ if err := decoder.Decode(&event); errors.Is(err, io.EOF) {
731+ break
732+ } else if err != nil {
733+ return fmt.Errorf("decode Podman pull response: %w", err)
734+ } else if event.Error != "" {
735+ return fmt.Errorf("podman image pull: %s: %w", event.Error, domain.ErrImagePullFailed)
736+ }
737+ }
738+ return nil
739+}
740+
631741 // PullImage pulls an image.
632742 func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
633743 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -646,10 +756,8 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
646756 }
647757 defer reader.Close()
648758
649- // Read the response to completion (this is required for the pull to complete)
650- _, err = io.Copy(io.Discard, reader)
651- if err != nil {
652- return log.WrapErr(err, "failed to read pull response")
759+ if err := reader.Wait(ctx); err != nil {
760+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete image pull")
653761 }
654762
655763 log.Info().Msg("image pulled successfully")
@@ -706,10 +814,8 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
706814 }
707815 defer reader.Close()
708816
709- // Read the response to completion (this is required for the pull to complete)
710- _, err = io.Copy(io.Discard, reader)
711- if err != nil {
712- return log.WrapErr(err, "failed to read pull response")
817+ if err := reader.Wait(ctx); err != nil {
818+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete authenticated image pull")
713819 }
714820
715821 log.Info().Msg("image pulled successfully with authentication")
@@ -1584,6 +1690,19 @@ func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[stri
15841690 }
15851691
15861692 // GetImageID returns the unique image ID (sha256 digest) for the given image reference.
1693+func (r *Runtime) VerifyImageDigest(ctx context.Context, imageRef, digest string) error {
1694+ inspect, err := r.client.ImageInspect(ctx, imageRef)
1695+ if err != nil {
1696+ return fmt.Errorf("inspect image digest: %w", err)
1697+ }
1698+ for _, repoDigest := range inspect.RepoDigests {
1699+ if strings.HasSuffix(repoDigest, "@"+digest) {
1700+ return nil
1701+ }
1702+ }
1703+ return fmt.Errorf("expected digest %s is absent from local image metadata: %w", digest, domain.ErrImagePullFailed)
1704+}
1705+
15871706 func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
15881707 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
15891708 zerowrap.FieldLayer: "adapter",
M internal/adapters/out/docker/runtime_create_test.go +84 -0
@@ -171,3 +171,87 @@ func TestRuntime_CreateContainerAppliesNetworkAndResourceLimits(t *testing.T) {
171171 require.True(t, ok)
172172 assert.Contains(t, endpoints, "gordon-app-abc123")
173173 }
174+
175+// TestRuntime_CreateContainerTranslatesBindMounts proves ephemeral host binds
176+// reach the Docker create request as TypeBind mounts in deterministic
177+// destination order, read-only preserved, alongside named-volume Binds.
178+func TestRuntime_CreateContainerTranslatesBindMounts(t *testing.T) {
179+ createBody := createTestContainer(t, &domain.ContainerConfig{
180+ Image: "nginx:latest",
181+ Name: "gordon-app",
182+ Volumes: map[string]string{"/data": "gordon-vol-data"},
183+ Binds: []domain.ContainerBind{
184+ {Name: "rw", Source: "/srv/binds/rw", Destination: "/etc/app.conf"},
185+ {Name: "ro", Source: "/srv/binds/ro", Destination: "/var/lib/data", ReadOnly: true},
186+ },
187+ })
188+
189+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
190+ require.True(t, ok)
191+
192+ // Existing named-volume Binds are preserved alongside explicit mounts.
193+ binds, ok := hostConfig["Binds"].([]any)
194+ require.True(t, ok)
195+ assert.Contains(t, binds, "gordon-vol-data:/data")
196+
197+ mounts, ok := hostConfig["Mounts"].([]any)
198+ require.True(t, ok)
199+ require.Len(t, mounts, 2)
200+
201+ first, ok := mounts[0].(map[string]any)
202+ require.True(t, ok)
203+ assert.Equal(t, "bind", first["Type"])
204+ assert.Equal(t, "/srv/binds/rw", first["Source"])
205+ assert.Equal(t, "/etc/app.conf", first["Target"])
206+ assert.NotContains(t, first, "ReadOnly")
207+
208+ second, ok := mounts[1].(map[string]any)
209+ require.True(t, ok)
210+ assert.Equal(t, "bind", second["Type"])
211+ assert.Equal(t, "/srv/binds/ro", second["Source"])
212+ assert.Equal(t, "/var/lib/data", second["Target"])
213+ assert.Equal(t, true, second["ReadOnly"])
214+}
215+
216+func TestBuildBindMounts(t *testing.T) {
217+ t.Run("sorts by destination", func(t *testing.T) {
218+ mounts, err := buildBindMounts(&domain.ContainerConfig{Binds: []domain.ContainerBind{
219+ {Name: "z", Source: "/srv/binds/z", Destination: "/z"},
220+ {Name: "a", Source: "/srv/binds/a", Destination: "/a"},
221+ }})
222+ require.NoError(t, err)
223+ require.Len(t, mounts, 2)
224+ assert.Equal(t, "/a", mounts[0].Target)
225+ assert.Equal(t, "/z", mounts[1].Target)
226+ })
227+
228+ t.Run("invalid paths rejected without echoing source", func(t *testing.T) {
229+ cases := []struct {
230+ name string
231+ bind domain.ContainerBind
232+ }{
233+ {"relative source", domain.ContainerBind{Name: "b", Source: "srv/binds", Destination: "/etc/app.conf"}},
234+ {"empty source", domain.ContainerBind{Name: "b", Destination: "/etc/app.conf"}},
235+ {"unclean source", domain.ContainerBind{Name: "b", Source: "/srv/../binds", Destination: "/etc/app.conf"}},
236+ {"relative destination", domain.ContainerBind{Name: "b", Source: "/srv/binds", Destination: "etc/app.conf"}},
237+ {"sensitive destination", domain.ContainerBind{Name: "b", Source: "/srv/binds", Destination: "/dev"}},
238+ }
239+ for _, tc := range cases {
240+ t.Run(tc.name, func(t *testing.T) {
241+ _, err := buildBindMounts(&domain.ContainerConfig{Binds: []domain.ContainerBind{tc.bind}})
242+ require.Error(t, err)
243+ if tc.bind.Source != "" {
244+ assert.NotContains(t, err.Error(), tc.bind.Source, "errors must never echo the host source")
245+ }
246+ })
247+ }
248+ })
249+
250+ t.Run("duplicate destination rejected", func(t *testing.T) {
251+ _, err := buildBindMounts(&domain.ContainerConfig{Binds: []domain.ContainerBind{
252+ {Name: "one", Source: "/srv/binds/one", Destination: "/etc/app.conf"},
253+ {Name: "two", Source: "/srv/binds/two", Destination: "/etc/app.conf"},
254+ }})
255+ require.Error(t, err)
256+ })
257+}
A internal/app/app_mounts.go +70 -0
@@ -0,0 +1,70 @@
1+package app
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "regexp"
7+ "strings"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// AppMountPolicy is one administrative bind policy declared under
13+// [[app_mounts.<name>]]. The map key is the stable mount name an app manifest
14+// bind references. Source is the only host path a bind may come from. Root,
15+// when set, is the administrative boundary the resolved source must stay
16+// under. AllowedApps and AllowedServices are exact, non-empty allowlists.
17+type AppMountPolicy struct {
18+ Source string `mapstructure:"source"`
19+ ReadOnly bool `mapstructure:"read_only"`
20+ AllowedApps []string `mapstructure:"allowed_apps"`
21+ AllowedServices []string `mapstructure:"allowed_services"`
22+ Root string `mapstructure:"root"`
23+}
24+
25+// policyQuotedValue matches the quoted values domain validation embeds in
26+// its messages, such as configured source paths or roots.
27+var policyQuotedValue = regexp.MustCompile(`"[^"]*"`)
28+
29+// redactedBindPolicyReason keeps the field-level reason from a domain policy
30+// validation error (for example "source must be absolute") while stripping
31+// every quoted value so an invalid config never echoes host paths.
32+func redactedBindPolicyReason(err error) string {
33+ reason := policyQuotedValue.ReplaceAllString(err.Error(), "")
34+ reason = strings.Join(strings.Fields(reason), " ")
35+ // Keep only the innermost detail: the outer wrap repeats the generic
36+ // violation and the policy name, which is already part of the mount key.
37+ if idx := strings.LastIndex(reason, ": "); idx >= 0 {
38+ reason = reason[idx+2:]
39+ }
40+ return reason
41+}
42+
43+// buildAppMountPolicies converts configured app mounts into validated domain
44+// policies. Failures name only the mount and the field-level reason: source
45+// paths are never echoed.
46+func buildAppMountPolicies(cfg Config) (map[string]domain.AppBindPolicy, error) {
47+ policies := make(map[string]domain.AppBindPolicy, len(cfg.AppMounts))
48+ for name, mount := range cfg.AppMounts {
49+ root := mount.Root
50+ if root == "" && filepath.IsAbs(mount.Source) {
51+ // The source's parent is the least surprising implicit
52+ // administrative boundary for the documented minimal form.
53+ // A symlink source may resolve within it, never escape it.
54+ root = filepath.Dir(mount.Source)
55+ }
56+ policy := domain.AppBindPolicy{
57+ Name: name,
58+ Source: mount.Source,
59+ ReadOnly: mount.ReadOnly,
60+ AllowedApps: mount.AllowedApps,
61+ AllowedServices: mount.AllowedServices,
62+ Root: root,
63+ }
64+ if err := policy.Validate(); err != nil {
65+ return nil, fmt.Errorf("invalid app_mounts.%s: %w: %s", name, domain.ErrBindPolicy, redactedBindPolicyReason(err))
66+ }
67+ policies[name] = policy
68+ }
69+ return policies, nil
70+}
A internal/app/app_mounts_test.go +103 -0
@@ -0,0 +1,103 @@
1+package app
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/spf13/viper"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestBuildAppMountPolicies_ValidConfigUnmarshal(t *testing.T) {
15+ v := viper.New()
16+ v.SetConfigType("toml")
17+ require.NoError(t, v.ReadConfig(strings.NewReader(`
18+[app_mounts.config]
19+source = "/srv/gordon/binds/blog/config"
20+read_only = true
21+allowed_apps = ["blog", "docs"]
22+allowed_services = ["web"]
23+root = "/srv/gordon"
24+`)))
25+
26+ var cfg Config
27+ require.NoError(t, v.Unmarshal(&cfg))
28+
29+ policies, err := buildAppMountPolicies(cfg)
30+ require.NoError(t, err)
31+ require.Contains(t, policies, "config")
32+ policy := policies["config"]
33+ assert.Equal(t, "config", policy.Name)
34+ assert.Equal(t, "/srv/gordon/binds/blog/config", policy.Source)
35+ assert.True(t, policy.ReadOnly)
36+ assert.Equal(t, []string{"blog", "docs"}, policy.AllowedApps)
37+ assert.Equal(t, []string{"web"}, policy.AllowedServices)
38+ assert.Equal(t, "/srv/gordon", policy.Root)
39+}
40+
41+func TestBuildAppMountPolicies_Invalid(t *testing.T) {
42+ cases := []struct {
43+ name string
44+ mount AppMountPolicy
45+ reason string
46+ }{
47+ {"empty source", AppMountPolicy{AllowedApps: []string{"blog"}, AllowedServices: []string{"web"}}, "source must not be empty"},
48+ {"relative source", AppMountPolicy{Source: "binds", AllowedApps: []string{"blog"}, AllowedServices: []string{"web"}}, "source must be absolute"},
49+ {"empty allowed apps", AppMountPolicy{Source: "/srv/binds", AllowedServices: []string{"web"}}, "allowed apps must not be empty"},
50+ {"empty allowed services", AppMountPolicy{Source: "/srv/binds", AllowedApps: []string{"blog"}}, "allowed services must not be empty"},
51+ {"unclean root", AppMountPolicy{Source: "/srv/binds", AllowedApps: []string{"blog"}, AllowedServices: []string{"web"}, Root: "/srv/../etc"}, "root must be a normalized clean path"},
52+ }
53+ for _, tc := range cases {
54+ t.Run(tc.name, func(t *testing.T) {
55+ cfg := Config{AppMounts: map[string]AppMountPolicy{"config": tc.mount}}
56+ _, err := buildAppMountPolicies(cfg)
57+ require.Error(t, err)
58+ assert.Contains(t, err.Error(), "app_mounts.config")
59+ assert.Contains(t, err.Error(), tc.reason, "the field-level reason must survive redaction")
60+ assert.ErrorIs(t, err, domain.ErrBindPolicy)
61+ if tc.mount.Source != "" {
62+ assert.NotContains(t, err.Error(), tc.mount.Source, "config errors must not echo the source path")
63+ }
64+ if tc.mount.Root != "" {
65+ assert.NotContains(t, err.Error(), tc.mount.Root, "config errors must not echo the root path")
66+ }
67+ })
68+ }
69+}
70+
71+func TestRedactedBindPolicyReason_RedactsConfiguredValues(t *testing.T) {
72+ _, err := buildAppMountPolicies(Config{AppMounts: map[string]AppMountPolicy{
73+ "config": {
74+ Source: "/srv/binds/blog/config",
75+ AllowedApps: []string{"Blog"},
76+ AllowedServices: []string{"web"},
77+ },
78+ }})
79+
80+ require.Error(t, err)
81+ assert.Contains(t, err.Error(), "app name must be a DNS label")
82+ assert.NotContains(t, err.Error(), "/srv/binds/blog/config", "configured paths must never be echoed")
83+ assert.NotContains(t, err.Error(), "Blog", "configured names must never be echoed")
84+}
85+
86+func TestBuildAppMountPolicies_DefaultRootIsSourceParent(t *testing.T) {
87+ cfg := Config{AppMounts: map[string]AppMountPolicy{
88+ "config": {
89+ Source: "/srv/binds/config",
90+ AllowedApps: []string{"blog"},
91+ AllowedServices: []string{"web"},
92+ },
93+ }}
94+ policies, err := buildAppMountPolicies(cfg)
95+ require.NoError(t, err)
96+ assert.Equal(t, "/srv/binds", policies["config"].Root)
97+}
98+
99+func TestConfig_AppMountsDefaultsEmpty(t *testing.T) {
100+ policies, err := buildAppMountPolicies(Config{})
101+ require.NoError(t, err)
102+ assert.Empty(t, policies)
103+}
A internal/app/config_validation.go +13 -0
@@ -0,0 +1,13 @@
1+package app
2+
3+import "fmt"
4+
5+// ValidateConfigFile loads and statically validates an explicit configuration
6+// file without initializing runtime services or binding listeners.
7+func ValidateConfigFile(path string) error {
8+ if path == "" {
9+ return fmt.Errorf("configuration file path is required")
10+ }
11+ _, _, err := initConfig(path)
12+ return err
13+}
M internal/app/paths.go +11 -0
@@ -4,6 +4,7 @@ package app
44 import (
55 "os"
66 "path/filepath"
7+ "strings"
78
89 "github.com/spf13/viper"
910 )
@@ -23,6 +24,16 @@ func DefaultDataDir() string {
2324 func ConfigureViper(v *viper.Viper, configPath string) {
2425 if configPath != "" {
2526 v.SetConfigFile(configPath)
27+ switch ext := strings.ToLower(filepath.Ext(configPath)); ext {
28+ case ".json":
29+ v.SetConfigType("json")
30+ case ".yaml", ".yml":
31+ v.SetConfigType("yaml")
32+ case ".toml":
33+ v.SetConfigType("toml")
34+ default:
35+ v.SetConfigType("toml")
36+ }
2637 } else {
2738 v.SetConfigName("gordon")
2839 v.SetConfigType("toml")
A internal/app/paths_test.go +47 -0
@@ -0,0 +1,47 @@
1+package app
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/spf13/viper"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestConfigureViper_ExplicitPathExtensionIsCaseInsensitive(t *testing.T) {
14+ tests := []struct {
15+ name string
16+ ext string
17+ content string
18+ }{
19+ {name: "lowercase yaml", ext: ".yaml", content: "foo: bar\n"},
20+ {name: "uppercase yaml", ext: ".YAML", content: "foo: bar\n"},
21+ {name: "mixed case yml", ext: ".YmL", content: "foo: bar\n"},
22+ {name: "uppercase json", ext: ".JSON", content: "{\"foo\":\"bar\"}\n"},
23+ {name: "uppercase toml", ext: ".TOML", content: "foo = \"bar\"\n"},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ path := filepath.Join(t.TempDir(), "gordon"+tt.ext)
29+ require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o600))
30+
31+ v := viper.New()
32+ ConfigureViper(v, path)
33+ require.NoError(t, v.ReadInConfig())
34+ assert.Equal(t, "bar", v.GetString("foo"))
35+ })
36+ }
37+}
38+
39+func TestConfigureViper_UnknownExtensionFallsBackToTOML(t *testing.T) {
40+ path := filepath.Join(t.TempDir(), "gordon.conf")
41+ require.NoError(t, os.WriteFile(path, []byte("foo = \"bar\"\n"), 0o600))
42+
43+ v := viper.New()
44+ ConfigureViper(v, path)
45+ require.NoError(t, v.ReadInConfig())
46+ assert.Equal(t, "bar", v.GetString("foo"))
47+}
M internal/app/run.go +193 -34
@@ -128,6 +128,10 @@ type Config struct {
128128 RevisionRetention int `mapstructure:"revision_retention"`
129129 } `mapstructure:"apps"`
130130
131+ // AppMounts declares administrative bind policies keyed by stable mount
132+ // name, referenced by name from an app manifest's [[service.bind]].
133+ AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"`
134+
131135 Logging struct {
132136 Level string `mapstructure:"level"`
133137 Format string `mapstructure:"format"`
@@ -320,6 +324,7 @@ type services struct {
320324 gcBarrier out.GCBarrier
321325 appDeploySvc *deployment.Service
322326 appSvc in.AppService
327+ appSvcImpl *apps.AppServiceImpl
323328 appActivator *apptraffic.Activator
324329 appHostIndex *apptraffic.HostIndex
325330 appTrafficPublisher *appTrafficPublisher
@@ -440,6 +445,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
440445 if err := validateRetiredAppConfig(v); err != nil {
441446 return nil, Config{}, err
442447 }
448+ if _, err := buildAppMountPolicies(cfg); err != nil {
449+ return nil, Config{}, err
450+ }
443451
444452 return v, cfg, nil
445453 }
@@ -459,7 +467,6 @@ var retiredAppConfigKeys = []struct {
459467 {"auto", "feature removed; declare explicit interfaces"},
460468 {"auto_route", "feature removed; declare explicit interfaces"},
461469 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
462- {"network_isolation", "installation network policy; per-app isolation is declared via [[network.shared]] in app files"},
463470 {"previews", "staging is an ordinary app file"},
464471 }
465472
@@ -468,11 +475,15 @@ var retiredAppConfigKeys = []struct {
468475 // (not IsSet) targets explicit file keys only — installation defaults
469476 // registered via SetDefault must not trip the rejection.
470477 func validateRetiredAppConfig(v *viper.Viper) error {
478+ diagnostics := make([]string, 0, len(retiredAppConfigKeys))
471479 for _, retired := range retiredAppConfigKeys {
472480 if v.InConfig(retired.key) {
473- return fmt.Errorf("config-retired: key %q was removed in v2.50; %s", retired.key, retired.hint)
481+ diagnostics = append(diagnostics, fmt.Sprintf("key %q was removed in v2.50; %s", retired.key, retired.hint))
474482 }
475483 }
484+ if len(diagnostics) > 0 {
485+ return fmt.Errorf("config-retired: %s", strings.Join(diagnostics, "; "))
486+ }
476487 return nil
477488 }
478489
@@ -893,10 +904,18 @@ func (si *serviceInit) initApps() error {
893904 Traffic: si.svc.appTrafficPublisher,
894905 }, si.log).WithGCBarrier(si.svc.gcBarrier)
895906 si.svc.appActivator = apptraffic.NewActivator(si.log)
896- si.svc.appSvc = apps.NewAppServiceImpl(store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
907+ mountPolicies, err := buildAppMountPolicies(si.cfg)
908+ if err != nil {
909+ return err
910+ }
911+ si.svc.appDeploySvc.SetBindPolicies(mountPolicies)
912+ appSvcImpl := apps.NewAppServiceImpl(store, si.svc.appDeploySvc, appsecrets.NewStore(si.log), si.log).
897913 WithEntrypoints(appEntrypointListeners(si.cfg)).
898914 WithGCBarrier(si.svc.gcBarrier).
899- WithImagePolicy(imagePolicy)
915+ WithImagePolicy(imagePolicy).
916+ WithBindPolicies(mountPolicies)
917+ si.svc.appSvcImpl = appSvcImpl
918+ si.svc.appSvc = appSvcImpl
900919 // Health checks resolve from ACTIVE state (loopback backends).
901920 si.svc.healthSvc = health.NewService(store, si.svc.runtime, httpprober.New(), si.log)
902921 // ACTIVE-derived host index for the proxy: rebuilt after every
@@ -1035,7 +1054,23 @@ func (si *serviceInit) initRuntimeAndProxy() error {
10351054
10361055 // initHandlers creates the auth, health, log, preview, and admin handlers.
10371056 func (si *serviceInit) registerReloadCoordinatorHooks() {
1038- if si.svc.reloadCoordinator == nil || si.svc.containerSvc == nil {
1057+ if si.svc.reloadCoordinator == nil {
1058+ return
1059+ }
1060+ if si.svc.appSvcImpl != nil {
1061+ si.svc.reloadCoordinator.SetAppMountPoliciesApplier(func(_ context.Context, reloadCfg Config) error {
1062+ policies, err := buildAppMountPolicies(reloadCfg)
1063+ if err != nil {
1064+ return err
1065+ }
1066+ si.svc.appSvcImpl.SetBindPolicies(policies)
1067+ if si.svc.appDeploySvc != nil {
1068+ si.svc.appDeploySvc.SetBindPolicies(policies)
1069+ }
1070+ return nil
1071+ })
1072+ }
1073+ if si.svc.containerSvc == nil {
10391074 return
10401075 }
10411076
@@ -1836,14 +1871,25 @@ type loadedConfigApplier interface {
18361871 }
18371872
18381873 type reloadCoordinator struct {
1839- mu sync.Mutex
1840- lastRun time.Time
1841- debounce time.Duration
1874+ mu sync.Mutex
1875+ lastRun time.Time
1876+ debounce time.Duration
1877+ trailingTimer *time.Timer
1878+ trailingGeneration uint64
1879+ stopped bool
1880+
1881+ // lifecycleCtx owns the context used by debounced trailing reloads. Its
1882+ // base is detached from any caller's cancellation so a short-lived request
1883+ // context cannot abort a coalesced apply, while lifecycleCancel lets Stop
1884+ // tear the coordinator's own lifecycle down explicitly.
1885+ lifecycleCtx context.Context
1886+ lifecycleCancel context.CancelFunc
18421887
18431888 configSvc configReloader
18441889 v *viper.Viper
18451890 proxySvc proxyConfigUpdater
18461891 applyContainerConfig func(context.Context, Config) error
1892+ applyAppMountPolicy func(context.Context, Config) error
18471893 registryLimits interface {
18481894 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
18491895 }
@@ -1881,27 +1927,93 @@ func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context
18811927 c.applyContainerConfig = apply
18821928 }
18831929
1930+// SetAppMountPoliciesApplier wires the callback that atomically republishes
1931+// validated administrative bind policies after a successful reload.
1932+func (c *reloadCoordinator) SetAppMountPoliciesApplier(apply func(context.Context, Config) error) {
1933+ c.mu.Lock()
1934+ defer c.mu.Unlock()
1935+ c.applyAppMountPolicy = apply
1936+}
1937+
1938+// Trigger requests a config reload that first re-reads config from disk.
18841939 func (c *reloadCoordinator) Trigger(ctx context.Context) error {
18851940 c.mu.Lock()
18861941 defer c.mu.Unlock()
18871942
1888- return c.reloadLocked(ctx, true)
1943+ return c.reloadDebouncedLocked(ctx, true)
18891944 }
18901945
1946+// ApplyLoadedConfig requests a reload of the config the watcher already
1947+// loaded from disk. It shares the debounce/coalescing policy with Trigger so
1948+// a burst of fsnotify callbacks applies the final state exactly once.
18911949 func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {
18921950 c.mu.Lock()
18931951 defer c.mu.Unlock()
18941952
1895- return c.reloadLocked(ctx, false)
1953+ return c.reloadDebouncedLocked(ctx, false)
18961954 }
18971955
1898-func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) error {
1956+// reloadDebouncedLocked is the single owner of the debounce/coalescing policy
1957+// shared by every reload entrypoint. Requests inside the debounce window are
1958+// merged into one trailing reload carrying the latest loadConfig intent.
1959+func (c *reloadCoordinator) reloadDebouncedLocked(ctx context.Context, loadConfig bool) error {
18991960 now := time.Now()
19001961 if !c.lastRun.IsZero() && now.Sub(c.lastRun) < c.debounce {
1901- c.log.Debug().Dur("since_last_reload", now.Sub(c.lastRun)).Msg("skipping config reload trigger due to debounce")
1962+ c.scheduleTrailingReloadLocked(ctx, loadConfig)
19021963 return nil
19031964 }
19041965
1966+ return c.reloadLocked(ctx, loadConfig)
1967+}
1968+
1969+func (c *reloadCoordinator) scheduleTrailingReloadLocked(ctx context.Context, loadConfig bool) {
1970+ if c.stopped {
1971+ return
1972+ }
1973+ if c.trailingTimer != nil {
1974+ c.trailingTimer.Stop()
1975+ }
1976+ if c.lifecycleCancel == nil {
1977+ c.lifecycleCtx, c.lifecycleCancel = context.WithCancel(context.WithoutCancel(ctx))
1978+ }
1979+ c.trailingGeneration++
1980+ generation := c.trailingGeneration
1981+ trailingCtx := c.lifecycleCtx
1982+ c.trailingTimer = time.AfterFunc(c.debounce, func() {
1983+ c.runTrailingReload(trailingCtx, generation, loadConfig)
1984+ })
1985+ c.log.Debug().Msg("coalescing config reload trigger")
1986+}
1987+
1988+func (c *reloadCoordinator) runTrailingReload(ctx context.Context, generation uint64, loadConfig bool) {
1989+ c.mu.Lock()
1990+ defer c.mu.Unlock()
1991+
1992+ if c.stopped || generation != c.trailingGeneration {
1993+ return
1994+ }
1995+ c.trailingTimer = nil
1996+ if err := c.reloadLocked(ctx, loadConfig); err != nil {
1997+ c.log.Error().Err(err).Msg("failed trailing config reload")
1998+ }
1999+}
2000+
2001+func (c *reloadCoordinator) Stop() {
2002+ c.mu.Lock()
2003+ defer c.mu.Unlock()
2004+ c.stopped = true
2005+ c.trailingGeneration++
2006+ if c.trailingTimer != nil {
2007+ c.trailingTimer.Stop()
2008+ c.trailingTimer = nil
2009+ }
2010+ if c.lifecycleCancel != nil {
2011+ c.lifecycleCancel()
2012+ }
2013+}
2014+
2015+func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) error {
2016+ now := time.Now()
19052017 if loadConfig {
19062018 if err := c.configSvc.Reload(ctx); err != nil {
19072019 c.log.Error().Err(err).Msg("failed to reload config")
@@ -1928,6 +2040,9 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
19282040 if err := validateRetiredAppConfig(c.v); err != nil {
19292041 return err
19302042 }
2043+ if _, err := buildAppMountPolicies(reloadCfg); err != nil {
2044+ return err
2045+ }
19312046
19322047 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
19332048 if err != nil {
@@ -1941,6 +2056,14 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
19412056 return fmt.Errorf("failed to apply container config on reload: %w", err)
19422057 }
19432058 }
2059+ // Publish the reloaded bind policies only after conversion and shape
2060+ // validation succeed, so a bad edit keeps the previous policies live.
2061+ if c.applyAppMountPolicy != nil {
2062+ if err := c.applyAppMountPolicy(ctx, reloadCfg); err != nil {
2063+ c.log.Error().Err(err).Msg("failed to apply app mount policies on reload")
2064+ return fmt.Errorf("failed to apply app mount policies on reload: %w", err)
2065+ }
2066+ }
19442067 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
19452068 if c.registryLimits != nil {
19462069 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
@@ -2329,6 +2452,9 @@ func registerEventHandlers(ctx context.Context, svc *services) (func(), error) {
23292452 }
23302453
23312454 cleanup := func() {
2455+ if svc.reloadCoordinator != nil {
2456+ svc.reloadCoordinator.Stop()
2457+ }
23322458 }
23332459
23342460 return cleanup, nil
@@ -2901,18 +3027,38 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
29013027 return err
29023028 }
29033029
3030+ // cleanupAfterLocalAdmin releases the local admin socket and traffic
3031+ // manager in addition to the servers that already bound, so no startup
3032+ // failure after this point leaks a listening socket or a live manager.
3033+ cleanupAfterLocalAdmin := func(servers ...*http.Server) {
3034+ cleanupStartupResources(localAdmin, svc.trafficManager, log, servers...)
3035+ }
3036+
29043037 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
29053038 svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
29063039
29073040 // Wait for both registry listeners and the HTTP proxy before applying traffic.
2908- if err := waitForServerReady(internalRegistryReady, errChan); err != nil {
2909- closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
3041+ if err := waitForServerReady(ctx, internalRegistryReady, errChan); err != nil {
3042+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
29103043 return err
29113044 }
2912- if err := waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, registryReady, proxyReady, errChan); err != nil {
2913- closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
3045+ if err := waitForCoreProxyReady(ctx, registryReady, proxyReady, errChan); err != nil {
3046+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
29143047 return err
29153048 }
3049+ // Apply only the installation graph fail-fast before slower app
3050+ // readiness checks. Persisted ACTIVE binds are intentionally excluded
3051+ // until boot reconciliation re-inspects them.
3052+ if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc, nil); err != nil {
3053+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
3054+ return err
3055+ }
3056+
3057+ // Reconcile ACTIVE and rebuild its host index before announcing the
3058+ // daemon ready. Applying traffic first publishes an empty app graph and
3059+ // leaves healthy converged apps returning 404 until a later lifecycle
3060+ // operation happens to rebuild it.
3061+ reconcileAppsAtBoot(ctx, svc, log)
29163062
29173063 logEvent := log.Info().
29183064 Int("proxy_port", cfg.Server.Port).
@@ -2926,22 +3072,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
29263072
29273073 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
29283074 if err != nil {
2929- shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log)
2930- closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
3075+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
29313076 return err
29323077 }
29333078 if schedulerCleanup != nil {
29343079 defer schedulerCleanup()
29353080 }
29363081
2937- // Reconcile declarative apps intended to run: recovery first, then
2938- // start missing/stopped instances without duplicating running ones.
2939- // Explicitly stopped apps stay stopped across reboot. This is the sole
2940- // boot recovery path — the pre-v2.50 configured-route recovery was
2941- // removed with the declarative-apps cutover. The periodic recovery
2942- // monitor starts here and is cancelled and joined on shutdown.
2943- reconcileAppsAtBoot(ctx, svc, log)
2944-
29453082 waitForShutdown(ctx, errChan, reloadChan, reload, svc.eventBus, log)
29463083 cleanupHandlers() // Stop debounce timers before draining containers
29473084 // Stop app administration before tearing down traffic/runtime dependencies,
@@ -2980,17 +3117,37 @@ func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime,
29803117 }
29813118 }
29823119
2983-func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *services, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {
2984- if err := waitForServerReady(registryReady, errChan); err != nil {
3120+func waitForCoreProxyReady(ctx context.Context, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {
3121+ if err := waitForServerReady(ctx, registryReady, errChan); err != nil {
29853122 return err
29863123 }
2987- if err := waitForServerReady(proxyReady, errChan); err != nil {
2988- return err
3124+ return waitForServerReady(ctx, proxyReady, errChan)
3125+}
3126+
3127+// shutdownStartedServers gracefully shuts down listeners that bound before a
3128+// partial startup failure so an error return does not leak them.
3129+func shutdownStartedServers(servers []*http.Server, log zerowrap.Logger) {
3130+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
3131+ defer shutdownCancel()
3132+ for _, srv := range servers {
3133+ if srv != nil {
3134+ if err := srv.Shutdown(shutdownCtx); err != nil {
3135+ log.Error().Err(err).Msg("failed to shut down server during startup cleanup")
3136+ }
3137+ }
29893138 }
2990- if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc, svc.appHostIndex); err != nil {
2991- return err
3139+}
3140+
3141+// cleanupStartupResources releases every resource a partial startup may have
3142+// acquired once the local admin socket is serving: the admin socket first (no
3143+// further mutations can begin), then the bound servers, then the traffic
3144+// manager that startProxyServers may already have populated.
3145+func cleanupStartupResources(localAdmin *localAdminServer, trafficManager *trafficadapter.Manager, log zerowrap.Logger, servers ...*http.Server) {
3146+ if localAdmin != nil {
3147+ localAdmin.Close()
29923148 }
2993- return nil
3149+ shutdownStartedServers(servers, log)
3150+ shutdownTrafficManagerForStartupCleanup(trafficManager, log)
29943151 }
29953152
29963153 func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {
@@ -3004,7 +3161,7 @@ func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, lo
30043161 }
30053162 }
30063163
3007-func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
3164+func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-chan error) error {
30083165 if ready == nil {
30093166 return nil
30103167 }
@@ -3013,6 +3170,8 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
30133170 return nil
30143171 case err := <-errChan:
30153172 return err
3173+ case <-ctx.Done():
3174+ return ctx.Err()
30163175 }
30173176 }
30183177
M internal/app/run_reload_test.go +190 -31
@@ -101,27 +101,57 @@ func (r *reloadRecorder) Calls() int {
101101 }
102102
103103 type proxyRecorder struct {
104+ mu sync.Mutex
104105 calls int
105106 config proxy.Config
106107 }
107108
108109 func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
109112 p.calls++
110113 p.config = config
111114 }
112115
116+func (p *proxyRecorder) Calls() int {
117+ p.mu.Lock()
118+ defer p.mu.Unlock()
119+ return p.calls
120+}
121+
122+func (p *proxyRecorder) Config() proxy.Config {
123+ p.mu.Lock()
124+ defer p.mu.Unlock()
125+ return p.config
126+}
127+
113128 type registryLimitsRecorder struct {
129+ mu sync.Mutex
114130 calls int
115131 maxBlobChunkSize int64
116132 maxBlobSize int64
117133 }
118134
119135 func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
136+ r.mu.Lock()
137+ defer r.mu.Unlock()
120138 r.calls++
121139 r.maxBlobChunkSize = maxBlobChunkSize
122140 r.maxBlobSize = maxBlobSize
123141 }
124142
143+func (r *registryLimitsRecorder) Calls() int {
144+ r.mu.Lock()
145+ defer r.mu.Unlock()
146+ return r.calls
147+}
148+
149+func (r *registryLimitsRecorder) Limits() (chunk, size int64) {
150+ r.mu.Lock()
151+ defer r.mu.Unlock()
152+ return r.maxBlobChunkSize, r.maxBlobSize
153+}
154+
125155 type containerConfigApplyRecorder struct {
126156 calls int
127157 ctx context.Context
@@ -168,6 +198,12 @@ func (e *eventBusRecorder) Calls() int {
168198 return e.calls
169199 }
170200
201+func (e *eventBusRecorder) Type() domain.EventType {
202+ e.mu.Lock()
203+ defer e.mu.Unlock()
204+ return e.eventType
205+}
206+
171207 func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
172208 ctx := context.Background()
173209 configSvc := &watchRecorder{}
@@ -225,13 +261,14 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
225261 require.NoError(t, coord.ApplyLoadedConfig(ctx))
226262
227263 require.Equal(t, 0, reloadSvc.Calls())
228- require.Equal(t, 1, proxySvc.calls)
264+ require.Equal(t, 1, proxySvc.Calls())
229265 require.Equal(t, 1, events.Calls())
230- require.Equal(t, 1, registryLimits.calls)
266+ require.Equal(t, 1, registryLimits.Calls())
231267 require.Equal(t, 1, containerCfg.calls)
232- require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
233- require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
234- require.Equal(t, domain.EventConfigReload, events.eventType)
268+ rlChunk, rlSize := registryLimits.Limits()
269+ require.Equal(t, int64(6<<20), rlChunk)
270+ require.Equal(t, int64(8<<20), rlSize)
271+ require.Equal(t, domain.EventConfigReload, events.Type())
235272 require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)
236273 require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)
237274 require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)
@@ -241,7 +278,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
241278 MaxBodySize: 5 << 20,
242279 MaxResponseSize: 7 << 20,
243280 MaxConcurrentConns: 99,
244- }, proxySvc.config)
281+ }, proxySvc.Config())
245282 }
246283
247284 // newTestServices wires the serialized traffic publisher the reload hook
@@ -412,24 +449,52 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T)
412449 assert.NotEmpty(t, manager.Status().EntryPoints)
413450 }
414451
415-func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTraffic(t *testing.T) {
452+func TestWaitForCoreProxyReady_WaitsWithoutPublishingEmptyTraffic(t *testing.T) {
416453 ctx := context.Background()
417- v := viper.New()
418- configSvc := cfgusecase.NewService(v, nil)
419- require.NoError(t, configSvc.Load(ctx))
420454 manager := trafficadapter.NewManager()
421455 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
422456
423- cfg := Config{}
424- cfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
425- cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
426- cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
427-
428457 ready := make(chan struct{})
429458 close(ready)
430- svc := &services{configSvc: configSvc, trafficManager: manager}
431- require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil))
432- assert.NotEmpty(t, manager.Status().EntryPoints)
459+ require.NoError(t, waitForCoreProxyReady(ctx, ready, ready, nil))
460+ assert.Empty(t, manager.Status().EntryPoints)
461+}
462+
463+func TestWaitForCoreProxyReady_ObservesContextCancellation(t *testing.T) {
464+ ctx, cancel := context.WithCancel(context.Background())
465+ cancel()
466+
467+ // Neither readiness channel ever closes: the canceled startup context must
468+ // unblock the wait rather than hang on a listener that will never bind.
469+ err := waitForCoreProxyReady(ctx, make(chan struct{}), make(chan struct{}), make(chan error))
470+ require.ErrorIs(t, err, context.Canceled)
471+}
472+
473+func TestReloadCoordinator_StopCancelsOwnedTrailingReloadLifecycle(t *testing.T) {
474+ ctx := context.Background()
475+ v := viper.New()
476+ v.Set("server.gordon_domain", "lifecycle.example.com")
477+ v.Set("server.registry_port", 5000)
478+
479+ reloadSvc := &reloadRecorder{}
480+ proxySvc := &proxyRecorder{}
481+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
482+ // Keep the trailing timer pending so Stop is the only thing that can tear
483+ // the coordinator-owned lifecycle down.
484+ coord.debounce = time.Hour
485+
486+ require.NoError(t, coord.Trigger(ctx)) // immediate apply records lastRun
487+ require.NoError(t, coord.Trigger(ctx)) // coalesced into the trailing timer
488+
489+ coord.mu.Lock()
490+ lifecycleCtx := coord.lifecycleCtx
491+ coord.mu.Unlock()
492+ require.NotNil(t, lifecycleCtx, "trailing reload must own a lifecycle context")
493+ require.NoError(t, lifecycleCtx.Err())
494+
495+ coord.Stop()
496+ require.ErrorIs(t, lifecycleCtx.Err(), context.Canceled)
497+ require.Equal(t, 1, proxySvc.Calls(), "stopped coordinator must not apply the trailing reload")
433498 }
434499
435500 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
@@ -449,23 +514,115 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
449514
450515 registryLimits := &registryLimitsRecorder{}
451516 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
517+ coord.debounce = 20 * time.Millisecond
518+ require.NoError(t, coord.Trigger(ctx))
452519 require.NoError(t, coord.Trigger(ctx))
453520 require.NoError(t, coord.Trigger(ctx))
454521
455- require.Equal(t, 1, reloadSvc.Calls())
456- require.Equal(t, 1, proxySvc.calls)
457- require.Equal(t, 1, events.Calls())
458- require.Equal(t, 1, registryLimits.calls)
459- require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)
460- require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)
461- require.Equal(t, domain.EventConfigReload, events.eventType)
522+ // Observe the trailing reload through the recorders' own locks instead of
523+ // locking the coordinator: the reload event is published last, so waiting
524+ // for all recorders proves the trailing reload finished applying.
525+ require.Eventually(t, func() bool {
526+ return reloadSvc.Calls() == 2 && proxySvc.Calls() == 2 &&
527+ events.Calls() == 2 && registryLimits.Calls() == 2
528+ }, time.Second, 5*time.Millisecond)
529+ // No further reload may fire: the debounce window is over.
530+ require.Never(t, func() bool {
531+ return proxySvc.Calls() > 2 || events.Calls() > 2
532+ }, 50*time.Millisecond, 5*time.Millisecond)
533+ require.Equal(t, 2, proxySvc.Calls())
534+ require.Equal(t, 2, events.Calls())
535+ require.Equal(t, 2, registryLimits.Calls())
536+ rlChunk, rlSize := registryLimits.Limits()
537+ require.Equal(t, int64(6<<20), rlChunk)
538+ require.Equal(t, int64(8<<20), rlSize)
539+ require.Equal(t, domain.EventConfigReload, events.Type())
462540 require.Equal(t, proxy.Config{
463541 RegistryDomain: "new.example.com",
464542 RegistryPort: 5000,
465543 MaxBodySize: 5 << 20,
466544 MaxResponseSize: 7 << 20,
467545 MaxConcurrentConns: 99,
468- }, proxySvc.config)
546+ }, proxySvc.Config())
547+}
548+
549+func TestSetupConfigHotReload_CoalescesRepeatedWatcherCallbacks(t *testing.T) {
550+ ctx := context.Background()
551+ v := viper.New()
552+ v.Set("server.gordon_domain", "watch.example.com")
553+ v.Set("server.registry_port", 5000)
554+
555+ configSvc := &watchRecorder{}
556+ reloadSvc := &reloadRecorder{}
557+ proxySvc := &proxyRecorder{}
558+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
559+ coord.debounce = 20 * time.Millisecond
560+ defer coord.Stop()
561+
562+ require.NoError(t, setupConfigHotReload(ctx, configSvc, coord))
563+ require.NotNil(t, configSvc.onChange)
564+
565+ // fsnotify delivers a burst of events for a single edit; each callback
566+ // must not apply the config on its own.
567+ configSvc.onChange()
568+ configSvc.onChange()
569+ configSvc.onChange()
570+
571+ require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond)
572+ require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond)
573+ // ApplyLoadedConfig applies what the watcher already loaded: the trailing
574+ // apply must not read the config again.
575+ require.Zero(t, reloadSvc.Calls())
576+ require.Equal(t, "watch.example.com", proxySvc.Config().RegistryDomain)
577+}
578+
579+func TestReloadCoordinator_TrailingReloadAppliesFinalState(t *testing.T) {
580+ ctx := context.Background()
581+ v := viper.New()
582+ v.Set("server.gordon_domain", "initial.example.com")
583+ v.Set("server.registry_port", 5000)
584+
585+ reloadSvc := &reloadRecorder{}
586+ proxySvc := &proxyRecorder{}
587+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
588+ coord.debounce = 20 * time.Millisecond
589+
590+ require.NoError(t, coord.Trigger(ctx))
591+ v.Set("server.gordon_domain", "intermediate.example.com")
592+ require.NoError(t, coord.Trigger(ctx))
593+ v.Set("server.gordon_domain", "final.example.com")
594+ require.NoError(t, coord.Trigger(ctx))
595+
596+ // The trailing reload is the only writer of the final state: wait for
597+ // the observable outcome instead of locking the coordinator.
598+ require.Eventually(t, func() bool {
599+ return proxySvc.Config().RegistryDomain == "final.example.com"
600+ }, time.Second, 5*time.Millisecond)
601+}
602+
603+func TestReloadCoordinator_InvalidReloadKeepsActiveConfigThenAcceptsValidChange(t *testing.T) {
604+ ctx := context.Background()
605+ v := viper.New()
606+ v.Set("server.gordon_domain", "active.example.com")
607+ v.Set("server.registry_port", 5000)
608+
609+ reloadSvc := &reloadRecorder{}
610+ proxySvc := &proxyRecorder{}
611+ coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
612+ coord.debounce = 10 * time.Millisecond
613+
614+ require.NoError(t, coord.Trigger(ctx))
615+ require.Equal(t, "active.example.com", proxySvc.Config().RegistryDomain)
616+
617+ time.Sleep(coord.debounce)
618+ v.Set("server.max_proxy_body_size", "invalid")
619+ require.Error(t, coord.Trigger(ctx))
620+ require.Equal(t, "active.example.com", proxySvc.Config().RegistryDomain)
621+
622+ v.Set("server.max_proxy_body_size", "5MB")
623+ v.Set("server.gordon_domain", "recovered.example.com")
624+ require.NoError(t, coord.Trigger(ctx))
625+ require.Equal(t, "recovered.example.com", proxySvc.Config().RegistryDomain)
469626 }
470627
471628 func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
@@ -492,7 +649,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
492649
493650 require.NoError(t, coord.Trigger(ctx))
494651 require.Equal(t, 2, reloadSvc.Calls())
495- assert.Equal(t, 1, proxySvc.calls)
652+ assert.Equal(t, 1, proxySvc.Calls())
496653 }
497654
498655 func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) {
@@ -520,10 +677,10 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
520677 require.ErrorIs(t, err, publishErr)
521678
522679 require.Equal(t, 2, reloadSvc.Calls())
523- require.Equal(t, 2, proxySvc.calls)
680+ require.Equal(t, 2, proxySvc.Calls())
524681 require.Equal(t, 2, events.Calls())
525682 require.Equal(t, 2, publicTLS.calls)
526- require.Equal(t, domain.EventConfigReload, events.eventType)
683+ require.Equal(t, domain.EventConfigReload, events.Type())
527684 }
528685
529686 func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
@@ -545,6 +702,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
545702 }}
546703 proxySvc := &proxyRecorder{}
547704 coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
705+ coord.debounce = 20 * time.Millisecond
548706
549707 firstDone := make(chan struct{})
550708 go func() {
@@ -585,6 +743,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
585743 t.Fatal("second reload did not finish")
586744 }
587745
588- require.Equal(t, 1, reloadSvc.Calls())
589- assert.Equal(t, 1, proxySvc.calls)
746+ require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond)
747+ require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond)
748+ assert.Equal(t, 2, proxySvc.Calls())
590749 }
M internal/app/start_proxy_servers_test.go +44 -2
@@ -8,6 +8,7 @@ import (
88 "io"
99 "net"
1010 "net/http"
11+ "path/filepath"
1112 "strconv"
1213 "testing"
1314 "time"
@@ -16,6 +17,7 @@ import (
1617 "github.com/stretchr/testify/require"
1718
1819 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
20+ "github.com/bnema/gordon/internal/adapters/localadmin"
1921 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
2022 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
2123 "github.com/bnema/gordon/internal/domain"
@@ -28,7 +30,7 @@ func TestWaitForServerReady_NilReadyReturnsImmediately(t *testing.T) {
2830 done := make(chan error, 1)
2931
3032 go func() {
31- done <- waitForServerReady(nil, errChan)
33+ done <- waitForServerReady(context.Background(), nil, errChan)
3234 }()
3335
3436 select {
@@ -44,10 +46,50 @@ func TestWaitForServerReady_NonNilReadyPreservesErrorBehavior(t *testing.T) {
4446 errChan := make(chan error, 1)
4547 errChan <- expected
4648
47- err := waitForServerReady(make(chan struct{}), errChan)
49+ err := waitForServerReady(context.Background(), make(chan struct{}), errChan)
4850 require.ErrorIs(t, err, expected)
4951 }
5052
53+func TestWaitForServerReady_ReturnsOnContextCancellation(t *testing.T) {
54+ ctx, cancel := context.WithCancel(context.Background())
55+ cancel()
56+
57+ // A canceled startup context must unblock readiness instead of waiting on a
58+ // server that will never signal ready.
59+ err := waitForServerReady(ctx, make(chan struct{}), make(chan error))
60+ require.ErrorIs(t, err, context.Canceled)
61+}
62+
63+func TestCleanupStartupResources_ClosesAdminSocketsServersAndTrafficManager(t *testing.T) {
64+ xdg := t.TempDir()
65+ t.Setenv("XDG_RUNTIME_DIR", xdg)
66+
67+ localAdmin, err := startLocalAdminServer(localAdminTestServices(t, inmocks.NewMockAppService(t)), make(chan error, 4), zerowrap.Default())
68+ require.NoError(t, err)
69+ require.NotNil(t, localAdmin)
70+ socketPath := localadmin.SocketPath(filepath.Join(xdg, "gordon"))
71+ require.NoError(t, localadmin.ValidateSocket(socketPath))
72+
73+ ln, err := net.Listen("tcp", "127.0.0.1:0")
74+ require.NoError(t, err)
75+ started := make(chan struct{})
76+ srv := &http.Server{Handler: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})}
77+ go func() {
78+ close(started)
79+ _ = srv.Serve(ln)
80+ }()
81+ <-started
82+ addr := ln.Addr().String()
83+
84+ manager := trafficadapter.NewManager()
85+ cleanupStartupResources(localAdmin, manager, zerowrap.Default(), srv)
86+
87+ require.NoFileExists(t, socketPath, "local admin socket must be closed")
88+ _, err = net.DialTimeout("tcp", addr, 200*time.Millisecond)
89+ require.Error(t, err, "bound server must be shut down")
90+ require.Empty(t, manager.Status().EntryPoints)
91+}
92+
5193 func TestStartProxyServers_DoesNotStartLegacyHTTPListenerFromServerPort(t *testing.T) {
5294 cfg := Config{}
5395 cfg.Server.Port = freeTCPPort(t)
M internal/boundaries/in/apps.go +12 -0
@@ -56,6 +56,9 @@ type AppService interface {
5656 // client-supplied idempotency key.
5757 OperationByKey(ctx context.Context, app, key string) (*domain.AppOperation, error)
5858
59+ // ListSecrets returns metadata-only desired and active registrations.
60+ ListSecrets(ctx context.Context, app, service string) ([]AppSecretMetadata, error)
61+
5962 // SetSecrets writes secret values for pre-registered names in one
6063 // service. service is required; every key must already exist in
6164 // desired or active state.
@@ -66,6 +69,15 @@ type AppService interface {
6669 }
6770
6871 // AppApplyResult describes one accepted (or no-op) apply.
72+// AppSecretMetadata is a secret registration without its value.
73+type AppSecretMetadata struct {
74+ Service string
75+ Key string
76+ Name string
77+ Source string
78+ Presence string
79+}
80+
6981 type AppApplyResult struct {
7082 App string
7183 FormerRevision string
M internal/boundaries/in/mocks/mock_app_service.go +74 -0
@@ -410,6 +410,80 @@ func (_c *MockAppService_List_Call) RunAndReturn(run func(ctx context.Context) (
410410 return _c
411411 }
412412
413+// ListSecrets provides a mock function for the type MockAppService
414+func (_mock *MockAppService) ListSecrets(ctx context.Context, app string, service string) ([]in.AppSecretMetadata, error) {
415+ ret := _mock.Called(ctx, app, service)
416+
417+ if len(ret) == 0 {
418+ panic("no return value specified for ListSecrets")
419+ }
420+
421+ var r0 []in.AppSecretMetadata
422+ var r1 error
423+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]in.AppSecretMetadata, error)); ok {
424+ return returnFunc(ctx, app, service)
425+ }
426+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []in.AppSecretMetadata); ok {
427+ r0 = returnFunc(ctx, app, service)
428+ } else {
429+ if ret.Get(0) != nil {
430+ r0 = ret.Get(0).([]in.AppSecretMetadata)
431+ }
432+ }
433+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
434+ r1 = returnFunc(ctx, app, service)
435+ } else {
436+ r1 = ret.Error(1)
437+ }
438+ return r0, r1
439+}
440+
441+// MockAppService_ListSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListSecrets'
442+type MockAppService_ListSecrets_Call struct {
443+ *mock.Call
444+}
445+
446+// ListSecrets is a helper method to define mock.On call
447+// - ctx context.Context
448+// - app string
449+// - service string
450+func (_e *MockAppService_Expecter) ListSecrets(ctx any, app any, service any) *MockAppService_ListSecrets_Call {
451+ return &MockAppService_ListSecrets_Call{Call: _e.mock.On("ListSecrets", ctx, app, service)}
452+}
453+
454+func (_c *MockAppService_ListSecrets_Call) Run(run func(ctx context.Context, app string, service string)) *MockAppService_ListSecrets_Call {
455+ _c.Call.Run(func(args mock.Arguments) {
456+ var arg0 context.Context
457+ if args[0] != nil {
458+ arg0 = args[0].(context.Context)
459+ }
460+ var arg1 string
461+ if args[1] != nil {
462+ arg1 = args[1].(string)
463+ }
464+ var arg2 string
465+ if args[2] != nil {
466+ arg2 = args[2].(string)
467+ }
468+ run(
469+ arg0,
470+ arg1,
471+ arg2,
472+ )
473+ })
474+ return _c
475+}
476+
477+func (_c *MockAppService_ListSecrets_Call) Return(appSecretMetadatas []in.AppSecretMetadata, err error) *MockAppService_ListSecrets_Call {
478+ _c.Call.Return(appSecretMetadatas, err)
479+ return _c
480+}
481+
482+func (_c *MockAppService_ListSecrets_Call) RunAndReturn(run func(ctx context.Context, app string, service string) ([]in.AppSecretMetadata, error)) *MockAppService_ListSecrets_Call {
483+ _c.Call.Return(run)
484+ return _c
485+}
486+
413487 // OperationByKey provides a mock function for the type MockAppService
414488 func (_mock *MockAppService) OperationByKey(ctx context.Context, app string, key string) (*domain.AppOperation, error) {
415489 ret := _mock.Called(ctx, app, key)
M internal/boundaries/out/mocks/mock_container_runtime.go +186 -0
@@ -1858,6 +1858,72 @@ func (_c *MockContainerRuntime_Ping_Call) RunAndReturn(run func(ctx context.Cont
18581858 return _c
18591859 }
18601860
1861+// ProbeContainerNetwork provides a mock function for the type MockContainerRuntime
1862+func (_mock *MockContainerRuntime) ProbeContainerNetwork(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
1863+ ret := _mock.Called(ctx, request)
1864+
1865+ if len(ret) == 0 {
1866+ panic("no return value specified for ProbeContainerNetwork")
1867+ }
1868+
1869+ var r0 domain.ContainerNetworkProbeResult
1870+ var r1 error
1871+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error)); ok {
1872+ return returnFunc(ctx, request)
1873+ }
1874+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.ContainerNetworkProbeRequest) domain.ContainerNetworkProbeResult); ok {
1875+ r0 = returnFunc(ctx, request)
1876+ } else {
1877+ r0 = ret.Get(0).(domain.ContainerNetworkProbeResult)
1878+ }
1879+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.ContainerNetworkProbeRequest) error); ok {
1880+ r1 = returnFunc(ctx, request)
1881+ } else {
1882+ r1 = ret.Error(1)
1883+ }
1884+ return r0, r1
1885+}
1886+
1887+// MockContainerRuntime_ProbeContainerNetwork_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ProbeContainerNetwork'
1888+type MockContainerRuntime_ProbeContainerNetwork_Call struct {
1889+ *mock.Call
1890+}
1891+
1892+// ProbeContainerNetwork is a helper method to define mock.On call
1893+// - ctx context.Context
1894+// - request domain.ContainerNetworkProbeRequest
1895+func (_e *MockContainerRuntime_Expecter) ProbeContainerNetwork(ctx any, request any) *MockContainerRuntime_ProbeContainerNetwork_Call {
1896+ return &MockContainerRuntime_ProbeContainerNetwork_Call{Call: _e.mock.On("ProbeContainerNetwork", ctx, request)}
1897+}
1898+
1899+func (_c *MockContainerRuntime_ProbeContainerNetwork_Call) Run(run func(ctx context.Context, request domain.ContainerNetworkProbeRequest)) *MockContainerRuntime_ProbeContainerNetwork_Call {
1900+ _c.Call.Run(func(args mock.Arguments) {
1901+ var arg0 context.Context
1902+ if args[0] != nil {
1903+ arg0 = args[0].(context.Context)
1904+ }
1905+ var arg1 domain.ContainerNetworkProbeRequest
1906+ if args[1] != nil {
1907+ arg1 = args[1].(domain.ContainerNetworkProbeRequest)
1908+ }
1909+ run(
1910+ arg0,
1911+ arg1,
1912+ )
1913+ })
1914+ return _c
1915+}
1916+
1917+func (_c *MockContainerRuntime_ProbeContainerNetwork_Call) Return(containerNetworkProbeResult domain.ContainerNetworkProbeResult, err error) *MockContainerRuntime_ProbeContainerNetwork_Call {
1918+ _c.Call.Return(containerNetworkProbeResult, err)
1919+ return _c
1920+}
1921+
1922+func (_c *MockContainerRuntime_ProbeContainerNetwork_Call) RunAndReturn(run func(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error)) *MockContainerRuntime_ProbeContainerNetwork_Call {
1923+ _c.Call.Return(run)
1924+ return _c
1925+}
1926+
18611927 // PullImage provides a mock function for the type MockContainerRuntime
18621928 func (_mock *MockContainerRuntime) PullImage(ctx context.Context, image string) error {
18631929 ret := _mock.Called(ctx, image)
@@ -1984,6 +2050,63 @@ func (_c *MockContainerRuntime_PullImageWithAuth_Call) RunAndReturn(run func(ctx
19842050 return _c
19852051 }
19862052
2053+// PullImageWithOptions provides a mock function for the type MockContainerRuntime
2054+func (_mock *MockContainerRuntime) PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error {
2055+ ret := _mock.Called(ctx, request)
2056+
2057+ if len(ret) == 0 {
2058+ panic("no return value specified for PullImageWithOptions")
2059+ }
2060+
2061+ var r0 error
2062+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.ImagePullRequest) error); ok {
2063+ r0 = returnFunc(ctx, request)
2064+ } else {
2065+ r0 = ret.Error(0)
2066+ }
2067+ return r0
2068+}
2069+
2070+// MockContainerRuntime_PullImageWithOptions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PullImageWithOptions'
2071+type MockContainerRuntime_PullImageWithOptions_Call struct {
2072+ *mock.Call
2073+}
2074+
2075+// PullImageWithOptions is a helper method to define mock.On call
2076+// - ctx context.Context
2077+// - request domain.ImagePullRequest
2078+func (_e *MockContainerRuntime_Expecter) PullImageWithOptions(ctx any, request any) *MockContainerRuntime_PullImageWithOptions_Call {
2079+ return &MockContainerRuntime_PullImageWithOptions_Call{Call: _e.mock.On("PullImageWithOptions", ctx, request)}
2080+}
2081+
2082+func (_c *MockContainerRuntime_PullImageWithOptions_Call) Run(run func(ctx context.Context, request domain.ImagePullRequest)) *MockContainerRuntime_PullImageWithOptions_Call {
2083+ _c.Call.Run(func(args mock.Arguments) {
2084+ var arg0 context.Context
2085+ if args[0] != nil {
2086+ arg0 = args[0].(context.Context)
2087+ }
2088+ var arg1 domain.ImagePullRequest
2089+ if args[1] != nil {
2090+ arg1 = args[1].(domain.ImagePullRequest)
2091+ }
2092+ run(
2093+ arg0,
2094+ arg1,
2095+ )
2096+ })
2097+ return _c
2098+}
2099+
2100+func (_c *MockContainerRuntime_PullImageWithOptions_Call) Return(err error) *MockContainerRuntime_PullImageWithOptions_Call {
2101+ _c.Call.Return(err)
2102+ return _c
2103+}
2104+
2105+func (_c *MockContainerRuntime_PullImageWithOptions_Call) RunAndReturn(run func(ctx context.Context, request domain.ImagePullRequest) error) *MockContainerRuntime_PullImageWithOptions_Call {
2106+ _c.Call.Return(run)
2107+ return _c
2108+}
2109+
19872110 // RemoveContainer provides a mock function for the type MockContainerRuntime
19882111 func (_mock *MockContainerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
19892112 ret := _mock.Called(ctx, containerID, force)
@@ -2596,6 +2719,69 @@ func (_c *MockContainerRuntime_UntagImage_Call) RunAndReturn(run func(ctx contex
25962719 return _c
25972720 }
25982721
2722+// VerifyImageDigest provides a mock function for the type MockContainerRuntime
2723+func (_mock *MockContainerRuntime) VerifyImageDigest(ctx context.Context, imageRef string, digest string) error {
2724+ ret := _mock.Called(ctx, imageRef, digest)
2725+
2726+ if len(ret) == 0 {
2727+ panic("no return value specified for VerifyImageDigest")
2728+ }
2729+
2730+ var r0 error
2731+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
2732+ r0 = returnFunc(ctx, imageRef, digest)
2733+ } else {
2734+ r0 = ret.Error(0)
2735+ }
2736+ return r0
2737+}
2738+
2739+// MockContainerRuntime_VerifyImageDigest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyImageDigest'
2740+type MockContainerRuntime_VerifyImageDigest_Call struct {
2741+ *mock.Call
2742+}
2743+
2744+// VerifyImageDigest is a helper method to define mock.On call
2745+// - ctx context.Context
2746+// - imageRef string
2747+// - digest string
2748+func (_e *MockContainerRuntime_Expecter) VerifyImageDigest(ctx any, imageRef any, digest any) *MockContainerRuntime_VerifyImageDigest_Call {
2749+ return &MockContainerRuntime_VerifyImageDigest_Call{Call: _e.mock.On("VerifyImageDigest", ctx, imageRef, digest)}
2750+}
2751+
2752+func (_c *MockContainerRuntime_VerifyImageDigest_Call) Run(run func(ctx context.Context, imageRef string, digest string)) *MockContainerRuntime_VerifyImageDigest_Call {
2753+ _c.Call.Run(func(args mock.Arguments) {
2754+ var arg0 context.Context
2755+ if args[0] != nil {
2756+ arg0 = args[0].(context.Context)
2757+ }
2758+ var arg1 string
2759+ if args[1] != nil {
2760+ arg1 = args[1].(string)
2761+ }
2762+ var arg2 string
2763+ if args[2] != nil {
2764+ arg2 = args[2].(string)
2765+ }
2766+ run(
2767+ arg0,
2768+ arg1,
2769+ arg2,
2770+ )
2771+ })
2772+ return _c
2773+}
2774+
2775+func (_c *MockContainerRuntime_VerifyImageDigest_Call) Return(err error) *MockContainerRuntime_VerifyImageDigest_Call {
2776+ _c.Call.Return(err)
2777+ return _c
2778+}
2779+
2780+func (_c *MockContainerRuntime_VerifyImageDigest_Call) RunAndReturn(run func(ctx context.Context, imageRef string, digest string) error) *MockContainerRuntime_VerifyImageDigest_Call {
2781+ _c.Call.Return(run)
2782+ return _c
2783+}
2784+
25992785 // Version provides a mock function for the type MockContainerRuntime
26002786 func (_mock *MockContainerRuntime) Version(ctx context.Context) (string, error) {
26012787 ret := _mock.Called(ctx)
M internal/boundaries/out/runtime.go +13 -0
@@ -40,6 +40,7 @@ type ContainerRuntime interface {
4040
4141 // Image operations
4242 PullImage(ctx context.Context, image string) error
43+ PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error
4344 PullImageWithAuth(ctx context.Context, image, username, password string) error
4445 TagImage(ctx context.Context, sourceRef, targetRef string) error
4546 UntagImage(ctx context.Context, imageRef string) error
@@ -81,6 +82,7 @@ type ContainerRuntime interface {
8182
8283 // Image identity
8384 GetImageID(ctx context.Context, imageRef string) (string, error)
85+ VerifyImageDigest(ctx context.Context, imageRef, digest string) error
8486
8587 // In-container operations
8688 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
@@ -93,6 +95,17 @@ type ContainerRuntime interface {
9395 NetworkExists(ctx context.Context, name string) (bool, error)
9496 ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
9597 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
98+
99+ // Bounded network readiness
100+ //
101+ // ProbeContainerNetwork runs one bounded readiness session against one
102+ // declared internal container port, which has no host publication. The
103+ // adapter owns the helper lifecycle: it resolves the target endpoint on
104+ // the exact network in the request, runs one hardened helper attached
105+ // only to that network, and always removes the helper under an
106+ // independent bounded cleanup context. It never publishes a host port
107+ // and never targets a service alias.
108+ ProbeContainerNetwork(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error)
96109 }
97110
98111 // ContainerLister is the subset of ContainerRuntime needed by the orphan GC.
A internal/domain/app_bind_policy.go +189 -0
@@ -0,0 +1,189 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+)
9+
10+// AppBindPolicy is one named administrative bind policy. The installation
11+// declares policies; app manifests reference them by name. Source is the
12+// only host location a bind governed by this policy may come from. Root,
13+// when set, is the administrative boundary the resolved source must remain
14+// under. AllowedApps and AllowedServices are exact, non-empty allowlists.
15+type AppBindPolicy struct {
16+ Name string
17+ Source string
18+ ReadOnly bool
19+ AllowedApps []string
20+ AllowedServices []string
21+ Root string
22+}
23+
24+// ResolvedAppBind is a bind whose source has been resolved and verified for
25+// the runtime to mount. It carries no logging, DTO, or transport concerns.
26+type ResolvedAppBind struct {
27+ Name string
28+ Source string
29+ Destination string
30+ ReadOnly bool
31+}
32+
33+// ValidateBindPolicyName checks a stable policy name (service charset plus -- ban).
34+func ValidateBindPolicyName(name string) error {
35+ if !serviceNamePattern.MatchString(name) {
36+ return fmt.Errorf("%w: policy name %q must match [a-z0-9_.-], max 63", ErrBindPolicy, name)
37+ }
38+ if strings.Contains(name, "--") {
39+ return fmt.Errorf("%w: policy name %q must not contain -- (reserved separator)", ErrBindPolicy, name)
40+ }
41+ return nil
42+}
43+
44+// Validate checks the policy shape without touching the filesystem.
45+func (p AppBindPolicy) Validate() error {
46+ if err := ValidateBindPolicyName(p.Name); err != nil {
47+ return err
48+ }
49+ if err := validatePolicyPath("source", p.Source); err != nil {
50+ return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
51+ }
52+ if p.Root != "" {
53+ if err := validatePolicyPath("root", p.Root); err != nil {
54+ return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
55+ }
56+ }
57+ if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
58+ return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
59+ }
60+ if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
61+ return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
62+ }
63+ return nil
64+}
65+
66+// validatePolicyPath requires a non-empty, absolute, normalized host path.
67+func validatePolicyPath(kind, value string) error {
68+ if value == "" {
69+ return fmt.Errorf("%s must not be empty", kind)
70+ }
71+ if !filepath.IsAbs(value) {
72+ return fmt.Errorf("%s %q must be absolute", kind, value)
73+ }
74+ if filepath.Clean(value) != value {
75+ return fmt.Errorf("%s %q must be a normalized clean path", kind, value)
76+ }
77+ if kind == "source" && value == string(filepath.Separator) {
78+ return fmt.Errorf("source must not be the filesystem root")
79+ }
80+ return nil
81+}
82+
83+// validateAllowlist requires a non-empty exact list of valid, unique names.
84+func validateAllowlist(kind string, names []string, validate func(string) error) error {
85+ if len(names) == 0 {
86+ return fmt.Errorf("allowed %ss must not be empty", kind)
87+ }
88+ seen := map[string]struct{}{}
89+ for _, name := range names {
90+ if err := validate(name); err != nil {
91+ return fmt.Errorf("allowed %s %q: %v", kind, name, err)
92+ }
93+ if _, ok := seen[name]; ok {
94+ return fmt.Errorf("allowed %s %q is duplicated", kind, name)
95+ }
96+ seen[name] = struct{}{}
97+ }
98+ return nil
99+}
100+
101+// ResolveAppBind resolves one AppBind under this policy for the given app and
102+// service. It validates the policy and bind shapes, enforces the exact
103+// app/service allowlists, resolves the source through symlinks, verifies it is
104+// a regular file or directory that remains under the configured root, and
105+// never weakens read-only intent (policy read-only and bind read-only both
106+// force the resolved bind read-only). The result is runtime-ready: callers
107+// mount ResolvedAppBind.Source at Destination.
108+func (p AppBindPolicy) ResolveAppBind(app, service string, bind AppBind) (ResolvedAppBind, error) {
109+ if err := p.Validate(); err != nil {
110+ return ResolvedAppBind{}, err
111+ }
112+ if !allowlistContains(p.AllowedApps, app) {
113+ return ResolvedAppBind{}, fmt.Errorf("%w: policy %q does not allow app %q", ErrBindPolicy, p.Name, app)
114+ }
115+ if !allowlistContains(p.AllowedServices, service) {
116+ return ResolvedAppBind{}, fmt.Errorf("%w: policy %q does not allow service %q", ErrBindPolicy, p.Name, service)
117+ }
118+ if err := ValidateBindName(bind.Name); err != nil {
119+ return ResolvedAppBind{}, err
120+ }
121+ if err := ValidateBindDestination(bind.Path); err != nil {
122+ return ResolvedAppBind{}, err
123+ }
124+ source, err := p.resolveSource()
125+ if err != nil {
126+ return ResolvedAppBind{}, err
127+ }
128+ return ResolvedAppBind{
129+ Name: bind.Name,
130+ Source: source,
131+ Destination: bind.Path,
132+ ReadOnly: p.ReadOnly || bind.ReadOnly,
133+ }, nil
134+}
135+
136+// allowlistContains reports exact membership.
137+func allowlistContains(names []string, candidate string) bool {
138+ for _, name := range names {
139+ if name == candidate {
140+ return true
141+ }
142+ }
143+ return false
144+}
145+
146+// resolveSource resolves and verifies the policy source against the filesystem.
147+func (p AppBindPolicy) resolveSource() (string, error) {
148+ resolvedSource, err := filepath.EvalSymlinks(p.Source)
149+ if err != nil {
150+ return "", fmt.Errorf("%w: policy %q source %q: %v", ErrBindPolicy, p.Name, p.Source, err)
151+ }
152+ info, err := os.Lstat(resolvedSource)
153+ if err != nil {
154+ return "", fmt.Errorf("%w: policy %q source %q: %v", ErrBindPolicy, p.Name, p.Source, err)
155+ }
156+ if !bindSourceTypeAllowed(info.Mode()) {
157+ return "", fmt.Errorf("%w: policy %q source %q must be a regular file or directory", ErrBindPolicy, p.Name, p.Source)
158+ }
159+ if p.Root == "" {
160+ return resolvedSource, nil
161+ }
162+ resolvedRoot, err := filepath.EvalSymlinks(p.Root)
163+ if err != nil {
164+ return "", fmt.Errorf("%w: policy %q root %q: %v", ErrBindPolicy, p.Name, p.Root, err)
165+ }
166+ if !resolvedUnderRoot(resolvedRoot, resolvedSource) {
167+ return "", fmt.Errorf("%w: policy %q source %q resolves outside root %q", ErrBindPolicy, p.Name, p.Source, p.Root)
168+ }
169+ return resolvedSource, nil
170+}
171+
172+// bindSourceTypeAllowed accepts only regular files and directories. Devices,
173+// sockets, FIFOs, symlinks, and other special files are refused.
174+func bindSourceTypeAllowed(mode os.FileMode) bool {
175+ return mode.IsRegular() || mode.IsDir()
176+}
177+
178+// resolvedUnderRoot reports whether target is root itself or a descendant of
179+// root. Both paths must already be resolved.
180+func resolvedUnderRoot(root, target string) bool {
181+ rel, err := filepath.Rel(root, target)
182+ if err != nil {
183+ return false
184+ }
185+ if rel == "." {
186+ return true
187+ }
188+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
189+}
A internal/domain/app_bind_policy_test.go +258 -0
@@ -0,0 +1,258 @@
1+package domain
2+
3+import (
4+ "net"
5+ "os"
6+ "path/filepath"
7+ "syscall"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func validPolicy(root, source string) AppBindPolicy {
15+ return AppBindPolicy{
16+ Name: "config",
17+ Source: source,
18+ AllowedApps: []string{"blog"},
19+ AllowedServices: []string{"web"},
20+ Root: root,
21+ }
22+}
23+
24+func TestAppBindPolicy_Validate(t *testing.T) {
25+ root := t.TempDir()
26+ source := filepath.Join(root, "binds")
27+ require.NoError(t, os.MkdirAll(source, 0o755))
28+
29+ require.NoError(t, validPolicy(root, source).Validate())
30+
31+ cases := []struct {
32+ name string
33+ mutate func(*AppBindPolicy)
34+ wantErr string
35+ }{
36+ {"empty name", func(p *AppBindPolicy) { p.Name = "" }, "policy name"},
37+ {"bad name", func(p *AppBindPolicy) { p.Name = "Bad" }, "policy name"},
38+ {"reserved name", func(p *AppBindPolicy) { p.Name = "a--b" }, "reserved separator"},
39+ {"relative source", func(p *AppBindPolicy) { p.Source = "binds" }, "must be absolute"},
40+ {"unclean source", func(p *AppBindPolicy) { p.Source = root + "/binds/../etc" }, "normalized clean path"},
41+ {"empty source", func(p *AppBindPolicy) { p.Source = "" }, "source must not be empty"},
42+ {"relative root", func(p *AppBindPolicy) { p.Root = "root" }, "must be absolute"},
43+ {"unclean root", func(p *AppBindPolicy) { p.Root = root + "/x/.." }, "normalized clean path"},
44+ {"empty allowed apps", func(p *AppBindPolicy) { p.AllowedApps = nil }, "allowed apps must not be empty"},
45+ {"empty allowed services", func(p *AppBindPolicy) { p.AllowedServices = nil }, "allowed services must not be empty"},
46+ {"bad allowed app", func(p *AppBindPolicy) { p.AllowedApps = []string{"Bad!"} }, "allowed app"},
47+ {"bad allowed service", func(p *AppBindPolicy) { p.AllowedServices = []string{"Bad!"} }, "allowed service"},
48+ {"duplicate allowed app", func(p *AppBindPolicy) { p.AllowedApps = []string{"blog", "blog"} }, "duplicated"},
49+ {"duplicate allowed service", func(p *AppBindPolicy) { p.AllowedServices = []string{"web", "web"} }, "duplicated"},
50+ }
51+ for _, tc := range cases {
52+ t.Run(tc.name, func(t *testing.T) {
53+ policy := validPolicy(root, source)
54+ tc.mutate(&policy)
55+ err := policy.Validate()
56+ require.Error(t, err)
57+ assert.ErrorIs(t, err, ErrBindPolicy)
58+ assert.Contains(t, err.Error(), tc.wantErr)
59+ })
60+ }
61+}
62+
63+func TestAppBindPolicy_ResolveAppBind(t *testing.T) {
64+ root := t.TempDir()
65+ source := filepath.Join(root, "binds", "config")
66+ require.NoError(t, os.MkdirAll(source, 0o755))
67+
68+ policy := validPolicy(root, source)
69+ resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
70+ require.NoError(t, err)
71+ assert.Equal(t, "config", resolved.Name)
72+ assert.Equal(t, "/etc/app.conf", resolved.Destination)
73+ assert.False(t, resolved.ReadOnly)
74+ realSource, err := filepath.EvalSymlinks(source)
75+ require.NoError(t, err)
76+ assert.Equal(t, realSource, resolved.Source)
77+}
78+
79+func TestAppBindPolicy_ResolveAppBindReadOnlyNonWeakening(t *testing.T) {
80+ root := t.TempDir()
81+ source := filepath.Join(root, "file.txt")
82+ require.NoError(t, os.WriteFile(source, []byte("data"), 0o644))
83+
84+ cases := []struct {
85+ name string
86+ policyRO bool
87+ bindRO bool
88+ wantReadOnly bool
89+ }{
90+ {"policy enforces read-only", true, false, true},
91+ {"bind requests read-only", false, true, true},
92+ {"both read-only", true, true, true},
93+ {"writable by default", false, false, false},
94+ }
95+ for _, tc := range cases {
96+ t.Run(tc.name, func(t *testing.T) {
97+ policy := validPolicy(root, source)
98+ policy.ReadOnly = tc.policyRO
99+ resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: tc.bindRO})
100+ require.NoError(t, err)
101+ assert.Equal(t, tc.wantReadOnly, resolved.ReadOnly)
102+ })
103+ }
104+}
105+
106+func TestAppBindPolicy_ResolveAppBindAllowlists(t *testing.T) {
107+ root := t.TempDir()
108+ source := filepath.Join(root, "binds")
109+ require.NoError(t, os.MkdirAll(source, 0o755))
110+ policy := validPolicy(root, source)
111+
112+ _, err := policy.ResolveAppBind("other", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
113+ require.ErrorIs(t, err, ErrBindPolicy)
114+ assert.Contains(t, err.Error(), "does not allow app")
115+
116+ _, err = policy.ResolveAppBind("blog", "other", AppBind{Name: "config", Path: "/etc/app.conf"})
117+ require.ErrorIs(t, err, ErrBindPolicy)
118+ assert.Contains(t, err.Error(), "does not allow service")
119+
120+ // Exact, not prefix, matching.
121+ _, err = policy.ResolveAppBind("blog-staging", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
122+ require.ErrorIs(t, err, ErrBindPolicy)
123+}
124+
125+func TestAppBindPolicy_ResolveAppBindBindShape(t *testing.T) {
126+ root := t.TempDir()
127+ source := filepath.Join(root, "binds")
128+ require.NoError(t, os.MkdirAll(source, 0o755))
129+ policy := validPolicy(root, source)
130+
131+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "Bad", Path: "/etc/app.conf"})
132+ require.ErrorIs(t, err, ErrInvalidAppSpec)
133+
134+ _, err = policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "relative"})
135+ require.ErrorIs(t, err, ErrInvalidAppSpec)
136+}
137+
138+func TestAppBindPolicy_ResolveAppBindRootBoundary(t *testing.T) {
139+ root := t.TempDir()
140+ outside := t.TempDir()
141+ require.NoError(t, os.MkdirAll(filepath.Join(root, "binds"), 0o755))
142+ require.NoError(t, os.MkdirAll(filepath.Join(outside, "binds"), 0o755))
143+
144+ t.Run("source outside root", func(t *testing.T) {
145+ policy := validPolicy(root, filepath.Join(outside, "binds"))
146+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
147+ require.ErrorIs(t, err, ErrBindPolicy)
148+ assert.Contains(t, err.Error(), "outside root")
149+ })
150+
151+ t.Run("sibling prefix is not inside", func(t *testing.T) {
152+ // root/../root-sibling must not be treated as a descendant of root.
153+ sibling := root + "-sibling"
154+ require.NoError(t, os.MkdirAll(sibling, 0o755))
155+ t.Cleanup(func() { _ = os.RemoveAll(sibling) })
156+ policy := validPolicy(root, sibling)
157+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
158+ require.ErrorIs(t, err, ErrBindPolicy)
159+ assert.Contains(t, err.Error(), "outside root")
160+ })
161+
162+ t.Run("symlink escaping root is refused", func(t *testing.T) {
163+ link := filepath.Join(root, "escape")
164+ require.NoError(t, os.Symlink(filepath.Join(outside, "binds"), link))
165+ policy := validPolicy(root, link)
166+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
167+ require.ErrorIs(t, err, ErrBindPolicy)
168+ assert.Contains(t, err.Error(), "outside root")
169+ })
170+
171+ t.Run("symlink inside root resolves", func(t *testing.T) {
172+ real := filepath.Join(root, "binds")
173+ link := filepath.Join(root, "link")
174+ require.NoError(t, os.Symlink(real, link))
175+ policy := validPolicy(root, link)
176+ resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
177+ require.NoError(t, err)
178+ realSource, err := filepath.EvalSymlinks(real)
179+ require.NoError(t, err)
180+ assert.Equal(t, realSource, resolved.Source)
181+ })
182+
183+ t.Run("root itself is inside", func(t *testing.T) {
184+ policy := validPolicy(root, root)
185+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
186+ require.NoError(t, err)
187+ })
188+
189+ t.Run("optional root is not required", func(t *testing.T) {
190+ policy := validPolicy("", filepath.Join(outside, "binds"))
191+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
192+ require.NoError(t, err)
193+ })
194+}
195+
196+func TestAppBindPolicy_ResolveAppBindSourceTypes(t *testing.T) {
197+ root := t.TempDir()
198+ require.NoError(t, os.MkdirAll(filepath.Join(root, "binds"), 0o755))
199+
200+ t.Run("missing source", func(t *testing.T) {
201+ policy := validPolicy(root, filepath.Join(root, "binds", "nope"))
202+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
203+ require.ErrorIs(t, err, ErrBindPolicy)
204+ })
205+
206+ t.Run("fifo refused", func(t *testing.T) {
207+ fifo := filepath.Join(root, "binds", "fifo")
208+ require.NoError(t, syscall.Mkfifo(fifo, 0o600))
209+ policy := validPolicy(root, fifo)
210+ _, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
211+ require.ErrorIs(t, err, ErrBindPolicy)
212+ assert.Contains(t, err.Error(), "regular file or directory")
213+ })
214+
215+ t.Run("socket refused", func(t *testing.T) {
216+ socket := filepath.Join(root, "binds", "sock")
217+ listener, err := net.Listen("unix", socket)
218+ require.NoError(t, err)
219+ defer func() { _ = listener.Close() }()
220+ policy := validPolicy(root, socket)
221+ _, err = policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
222+ require.ErrorIs(t, err, ErrBindPolicy)
223+ assert.Contains(t, err.Error(), "regular file or directory")
224+ })
225+
226+ t.Run("regular file accepted", func(t *testing.T) {
227+ file := filepath.Join(root, "binds", "app.conf")
228+ require.NoError(t, os.WriteFile(file, []byte("x"), 0o644))
229+ policy := validPolicy(root, file)
230+ resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
231+ require.NoError(t, err)
232+ assert.Equal(t, file, resolved.Source)
233+ })
234+}
235+
236+func TestBindSourceTypeAllowed(t *testing.T) {
237+ assert.True(t, bindSourceTypeAllowed(0))
238+ assert.True(t, bindSourceTypeAllowed(os.ModeDir))
239+ refused := []os.FileMode{
240+ os.ModeSymlink,
241+ os.ModeDevice,
242+ os.ModeCharDevice,
243+ os.ModeNamedPipe,
244+ os.ModeSocket,
245+ os.ModeIrregular,
246+ }
247+ for _, mode := range refused {
248+ assert.False(t, bindSourceTypeAllowed(mode), mode.String())
249+ }
250+}
251+
252+func TestResolvedUnderRoot(t *testing.T) {
253+ assert.True(t, resolvedUnderRoot("/srv/binds", "/srv/binds"))
254+ assert.True(t, resolvedUnderRoot("/srv/binds", "/srv/binds/conf"))
255+ assert.False(t, resolvedUnderRoot("/srv/binds", "/srv/binds-sibling"))
256+ assert.False(t, resolvedUnderRoot("/srv/binds", "/srv/other"))
257+ assert.False(t, resolvedUnderRoot("/srv/binds", "/etc"))
258+}
A internal/domain/app_bind_test.go +111 -0
@@ -0,0 +1,111 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestAppSpec_ValidateBindsOK(t *testing.T) {
13+ spec := validSpec()
14+ spec.Services[0].Binds = []domain.AppBind{
15+ {Name: "config", Path: "/etc/app.conf", ReadOnly: true},
16+ {Name: "data", Path: "/var/lib/app"},
17+ }
18+ require.NoError(t, spec.Validate())
19+}
20+
21+func TestAppSpec_ValidateBindErrors(t *testing.T) {
22+ cases := []struct {
23+ name string
24+ mutate func(*domain.AppSpec)
25+ wantErr string
26+ }{
27+ {"bad bind name", func(s *domain.AppSpec) {
28+ s.Services[0].Binds = []domain.AppBind{{Name: "Bad_Name", Path: "/data"}}
29+ }, "bind name"},
30+ {"reserved separator name", func(s *domain.AppSpec) {
31+ s.Services[0].Binds = []domain.AppBind{{Name: "a--b", Path: "/data"}}
32+ }, "reserved separator"},
33+ {"relative destination", func(s *domain.AppSpec) {
34+ s.Services[0].Binds = []domain.AppBind{{Name: "data", Path: "relative/path"}}
35+ }, "must be absolute"},
36+ {"unnormalized destination", func(s *domain.AppSpec) {
37+ s.Services[0].Binds = []domain.AppBind{{Name: "data", Path: "/var/../data"}}
38+ }, "normalized clean path"},
39+ {"trailing slash destination", func(s *domain.AppSpec) {
40+ s.Services[0].Binds = []domain.AppBind{{Name: "data", Path: "/data/"}}
41+ }, "normalized clean path"},
42+ {"sensitive destination", func(s *domain.AppSpec) {
43+ s.Services[0].Binds = []domain.AppBind{{Name: "dev", Path: "/dev"}}
44+ }, "sensitive container path"},
45+ {"duplicate bind name", func(s *domain.AppSpec) {
46+ s.Services[0].Binds = []domain.AppBind{
47+ {Name: "data", Path: "/data"},
48+ {Name: "data", Path: "/other"},
49+ }
50+ }, "duplicate bind name"},
51+ {"duplicate bind destination", func(s *domain.AppSpec) {
52+ s.Services[0].Binds = []domain.AppBind{
53+ {Name: "one", Path: "/data"},
54+ {Name: "two", Path: "/data"},
55+ }
56+ }, "duplicate bind destination"},
57+ {"collision with volume", func(s *domain.AppSpec) {
58+ s.Services[0].Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
59+ s.Services[0].Binds = []domain.AppBind{{Name: "data-bind", Path: "/data"}}
60+ }, "collides with a declared volume"},
61+ }
62+ for _, tc := range cases {
63+ t.Run(tc.name, func(t *testing.T) {
64+ spec := validSpec()
65+ tc.mutate(&spec)
66+ err := spec.Validate()
67+ require.Error(t, err)
68+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
69+ assert.Contains(t, err.Error(), tc.wantErr)
70+ })
71+ }
72+}
73+
74+func TestValidateBindName(t *testing.T) {
75+ assert.NoError(t, domain.ValidateBindName("data"))
76+ assert.NoError(t, domain.ValidateBindName("app.config"))
77+ assert.ErrorIs(t, domain.ValidateBindName(""), domain.ErrInvalidAppSpec)
78+ assert.ErrorIs(t, domain.ValidateBindName("Bad"), domain.ErrInvalidAppSpec)
79+ assert.ErrorIs(t, domain.ValidateBindName("a--b"), domain.ErrInvalidAppSpec)
80+}
81+
82+func TestValidateBindDestination(t *testing.T) {
83+ valid := []string{"/data", "/etc/app.conf", "/var/lib/app"}
84+ for _, dest := range valid {
85+ assert.NoError(t, domain.ValidateBindDestination(dest), dest)
86+ }
87+ invalid := []string{"", "data", "/data/", "/data/../etc", "//data", "/./data", "/", "/proc", "/sys", "/dev", "/boot"}
88+ for _, dest := range invalid {
89+ err := domain.ValidateBindDestination(dest)
90+ require.Error(t, err, dest)
91+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec, dest)
92+ }
93+}
94+
95+func TestIsSensitiveBindDestination(t *testing.T) {
96+ for _, dest := range []string{"/", "/proc", "/sys", "/dev", "/boot"} {
97+ assert.True(t, domain.IsSensitiveBindDestination(dest), dest)
98+ }
99+ assert.False(t, domain.IsSensitiveBindDestination("/etc/app.conf"))
100+ assert.True(t, domain.IsSensitiveBindDestination("/proc/self"))
101+ assert.True(t, domain.IsSensitiveBindDestination("/dev/shm"))
102+}
103+
104+func TestDiffAppSpec_Binds(t *testing.T) {
105+ desired := validSpec()
106+ desired.Services[0].Binds = []domain.AppBind{{Name: "data", Path: "/data", ReadOnly: true}}
107+ effective := validSpec()
108+ effective.Services[0].Binds = []domain.AppBind{{Name: "data", Path: "/data"}}
109+ diff := domain.DiffAppSpec(desired, effective)
110+ assert.Contains(t, diff.Changed, "service/web/binds")
111+}
M internal/domain/app_readiness_test.go +4 -0
@@ -27,6 +27,10 @@ func TestValidateReadinessPath(t *testing.T) {
2727 "/healthz\x00",
2828 "/healthz\x7f",
2929 "/he\talthz",
30+ " /healthz",
31+ "/healthz ",
32+ "\t/healthz",
33+ "/healthz\n",
3034 }
3135 for _, path := range invalid {
3236 assert.Error(t, domain.ValidateReadinessPath(path), "path %q should be rejected", path)
M internal/domain/app_reservations.go +6 -1
@@ -7,11 +7,16 @@ import (
77
88 // ReservationsFor computes the global listener claims for a normalized
99 // spec. It is the single owner of reservation derivation; the apps and
10-// deployment use cases delegate to it.
10+// deployment use cases delegate to it. Only effective-public HTTP
11+// interfaces claim a host: internal interfaces are never published, so
12+// they hold no global listener reservation.
1113 func ReservationsFor(spec AppSpec) []AppListenerReservation {
1214 var reservations []AppListenerReservation
1315 for _, svc := range spec.Services {
1416 for _, h := range svc.HTTP {
17+ if !h.IsPublic() {
18+ continue
19+ }
1520 reservations = append(reservations, AppListenerReservation{
1621 Proto: "http",
1722 Host: h.Host,
A internal/domain/app_reservations_visibility_test.go +37 -0
@@ -0,0 +1,37 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// TestReservationsFor_InternalHTTPClaimsNothing proves internal HTTP holds
13+// no global listener reservation: there is no host to reserve, so a second
14+// app may use the same internal port without conflict.
15+func TestReservationsFor_InternalHTTPClaimsNothing(t *testing.T) {
16+ spec := domain.AppSpec{
17+ Name: "blog",
18+ Services: []domain.AppService{{
19+ Name: "api",
20+ Image: "registry.example.com/blog/api:1.0.0",
21+ HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
22+ }},
23+ }
24+ assert.Empty(t, domain.ReservationsFor(spec))
25+
26+ public := domain.AppSpec{
27+ Name: "blog",
28+ Services: []domain.AppService{{
29+ Name: "web",
30+ Image: "registry.example.com/blog/web:1.0.0",
31+ HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
32+ }},
33+ }
34+ reservations := domain.ReservationsFor(public)
35+ require.Len(t, reservations, 1)
36+ assert.Equal(t, "blog.example.com", reservations[0].Host)
37+}
M internal/domain/app_spec.go +356 -27
@@ -3,6 +3,7 @@ package domain
33 import (
44 "fmt"
55 "net"
6+ "path"
67 "reflect"
78 "regexp"
89 "sort"
@@ -44,6 +45,13 @@ const (
4445 AppTLSNever = "never"
4546 )
4647
48+// App visibility modes for HTTP interfaces. public keeps the historical
49+// host/TLS plane; internal is reachable only from the app private network.
50+const (
51+ AppVisibilityPublic = "public"
52+ AppVisibilityInternal = "internal"
53+)
54+
4755 // App database engines. Only postgres in v2.50.
4856 const (
4957 AppDBPostgres = "postgres"
@@ -68,7 +76,7 @@ const (
6876 // AppMaxReadinessTimeout bounds readiness timeout above.
6977 AppMaxReadinessTimeout = 10 * time.Minute
7078 // AppDefaultStopGrace applies when stop_grace is unset.
71- AppDefaultStopGrace = 10 * time.Second
79+ AppDefaultStopGrace = 30 * time.Second
7280 // AppMaxStopGrace caps stop_grace.
7381 AppMaxStopGrace = 5 * time.Minute
7482 )
@@ -95,6 +103,7 @@ type AppService struct {
95103 UDP []AppUDPInterface
96104 Secrets map[string]string
97105 Volumes []AppVolume
106+ Binds []AppBind
98107 Databases []AppDatabase
99108 Backup AppBackup
100109 }
@@ -108,11 +117,71 @@ type AppReadiness struct {
108117 Timeout time.Duration
109118 }
110119
111-// AppHTTPInterface is one HTTP service interface.
120+// AppHTTPInterface is one HTTP service interface. Visibility is explicit
121+// interface state: public HTTP keeps the historical host/TLS behavior,
122+// internal HTTP declares neither host nor tls and is reachable only from
123+// the app private network.
112124 type AppHTTPInterface struct {
113125 Host string
114126 Port int
115127 TLS string
128+ // Visibility is public or internal. The zero value normalizes to
129+ // public when read or projected, so manifests and stored state
130+ // written before visibility existed keep their meaning.
131+ Visibility string
132+}
133+
134+// EffectiveVisibility normalizes absent visibility to public.
135+func (h AppHTTPInterface) EffectiveVisibility() string {
136+ if h.Visibility == "" {
137+ return AppVisibilityPublic
138+ }
139+ return h.Visibility
140+}
141+
142+// IsPublic reports whether the interface is served on the public plane.
143+func (h AppHTTPInterface) IsPublic() bool {
144+ return h.EffectiveVisibility() == AppVisibilityPublic
145+}
146+
147+// IsInternal reports whether the interface is reachable only from the
148+// app private network.
149+func (h AppHTTPInterface) IsInternal() bool {
150+ return h.EffectiveVisibility() == AppVisibilityInternal
151+}
152+
153+// IsPublicHTTP reports whether a service declares at least one
154+// effective-public HTTP interface.
155+func (s AppService) IsPublicHTTP() bool {
156+ for _, h := range s.HTTP {
157+ if h.IsPublic() {
158+ return true
159+ }
160+ }
161+ return false
162+}
163+
164+// InternallyOnlyPort reports whether port is declared by internal HTTP
165+// interfaces and by no externally backed HTTP/TCP interface. Such a port
166+// is reached over the private network only and must never gain a host
167+// publication, including through readiness metadata.
168+func (s AppService) InternallyOnlyPort(port int) bool {
169+ internal := false
170+ for _, h := range s.HTTP {
171+ if h.Port != port {
172+ continue
173+ }
174+ if !h.IsInternal() {
175+ return false
176+ }
177+ internal = true
178+ }
179+ for _, t := range s.TCP {
180+ if t.Port == port {
181+ return false
182+ }
183+ }
184+ return internal
116185 }
117186
118187 // AppTCPInterface is one TCP service interface.
@@ -136,6 +205,15 @@ type AppVolume struct {
136205 ReadOnly bool
137206 }
138207
208+// AppBind is one named read-only host bind mount.
209+// Name is the stable identity reused across deploys; Path is the
210+// container destination.
211+type AppBind struct {
212+ Name string
213+ Path string
214+ ReadOnly bool
215+}
216+
139217 // AppDatabase is one explicit database declaration.
140218 type AppDatabase struct {
141219 Name string
@@ -194,6 +272,61 @@ func ValidateSecretName(name string) error {
194272 return ValidateServiceName(name)
195273 }
196274
275+// ValidateBindName checks the stable bind identity (service charset plus -- ban).
276+func ValidateBindName(name string) error {
277+ if !serviceNamePattern.MatchString(name) {
278+ return fmt.Errorf("%w: bind name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
279+ }
280+ if strings.Contains(name, "--") {
281+ return fmt.Errorf("%w: bind name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
282+ }
283+ return nil
284+}
285+
286+// sensitiveBindDestinations lists container paths a manifest bind must never
287+// shadow. Server policy may refuse more, but these are always sensitive.
288+var sensitiveBindDestinations = map[string]struct{}{
289+ "/": {},
290+ "/proc": {},
291+ "/sys": {},
292+ "/dev": {},
293+ "/boot": {},
294+}
295+
296+// IsSensitiveBindDestination reports whether dest is a reserved container path
297+// or lies below one. Mounting a child such as /proc/self is as dangerous as
298+// shadowing the reserved root itself.
299+func IsSensitiveBindDestination(dest string) bool {
300+ if dest == "/" {
301+ return true
302+ }
303+ for reserved := range sensitiveBindDestinations {
304+ if reserved != "/" && (dest == reserved || strings.HasPrefix(dest, reserved+"/")) {
305+ return true
306+ }
307+ }
308+ return false
309+}
310+
311+// ValidateBindDestination checks one bind destination: absolute, clean,
312+// normalized, and never a sensitive container path. It is pure and reusable
313+// by manifest validation and by server-side source policy.
314+func ValidateBindDestination(dest string) error {
315+ if dest == "" {
316+ return fmt.Errorf("%w: bind destination must not be empty", ErrInvalidAppSpec)
317+ }
318+ if !path.IsAbs(dest) {
319+ return fmt.Errorf("%w: bind destination %q must be absolute", ErrInvalidAppSpec, dest)
320+ }
321+ if path.Clean(dest) != dest {
322+ return fmt.Errorf("%w: bind destination %q must be a normalized clean path", ErrInvalidAppSpec, dest)
323+ }
324+ if IsSensitiveBindDestination(dest) {
325+ return fmt.Errorf("%w: bind destination %q is a sensitive container path", ErrInvalidAppSpec, dest)
326+ }
327+ return nil
328+}
329+
197330 // NormalizeServiceName applies the runtime-identifier normalization.
198331 func NormalizeServiceName(name string) string {
199332 return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
@@ -389,13 +522,16 @@ func (s *AppService) validate() error {
389522 if err != nil {
390523 return err
391524 }
525+ if err := s.validateBinds(); err != nil {
526+ return err
527+ }
392528 if err := s.validateDatabases(volumes); err != nil {
393529 return err
394530 }
395531 return nil
396532 }
397533
398-// validateInterfaces checks HTTP/TCP/UDP entries.
534+// validateInterfaces checks HTTP/TCP/UDP entries and their port ownership.
399535 func (s *AppService) validateInterfaces() error {
400536 for i := range s.HTTP {
401537 if err := s.HTTP[i].validate(s.Name); err != nil {
@@ -412,6 +548,37 @@ func (s *AppService) validateInterfaces() error {
412548 return err
413549 }
414550 }
551+ return s.validateInterfacePorts()
552+}
553+
554+// validateInterfacePorts rejects one container port claimed by both an
555+// internal HTTP interface and an externally backed HTTP/TCP interface:
556+// publication is socket-level, so publishing the port would expose the
557+// internal listener beyond the private network. Internal ports must also
558+// be declared at most once, since they carry no host to tell them apart.
559+func (s *AppService) validateInterfacePorts() error {
560+ external := map[int]string{}
561+ for _, h := range s.HTTP {
562+ if h.IsPublic() {
563+ external[h.Port] = "http"
564+ }
565+ }
566+ for _, t := range s.TCP {
567+ external[t.Port] = "tcp"
568+ }
569+ seenInternal := map[int]struct{}{}
570+ for _, h := range s.HTTP {
571+ if !h.IsInternal() {
572+ continue
573+ }
574+ if kind, ok := external[h.Port]; ok {
575+ return fmt.Errorf("%w: service %q internal http port %d is also declared by an externally backed %s interface", ErrInvalidAppSpec, s.Name, h.Port, kind)
576+ }
577+ if _, ok := seenInternal[h.Port]; ok {
578+ return fmt.Errorf("%w: service %q internal http port %d is declared more than once", ErrInvalidAppSpec, s.Name, h.Port)
579+ }
580+ seenInternal[h.Port] = struct{}{}
581+ }
415582 return nil
416583 }
417584
@@ -440,13 +607,44 @@ func (s *AppService) validateVolumes() (map[string]struct{}, error) {
440607 return nil, fmt.Errorf("%w: service %q duplicate volume %q", ErrInvalidAppSpec, s.Name, vol.Name)
441608 }
442609 seenVolumes[vol.Name] = struct{}{}
443- if !strings.HasPrefix(vol.Path, "/") || strings.Contains(vol.Path, "..") {
444- return nil, fmt.Errorf("%w: service %q volume %q path must be absolute without dot-dot", ErrInvalidAppSpec, s.Name, vol.Name)
610+ if !path.IsAbs(vol.Path) || path.Clean(vol.Path) != vol.Path {
611+ return nil, fmt.Errorf("%w: service %q volume %q path must be absolute and normalized", ErrInvalidAppSpec, s.Name, vol.Name)
445612 }
446613 }
447614 return seenVolumes, nil
448615 }
449616
617+// validateBinds checks bind names, destinations, duplicates, and volume collisions.
618+func (s *AppService) validateBinds() error {
619+ volumePaths := make(map[string]struct{}, len(s.Volumes))
620+ for _, vol := range s.Volumes {
621+ volumePaths[vol.Path] = struct{}{}
622+ }
623+ seenNames := map[string]struct{}{}
624+ seenDests := map[string]struct{}{}
625+ for i := range s.Binds {
626+ bind := &s.Binds[i]
627+ if err := ValidateBindName(bind.Name); err != nil {
628+ return fmt.Errorf("%w: service %q: %v", ErrInvalidAppSpec, s.Name, err)
629+ }
630+ if _, ok := seenNames[bind.Name]; ok {
631+ return fmt.Errorf("%w: service %q duplicate bind name %q", ErrInvalidAppSpec, s.Name, bind.Name)
632+ }
633+ seenNames[bind.Name] = struct{}{}
634+ if err := ValidateBindDestination(bind.Path); err != nil {
635+ return fmt.Errorf("%w: service %q bind %q: %v", ErrInvalidAppSpec, s.Name, bind.Name, err)
636+ }
637+ if _, ok := seenDests[bind.Path]; ok {
638+ return fmt.Errorf("%w: service %q duplicate bind destination %q", ErrInvalidAppSpec, s.Name, bind.Path)
639+ }
640+ seenDests[bind.Path] = struct{}{}
641+ if _, ok := volumePaths[bind.Path]; ok {
642+ return fmt.Errorf("%w: service %q bind destination %q collides with a declared volume", ErrInvalidAppSpec, s.Name, bind.Path)
643+ }
644+ }
645+ return nil
646+}
647+
450648 // validateDatabases checks database declarations and backup references.
451649 func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {
452650 seenDBs := map[string]struct{}{}
@@ -515,8 +713,8 @@ func (r AppReadiness) validateL4(svc *AppService) error {
515713 if hasUDPOnly(svc) {
516714 return fmt.Errorf("%w: service %q with UDP-only interfaces must use none or log readiness", ErrInvalidAppSpec, svc.Name)
517715 }
518- if countTCPInterfaces(svc) > 1 && r.Port == 0 {
519- return fmt.Errorf("%w: service %q has multiple TCP-capable interfaces, readiness.port is required", ErrInvalidAppSpec, svc.Name)
716+ if r.Port == 0 && readinessPortRequired(svc) {
717+ return fmt.Errorf("%w: service %q interfaces do not select one readiness port, readiness.port is required", ErrInvalidAppSpec, svc.Name)
520718 }
521719 if r.Port != 0 && !hasTCPContainerPort(svc, r.Port) {
522720 return fmt.Errorf("%w: service %q readiness.port %d matches no declared container port", ErrInvalidAppSpec, svc.Name, r.Port)
@@ -533,12 +731,16 @@ func (r AppReadiness) validateL4(svc *AppService) error {
533731 // leading slash, no scheme or authority, and no control characters. The
534732 // path is appended to an immutable loopback URL, so rejecting everything
535733 // that could be read as a different authority keeps the probe on the
536-// declared backend.
734+// declared backend. Surrounding whitespace is rejected rather than
735+// trimmed: the stored path is used verbatim, and a silently trimmed probe
736+// would target a different path than the manifest declares.
537737 func ValidateReadinessPath(path string) error {
538- path = strings.TrimSpace(path)
539- if path == "" {
738+ if strings.TrimSpace(path) == "" {
540739 return fmt.Errorf("http readiness requires path")
541740 }
741+ if path != strings.TrimSpace(path) {
742+ return fmt.Errorf("http readiness path must not have leading or trailing whitespace")
743+ }
542744 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
543745 return fmt.Errorf("http readiness path must be an origin-form path beginning with one slash")
544746 }
@@ -558,6 +760,43 @@ func hasUDPOnly(svc *AppService) bool {
558760 return len(svc.HTTP) == 0 && len(svc.TCP) == 0
559761 }
560762
763+// readinessPortRequired reports whether an omitted readiness.port leaves
764+// the probe target ambiguous. One effective-public HTTP port stays
765+// selectable even when internal HTTP interfaces are also declared, and a
766+// lone TCP-capable interface selects itself. Several public HTTP ports, any
767+// mix of TCP and HTTP, or several TCP interfaces must name it explicitly.
768+func readinessPortRequired(svc *AppService) bool {
769+ if _, ok := singleEffectivePublicHTTPPort(svc); ok {
770+ return false
771+ }
772+ return countTCPInterfaces(svc) > 1
773+}
774+
775+// singleEffectivePublicHTTPPort returns the only distinct effective-public
776+// HTTP container port when the service declares at least one and no TCP
777+// interface. Internal HTTP interfaces never contribute: they are not
778+// reachable through a host publication, so they cannot make the public
779+// backend ambiguous.
780+func singleEffectivePublicHTTPPort(svc *AppService) (int, bool) {
781+ if len(svc.TCP) > 0 {
782+ return 0, false
783+ }
784+ port := 0
785+ for _, h := range svc.HTTP {
786+ if !h.IsPublic() {
787+ continue
788+ }
789+ if port != 0 && port != h.Port {
790+ return 0, false
791+ }
792+ port = h.Port
793+ }
794+ if port == 0 {
795+ return 0, false
796+ }
797+ return port, true
798+}
799+
561800 // countTCPInterfaces counts http/tcp container ports.
562801 func countTCPInterfaces(svc *AppService) int {
563802 return len(svc.HTTP) + len(svc.TCP)
@@ -578,21 +817,38 @@ func hasTCPContainerPort(svc *AppService, port int) bool {
578817 return false
579818 }
580819
581-// validate checks one HTTP interface.
820+// validate checks one HTTP interface against its visibility. Public
821+// interfaces keep the historical host/TLS rules; internal interfaces are
822+// private-network only and must not claim a host or a TLS mode.
582823 func (h AppHTTPInterface) validate(service string) error {
583- if h.Host == "" {
584- return fmt.Errorf("%w: service %q http host is required", ErrInvalidAppSpec, service)
585- }
586- if _, ok := CanonicalRouteDomain(h.Host); !ok {
587- return fmt.Errorf("%w: service %q http host %q is not a valid public hostname", ErrInvalidAppSpec, service, h.Host)
588- }
589- if h.Port < 1 || h.Port > 65535 {
590- return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)
591- }
592- switch h.TLS {
593- case AppTLSAuto, AppTLSAlways, AppTLSNever:
824+ switch h.EffectiveVisibility() {
825+ case AppVisibilityPublic:
826+ if h.Host == "" {
827+ return fmt.Errorf("%w: service %q http host is required", ErrInvalidAppSpec, service)
828+ }
829+ if _, ok := CanonicalRouteDomain(h.Host); !ok {
830+ return fmt.Errorf("%w: service %q http host %q is not a valid public hostname", ErrInvalidAppSpec, service, h.Host)
831+ }
832+ if h.Port < 1 || h.Port > 65535 {
833+ return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)
834+ }
835+ switch h.TLS {
836+ case AppTLSAuto, AppTLSAlways, AppTLSNever:
837+ default:
838+ return fmt.Errorf("%w: service %q http tls must be auto|always|never", ErrInvalidAppSpec, service)
839+ }
840+ case AppVisibilityInternal:
841+ if h.Host != "" {
842+ return fmt.Errorf("%w: service %q internal http interface must not declare host", ErrInvalidAppSpec, service)
843+ }
844+ if h.TLS != "" {
845+ return fmt.Errorf("%w: service %q internal http interface must not declare tls", ErrInvalidAppSpec, service)
846+ }
847+ if h.Port < 1 || h.Port > 65535 {
848+ return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)
849+ }
594850 default:
595- return fmt.Errorf("%w: service %q http tls must be auto|always|never", ErrInvalidAppSpec, service)
851+ return fmt.Errorf("%w: service %q http visibility must be public|internal", ErrInvalidAppSpec, service)
596852 }
597853 return nil
598854 }
@@ -697,15 +953,60 @@ func DiffAppSpec(desired, effective AppSpec) AppDiff {
697953 if !equalStringMaps(desired.Env, effective.Env) {
698954 diff.Changed = append(diff.Changed, "env")
699955 }
700- if !reflect.DeepEqual(desired.Networks, effective.Networks) {
701- diff.Changed = append(diff.Changed, "networks")
702- }
956+ diff.Changed = append(diff.Changed, diffNetworks(desired.Networks, effective.Networks)...)
703957 sort.Strings(diff.Added)
704958 sort.Strings(diff.Removed)
705959 sort.Strings(diff.Changed)
706960 return diff
707961 }
708962
963+func diffNetworks(desired, effective []AppSharedNetwork) []string {
964+ desiredByName := make(map[string]AppSharedNetwork, len(desired))
965+ for _, network := range desired {
966+ desiredByName[network.Network] = network
967+ }
968+ effectiveByName := make(map[string]AppSharedNetwork, len(effective))
969+ for _, network := range effective {
970+ effectiveByName[network.Network] = network
971+ }
972+
973+ var changed []string
974+ for name, network := range desiredByName {
975+ previous, ok := effectiveByName[name]
976+ switch {
977+ case !ok:
978+ changed = append(changed, "network/"+name+"/added")
979+ case !equalStringSets(network.Services, previous.Services):
980+ changed = append(changed, "network/"+name+"/services")
981+ case !equalStringSets(network.Aliases, previous.Aliases):
982+ changed = append(changed, "network/"+name+"/aliases")
983+ }
984+ }
985+ for name := range effectiveByName {
986+ if _, ok := desiredByName[name]; !ok {
987+ changed = append(changed, "network/"+name+"/removed")
988+ }
989+ }
990+ return changed
991+}
992+
993+func equalStringSets(a, b []string) bool {
994+ if len(a) != len(b) {
995+ return false
996+ }
997+ counts := make(map[string]int, len(a))
998+ for _, value := range a {
999+ counts[value]++
1000+ }
1001+ for _, value := range b {
1002+ counts[value]--
1003+ if counts[value] < 0 {
1004+ return false
1005+ }
1006+ }
1007+ return true
1008+}
1009+
7091010 // diffService compares two services field by field.
7101011 func diffService(name string, desired, effective AppService) []string {
7111012 var changed []string
@@ -735,6 +1036,9 @@ func diffService(name string, desired, effective AppService) []string {
7351036 if !equalVolumes(desired.Volumes, effective.Volumes) {
7361037 changed = append(changed, "service/"+name+"/volumes")
7371038 }
1039+ if !equalBinds(desired.Binds, effective.Binds) {
1040+ changed = append(changed, "service/"+name+"/binds")
1041+ }
7381042 if !reflect.DeepEqual(desired.Databases, effective.Databases) {
7391043 changed = append(changed, "service/"+name+"/databases")
7401044 }
@@ -763,7 +1067,7 @@ func secretPathChanges(desired, effective AppService) map[string]struct{} {
7631067 // interfacesChanged compares interface slices.
7641068 func interfacesChanged(desired, effective AppService) bool {
7651069 for i := range desired.HTTP {
766- if desired.HTTP[i] != effective.HTTP[i] {
1070+ if !httpInterfacesEqual(desired.HTTP[i], effective.HTTP[i]) {
7671071 return true
7681072 }
7691073 }
@@ -780,6 +1084,18 @@ func interfacesChanged(desired, effective AppService) bool {
7801084 return false
7811085 }
7821086
1087+// httpInterfacesEqual compares two HTTP interfaces with visibility
1088+// normalized. State written before the field existed reads as the zero
1089+// value and must equal an explicit public, while a real public/internal
1090+// flip is still a change.
1091+func httpInterfacesEqual(a, b AppHTTPInterface) bool {
1092+ if a.EffectiveVisibility() != b.EffectiveVisibility() {
1093+ return false
1094+ }
1095+ a.Visibility, b.Visibility = "", ""
1096+ return a == b
1097+}
1098+
7831099 // equalStringMaps compares string maps.
7841100 func equalStringMaps(a, b map[string]string) bool {
7851101 if len(a) != len(b) {
@@ -805,3 +1121,16 @@ func equalVolumes(a, b []AppVolume) bool {
8051121 }
8061122 return true
8071123 }
1124+
1125+// equalBinds compares bind slices by value.
1126+func equalBinds(a, b []AppBind) bool {
1127+ if len(a) != len(b) {
1128+ return false
1129+ }
1130+ for i := range a {
1131+ if a[i] != b[i] {
1132+ return false
1133+ }
1134+ }
1135+ return true
1136+}
M internal/domain/app_spec_test.go +22 -1
@@ -131,7 +131,7 @@ func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) {
131131 assert.Contains(t, diff.Changed, "service/web/stop_grace")
132132 assert.Contains(t, diff.Changed, "service/web/databases")
133133 assert.Contains(t, diff.Changed, "service/web/backup")
134- assert.Contains(t, diff.Changed, "networks")
134+ assert.Contains(t, diff.Changed, "network/shared/added")
135135 assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2")
136136 assert.Contains(t, diff.Changed, "service/web/secret/database-url")
137137 assert.Contains(t, diff.Changed, "env")
@@ -153,6 +153,27 @@ func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) {
153153 assert.Equal(t, []string{"service/web"}, removed.Removed)
154154 }
155155
156+func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) {
157+ base := validSpec()
158+ base.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db", "primary"}}}
159+
160+ reordered := validSpec()
161+ reordered.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"primary", "db"}}}
162+ assert.Empty(t, domain.DiffAppSpec(reordered, base).Changed)
163+
164+ servicesChanged := validSpec()
165+ servicesChanged.Services = append(servicesChanged.Services, domain.AppService{Name: "worker", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
166+ servicesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web", "worker"}, Aliases: []string{"db", "primary"}}}
167+ assert.Contains(t, domain.DiffAppSpec(servicesChanged, base).Changed, "network/database/services")
168+
169+ aliasesChanged := base
170+ aliasesChanged.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}}
171+ assert.Equal(t, []string{"network/database/aliases"}, domain.DiffAppSpec(aliasesChanged, base).Changed)
172+
173+ removed := validSpec()
174+ assert.Equal(t, []string{"network/database/removed"}, domain.DiffAppSpec(removed, base).Changed)
175+}
176+
156177 func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {
157178 assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))
158179 }
M internal/domain/app_state.go +4 -0
@@ -151,6 +151,7 @@ type AppActive struct {
151151 ConvergedRevision string `json:"converged_revision"`
152152 Converged bool `json:"converged"`
153153 Services map[string]AppEffectiveService `json:"services"`
154+ Networks []AppSharedNetwork `json:"networks,omitempty"`
154155 StopIntent bool `json:"stop_intent"`
155156 }
156157
@@ -302,6 +303,9 @@ const (
302303 // AppInhibitReplacementPending marks the generation being replaced
303304 // while a volume-owning candidate may already be writing.
304305 AppInhibitReplacementPending = "replacement-pending"
306+ // AppInhibitRetirementPending marks a published predecessor that must
307+ // remain stopped and be retired by a later reconciliation pass.
308+ AppInhibitRetirementPending = "retirement-pending"
305309 )
306310
307311 // AppOwnership is the ownership record for one app.
A internal/domain/app_visibility_diff_test.go +39 -0
@@ -0,0 +1,39 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// TestDiffAppSpec_VisibilityChangeIsDetected proves flipping an interface
12+// between public and internal is a meaningful change: it must force a
13+// redeploy instead of being silently ignored.
14+func TestDiffAppSpec_VisibilityChangeIsDetected(t *testing.T) {
15+ desired := visibilitySpec(domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal})
16+ effective := visibilitySpec(domain.AppHTTPInterface{
17+ Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityPublic,
18+ })
19+
20+ diff := domain.DiffAppSpec(desired, effective)
21+ assert.Contains(t, diff.Changed, "service/web/interfaces")
22+}
23+
24+// TestDiffAppSpec_ZeroVisibilityEqualsPublic proves state written before
25+// the visibility field existed (zero value) is not a phantom change
26+// against an explicit public interface, so applying an unchanged manifest
27+// stays a no-op after upgrade.
28+func TestDiffAppSpec_ZeroVisibilityEqualsPublic(t *testing.T) {
29+ // Legacy stored state: same interface, visibility never serialized.
30+ legacy := visibilitySpec(domain.AppHTTPInterface{
31+ Host: "blog.example.com", Port: 8080, TLS: "auto",
32+ })
33+ desired := visibilitySpec(domain.AppHTTPInterface{
34+ Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityPublic,
35+ })
36+
37+ diff := domain.DiffAppSpec(desired, legacy)
38+ assert.NotContains(t, diff.Changed, "service/web/interfaces")
39+}
A internal/domain/app_visibility_test.go +191 -0
@@ -0,0 +1,191 @@
1+package domain_test
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func visibilitySpec(interfaces ...domain.AppHTTPInterface) domain.AppSpec {
14+ return domain.AppSpec{
15+ Name: "blog",
16+ Services: []domain.AppService{{
17+ Name: "web",
18+ Image: "registry.example.com/blog/web:1.4.2",
19+ StopGrace: 10 * time.Second,
20+ Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 30 * time.Second},
21+ HTTP: interfaces,
22+ }},
23+ }
24+}
25+
26+// TestAppHTTPInterface_EffectiveVisibility proves the zero value normalizes
27+// to public so manifests and stored state written before visibility existed
28+// keep their meaning.
29+func TestAppHTTPInterface_EffectiveVisibility(t *testing.T) {
30+ absent := domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"}
31+ assert.Equal(t, domain.AppVisibilityPublic, absent.EffectiveVisibility())
32+ assert.True(t, absent.IsPublic())
33+ assert.False(t, absent.IsInternal())
34+
35+ explicit := absent
36+ explicit.Visibility = domain.AppVisibilityInternal
37+ assert.Equal(t, domain.AppVisibilityInternal, explicit.EffectiveVisibility())
38+ assert.True(t, explicit.IsInternal())
39+ assert.False(t, explicit.IsPublic())
40+}
41+
42+// TestAppSpec_ValidateVisibility proves public and absent visibility stay
43+// valid, internal visibility accepts a port without host or tls, and every
44+// malformed combination is rejected.
45+func TestAppSpec_ValidateVisibility(t *testing.T) {
46+ t.Run("absent visibility remains a valid public manifest", func(t *testing.T) {
47+ require.NoError(t, visibilitySpec(domain.AppHTTPInterface{
48+ Host: "blog.example.com", Port: 8080, TLS: "auto",
49+ }).Validate())
50+ })
51+
52+ t.Run("explicit public remains valid", func(t *testing.T) {
53+ require.NoError(t, visibilitySpec(domain.AppHTTPInterface{
54+ Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityPublic,
55+ }).Validate())
56+ })
57+
58+ t.Run("internal accepts a port without host or tls", func(t *testing.T) {
59+ require.NoError(t, visibilitySpec(domain.AppHTTPInterface{
60+ Port: 8080, Visibility: domain.AppVisibilityInternal,
61+ }).Validate())
62+ })
63+
64+ reject := map[string]domain.AppHTTPInterface{
65+ "internal forbids host": {Host: "blog.example.com", Port: 8080, Visibility: domain.AppVisibilityInternal},
66+ "internal forbids tls": {Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityInternal},
67+ "unknown visibility": {Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: "vpn"},
68+ "internal without port": {Visibility: domain.AppVisibilityInternal},
69+ "public without host": {Port: 8080, TLS: "auto"},
70+ "public without valid host": {Host: "not a host", Port: 8080, TLS: "auto"},
71+ "public without tls mode": {Host: "blog.example.com", Port: 8080},
72+ "public with unknown tls mode": {Host: "blog.example.com", Port: 8080, TLS: "sometimes"},
73+ "public with out of range port": {Host: "blog.example.com", Port: 0, TLS: "auto"},
74+ "internal with out of range port": {Port: -1, Visibility: domain.AppVisibilityInternal},
75+ }
76+ for name, iface := range reject {
77+ t.Run(name, func(t *testing.T) {
78+ err := visibilitySpec(iface).Validate()
79+ require.Error(t, err)
80+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
81+ })
82+ }
83+}
84+
85+// TestAppService_InternallyOnlyPort proves a port is internal-only only
86+// when internal HTTP declares it and no externally backed interface does.
87+func TestAppService_InternallyOnlyPort(t *testing.T) {
88+ internalOnly := domain.AppService{
89+ HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
90+ }
91+ assert.True(t, internalOnly.InternallyOnlyPort(8080))
92+ assert.False(t, internalOnly.InternallyOnlyPort(8081))
93+ assert.True(t, internalOnly.IsPublicHTTP() == false)
94+
95+ mixed := domain.AppService{
96+ HTTP: []domain.AppHTTPInterface{
97+ {Host: "blog.example.com", Port: 8080, TLS: "auto"},
98+ {Port: 9090, Visibility: domain.AppVisibilityInternal},
99+ },
100+ }
101+ assert.True(t, mixed.IsPublicHTTP())
102+ assert.True(t, mixed.InternallyOnlyPort(9090))
103+ assert.False(t, mixed.InternallyOnlyPort(8080))
104+
105+ // A TCP interface on the same port is externally backed: the port is
106+ // never internal-only, even though an internal HTTP interface wants it.
107+ // Validation rejects that combination, and the read helper must not
108+ // treat it as internal-only either.
109+ shadowed := domain.AppService{
110+ HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
111+ TCP: []domain.AppTCPInterface{{Port: 8080}},
112+ }
113+ assert.False(t, shadowed.InternallyOnlyPort(8080))
114+}
115+
116+// TestAppSpec_ValidateInterfacePorts proves an internal HTTP port may not
117+// collide with an externally backed interface on the same container port,
118+// and that internal ports stay unique.
119+func TestAppSpec_ValidateInterfacePorts(t *testing.T) {
120+ collide := visibilitySpec(
121+ domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal},
122+ )
123+ collide.Services[0].Readiness.Port = 8080
124+ collide.Services[0].TCP = []domain.AppTCPInterface{{Port: 8080}}
125+ require.ErrorIs(t, collide.Validate(), domain.ErrInvalidAppSpec)
126+
127+ duplicate := visibilitySpec(
128+ domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal},
129+ domain.AppHTTPInterface{Port: 8080, Visibility: domain.AppVisibilityInternal},
130+ )
131+ // An explicit port keeps this test on the duplicate-internal-port rule
132+ // instead of short-circuiting on readiness-port ambiguity.
133+ duplicate.Services[0].Readiness.Port = 8080
134+ require.ErrorIs(t, duplicate.Validate(), domain.ErrInvalidAppSpec)
135+}
136+
137+// TestAppSpec_ValidateMixedVisibilityReadiness proves one public HTTP
138+// interface plus internal HTTP interfaces is a valid readiness target with
139+// no explicit readiness.port, while interface sets that cannot select one
140+// port stay strict.
141+func TestAppSpec_ValidateMixedVisibilityReadiness(t *testing.T) {
142+ require.NoError(t, visibilitySpec(
143+ domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
144+ domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
145+ ).Validate())
146+
147+ tcpReadiness := visibilitySpec(
148+ domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
149+ domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
150+ )
151+ tcpReadiness.Services[0].Readiness.Type = domain.AppReadinessTCP
152+ require.NoError(t, tcpReadiness.Validate())
153+
154+ explicit := visibilitySpec(
155+ domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
156+ domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
157+ )
158+ explicit.Services[0].Readiness.Port = 9090
159+ require.NoError(t, explicit.Validate())
160+
161+ reject := map[string]func(spec domain.AppSpec){
162+ "several internal ports": func(spec domain.AppSpec) {
163+ spec.Services[0].HTTP = []domain.AppHTTPInterface{
164+ {Port: 8080, Visibility: domain.AppVisibilityInternal},
165+ {Port: 9090, Visibility: domain.AppVisibilityInternal},
166+ }
167+ },
168+ "public http plus tcp": func(spec domain.AppSpec) {
169+ spec.Services[0].TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}
170+ },
171+ "several public http ports": func(spec domain.AppSpec) {
172+ spec.Services[0].HTTP = []domain.AppHTTPInterface{
173+ {Host: "blog.example.com", Port: 8080, TLS: "auto"},
174+ {Host: "www.example.com", Port: 8081, TLS: "auto"},
175+ }
176+ },
177+ }
178+ for name, mutate := range reject {
179+ t.Run(name, func(t *testing.T) {
180+ spec := visibilitySpec(
181+ domain.AppHTTPInterface{Host: "blog.example.com", Port: 8080, TLS: "auto"},
182+ domain.AppHTTPInterface{Port: 9090, Visibility: domain.AppVisibilityInternal},
183+ )
184+ mutate(spec)
185+ err := spec.Validate()
186+ require.Error(t, err)
187+ assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
188+ assert.Contains(t, err.Error(), "readiness.port is required")
189+ })
190+ }
191+}
M internal/domain/container.go +11 -0
@@ -90,6 +90,16 @@ type ContainerBackendBind struct {
9090 Protocol NetworkProtocol
9191 }
9292
93+// ContainerBind is one ephemeral host bind mount for a container. Source is a
94+// resolved host path produced by administrative policy: it is never persisted
95+// and never logged by the runtime adapter. Destination is the container path.
96+type ContainerBind struct {
97+ Name string
98+ Source string
99+ Destination string
100+ ReadOnly bool
101+}
102+
93103 // ContainerConfig holds configuration for creating a container.
94104 type ContainerConfig struct {
95105 Image string
@@ -105,6 +115,7 @@ type ContainerConfig struct {
105115 RestartPolicy string
106116 Volumes map[string]string // map[containerPath]volumeName
107117 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
118+ Binds []ContainerBind // ephemeral resolved host binds, keyed by destination
108119 NetworkMode string // Network to join
109120 Hostname string // Container hostname for DNS
110121 Aliases []string // Additional network aliases
A internal/domain/container_network_probe.go +89 -0
@@ -0,0 +1,89 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+)
8+
9+// ProbeProtocol is the transport one bounded container-network readiness
10+// session uses.
11+type ProbeProtocol string
12+
13+const (
14+ ProbeProtocolHTTP ProbeProtocol = "http"
15+ ProbeProtocolTCP ProbeProtocol = "tcp"
16+)
17+
18+// ContainerNetworkProbeRequest is one bounded readiness session against a
19+// declared internal container port. It carries exact identity: the target
20+// container, the execution start that must still be current when the
21+// session completes, and the already-derived private network the helper
22+// joins. Callers never pass a network they did not derive from the app
23+// incarnation.
24+type ContainerNetworkProbeRequest struct {
25+ // TargetContainerID is the exact container to probe.
26+ TargetContainerID string
27+ // ExpectedStartedAt is the execution start the adapter must observe on
28+ // the target before and after the session. A mismatch means the
29+ // generation changed under the probe and the result is discarded.
30+ ExpectedStartedAt time.Time
31+ // Network is the derived private network the helper joins. It is the
32+ // only network the helper is attached to.
33+ Network string
34+ Protocol ProbeProtocol
35+ Port int
36+ // Path is the origin-form request path for HTTP probes; empty for TCP.
37+ Path string
38+ // Timeout bounds the operational session through outcome validation.
39+ // Mandatory force-removal uses its own independent bounded context.
40+ Timeout time.Duration
41+}
42+
43+// ContainerNetworkProbeResult reports one completed session.
44+type ContainerNetworkProbeResult struct {
45+ // Ready is true when the target accepted a 2xx/3xx HTTP status, or an
46+ // accepted TCP connection.
47+ Ready bool
48+ // Status is the observed HTTP status code when one was read, else 0.
49+ Status int
50+ // Diagnostic is a bounded, non-sensitive description of an unhealthy
51+ // attempt. It never carries addresses, paths, or raw command output.
52+ Diagnostic string
53+}
54+
55+// Validate checks the request shape. It rejects missing identity or
56+// network, an invalid protocol, port, path, or timeout, and a path on a
57+// TCP probe.
58+func (r ContainerNetworkProbeRequest) Validate() error {
59+ if strings.TrimSpace(r.TargetContainerID) == "" {
60+ return fmt.Errorf("%w: target container id is required", ErrInvalidNetworkProbe)
61+ }
62+ if strings.TrimSpace(r.Network) == "" {
63+ return fmt.Errorf("%w: private network is required", ErrInvalidNetworkProbe)
64+ }
65+ if r.ExpectedStartedAt.IsZero() {
66+ // Without an execution boundary the probe could report readiness
67+ // for a generation that already restarted. Fail closed.
68+ return fmt.Errorf("%w: expected execution start is required", ErrInvalidNetworkProbe)
69+ }
70+ if r.Port < 1 || r.Port > 65535 {
71+ return fmt.Errorf("%w: port must be 1-65535", ErrInvalidNetworkProbe)
72+ }
73+ if r.Timeout <= 0 {
74+ return fmt.Errorf("%w: timeout must be positive", ErrInvalidNetworkProbe)
75+ }
76+ switch r.Protocol {
77+ case ProbeProtocolHTTP:
78+ if err := ValidateReadinessPath(r.Path); err != nil {
79+ return fmt.Errorf("%w: %v", ErrInvalidNetworkProbe, err)
80+ }
81+ case ProbeProtocolTCP:
82+ if r.Path != "" {
83+ return fmt.Errorf("%w: tcp probe takes no path", ErrInvalidNetworkProbe)
84+ }
85+ default:
86+ return fmt.Errorf("%w: protocol must be http|tcp", ErrInvalidNetworkProbe)
87+ }
88+ return nil
89+}
A internal/domain/container_network_probe_test.go +69 -0
@@ -0,0 +1,69 @@
1+package domain_test
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// TestContainerNetworkProbeRequest_Validate proves the bounded probe
15+// request fails closed on every missing or malformed field.
16+func TestContainerNetworkProbeRequest_Validate(t *testing.T) {
17+ valid := domain.ContainerNetworkProbeRequest{
18+ TargetContainerID: "abc123",
19+ ExpectedStartedAt: time.Now().UTC(),
20+ Network: "gordon--app--net",
21+ Protocol: domain.ProbeProtocolHTTP,
22+ Port: 8080,
23+ Path: "/healthz",
24+ Timeout: time.Second,
25+ }
26+ require.NoError(t, valid.Validate())
27+
28+ tests := []struct {
29+ name string
30+ mutate func(*domain.ContainerNetworkProbeRequest)
31+ }{
32+ {"missing target id", func(r *domain.ContainerNetworkProbeRequest) { r.TargetContainerID = " " }},
33+ {"missing network", func(r *domain.ContainerNetworkProbeRequest) { r.Network = "" }},
34+ {"missing execution start", func(r *domain.ContainerNetworkProbeRequest) { r.ExpectedStartedAt = time.Time{} }},
35+ {"zero port", func(r *domain.ContainerNetworkProbeRequest) { r.Port = 0 }},
36+ {"port too high", func(r *domain.ContainerNetworkProbeRequest) { r.Port = 70000 }},
37+ {"zero timeout", func(r *domain.ContainerNetworkProbeRequest) { r.Timeout = 0 }},
38+ {"unknown protocol", func(r *domain.ContainerNetworkProbeRequest) { r.Protocol = "grpc" }},
39+ {"http without path", func(r *domain.ContainerNetworkProbeRequest) { r.Path = "" }},
40+ {"http path not origin form", func(r *domain.ContainerNetworkProbeRequest) { r.Path = "healthz" }},
41+ {"http path with surrounding whitespace", func(r *domain.ContainerNetworkProbeRequest) { r.Path = " /healthz " }},
42+ {"tcp with path", func(r *domain.ContainerNetworkProbeRequest) {
43+ r.Protocol = domain.ProbeProtocolTCP
44+ }},
45+ }
46+ for _, tc := range tests {
47+ t.Run(tc.name, func(t *testing.T) {
48+ request := valid
49+ tc.mutate(&request)
50+ err := request.Validate()
51+ require.Error(t, err)
52+ assert.True(t, errors.Is(err, domain.ErrInvalidNetworkProbe), "want ErrInvalidNetworkProbe, got %v", err)
53+ })
54+ }
55+}
56+
57+// TestContainerNetworkProbeRequest_ValidateTCP proves a pathless TCP probe
58+// is valid.
59+func TestContainerNetworkProbeRequest_ValidateTCP(t *testing.T) {
60+ request := domain.ContainerNetworkProbeRequest{
61+ TargetContainerID: "abc123",
62+ ExpectedStartedAt: time.Now().UTC(),
63+ Network: "gordon--app--net",
64+ Protocol: domain.ProbeProtocolTCP,
65+ Port: 5432,
66+ Timeout: time.Second,
67+ }
68+ require.NoError(t, request.Validate())
69+}
M internal/domain/errors.go +11 -0
@@ -64,6 +64,17 @@ var (
6464
6565 // App manifest errors
6666 ErrInvalidAppSpec = errors.New("invalid app manifest")
67+ // ErrInvalidNetworkProbe wraps malformed bounded container-network
68+ // readiness requests. It is distinct from ErrInvalidAppSpec: the
69+ // manifest may be valid while one probe request is not.
70+ ErrInvalidNetworkProbe = errors.New("invalid network probe request")
71+ // ErrNetworkProbeCleanup marks a helper that could not be force-removed.
72+ // Callers must fail immediately rather than create another helper.
73+ ErrNetworkProbeCleanup = errors.New("network probe helper cleanup failed")
74+ // ErrBindPolicy wraps administrative bind policy violations. It is
75+ // distinct from ErrInvalidAppSpec: the manifest may be valid while the
76+ // installation policy refuses to serve it.
77+ ErrBindPolicy = errors.New("bind policy violation")
6778
6879 // App state errors
6980 ErrAppStateIO = errors.New("app state storage failure")
M internal/domain/image_policy.go +24 -0
@@ -11,6 +11,30 @@ import (
1111
1212 var defaultImageRegistries = []string{"docker.io", "ghcr.io", "quay.io"}
1313
14+type ImagePullTransport string
15+
16+const (
17+ ImagePullTransportVerifiedTLS ImagePullTransport = "verified-tls"
18+ ImagePullTransportHTTP ImagePullTransport = "http"
19+)
20+
21+type ImagePullRequest struct {
22+ Reference string
23+ Username string
24+ Password string
25+ Transport ImagePullTransport
26+}
27+
28+// IsInstallationImage reports whether ref belongs to the configured installation registry.
29+func (p ImageSourcePolicy) IsInstallationImage(ref string) bool {
30+ host, _, err := parseImageRegistry(strings.TrimSpace(ref))
31+ if err != nil || p.InstallationRegistry == "" {
32+ return false
33+ }
34+ installation, err := canonicalRegistryHost(p.InstallationRegistry)
35+ return err == nil && dockerRegistryAlias(host) == dockerRegistryAlias(installation)
36+}
37+
1438 // ImageSourcePolicy is the installation policy for every image reference
1539 // the daemon validates, resolves, pulls, or runs.
1640 type ImageSourcePolicy struct {
M internal/domain/image_policy_test.go +21 -0
@@ -94,6 +94,27 @@ func TestImageSourcePolicy_RejectsMalformedReferences(t *testing.T) {
9494 }
9595 }
9696
97+func TestImageSourcePolicy_IsInstallationImageDockerHubAlias(t *testing.T) {
98+ cases := []struct {
99+ name string
100+ installation string
101+ ref string
102+ want bool
103+ }{
104+ {"implicit docker.io ref matches docker.io installation", "docker.io", "library/nginx:1", true},
105+ {"registry-1 ref matches docker.io installation", "docker.io", "registry-1.docker.io/library/nginx:1", true},
106+ {"docker.io ref matches registry-1 installation", "registry-1.docker.io", "docker.io/library/nginx:1", true},
107+ {"installation registry is not docker.io", "gordon.example.com", "docker.io/library/nginx:1", false},
108+ {"foreign ref does not match docker.io installation", "docker.io", "ghcr.io/example/app:1", false},
109+ }
110+ for _, tc := range cases {
111+ t.Run(tc.name, func(t *testing.T) {
112+ policy := domain.ImageSourcePolicy{InstallationRegistry: tc.installation}
113+ assert.Equal(t, tc.want, policy.IsInstallationImage(tc.ref))
114+ })
115+ }
116+}
117+
97118 func TestIsLocalOrPrivateHost(t *testing.T) {
98119 local := []string{"localhost", "sub.localhost", "127.0.0.1", "127.0.0.1:5000", "[::1]:5000", "10.0.0.1", "192.168.0.1", "172.31.0.1", "fc00::1", "0.0.0.0", "169.254.169.254"}
99120 for _, host := range local {
M internal/domain/labels.go +11 -0
@@ -52,4 +52,15 @@ const (
5252 LabelPort = "gordon.port"
5353 // LabelEnvFile specifies the path to .env file inside the image.
5454 LabelEnvFile = "gordon.env-file"
55+ // LabelPurpose marks Gordon-owned helper containers that are not
56+ // managed workloads (volume archives, bounded readiness probes).
57+ // Lifecycle, reconcile and prune paths must treat them as helper
58+ // infrastructure, never as apps or services.
59+ LabelPurpose = "gordon.purpose"
60+)
61+
62+// Purpose values for Gordon-owned helper containers.
63+const (
64+ // PurposeNetworkProbe identifies one bounded readiness probe helper.
65+ PurposeNetworkProbe = "network-probe"
5566 )
M internal/usecase/apps/app_service.go +68 -0
@@ -66,6 +66,18 @@ func (s *AppServiceImpl) WithImagePolicy(policy domain.ImageSourcePolicy) *AppSe
6666 return s
6767 }
6868
69+// WithBindPolicies supplies the administrative bind policies used at apply time.
70+func (s *AppServiceImpl) WithBindPolicies(policies map[string]domain.AppBindPolicy) *AppServiceImpl {
71+ s.core.SetBindPolicies(policies)
72+ return s
73+}
74+
75+// SetBindPolicies atomically replaces the administrative bind policies. It is
76+// safe to call from a config reload while applies are in flight.
77+func (s *AppServiceImpl) SetBindPolicies(policies map[string]domain.AppBindPolicy) {
78+ s.core.SetBindPolicies(policies)
79+}
80+
6981 var _ in.AppService = (*AppServiceImpl)(nil)
7082
7183 // Apply implements in.AppService.
@@ -339,6 +351,62 @@ func (s *AppServiceImpl) OperationByKey(ctx context.Context, app, key string) (*
339351 return &op, nil
340352 }
341353
354+// ListSecrets implements in.AppService without reading secret values. A
355+// name with no live identity is not found; ownership-only apps remain live
356+// and report their registrations (possibly none).
357+func (s *AppServiceImpl) ListSecrets(ctx context.Context, app, service string) ([]in.AppSecretMetadata, error) {
358+ live, err := s.store.AppExists(ctx, app)
359+ if err != nil {
360+ return nil, fmt.Errorf("list app secrets: check app %q: %w", app, err)
361+ }
362+ if !live {
363+ return nil, fmt.Errorf("apps: app %q does not exist: %w", app, domain.ErrAppNotFound)
364+ }
365+ entries := []in.AppSecretMetadata{}
366+ desired, ok, err := s.store.LoadDesired(ctx, app)
367+ if err != nil {
368+ return nil, fmt.Errorf("list app secrets: load desired for %q: %w", app, err)
369+ }
370+ if ok {
371+ for _, svc := range desired.Spec.Services {
372+ entries = appendSecretMetadata(entries, service, svc.Name, svc.Secrets, "desired")
373+ }
374+ }
375+ active, ok, err := s.store.LoadActive(ctx, app)
376+ if err != nil {
377+ return nil, fmt.Errorf("list app secrets: load active for %q: %w", app, err)
378+ }
379+ if ok {
380+ for svcName, svc := range active.Services {
381+ entries = appendSecretMetadata(entries, service, svcName, svc.Spec.Secrets, "active")
382+ }
383+ }
384+ sort.Slice(entries, func(i, j int) bool {
385+ a, b := entries[i], entries[j]
386+ if a.Service != b.Service {
387+ return a.Service < b.Service
388+ }
389+ if a.Key != b.Key {
390+ return a.Key < b.Key
391+ }
392+ if a.Source != b.Source {
393+ return a.Source < b.Source
394+ }
395+ return a.Name < b.Name
396+ })
397+ return entries, nil
398+}
399+
400+func appendSecretMetadata(entries []in.AppSecretMetadata, filter, service string, secrets map[string]string, source string) []in.AppSecretMetadata {
401+ if filter != "" && filter != service {
402+ return entries
403+ }
404+ for key, name := range secrets {
405+ entries = append(entries, in.AppSecretMetadata{Service: service, Key: key, Name: name, Source: source, Presence: "unknown"})
406+ }
407+ return entries
408+}
409+
342410 // SetSecrets implements in.AppService.
343411 func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, values map[string]string) error {
344412 if service == "" {
M internal/usecase/apps/app_service_test.go +36 -0
@@ -2,6 +2,7 @@ package apps_test
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67 "time"
78
@@ -88,3 +89,38 @@ func TestAppServiceImpl_OperationByKey(t *testing.T) {
8889 _, err = svc.OperationByKey(ctx, "blog", "missing")
8990 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
9091 }
92+
93+func TestAppServiceImpl_ListSecrets_MissingAppNotFound(t *testing.T) {
94+ ctx := context.Background()
95+ store := newMockAppState(t)
96+ svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
97+
98+ store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
99+ _, err := svc.ListSecrets(ctx, "blog", "")
100+ require.ErrorIs(t, err, domain.ErrAppNotFound)
101+}
102+
103+func TestAppServiceImpl_ListSecrets_OwnershipOnlyAppStaysLive(t *testing.T) {
104+ ctx := context.Background()
105+ store := newMockAppState(t)
106+ svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
107+
108+ store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
109+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
110+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
111+ entries, err := svc.ListSecrets(ctx, "blog", "")
112+ require.NoError(t, err)
113+ assert.Empty(t, entries)
114+}
115+
116+func TestAppServiceImpl_ListSecrets_WrapsLoadErrors(t *testing.T) {
117+ ctx := context.Background()
118+ store := newMockAppState(t)
119+ svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
120+
121+ store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
122+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, errors.New("boom")).Once()
123+ _, err := svc.ListSecrets(ctx, "blog", "")
124+ require.ErrorContains(t, err, "load desired")
125+ require.ErrorContains(t, err, "boom")
126+}
A internal/usecase/apps/bind_policy_test.go +133 -0
@@ -0,0 +1,133 @@
1+package apps_test
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
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/apps"
16+)
17+
18+func bindPolicy(root, source string, apps, services []string) domain.AppBindPolicy {
19+ return domain.AppBindPolicy{
20+ Name: "config",
21+ Source: source,
22+ Root: root,
23+ AllowedApps: apps,
24+ AllowedServices: services,
25+ }
26+}
27+
28+func TestApply_AcceptsBindsUnderAdministrativePolicy(t *testing.T) {
29+ root := t.TempDir()
30+ source := filepath.Join(root, "binds")
31+ require.NoError(t, os.MkdirAll(source, 0o755))
32+
33+ store := outmocks.NewMockAppState(t)
34+ spec := testSpec("blog")
35+ spec.Services[0].Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
36+ applySuccess(store, spec)
37+
38+ svc := apps.NewService(store, zerowrap.Default()).
39+ WithBindPolicies(map[string]domain.AppBindPolicy{
40+ "config": bindPolicy(root, source, []string{"blog"}, []string{"web"}),
41+ })
42+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
43+ require.NoError(t, err)
44+}
45+
46+func TestApply_RejectsBindWithoutPolicyBeforeStoreAccess(t *testing.T) {
47+ store := outmocks.NewMockAppState(t)
48+ spec := testSpec("blog")
49+ spec.Services[0].Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
50+ svc := apps.NewService(store, zerowrap.Default())
51+
52+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
53+
54+ require.ErrorIs(t, err, domain.ErrBindPolicy)
55+ assert.Contains(t, err.Error(), "blog")
56+ assert.Contains(t, err.Error(), "web")
57+ assert.Contains(t, err.Error(), "config")
58+}
59+
60+func TestApply_RejectsBindOutsideAllowlists(t *testing.T) {
61+ root := t.TempDir()
62+ source := filepath.Join(root, "binds")
63+ require.NoError(t, os.MkdirAll(source, 0o755))
64+ spec := testSpec("blog")
65+ spec.Services[0].Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
66+
67+ t.Run("app not allowed", func(t *testing.T) {
68+ store := outmocks.NewMockAppState(t)
69+ svc := apps.NewService(store, zerowrap.Default()).
70+ WithBindPolicies(map[string]domain.AppBindPolicy{
71+ "config": bindPolicy(root, source, []string{"other"}, []string{"web"}),
72+ })
73+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
74+ require.ErrorIs(t, err, domain.ErrBindPolicy)
75+ assert.NotContains(t, err.Error(), source)
76+ })
77+
78+ t.Run("service not allowed", func(t *testing.T) {
79+ store := outmocks.NewMockAppState(t)
80+ svc := apps.NewService(store, zerowrap.Default()).
81+ WithBindPolicies(map[string]domain.AppBindPolicy{
82+ "config": bindPolicy(root, source, []string{"blog"}, []string{"other"}),
83+ })
84+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
85+ require.ErrorIs(t, err, domain.ErrBindPolicy)
86+ assert.NotContains(t, err.Error(), source)
87+ })
88+}
89+
90+func TestApply_RejectsBindSourceOutsideRoot(t *testing.T) {
91+ root := t.TempDir()
92+ outside := t.TempDir()
93+ source := filepath.Join(outside, "binds")
94+ require.NoError(t, os.MkdirAll(source, 0o755))
95+ store := outmocks.NewMockAppState(t)
96+ spec := testSpec("blog")
97+ spec.Services[0].Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
98+ svc := apps.NewService(store, zerowrap.Default()).
99+ WithBindPolicies(map[string]domain.AppBindPolicy{
100+ "config": bindPolicy(root, source, []string{"blog"}, []string{"web"}),
101+ })
102+
103+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
104+
105+ require.ErrorIs(t, err, domain.ErrBindPolicy)
106+ assert.NotContains(t, err.Error(), source, "apply errors must never leak the policy source path")
107+ assert.NotContains(t, err.Error(), outside)
108+}
109+
110+// TestApply_BindPoliciesReplaceOnReload proves a reload-style replacement takes
111+// effect immediately: the second apply is refused before any store access.
112+func TestApply_BindPoliciesReplaceOnReload(t *testing.T) {
113+ root := t.TempDir()
114+ source := filepath.Join(root, "binds")
115+ require.NoError(t, os.MkdirAll(source, 0o755))
116+ spec := testSpec("blog")
117+ spec.Services[0].Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
118+
119+ store := outmocks.NewMockAppState(t)
120+ applySuccess(store, spec)
121+ svc := apps.NewService(store, zerowrap.Default()).
122+ WithBindPolicies(map[string]domain.AppBindPolicy{
123+ "config": bindPolicy(root, source, []string{"blog"}, []string{"web"}),
124+ })
125+ _, _, err := svc.Apply(context.Background(), spec, []byte("manifest"), false)
126+ require.NoError(t, err)
127+
128+ svc.SetBindPolicies(map[string]domain.AppBindPolicy{
129+ "config": bindPolicy(root, source, []string{"other"}, []string{"web"}),
130+ })
131+ _, _, err = svc.Apply(context.Background(), spec, []byte("manifest"), false)
132+ require.ErrorIs(t, err, domain.ErrBindPolicy)
133+}
M internal/usecase/apps/service.go +56 -1
@@ -9,6 +9,7 @@ import (
99 "crypto/sha256"
1010 "encoding/hex"
1111 "fmt"
12+ "sync"
1213 "time"
1314
1415 "github.com/bnema/zerowrap"
@@ -28,6 +29,10 @@ type Service struct {
2829 // never snapshot between a staged intent and its materialization.
2930 barrier out.GCBarrier
3031 imagePolicy domain.ImageSourcePolicy
32+ // bindMu guards bindPolicies. Reload replaces the whole map
33+ // atomically; applies read the current map without holding the lock.
34+ bindMu sync.RWMutex
35+ bindPolicies map[string]domain.AppBindPolicy
3136 }
3237
3338 // NewService creates the apps use case over an AppState store.
@@ -49,6 +54,33 @@ func (s *Service) WithImagePolicy(policy domain.ImageSourcePolicy) *Service {
4954 return s
5055 }
5156
57+// WithBindPolicies supplies the initial administrative bind policies.
58+func (s *Service) WithBindPolicies(policies map[string]domain.AppBindPolicy) *Service {
59+ s.SetBindPolicies(policies)
60+ return s
61+}
62+
63+// SetBindPolicies atomically replaces the administrative bind policies used
64+// to authorize manifest binds. The map is copied so later caller mutation
65+// cannot race an in-flight apply.
66+func (s *Service) SetBindPolicies(policies map[string]domain.AppBindPolicy) {
67+ copied := make(map[string]domain.AppBindPolicy, len(policies))
68+ for name, policy := range policies {
69+ copied[name] = policy
70+ }
71+ s.bindMu.Lock()
72+ s.bindPolicies = copied
73+ s.bindMu.Unlock()
74+}
75+
76+// snapshotBindPolicies returns the current policy map. SetBindPolicies never
77+// mutates a published map, so the snapshot stays safe after the lock drops.
78+func (s *Service) snapshotBindPolicies() map[string]domain.AppBindPolicy {
79+ s.bindMu.RLock()
80+ defer s.bindMu.RUnlock()
81+ return s.bindPolicies
82+}
83+
5284 // noopLease is a GC lease for an unwired barrier.
5385 type noopLease struct{}
5486
@@ -139,6 +171,9 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte,
139171 return nil, nil, fmt.Errorf("apps: service %q image %q: %w", service.Name, service.Image, err)
140172 }
141173 }
174+ if err := s.validateBindPolicies(spec); err != nil {
175+ return nil, nil, err
176+ }
142177 if err := s.validateEntrypointCompatibility(spec); err != nil {
143178 return nil, nil, err
144179 }
@@ -173,6 +208,26 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte,
173208 return s.persistApply(ctx, spec, source, prepared)
174209 }
175210
211+// validateBindPolicies resolves every declared bind against the
212+// administrative bind policies before any desired state is persisted.
213+// Errors name only the mount, app, and service: policy source paths never
214+// appear in apply errors.
215+func (s *Service) validateBindPolicies(spec domain.AppSpec) error {
216+ policies := s.snapshotBindPolicies()
217+ for _, svc := range spec.Services {
218+ for _, bind := range svc.Binds {
219+ policy, ok := policies[bind.Name]
220+ if !ok {
221+ return fmt.Errorf("%w: app %q service %q mount %q: no administrative mount policy configured", domain.ErrBindPolicy, spec.Name, svc.Name, bind.Name)
222+ }
223+ if _, err := policy.ResolveAppBind(spec.Name, svc.Name, bind); err != nil {
224+ return fmt.Errorf("%w: app %q service %q mount %q: refused by administrative mount policy", domain.ErrBindPolicy, spec.Name, svc.Name, bind.Name)
225+ }
226+ }
227+ }
228+ return nil
229+}
230+
176231 // validateEntrypointCompatibility checks every declared TCP/UDP interface
177232 // against the installation entrypoint listeners before any persistence or
178233 // workload effect. The runtime binds the entrypoint address, so a publish
@@ -285,7 +340,7 @@ func (s *Service) persistApply(ctx context.Context, spec domain.AppSpec, source
285340
286341 // activeSpec rebuilds an AppSpec view from effective definitions for diffing.
287342 func activeSpec(active domain.AppActive) domain.AppSpec {
288- spec := domain.AppSpec{Name: active.App, Env: map[string]string{}}
343+ spec := domain.AppSpec{Name: active.App, Env: map[string]string{}, Networks: append([]domain.AppSharedNetwork(nil), active.Networks...)}
289344 for name, svc := range active.Services {
290345 svcSpec := svc.Spec
291346 svcSpec.Name = name
M internal/usecase/apps/service_test.go +27 -0
@@ -177,6 +177,33 @@ func TestApply_DryRunWritesNothing(t *testing.T) {
177177 store.AssertNotCalled(t, "StageApply", mock.Anything, mock.Anything)
178178 }
179179
180+func TestApply_ImageOnlyDiffPreservesActiveNetworks(t *testing.T) {
181+ ctx := context.Background()
182+ store := outmocks.NewMockAppState(t)
183+ svc := apps.NewService(store, zerowrap.Default())
184+ spec := testSpec("blog")
185+ spec.Networks = []domain.AppSharedNetwork{{Network: "database", Services: []string{"web"}, Aliases: []string{"db"}}}
186+ activeService := spec.Services[0]
187+ activeService.Image = "img:1"
188+ spec.Services[0].Image = "img:2"
189+
190+ store.EXPECT().Recover(mock.Anything).Return(nil).Once()
191+ store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
192+ previousDesired := spec
193+ previousDesired.Services = append([]domain.AppService(nil), spec.Services...)
194+ previousDesired.Services[0].Image = "img:1"
195+ store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{Revision: "rev-1", App: "blog", Spec: previousDesired}, true, nil).Once()
196+ store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
197+ App: "blog", Networks: spec.Networks,
198+ Services: map[string]domain.AppEffectiveService{"web": {Spec: activeService}},
199+ }, true, nil).Once()
200+
201+ _, dry, err := svc.Apply(ctx, spec, []byte("manifest"), true)
202+ require.NoError(t, err)
203+ require.NotNil(t, dry)
204+ assert.Equal(t, []string{"service/web/image"}, dry.Diff.Changed)
205+}
206+
180207 func TestApply_RejectsCrossAppConflict(t *testing.T) {
181208 ctx := context.Background()
182209 store := outmocks.NewMockAppState(t)
M internal/usecase/apptraffic/projection.go +6 -0
@@ -88,6 +88,12 @@ func projectService(app, name string, eff domain.AppEffectiveService, entrypoint
8888 spec.Name = name
8989 var entries []RouteEntry
9090 for _, h := range spec.HTTP {
91+ // Internal HTTP is reachable only from the app private network: it
92+ // is never routed, so it must not project a traffic entry or a
93+ // certificate host. Fail closed by skipping it entirely.
94+ if !h.IsPublic() {
95+ continue
96+ }
9197 entries = append(entries, RouteEntry{
9298 Kind: "http",
9399 RouterName: "app-" + app + "--" + spec.Name + "--http-" + sanitizeHost(h.Host),
A internal/usecase/apptraffic/projection_visibility_test.go +56 -0
@@ -0,0 +1,56 @@
1+package apptraffic_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+ "github.com/bnema/gordon/internal/usecase/apptraffic"
11+)
12+
13+// internalActive is one app whose only HTTP interface is internal.
14+func internalActive() domain.AppActive {
15+ return domain.AppActive{
16+ App: "blog",
17+ Services: map[string]domain.AppEffectiveService{
18+ "api": {
19+ EffectiveRevision: "rev-1",
20+ Image: "img:1",
21+ Container: "c-api",
22+ Spec: domain.AppService{
23+ HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
24+ },
25+ },
26+ },
27+ }
28+}
29+
30+// TestProject_InternalHTTPExcluded proves an internal HTTP interface
31+// projects no route: it can never appear in the host index, so it also
32+// yields no certificate host and no TLS mode.
33+func TestProject_InternalHTTPExcluded(t *testing.T) {
34+ entries, err := apptraffic.Project("blog", internalActive(), nil)
35+ require.NoError(t, err)
36+ assert.Empty(t, entries)
37+}
38+
39+// TestProject_MixedVisibilityProjectsOnlyPublic proves a service with both
40+// planes projects only the public interface.
41+func TestProject_MixedVisibilityProjectsOnlyPublic(t *testing.T) {
42+ active := internalActive()
43+ svc := active.Services["api"]
44+ svc.BackendBinds = map[int]int{8080: 18080}
45+ svc.Spec.HTTP = []domain.AppHTTPInterface{
46+ {Host: "blog.example.com", Port: 8080, TLS: "auto", Visibility: domain.AppVisibilityPublic},
47+ {Port: 9090, Visibility: domain.AppVisibilityInternal},
48+ }
49+ active.Services["api"] = svc
50+
51+ entries, err := apptraffic.Project("blog", active, nil)
52+ require.NoError(t, err)
53+ require.Len(t, entries, 1)
54+ assert.Equal(t, "blog.example.com", entries[0].Host)
55+ assert.Equal(t, 8080, entries[0].Backend.ContainerPort)
56+}
A internal/usecase/deployment/bind_policy_internal_test.go +294 -0
@@ -0,0 +1,294 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "testing"
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+)
18+
19+func bindPolicyFor(source, root string, readOnly bool, apps, services []string) domain.AppBindPolicy {
20+ return domain.AppBindPolicy{
21+ Name: "config",
22+ Source: source,
23+ Root: root,
24+ ReadOnly: readOnly,
25+ AllowedApps: apps,
26+ AllowedServices: services,
27+ }
28+}
29+
30+func TestResolveServiceBinds_ExactTranslation(t *testing.T) {
31+ root := t.TempDir()
32+ source := filepath.Join(root, "binds", "config")
33+ require.NoError(t, os.MkdirAll(source, 0o755))
34+ realSource, err := filepath.EvalSymlinks(source)
35+ require.NoError(t, err)
36+
37+ svc := NewService(Deps{}, zerowrap.Default()).
38+ WithBindPolicies(map[string]domain.AppBindPolicy{
39+ "config": bindPolicyFor(source, root, false, []string{"blog"}, []string{"web"}),
40+ })
41+
42+ got, err := svc.resolveServiceBinds("blog", domain.AppService{
43+ Name: "web",
44+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}},
45+ })
46+ require.NoError(t, err)
47+ require.Len(t, got, 1)
48+ assert.Equal(t, domain.ContainerBind{
49+ Name: "config",
50+ Source: realSource,
51+ Destination: "/etc/app.conf",
52+ ReadOnly: true,
53+ }, got[0])
54+}
55+
56+func TestResolveServiceBinds_ReadOnlyNonWeakening(t *testing.T) {
57+ root := t.TempDir()
58+ source := filepath.Join(root, "binds")
59+ require.NoError(t, os.MkdirAll(source, 0o755))
60+
61+ svc := NewService(Deps{}, zerowrap.Default()).
62+ WithBindPolicies(map[string]domain.AppBindPolicy{
63+ "config": bindPolicyFor(source, root, true, []string{"blog"}, []string{"web"}),
64+ })
65+
66+ got, err := svc.resolveServiceBinds("blog", domain.AppService{
67+ Name: "web",
68+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
69+ })
70+ require.NoError(t, err)
71+ require.Len(t, got, 1)
72+ assert.True(t, got[0].ReadOnly, "a read-only policy must not be weakened by the manifest")
73+}
74+
75+func TestResolveServiceBinds_UnknownAndRevokedPolicy(t *testing.T) {
76+ spec := domain.AppService{
77+ Name: "web",
78+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
79+ }
80+
81+ t.Run("unknown policy", func(t *testing.T) {
82+ svc := NewService(Deps{}, zerowrap.Default())
83+ _, err := svc.resolveServiceBinds("blog", spec)
84+ require.ErrorIs(t, err, domain.ErrBindPolicy)
85+ assert.Contains(t, err.Error(), "blog")
86+ assert.Contains(t, err.Error(), "web")
87+ assert.Contains(t, err.Error(), "config")
88+ })
89+
90+ t.Run("revoked policy", func(t *testing.T) {
91+ root := t.TempDir()
92+ source := filepath.Join(root, "binds")
93+ require.NoError(t, os.MkdirAll(source, 0o755))
94+ svc := NewService(Deps{}, zerowrap.Default()).
95+ WithBindPolicies(map[string]domain.AppBindPolicy{
96+ "config": bindPolicyFor(source, root, false, []string{"blog"}, []string{"web"}),
97+ })
98+ _, err := svc.resolveServiceBinds("blog", spec)
99+ require.NoError(t, err)
100+
101+ svc.SetBindPolicies(nil)
102+ _, err = svc.resolveServiceBinds("blog", spec)
103+ require.ErrorIs(t, err, domain.ErrBindPolicy)
104+ })
105+
106+ t.Run("refused policy omits source", func(t *testing.T) {
107+ const secretSource = "/srv/gordon/secret-source"
108+ svc := NewService(Deps{}, zerowrap.Default()).
109+ WithBindPolicies(map[string]domain.AppBindPolicy{
110+ "config": bindPolicyFor(secretSource, "/srv/gordon", false, []string{"other"}, []string{"web"}),
111+ })
112+ _, err := svc.resolveServiceBinds("blog", spec)
113+ require.ErrorIs(t, err, domain.ErrBindPolicy)
114+ assert.NotContains(t, err.Error(), secretSource, "errors must never leak the host source")
115+ })
116+}
117+
118+// TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation proves the
119+// immediate-pre-create re-resolution fails before any runtime call when the
120+// policy is missing or revoked.
121+func TestCreateAndStart_RefusesRevokedBindBeforeRuntimeMutation(t *testing.T) {
122+ runtime := outmocks.NewMockContainerRuntime(t)
123+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
124+ p := pinnedService{
125+ name: "web",
126+ spec: domain.AppService{
127+ Name: "web",
128+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
129+ },
130+ }
131+
132+ _, _, _, err := svc.createAndStart(context.Background(), "blog", "rev-1", p, "op-1")
133+
134+ require.ErrorIs(t, err, domain.ErrBindPolicy)
135+ assert.Contains(t, err.Error(), "config")
136+ runtime.AssertNotCalled(t, "CreateContainer")
137+ runtime.AssertNotCalled(t, "CreateVolume")
138+ runtime.AssertNotCalled(t, "StartContainer")
139+ runtime.AssertNotCalled(t, "ConnectContainerToNetwork")
140+ runtime.AssertExpectations(t)
141+}
142+
143+// TestCreateContainer_BindBearingRuntimeErrorIsRedactedNotBindPolicy proves a
144+// CreateContainer failure on a bind-bearing service is reported as a generic
145+// redacted runtime error, never as a bind policy violation, and never leaks
146+// the resolved host source embedded in the runtime error.
147+func TestCreateContainer_BindBearingRuntimeErrorIsRedactedNotBindPolicy(t *testing.T) {
148+ const secretSource = "/srv/gordon/secret-source"
149+ runtime := outmocks.NewMockContainerRuntime(t)
150+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
151+ Return(nil, fmt.Errorf("mount %s: permission denied", secretSource)).Once()
152+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
153+
154+ _, err := svc.createContainer(context.Background(), "web", &domain.ContainerConfig{
155+ Binds: []domain.ContainerBind{{Name: "config", Source: secretSource, Destination: "/etc/app.conf"}},
156+ })
157+
158+ require.Error(t, err)
159+ require.NotErrorIs(t, err, domain.ErrBindPolicy, "a runtime failure is not a policy refusal")
160+ assert.NotContains(t, err.Error(), secretSource, "errors must never leak the resolved host source")
161+ assert.NotContains(t, err.Error(), "permission denied", "no runtime text may reach the caller")
162+ runtime.AssertExpectations(t)
163+}
164+
165+func TestCheckImageVolumes_IncludesBindDestinations(t *testing.T) {
166+ t.Run("bind destination maps image volume", func(t *testing.T) {
167+ runtime := outmocks.NewMockContainerRuntime(t)
168+ runtime.EXPECT().InspectImageVolumes(context.Background(), "img:1").Return([]string{"/data"}, nil).Once()
169+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
170+
171+ err := svc.checkImageVolumes(context.Background(), domain.AppService{
172+ Name: "web",
173+ Binds: []domain.AppBind{{Name: "config", Path: "/data"}},
174+ }, "img:1")
175+
176+ require.NoError(t, err)
177+ })
178+
179+ t.Run("unmapped image volume still refused", func(t *testing.T) {
180+ runtime := outmocks.NewMockContainerRuntime(t)
181+ runtime.EXPECT().InspectImageVolumes(context.Background(), "img:1").Return([]string{"/data"}, nil).Once()
182+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
183+
184+ err := svc.checkImageVolumes(context.Background(), domain.AppService{Name: "web"}, "img:1")
185+
186+ require.ErrorIs(t, err, domain.ErrAppUnmanagedImageVolume)
187+ })
188+}
189+
190+func TestHTTPEligibleAndSingleWriterGating(t *testing.T) {
191+ httpSpec := func() domain.AppService {
192+ return domain.AppService{
193+ Name: "web",
194+ HTTP: []domain.AppHTTPInterface{{Host: "app.example.com", Port: 8080, TLS: domain.AppTLSAuto}},
195+ }
196+ }
197+
198+ t.Run("http-only without binds is parallel eligible", func(t *testing.T) {
199+ assert.True(t, httpEligible(httpSpec()))
200+ assert.False(t, singleWriterRequired(httpSpec()))
201+ })
202+
203+ t.Run("internal-only http is not overlap eligible", func(t *testing.T) {
204+ spec := domain.AppService{
205+ Name: "api",
206+ HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
207+ }
208+ assert.False(t, httpEligible(spec),
209+ "an internal-only service has no public generation to cut over and must use interrupted replacement")
210+ assert.False(t, singleWriterRequired(spec))
211+ })
212+
213+ t.Run("mixed public and internal http must interrupt", func(t *testing.T) {
214+ spec := domain.AppService{
215+ Name: "api",
216+ HTTP: []domain.AppHTTPInterface{
217+ {Host: "app.example.com", Port: 8080, TLS: domain.AppTLSAuto},
218+ {Port: 9090, Visibility: domain.AppVisibilityInternal},
219+ },
220+ }
221+ assert.False(t, httpEligible(spec),
222+ "the candidate is resolvable by service alias over the private network before readiness, so any internal interface must use interrupted replacement")
223+ assert.False(t, singleWriterRequired(spec))
224+ })
225+
226+ t.Run("writable bind is single-writer", func(t *testing.T) {
227+ spec := httpSpec()
228+ spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}}
229+ assert.False(t, httpEligible(spec), "a bind must disable parallel HTTP replacement")
230+ assert.True(t, singleWriterRequired(spec))
231+ })
232+
233+ t.Run("read-only bind is also single-writer", func(t *testing.T) {
234+ spec := httpSpec()
235+ spec.Binds = []domain.AppBind{{Name: "config", Path: "/etc/app.conf", ReadOnly: true}}
236+ assert.False(t, httpEligible(spec))
237+ assert.True(t, singleWriterRequired(spec))
238+ })
239+
240+ t.Run("volume is single-writer", func(t *testing.T) {
241+ spec := httpSpec()
242+ spec.Volumes = []domain.AppVolume{{Name: "data", Path: "/data"}}
243+ assert.False(t, httpEligible(spec))
244+ assert.True(t, singleWriterRequired(spec))
245+ })
246+}
247+
248+// TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation proves an
249+// in-place restart fails closed when the bind policy is missing/revoked,
250+// before touching the runtime.
251+func TestRestartOneService_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) {
252+ runtime := outmocks.NewMockContainerRuntime(t)
253+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
254+ eff := domain.AppEffectiveService{
255+ Container: "c1",
256+ Spec: domain.AppService{
257+ Name: "web",
258+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
259+ },
260+ }
261+
262+ step, result := svc.restartOneService(context.Background(), "blog", "web", eff)
263+
264+ assert.Equal(t, domain.AppStepFailed, step.State)
265+ assert.Contains(t, step.Error, "config")
266+ assert.Equal(t, "failed", result.Result)
267+ runtime.AssertNotCalled(t, "RestartContainer")
268+ runtime.AssertExpectations(t)
269+}
270+
271+// TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation proves the
272+// boot/start recovery path fails closed before starting a container when the
273+// bind policy is missing/revoked.
274+func TestEnsureServiceRunning_RevokedBindFailsWithoutRuntimeMutation(t *testing.T) {
275+ runtime := outmocks.NewMockContainerRuntime(t)
276+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
277+ eff := domain.AppEffectiveService{
278+ Container: "c1",
279+ Spec: domain.AppService{
280+ Name: "web",
281+ Binds: []domain.AppBind{{Name: "config", Path: "/etc/app.conf"}},
282+ },
283+ }
284+ step := &domain.AppOperationStep{}
285+ result := &LifecycleResult{Services: map[string]ServiceResult{}}
286+
287+ cont := svc.ensureServiceRunning(context.Background(), "blog", "op-1", "web", eff, step, result)
288+
289+ assert.True(t, cont)
290+ assert.Equal(t, domain.AppStepFailed, step.State)
291+ assert.Contains(t, step.Error, "config")
292+ runtime.AssertNotCalled(t, "StartContainer")
293+ runtime.AssertExpectations(t)
294+}
M internal/usecase/deployment/deploy_mock_test.go +3 -1
@@ -102,7 +102,9 @@ func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) {
102102 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
103103 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
104104 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
105- runtime.EXPECT().PullImageWithAuth(mock.Anything, "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "gordon", "s3cret").Return(nil).Once()
105+ pullRef := "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
106+ runtime.EXPECT().PullImageWithOptions(mock.Anything, domain.ImagePullRequest{Reference: pullRef, Username: "gordon", Password: "s3cret", Transport: domain.ImagePullTransportHTTP}).Return(nil).Once()
107+ runtime.EXPECT().VerifyImageDigest(mock.Anything, pullRef, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil).Once()
106108 runtime.EXPECT().InspectImageVolumes(mock.Anything, "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, nil)
107109 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
108110 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
M internal/usecase/deployment/execute.go +104 -40
@@ -21,12 +21,13 @@ const failLogTailLines = 50
2121 const failLogTailTimeout = 10 * time.Second
2222
2323 // Deploy executes a preflighted revision: fail-fast across services in
24-// sorted name order. HTTP services without volumes keep the old container
25-// serving until the replacement passes readiness, then switch with a
26-// bounded drain and retire the old container by exact ID. TCP/UDP, mixed,
27-// and volume-owning services use replacement with interruption. Volumes
28-// are never deleted: no RemoveVolume call, no volume-deletion flags on
29-// container removal.
24+// sorted name order. HTTP-only services whose interfaces are all public,
25+// with no volumes and no binds, keep the old container serving until the
26+// replacement passes readiness, then switch with a bounded drain and
27+// retire the old container by exact ID. TCP/UDP, any internal HTTP
28+// interface, and bind- or volume-owning services use replacement with
29+// interruption. Volumes are never deleted: no RemoveVolume call, no
30+// volume-deletion flags on container removal.
3031 func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) {
3132 release, err := s.acquireAppContext(ctx, input.App)
3233 if err != nil {
@@ -71,12 +72,12 @@ func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployR
7172 Revision: rev.Revision,
7273 Services: map[string]ServiceResult{},
7374 }
74- // Reconcile services the new desired state no longer declares BEFORE
75- // publishing anything: a service the operator removed must stop being
76- // reachable, and its exact container must be stopped and removed
77- // while its data is retained.
78- if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, op, result, log); err != nil {
79- return result, err
75+ // A targeted deploy is intentionally a partial plan: services omitted
76+ // from pinned remain active. Full deploys reconcile actual removals.
77+ if input.Service == "" {
78+ if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, op, result, log); err != nil {
79+ return result, err
80+ }
8081 }
8182 for i, p := range pinned {
8283 if err := s.runServiceStep(ctx, input.App, rev.Revision, i, p, op, active, result, log); err != nil {
@@ -340,14 +341,36 @@ func (s *Service) deployService(ctx context.Context, app, revision string, p pin
340341 return s.deployInterrupted(ctx, app, revision, p, opID, before, beforeGrace)
341342 }
342343
343-// httpEligible reports HTTP-only services without volumes.
344+// httpEligible reports HTTP services whose generations may overlap while
345+// the candidate proves readiness. Every declared HTTP interface must be
346+// effective-public: the proxy repoints each public host to the candidate
347+// only after readiness, so overlap buys availability. Any internal HTTP
348+// interface is instead resolved over the app private network by service
349+// alias, and the candidate joins that network with the same alias as soon
350+// as it starts — before readiness — so an internal consumer could reach an
351+// unready generation with no cutover gate to stop it. Such a service takes
352+// interrupted replacement like L4 services. Volumes, binds, and L4 ports
353+// remain interrupted because two generations must not share their state or
354+// publications.
344355 func httpEligible(spec domain.AppService) bool {
345- if len(spec.HTTP) == 0 || len(spec.TCP) > 0 || len(spec.UDP) > 0 || len(spec.Volumes) > 0 {
356+ if len(spec.HTTP) == 0 || len(spec.TCP) > 0 || len(spec.UDP) > 0 || len(spec.Volumes) > 0 || len(spec.Binds) > 0 {
346357 return false
347358 }
359+ for _, h := range spec.HTTP {
360+ if !h.IsPublic() {
361+ return false
362+ }
363+ }
348364 return true
349365 }
350366
367+// singleWriterRequired reports services that must never have two generations
368+// running concurrently. Persistent volumes and any bind (especially a
369+// writable one) may be written by both, so they are treated identically.
370+func singleWriterRequired(spec domain.AppService) bool {
371+ return len(spec.Volumes) > 0 || len(spec.Binds) > 0
372+}
373+
351374 // deployHTTP keeps the old container serving until the replacement passes
352375 // readiness AND the new effective state is published: the proxy switches
353376 // to the new loopback binds at publication, the old container drains,
@@ -358,7 +381,7 @@ func (s *Service) deployHTTP(ctx context.Context, app, revision string, p pinned
358381 if err != nil {
359382 return s.failResult(revision, before, "", err), false
360383 }
361- if err := waitServiceReadyWithDeps(ctx, s.probeDeps(), created.ID, httpReadiness(p.spec), binds); err != nil {
384+ if err := s.waitServiceReady(ctx, app, created.ID, httpReadiness(p.spec), binds); err != nil {
362385 // Old version keeps serving. Capture redacted diagnostics while
363386 // the failed replacement still exists, then remove only that
364387 // candidate.
@@ -419,13 +442,19 @@ func retireContainerID(before, after string) string {
419442 // the replacement. Volume-owning failures never restart the old image:
420443 // the replacement may already have written data.
421444 func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {
445+ // Revalidate the current authorization and source before inhibiting or
446+ // retiring the serving generation. A reload between preflight and this
447+ // service step must fail without mutating the existing workload.
448+ if _, err := s.resolveServiceBinds(app, p.spec); err != nil {
449+ return s.failResult(revision, before, "", err), true
450+ }
422451 // A volume-owning replacement may write while the old generation
423452 // still exists and could be revived by native restart policy.
424453 // Inhibit that generation durably BEFORE the write can happen, so
425454 // boot or periodic recovery can never restart the old writer on
426455 // top of the new one. Cleared once the safe generation is
427456 // published (publishService) or the operator removes the app.
428- inhibited := before != "" && len(p.spec.Volumes) > 0
457+ inhibited := before != "" && singleWriterRequired(p.spec)
429458 if inhibited {
430459 if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil {
431460 return s.failResult(revision, before, "", err), true
@@ -447,7 +476,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
447476 if err != nil {
448477 return s.failResult(revision, before, "", err), true
449478 }
450- if err := waitServiceReadyWithDeps(ctx, s.probeDeps(), created.ID, p.spec, binds); err != nil {
479+ if err := s.waitServiceReady(ctx, app, created.ID, p.spec, binds); err != nil {
451480 // Capture redacted diagnostics while the candidate still exists,
452481 // then remove only that candidate.
453482 tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))
@@ -457,7 +486,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
457486 EffectiveRevision: revision,
458487 Before: before,
459488 After: created.ID,
460- RestartUnsafe: len(p.spec.Volumes) > 0,
489+ RestartUnsafe: singleWriterRequired(p.spec),
461490 Error: err.Error(),
462491 Diagnostics: tail,
463492 CleanupWarnings: cleanup,
@@ -468,7 +497,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
468497 EffectiveRevision: revision,
469498 Before: before,
470499 After: created.ID,
471- RestartUnsafe: len(p.spec.Volumes) > 0,
500+ RestartUnsafe: singleWriterRequired(p.spec),
472501 BackendBinds: binds,
473502 UDPBackendBinds: udpBinds,
474503 }, true
@@ -540,6 +569,13 @@ func volumeProvenanceLabels(app, appID, service, revision string) map[string]str
540569 // reconciles intent at boot and in the monitor (accepted decision:
541570 // native runtime restarts plus daemon reconciliation).
542571 func (s *Service) createAndStart(ctx context.Context, app, revision string, p pinnedService, opID string) (*domain.Container, map[int]int, map[int]int, error) {
572+ // Re-resolve binds from the current policy immediately before any
573+ // runtime mutation: a bind revoked since preflight must fail here,
574+ // before volume ownership or container creation.
575+ resolvedBinds, err := s.resolveServiceBinds(app, p.spec)
576+ if err != nil {
577+ return nil, nil, nil, err
578+ }
543579 env, err := s.serviceEnv(ctx, app, p)
544580 if err != nil {
545581 return nil, nil, nil, err
@@ -594,6 +630,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
594630 Entrypoint: append([]string(nil), p.spec.Command...),
595631 Volumes: volumes,
596632 ReadOnlyVolumes: readOnlyVolumes,
633+ Binds: resolvedBinds,
597634 Labels: appLabels(app, p.spec.Name, revision),
598635 AutoRemove: false,
599636 RestartPolicy: domain.RestartPolicyAlways,
@@ -605,9 +642,9 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
605642 NanoCPUs: s.deps.Limits.NanoCPUs,
606643 PidsLimit: s.deps.Limits.PidsLimit,
607644 }
608- created, err := s.deps.Runtime.CreateContainer(ctx, config)
645+ created, err := s.createContainer(ctx, p.name, config)
609646 if err != nil {
610- return nil, nil, nil, fmt.Errorf("deployment: create container: %w", err)
647+ return nil, nil, nil, err
611648 }
612649 if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil {
613650 s.retireCandidate(ctx, app, p.spec.Name, created.ID)
@@ -625,10 +662,28 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
625662 return created, binds, udpBinds, nil
626663 }
627664
628-// backendPublishes collects every interface container port for loopback
629-// publication: HTTP + TCP interfaces on tcp plus UDP interfaces on udp,
630-// plus an explicit TCP readiness port. Each publishes on 127.0.0.1
631-// ephemeral (never public). Deduplicated by (protocol, container port).
665+func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
666+ created, err := s.deps.Runtime.CreateContainer(ctx, config)
667+ if err == nil {
668+ return created, nil
669+ }
670+ if len(config.Binds) > 0 {
671+ // A CreateContainer failure is a runtime error, not a bind policy
672+ // violation: policy was already enforced by resolveServiceBinds
673+ // before this call. Runtime errors may embed the resolved host
674+ // path, so redact the whole cause instead of mislabelling it and
675+ // keep it out of operation journals and API/CLI responses.
676+ return nil, fmt.Errorf("deployment: create container for service %q with administrative mounts: runtime error redacted", service)
677+ }
678+ return nil, fmt.Errorf("deployment: create container: %w", err)
679+}
680+
681+// backendPorts collects every interface container port for loopback
682+// publication: public HTTP + TCP interfaces on tcp plus UDP interfaces on
683+// udp, plus an explicit TCP readiness port. Internal HTTP ports are
684+// excluded: they are reached only over the private network and must keep
685+// no host binding. Each publish is on 127.0.0.1 ephemeral (never public).
686+// Deduplicated by (protocol, container port).
632687 func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
633688 seen := map[domain.ContainerBackendPort]struct{}{}
634689 var ports []domain.ContainerBackendPort
@@ -643,6 +698,9 @@ func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
643698 }
644699 }
645700 for _, h := range spec.HTTP {
701+ if !h.IsPublic() {
702+ continue
703+ }
646704 add(h.Port, domain.NetworkProtocolTCP)
647705 }
648706 for _, t := range spec.TCP {
@@ -653,8 +711,12 @@ func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
653711 }
654712 // Readiness probes are TCP-only (validation rejects UDP-only
655713 // services with tcp/http readiness); the readiness port matches a
656- // declared TCP container port.
657- add(spec.Readiness.Port, domain.NetworkProtocolTCP)
714+ // declared TCP container port. An internal-only port is never
715+ // published, so readiness metadata cannot create a host binding for
716+ // it.
717+ if spec.Readiness.Port > 0 && !spec.InternallyOnlyPort(spec.Readiness.Port) {
718+ add(spec.Readiness.Port, domain.NetworkProtocolTCP)
719+ }
658720 sort.Slice(ports, func(i, j int) bool {
659721 if ports[i].Protocol != ports[j].Protocol {
660722 return ports[i].Protocol < ports[j].Protocol
@@ -788,25 +850,24 @@ func (s *Service) pullImage(ctx context.Context, image string) (string, error) {
788850 if s.deps.Registry.Domain == "" {
789851 return image, nil
790852 }
791- host, _, _ := strings.Cut(image, "/")
792- if host != s.deps.Registry.Domain {
853+ if !s.deps.ImagePolicy.IsInstallationImage(image) {
793854 if err := s.deps.Runtime.PullImage(ctx, image); err != nil {
794855 return "", fmt.Errorf("deployment: pull image %q: %w", image, err)
795856 }
796857 return image, nil
797858 }
798- if s.deps.Registry.Username == "" {
799- if err := s.deps.Runtime.PullImage(ctx, image); err != nil {
800- return "", fmt.Errorf("deployment: pull image %q: %w", image, err)
801- }
802- return image, nil
859+ _, remainder, ok := strings.Cut(image, "/")
860+ if !ok || !strings.Contains(remainder, "@sha256:") {
861+ return "", fmt.Errorf("deployment: installation image must include an exact sha256 digest: %w", domain.ErrAppImageNotAllowed)
803862 }
804- pullImage := image
805- if s.deps.Registry.PullAddress != "" {
806- pullImage = s.deps.Registry.PullAddress + strings.TrimPrefix(image, s.deps.Registry.Domain)
863+ pullImage := s.deps.Registry.PullAddress + "/" + remainder
864+ request := domain.ImagePullRequest{Reference: pullImage, Username: s.deps.Registry.Username, Password: s.deps.Registry.Password, Transport: domain.ImagePullTransportHTTP}
865+ if err := s.deps.Runtime.PullImageWithOptions(ctx, request); err != nil {
866+ return "", fmt.Errorf("deployment: pull installation image %q via configured local HTTP transport %q: %w", image, s.deps.Registry.PullAddress, err)
807867 }
808- if err := s.deps.Runtime.PullImageWithAuth(ctx, pullImage, s.deps.Registry.Username, s.deps.Registry.Password); err != nil {
809- return "", fmt.Errorf("deployment: pull image %q: %w", image, err)
868+ digest := remainder[strings.LastIndex(remainder, "@")+1:]
869+ if err := s.deps.Runtime.VerifyImageDigest(ctx, pullImage, digest); err != nil {
870+ return "", fmt.Errorf("deployment: verify pulled installation image %q: %w", image, err)
810871 }
811872 return pullImage, nil
812873 }
@@ -901,6 +962,9 @@ func (s *Service) publishService(ctx context.Context, app, revision string, p pi
901962 }
902963 }
903964 active.Converged = converged
965+ if converged {
966+ active.Networks = append([]domain.AppSharedNetwork(nil), p.appNetworks...)
967+ }
904968 if err := s.deps.State.SaveActive(ctx, active); err != nil {
905969 return fmt.Errorf("deployment: publish active: %w", err)
906970 }
@@ -976,7 +1040,7 @@ func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedServi
9761040 if ownership.Services == nil {
9771041 ownership.Services = map[string]domain.AppServiceRecovery{}
9781042 }
979- ownership.Services[p.spec.Name] = domain.AppServiceRecovery{RestartUnsafe: len(p.spec.Volumes) > 0}
1043+ ownership.Services[p.spec.Name] = domain.AppServiceRecovery{RestartUnsafe: singleWriterRequired(p.spec)}
9801044 if err := s.deps.State.SaveOwnership(ctx, ownership); err != nil {
9811045 return fmt.Errorf("deployment: record ownership: %w", err)
9821046 }
A internal/usecase/deployment/internal_readiness_test.go +363 -0
@@ -0,0 +1,363 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
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+)
18+
19+// internalSpec is an HTTP service whose only interface is internal.
20+func internalSpec() domain.AppService {
21+ return domain.AppService{
22+ Name: "api",
23+ Image: "registry.example.com/blog/api:1.0.0",
24+ HTTP: []domain.AppHTTPInterface{{Port: 8080, Visibility: domain.AppVisibilityInternal}},
25+ Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Path: "/healthz", Timeout: 2 * time.Second},
26+ }
27+}
28+
29+// TestBackendPorts_InternalHTTPIsNeverPublished proves internal HTTP ports
30+// gain no loopback publication, including through readiness metadata, while
31+// public interfaces keep theirs.
32+func TestBackendPorts_InternalHTTPIsNeverPublished(t *testing.T) {
33+ internal := internalSpec()
34+ assert.Empty(t, backendPorts(internal), "internal http must not be published")
35+
36+ public := domain.AppService{
37+ Name: "web",
38+ HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
39+ }
40+ assert.Equal(t, []int{8080}, containerPorts(backendPorts(public)))
41+
42+ mixed := domain.AppService{
43+ HTTP: []domain.AppHTTPInterface{
44+ {Host: "blog.example.com", Port: 8080, TLS: "auto"},
45+ {Port: 9090, Visibility: domain.AppVisibilityInternal},
46+ },
47+ Readiness: domain.AppReadiness{Type: domain.AppReadinessHTTP, Port: 9090},
48+ }
49+ assert.Equal(t, []int{8080}, containerPorts(backendPorts(mixed)),
50+ "the internal readiness port must not add a second publication")
51+
52+ tcp := domain.AppService{
53+ TCP: []domain.AppTCPInterface{{Port: 5432}},
54+ Readiness: domain.AppReadiness{Type: domain.AppReadinessTCP},
55+ }
56+ assert.Equal(t, []int{5432}, containerPorts(backendPorts(tcp)))
57+}
58+
59+func containerPorts(ports []domain.ContainerBackendPort) []int {
60+ result := make([]int, 0, len(ports))
61+ for _, port := range ports {
62+ result = append(result, port.ContainerPort)
63+ }
64+ return result
65+}
66+
67+// TestInternalProbeShape proves readiness type selects the probe transport.
68+func TestInternalProbeShape(t *testing.T) {
69+ httpProtocol, path := internalProbeShape(internalSpec())
70+ assert.Equal(t, domain.ProbeProtocolHTTP, httpProtocol)
71+ assert.Equal(t, "/healthz", path)
72+
73+ tcpSpec := internalSpec()
74+ tcpSpec.Readiness = domain.AppReadiness{Type: domain.AppReadinessTCP}
75+ tcpProtocol, tcpPath := internalProbeShape(tcpSpec)
76+ assert.Equal(t, domain.ProbeProtocolTCP, tcpProtocol)
77+ assert.Empty(t, tcpPath)
78+}
79+
80+// TestWaitInternalReady_RetriesUntilReady proves an unhealthy attempt is
81+// retried and the request carries the exact identity, network, and shape.
82+func TestWaitInternalReady_RetriesUntilReady(t *testing.T) {
83+ var requests []domain.ContainerNetworkProbeRequest
84+ attempts := 0
85+ deps := ProbeDeps{
86+ containerStart: func(context.Context, string) (time.Time, error) {
87+ return time.Unix(1700000000, 0).UTC(), nil
88+ },
89+ networkProbe: func(_ context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
90+ requests = append(requests, request)
91+ attempts++
92+ if attempts < 3 {
93+ return domain.ContainerNetworkProbeResult{Diagnostic: "no http response"}, nil
94+ }
95+ return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
96+ },
97+ }
98+ require.NoError(t, waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "gordon--blog--net", 8080))
99+ require.Len(t, requests, 3)
100+ assert.Equal(t, "c-api", requests[0].TargetContainerID)
101+ assert.Equal(t, "gordon--blog--net", requests[0].Network)
102+ assert.Equal(t, domain.ProbeProtocolHTTP, requests[0].Protocol)
103+ assert.Equal(t, "/healthz", requests[0].Path)
104+ assert.Equal(t, 8080, requests[0].Port)
105+ assert.Equal(t, time.Unix(1700000000, 0).UTC(), requests[0].ExpectedStartedAt)
106+}
107+
108+// TestWaitInternalReady_InfrastructureErrorFailsImmediately proves a helper
109+// that cannot run is never retried as an unhealthy attempt.
110+func TestWaitInternalReady_InfrastructureErrorFailsImmediately(t *testing.T) {
111+ attempts := 0
112+ deps := ProbeDeps{
113+ containerStart: func(context.Context, string) (time.Time, error) {
114+ return time.Unix(1700000000, 0).UTC(), nil
115+ },
116+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
117+ attempts++
118+ return domain.ContainerNetworkProbeResult{}, errors.New("helper create failed")
119+ },
120+ }
121+ err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080)
122+ require.ErrorContains(t, err, "infrastructure error")
123+ assert.Equal(t, 1, attempts, "an infrastructure failure must not be retried")
124+}
125+
126+func TestRunInternalProbeAttempt_CleanupFailureIsNeverSuppressed(t *testing.T) {
127+ request := domain.ContainerNetworkProbeRequest{Timeout: 10 * time.Millisecond}
128+ deps := ProbeDeps{networkProbe: func(ctx context.Context, _ domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
129+ <-ctx.Done()
130+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("%w: remove failed", domain.ErrNetworkProbeCleanup)
131+ }}
132+
133+ _, err := runInternalProbeAttempt(context.Background(), deps, request)
134+ require.ErrorIs(t, err, domain.ErrNetworkProbeCleanup)
135+}
136+
137+// TestWaitInternalReady_CombinedCleanupAndStaleErrorAbortsAfterOneAttempt
138+// proves a helper cleanup failure wins over the stale-generation retry: a
139+// probe error wrapping both a stale sentinel and ErrNetworkProbeCleanup
140+// aborts the wait after the first attempt instead of polling until the
141+// readiness deadline.
142+func TestWaitInternalReady_CombinedCleanupAndStaleErrorAbortsAfterOneAttempt(t *testing.T) {
143+ attempts := 0
144+ startReads := 0
145+ deps := ProbeDeps{
146+ containerStart: func(context.Context, string) (time.Time, error) {
147+ startReads++
148+ return time.Unix(1700000000, 0).UTC(), nil
149+ },
150+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
151+ attempts++
152+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("stale candidate: %w (helper not removed: %w)", domain.ErrAppStateConflict, domain.ErrNetworkProbeCleanup)
153+ },
154+ }
155+ err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080)
156+ require.ErrorIs(t, err, domain.ErrNetworkProbeCleanup)
157+ assert.NotContains(t, err.Error(), "timeout")
158+ assert.Equal(t, 1, attempts, "a cleanup failure must abort after one attempt")
159+ assert.Equal(t, 1, startReads, "a cleanup failure must not re-read the execution boundary")
160+}
161+
162+// TestWaitInternalReady_Timeout proves a never-ready target fails at the
163+// readiness deadline.
164+func TestWaitInternalReady_Timeout(t *testing.T) {
165+ spec := internalSpec()
166+ spec.Readiness.Timeout = 50 * time.Millisecond
167+ deps := ProbeDeps{
168+ containerStart: func(context.Context, string) (time.Time, error) {
169+ return time.Unix(1700000000, 0).UTC(), nil
170+ },
171+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
172+ return domain.ContainerNetworkProbeResult{Diagnostic: "http status 503"}, nil
173+ },
174+ }
175+ err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", spec, "net", 8080)
176+ require.ErrorContains(t, err, "timeout")
177+ assert.ErrorContains(t, err, "http status 503")
178+}
179+
180+// TestWaitInternalReady_Cancellation proves a canceled deployment context
181+// stops polling immediately.
182+func TestWaitInternalReady_Cancellation(t *testing.T) {
183+ ctx, cancel := context.WithCancel(context.Background())
184+ deps := ProbeDeps{
185+ containerStart: func(context.Context, string) (time.Time, error) {
186+ return time.Unix(1700000000, 0).UTC(), nil
187+ },
188+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
189+ cancel()
190+ return domain.ContainerNetworkProbeResult{}, nil
191+ },
192+ }
193+ err := waitInternalReadyWithDeps(ctx, deps, "c-api", internalSpec(), "net", 8080)
194+ require.ErrorIs(t, err, context.Canceled)
195+}
196+
197+// TestWaitInternalReady_MissingExecutionStart proves a runtime that reports
198+// no execution boundary fails closed rather than probing an unidentified
199+// generation.
200+func TestWaitInternalReady_MissingExecutionStart(t *testing.T) {
201+ deps := ProbeDeps{
202+ containerStart: func(context.Context, string) (time.Time, error) {
203+ return time.Time{}, nil
204+ },
205+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
206+ t.Fatal("must not probe without an execution start")
207+ return domain.ContainerNetworkProbeResult{}, nil
208+ },
209+ }
210+ err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080)
211+ require.ErrorContains(t, err, "execution start")
212+}
213+
214+// TestWaitInternalReady_StaleGenerationIsRetried proves a candidate that
215+// restarts during the readiness window is re-probed against its new
216+// execution boundary instead of aborting the wait as an infrastructure
217+// error.
218+func TestWaitInternalReady_StaleGenerationIsRetried(t *testing.T) {
219+ first := time.Unix(1700000000, 0).UTC()
220+ second := time.Unix(1700000100, 0).UTC()
221+ starts := []time.Time{first, second, second}
222+ startReads := 0
223+ attempts := 0
224+ deps := ProbeDeps{
225+ containerStart: func(context.Context, string) (time.Time, error) {
226+ start := starts[min(startReads, len(starts)-1)]
227+ startReads++
228+ return start, nil
229+ },
230+ networkProbe: func(_ context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
231+ attempts++
232+ if attempts == 1 {
233+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("execution changed: %w", domain.ErrAppStateConflict)
234+ }
235+ assert.Equal(t, second, request.ExpectedStartedAt, "the retry must use the new execution boundary")
236+ return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
237+ },
238+ }
239+ require.NoError(t, waitInternalReadyWithDeps(context.Background(), deps, "c-api", internalSpec(), "net", 8080))
240+ assert.Equal(t, 2, attempts)
241+}
242+
243+// TestWaitInternalReady_StaleGenerationUntilDeadlineTimesOut proves a
244+// target that keeps restarting never gets reported ready: the wait ends at
245+// the readiness deadline, not with a false infrastructure error.
246+func TestWaitInternalReady_StaleGenerationUntilDeadlineTimesOut(t *testing.T) {
247+ spec := internalSpec()
248+ spec.Readiness.Timeout = 50 * time.Millisecond
249+ deps := ProbeDeps{
250+ containerStart: func(context.Context, string) (time.Time, error) {
251+ return time.Unix(1700000000, 0).UTC(), nil
252+ },
253+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
254+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("gone: %w", domain.ErrContainerNotFound)
255+ },
256+ }
257+ err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", spec, "net", 8080)
258+ require.Error(t, err)
259+ assert.NotContains(t, err.Error(), "infrastructure error")
260+}
261+
262+// TestWaitInternalReady_VanishedCandidateKeepsPolling proves a candidate
263+// that is gone on the boundary re-read is still "not ready yet": the wait
264+// keeps its previous execution boundary and polls to the readiness
265+// deadline instead of aborting as an infrastructure error.
266+func TestWaitInternalReady_VanishedCandidateKeepsPolling(t *testing.T) {
267+ spec := internalSpec()
268+ spec.Readiness.Timeout = 600 * time.Millisecond
269+ startReads := 0
270+ attempts := 0
271+ deps := ProbeDeps{
272+ containerStart: func(context.Context, string) (time.Time, error) {
273+ startReads++
274+ if startReads == 1 {
275+ return time.Unix(1700000000, 0).UTC(), nil
276+ }
277+ return time.Time{}, fmt.Errorf("gone: %w", domain.ErrContainerNotFound)
278+ },
279+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
280+ attempts++
281+ return domain.ContainerNetworkProbeResult{}, fmt.Errorf("gone: %w", domain.ErrContainerNotFound)
282+ },
283+ }
284+ err := waitInternalReadyWithDeps(context.Background(), deps, "c-api", spec, "net", 8080)
285+ require.ErrorContains(t, err, "timeout")
286+ assert.NotContains(t, err.Error(), "infrastructure error")
287+ assert.GreaterOrEqual(t, attempts, 2, "a vanished candidate must be retried, not abort the wait")
288+ assert.GreaterOrEqual(t, startReads, 2, "a vanished candidate must be re-read, not abort the wait")
289+}
290+
291+// TestReadinessContainerPort_PrefersSinglePublicHTTP proves a public+
292+// internal service with no explicit readiness port probes the public HTTP
293+// backend instead of failing on an unresolved port.
294+func TestReadinessContainerPort_PrefersSinglePublicHTTP(t *testing.T) {
295+ mixed := domain.AppService{
296+ HTTP: []domain.AppHTTPInterface{
297+ {Host: "blog.example.com", Port: 3000, TLS: "auto"},
298+ {Port: 8080, Visibility: domain.AppVisibilityInternal},
299+ },
300+ }
301+ assert.Equal(t, 3000, readinessContainerPort(mixed))
302+
303+ single := domain.AppService{HTTP: []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 3000, TLS: "auto"}}}
304+ assert.Equal(t, 3000, readinessContainerPort(single))
305+
306+ tcpOnly := domain.AppService{TCP: []domain.AppTCPInterface{{Port: 5432}}}
307+ assert.Equal(t, 5432, readinessContainerPort(tcpOnly))
308+}
309+
310+// TestWaitServiceReady_InternalUsesNetworkPath proves the dispatcher routes
311+// an internal readiness port to the bounded network probe and never to the
312+// loopback path.
313+func TestWaitServiceReady_InternalUsesNetworkPath(t *testing.T) {
314+ ctx := context.Background()
315+ state := outmocks.NewMockAppState(t)
316+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "inc-1"}, nil)
317+
318+ networkCalls := 0
319+ loopbackCalls := 0
320+ service := NewService(Deps{State: state, Networks: NetworkConfig{Prefix: "gordon"}}, zerowrap.Default()).
321+ WithProbeDeps(ProbeDeps{
322+ containerStart: func(context.Context, string) (time.Time, error) {
323+ return time.Unix(1700000000, 0).UTC(), nil
324+ },
325+ networkProbe: func(_ context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
326+ networkCalls++
327+ assert.Equal(t, domain.AppPrivateNetworkName("gordon", "inc-1"), request.Network)
328+ return domain.ContainerNetworkProbeResult{Ready: true, Status: 200}, nil
329+ },
330+ httpGet: func(context.Context, string) (int, error) {
331+ loopbackCalls++
332+ return 200, nil
333+ },
334+ })
335+
336+ require.NoError(t, service.waitServiceReady(ctx, "blog", "c-api", internalSpec(), nil))
337+ assert.Equal(t, 1, networkCalls)
338+ assert.Zero(t, loopbackCalls, "an internal port must never take the loopback path")
339+}
340+
341+// TestWaitServiceReady_PublicKeepsLoopback proves a public readiness port
342+// still probes the loopback bind and never the network path.
343+func TestWaitServiceReady_PublicKeepsLoopback(t *testing.T) {
344+ ctx := context.Background()
345+ networkCalls := 0
346+ loopbackCalls := 0
347+ service := NewService(Deps{}, zerowrap.Default()).
348+ WithProbeDeps(ProbeDeps{
349+ httpGet: func(context.Context, string) (int, error) {
350+ loopbackCalls++
351+ return 200, nil
352+ },
353+ networkProbe: func(context.Context, domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
354+ networkCalls++
355+ return domain.ContainerNetworkProbeResult{}, nil
356+ },
357+ })
358+
359+ spec := readySpec()
360+ require.NoError(t, service.waitServiceReady(ctx, "blog", "c-web", spec, map[int]int{8080: 18080}))
361+ assert.Equal(t, 1, loopbackCalls)
362+ assert.Zero(t, networkCalls, "a public port must never take the network path")
363+}
M internal/usecase/deployment/lifecycle.go +190 -22
@@ -196,7 +196,10 @@ func (s *Service) startLocked(ctx context.Context, app, opID string) (*Lifecycle
196196 }
197197
198198 // Restart restarts from pinned digests without re-resolution. Empty
199-// service means all services in sorted order.
199+// service means all services in sorted order. HTTP-only, volume-less
200+// services restart with zero downtime: the old container keeps serving
201+// until the candidate passes readiness and traffic has moved to it.
202+// HTTP-with-volume, TCP/UDP, and mixed services restart in place.
200203 func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) {
201204 release, err := s.acquireAppContext(ctx, app)
202205 if err != nil {
@@ -241,7 +244,13 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
241244 var failures []string
242245 for _, name := range names {
243246 eff := active.Services[name]
244- step, svcResult := s.restartOneService(ctx, app, name, eff)
247+ var step domain.AppOperationStep
248+ var svcResult ServiceResult
249+ if httpEligible(eff.Spec) {
250+ step, svcResult = s.restartOneHTTPService(ctx, app, name, eff, op.Op)
251+ } else {
252+ step, svcResult = s.restartOneService(ctx, app, name, eff)
253+ }
245254 op.Steps = append(op.Steps, step)
246255 result.Services[name] = svcResult
247256 if svcResult.Result != "deployed" {
@@ -263,6 +272,129 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
263272 return result, nil
264273 }
265274
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+
266398 // restartOneService withdraws one service, restarts its exact container,
267399 // re-inspects its binds, and verifies readiness before it may be published
268400 // again. Every failure leaves the service withdrawn with its recorded binds
@@ -280,6 +412,11 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
280412 result.After = ""
281413 return fail("no active container")
282414 }
415+ // Fail closed before any runtime mutation if a bind's policy was
416+ // revoked or became invalid since ACTIVE was published.
417+ if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
418+ return fail(err.Error())
419+ }
283420 if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {
284421 _ = s.withdrawForRecovery(ctx, app, name)
285422 return fail(err.Error())
@@ -296,12 +433,12 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
296433 // Ephemeral loopback binds are not guaranteed stable across a runtime
297434 // restart: re-inspect before probing so the proxy never dials a stale
298435 // bind.
299- binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff)
436+ binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
300437 if err != nil {
301438 s.clearServiceBinds(ctx, app, name)
302439 return fail(err.Error())
303440 }
304- if err := waitServiceReadyWithDeps(ctx, s.probeDeps(), eff.Container, eff.Spec, binds); err != nil {
441+ if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {
305442 s.clearServiceBinds(ctx, app, name)
306443 return fail(err.Error())
307444 }
@@ -538,6 +675,12 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
538675 // step inline (false, reserved for future fallible publishes).
539676 func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool {
540677 if eff.Container != "" {
678+ // Fail closed before any runtime mutation if a bind's policy was
679+ // revoked or became invalid since ACTIVE was published.
680+ if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
681+ s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
682+ return true
683+ }
541684 // Boot recovery refuses a generation whose recovery is durably
542685 // inhibited: a replacement may already have written to its
543686 // volume, so reviving this ID could corrupt the newer data.
@@ -574,8 +717,15 @@ func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name stri
574717 result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
575718 return true
576719 }
720+ if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
721+ step.State = domain.AppStepFailed
722+ step.Error = err.Error()
723+ result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
724+ return true
725+ }
577726 pinned := pinnedService{
578727 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...),
579729 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),
580730 }
581731 svcResult, _ := s.deployService(ctx, app, rev.Revision, pinned, opID, eff.Container, serviceStopGrace(eff))
@@ -629,12 +779,12 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
629779 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
630780 return true
631781 }
632- binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff)
782+ binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
633783 if err != nil {
634784 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
635785 return true
636786 }
637- if err := waitServiceReadyWithDeps(ctx, s.probeDeps(), eff.Container, eff.Spec, binds); err != nil {
787+ if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {
638788 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
639789 return true
640790 }
@@ -648,12 +798,12 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
648798 }
649799
650800 // refreshBackendBinds re-inspects the loopback publishes of a restarted
651-// container and persists them to the active record when they changed.
801+// container and persists them to the current active record when needed.
652802 // Ephemeral binds may shift across runtime restarts; the proxy must
653803 // never dial the stale recorded bind. Uses the non-destructive
654804 // inspectBackendBinds: an existing ACTIVE container is never stopped
655805 // or removed by re-inspection.
656-func (s *Service) refreshBackendBinds(ctx context.Context, app, name string, eff domain.AppEffectiveService) (map[int]int, map[int]int, error) {
806+func (s *Service) refreshBackendBinds(ctx context.Context, app, name string, eff domain.AppEffectiveService, forcePersist bool) (map[int]int, map[int]int, error) {
657807 ports := backendPorts(eff.Spec)
658808 if len(ports) == 0 {
659809 return nil, nil, nil
@@ -662,24 +812,42 @@ func (s *Service) refreshBackendBinds(ctx context.Context, app, name string, eff
662812 if err != nil {
663813 return nil, nil, err
664814 }
665- if !equalBinds(binds, eff.BackendBinds) || !equalBinds(udpBinds, eff.UDPBackendBinds) {
666- active, ok, err := s.deps.State.LoadActive(ctx, app)
667- if err != nil {
668- return nil, nil, fmt.Errorf("deployment: reload active for bind refresh: %w", err)
669- }
670- if !ok {
671- return nil, nil, fmt.Errorf("deployment: app %q has no active state: %w", app, domain.ErrAppStateConflict)
672- }
673- eff.BackendBinds = binds
674- eff.UDPBackendBinds = udpBinds
675- active.Services[name] = eff
676- if err := s.deps.State.SaveActive(ctx, active); err != nil {
677- return nil, nil, fmt.Errorf("deployment: persist refreshed binds: %w", err)
678- }
815+ if !forcePersist && equalBinds(binds, eff.BackendBinds) && equalBinds(udpBinds, eff.UDPBackendBinds) {
816+ return binds, udpBinds, nil
817+ }
818+ active, ok, err := s.deps.State.LoadActive(ctx, app)
819+ if err != nil {
820+ return nil, nil, fmt.Errorf("deployment: reload active for bind refresh: %w", err)
821+ }
822+ if !ok {
823+ return nil, nil, fmt.Errorf("deployment: app %q has no active state: %w", app, domain.ErrAppStateConflict)
824+ }
825+ if err := s.persistBackendBinds(ctx, active, name, eff, binds, udpBinds, forcePersist); err != nil {
826+ return nil, nil, err
679827 }
680828 return binds, udpBinds, nil
681829 }
682830
831+func (s *Service) persistBackendBinds(ctx context.Context, active domain.AppActive, name string, eff domain.AppEffectiveService, binds, udpBinds map[int]int, force bool) error {
832+ current, exists := active.Services[name]
833+ if exists && current.Container != eff.Container {
834+ return fmt.Errorf("deployment: active generation changed during bind refresh: %w", domain.ErrAppStateConflict)
835+ }
836+ if !force && exists && equalBinds(binds, current.BackendBinds) && equalBinds(udpBinds, current.UDPBackendBinds) {
837+ return nil
838+ }
839+ if !exists {
840+ current = eff
841+ }
842+ current.BackendBinds = binds
843+ current.UDPBackendBinds = udpBinds
844+ active.Services[name] = current
845+ if err := s.deps.State.SaveActive(ctx, active); err != nil {
846+ return fmt.Errorf("deployment: persist refreshed binds: %w", err)
847+ }
848+ return nil
849+}
850+
683851 // failServiceStep records a failed service step and withdraws its
684852 // recorded backend binds from ACTIVE: a bind that failed verification
685853 // (or could not be refreshed) must never be served again — the
M internal/usecase/deployment/monitor.go +28 -6
@@ -358,11 +358,7 @@ func (s *Service) reconcileAppRunning(ctx context.Context, app string) error {
358358 if err != nil {
359359 return fmt.Errorf("deployment: reconcile %q: load recovery inhibitions: %w", app, err)
360360 }
361- inhibited := map[recoveryKey]struct{}{}
362- for _, inhibition := range inhibitions {
363- inhibited[recoveryKey{app: app, service: inhibition.Service, container: inhibition.ContainerID}] = struct{}{}
364- }
365- var failures []error
361+ inhibited, failures := s.processRecoveryInhibitions(ctx, app, inhibitions)
366362 for _, name := range sortedServiceNames(active) {
367363 eff := active.Services[name]
368364 if intent.Stopped {
@@ -389,6 +385,22 @@ func (s *Service) reconcileAppRunning(ctx context.Context, app string) error {
389385 return errors.Join(failures...)
390386 }
391387
388+func (s *Service) processRecoveryInhibitions(ctx context.Context, app string, inhibitions []domain.AppRecoveryInhibition) (map[recoveryKey]struct{}, []error) {
389+ inhibited := map[recoveryKey]struct{}{}
390+ var failures []error
391+ for _, inhibition := range inhibitions {
392+ if inhibition.Reason != domain.AppInhibitRetirementPending {
393+ inhibited[recoveryKey{app: app, service: inhibition.Service, container: inhibition.ContainerID}] = struct{}{}
394+ continue
395+ }
396+ retired := s.retireContainer(ctx, app, retireOptions{Service: inhibition.Service}, inhibition.ContainerID)
397+ if !retired.Gone {
398+ failures = append(failures, fmt.Errorf("deployment: retirement pending for %s/%s", app, inhibition.ContainerID))
399+ }
400+ }
401+ return inhibited, failures
402+}
403+
392404 // convergeStoppedService enforces durable stopped intent: it never
393405 // starts or publishes, and it stops a verified running exact ACTIVE ID
394406 // without deleting the container or its volumes.
@@ -598,7 +610,7 @@ func (s *Service) publishRecoveredService(ctx context.Context, app, name string,
598610 return errors.Join(s.withdrawForRecovery(ctx, app, name), err)
599611 }
600612 if started || restarted || fresh {
601- if err := waitServiceReadyWithDeps(ctx, s.probeDeps(), eff.Container, eff.Spec, binds); err != nil {
613+ if err := s.waitServiceReady(ctx, app, eff.Container, eff.Spec, binds); err != nil {
602614 s.backoff.recordFailure(key)
603615 return errors.Join(s.withdrawForRecovery(ctx, app, name), err)
604616 }
@@ -621,6 +633,11 @@ func (s *Service) publishRecoveredService(ctx context.Context, app, name string,
621633 // re-inspected: if native restart policy already revived it, no failure
622634 // is charged and no second restart is issued.
623635 func (s *Service) startRecovered(serviceCtx context.Context, app, name string, eff domain.AppEffectiveService, key recoveryKey) (bool, error) {
636+ // Fail closed before mutating the runtime if a bind's policy was
637+ // revoked or became invalid since ACTIVE was published.
638+ if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
639+ return false, err
640+ }
624641 if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil {
625642 if errors.Is(err, domain.ErrContainerNotFound) {
626643 s.backoff.forget(key)
@@ -668,6 +685,11 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef
668685 if !s.backoff.allow(key) {
669686 return false, false, nil
670687 }
688+ // Fail closed before mutating the runtime if a bind's policy was
689+ // revoked or became invalid since ACTIVE was published.
690+ if _, err := s.resolveServiceBinds(app, eff.Spec); err != nil {
691+ return false, false, err
692+ }
671693 if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil {
672694 s.backoff.recordFailure(key)
673695 return false, false, fmt.Errorf("deployment: app %q service %q restart unhealthy %s: %w", app, name, eff.Container, err)
M internal/usecase/deployment/publication_internal_test.go +3 -2
@@ -42,7 +42,8 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
4242 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
4343 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
4444 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
45-
45+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{"web": {Container: "c-1"}}}, true, nil).Once()
46+ state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
4647 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
4748 WithProbeDeps(NewTestProbeDeps(runtime,
4849 func(context.Context, string) (int, error) { return 500, nil },
@@ -59,7 +60,7 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
5960 // the canonical boundary; the fail-closed ACTIVE write itself is
6061 // covered by the publisher tests in internal/app.
6162 assert.Equal(t, []string{"blog/web", "blog/web"}, traffic.withdrawn)
62- state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
63+ state.AssertNumberOfCalls(t, "SaveActive", 1)
6364 }
6465
6566 // TestVerifyRunningService_ReadyPublishesBinds proves a ready generation is
M internal/usecase/deployment/readiness.go +224 -4
@@ -2,6 +2,7 @@ package deployment
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "net"
@@ -21,6 +22,10 @@ type ProbeDeps struct {
2122 logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error)
2223 httpGet func(ctx context.Context, url string) (int, error)
2324 tcpDial func(ctx context.Context, addr string) error
25+ // networkProbe runs one bounded session against an internal port that
26+ // has no host publication. Nil disables the internal path: it must
27+ // never fall back to a loopback bind that does not exist.
28+ networkProbe func(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error)
2429 }
2530
2631 // ProbeRuntime is the runtime subset readiness probing needs. It mirrors
@@ -30,6 +35,7 @@ type ProbeRuntime interface {
3035 GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)
3136 GetContainerLogsSince(ctx context.Context, containerID string, since time.Time, follow bool) (io.ReadCloser, error)
3237 InspectContainer(ctx context.Context, containerID string) (*domain.Container, error)
38+ ProbeContainerNetwork(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error)
3339 }
3440
3541 // NewProbeDeps wires readiness probes to a runtime implementation.
@@ -52,6 +58,9 @@ func NewProbeDeps(runtime ProbeRuntime) ProbeDeps {
5258 },
5359 httpGet: httpGetOnce,
5460 tcpDial: tcpDialOnce,
61+ networkProbe: func(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
62+ return runtime.ProbeContainerNetwork(ctx, request)
63+ },
5564 }
5665 }
5766
@@ -78,6 +87,183 @@ func (s *Service) probeDeps() ProbeDeps {
7887 return s.defaultProbeDeps()
7988 }
8089
90+// waitServiceReady dispatches readiness for one service. It resolves the
91+// app private network lazily: only an internal interface needs it. A
92+// service whose readiness port belongs to an internal HTTP interface
93+// always probes over the private network; it never falls back to a
94+// loopback bind, and a public service never takes the network path.
95+func (s *Service) waitServiceReady(ctx context.Context, app, containerID string, spec domain.AppService, binds map[int]int) error {
96+ switch spec.Readiness.Type {
97+ case domain.AppReadinessHTTP, domain.AppReadinessTCP:
98+ port := readinessContainerPort(spec)
99+ if port > 0 && spec.InternallyOnlyPort(port) {
100+ network, err := s.privateNetworkForApp(ctx, app)
101+ if err != nil {
102+ return err
103+ }
104+ return waitInternalReadyWithDeps(ctx, s.probeDeps(), containerID, spec, network, port)
105+ }
106+ }
107+ return waitServiceReadyWithDeps(ctx, s.probeDeps(), containerID, spec, binds)
108+}
109+
110+// privateNetworkForApp resolves the incarnation-owned private network, the
111+// only network an internal readiness helper may join. It fails closed when
112+// the app has no incarnation id.
113+func (s *Service) privateNetworkForApp(ctx context.Context, app string) (string, error) {
114+ ownership, err := s.deps.State.LoadOwnership(ctx, app)
115+ if err != nil {
116+ return "", fmt.Errorf("deployment: load ownership for private network: %w", err)
117+ }
118+ if ownership.ID == "" {
119+ return "", fmt.Errorf("deployment: app %q has no incarnation id for internal readiness: %w", app, domain.ErrAppStateConflict)
120+ }
121+ return domain.AppPrivateNetworkName(s.deps.Networks.Prefix, ownership.ID), nil
122+}
123+
124+// waitInternalReadyWithDeps polls one bounded private-network session per
125+// attempt until the target is ready or the readiness timeout expires. An
126+// infrastructure error fails immediately and is never retried as an
127+// unhealthy attempt: a helper that cannot run says nothing about the
128+// target.
129+func waitInternalReadyWithDeps(ctx context.Context, deps ProbeDeps, containerID string, spec domain.AppService, network string, port int) error {
130+ timeout := readinessTimeout(spec)
131+ protocol, path := internalProbeShape(spec)
132+ if deps.networkProbe == nil || deps.containerStart == nil {
133+ return fmt.Errorf("deployment: internal readiness probe is not wired")
134+ }
135+ startedAt, err := deps.containerStart(ctx, containerID)
136+ if err != nil {
137+ return fmt.Errorf("deployment: internal readiness execution start for %s: %w", containerID, err)
138+ }
139+ if startedAt.IsZero() {
140+ // Without an execution boundary the probe could report readiness
141+ // for a generation that already restarted. Fail closed.
142+ return fmt.Errorf("deployment: internal readiness needs the execution start of %s, runtime reported none", containerID)
143+ }
144+ deadline := time.Now().Add(timeout)
145+ lastAttempt := "no attempt completed"
146+ for {
147+ if err := ctx.Err(); err != nil {
148+ return err
149+ }
150+ remaining := time.Until(deadline)
151+ if remaining <= 0 {
152+ return fmt.Errorf("deployment: internal readiness timeout after %s on container port %d (last attempt: %s)", timeout, port, lastAttempt)
153+ }
154+ attemptTimeout := min(5*time.Second, remaining)
155+ result, probeErr := runInternalProbeAttempt(ctx, deps, domain.ContainerNetworkProbeRequest{
156+ TargetContainerID: containerID,
157+ ExpectedStartedAt: startedAt,
158+ Network: network,
159+ Protocol: protocol,
160+ Port: port,
161+ Path: path,
162+ Timeout: attemptTimeout,
163+ })
164+ ready, retryStale, diagnostic, classifyErr := classifyInternalProbe(result, probeErr)
165+ if classifyErr != nil {
166+ return classifyErr
167+ }
168+ if ready {
169+ return nil
170+ }
171+ if diagnostic != "" {
172+ lastAttempt = diagnostic
173+ }
174+ if retryStale {
175+ // The candidate restarted or briefly vanished: re-read its
176+ // execution boundary and keep polling until the deadline. This
177+ // is "not ready yet", not a broken helper, so it must never
178+ // abort the wait as an infrastructure error.
179+ next, startErr := rereadInternalProbeStart(ctx, deps, containerID, startedAt)
180+ if startErr != nil {
181+ return startErr
182+ }
183+ startedAt = next
184+ }
185+ select {
186+ case <-ctx.Done():
187+ return ctx.Err()
188+ case <-time.After(500 * time.Millisecond):
189+ }
190+ }
191+}
192+
193+func runInternalProbeAttempt(ctx context.Context, deps ProbeDeps, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
194+ attemptCtx, cancel := context.WithTimeout(ctx, request.Timeout)
195+ result, err := deps.networkProbe(attemptCtx, request)
196+ attemptErr := attemptCtx.Err()
197+ cancel()
198+ if err != nil && !errors.Is(err, domain.ErrNetworkProbeCleanup) && errors.Is(attemptErr, context.DeadlineExceeded) && ctx.Err() == nil {
199+ return domain.ContainerNetworkProbeResult{Diagnostic: "probe attempt timed out"}, nil
200+ }
201+ return result, err
202+}
203+
204+// rereadInternalProbeStart refreshes the execution boundary after a stale
205+// or vanished candidate. A candidate that briefly vanished (not found)
206+// keeps its previous boundary so the caller keeps polling until the
207+// deadline; any other runtime failure is infrastructure and aborts. It
208+// keeps the previous value when the runtime reports none so the next
209+// attempt still fails closed.
210+func rereadInternalProbeStart(ctx context.Context, deps ProbeDeps, containerID string, current time.Time) (time.Time, error) {
211+ next, err := deps.containerStart(ctx, containerID)
212+ if err != nil {
213+ // ErrContainerNotFound is the same "not ready yet" condition the
214+ // probe reported: preserve the boundary and keep polling instead of
215+ // aborting the wait as an infrastructure error.
216+ if errors.Is(err, domain.ErrContainerNotFound) {
217+ return current, nil
218+ }
219+ return current, fmt.Errorf("deployment: internal readiness execution start for %s: %w", containerID, err)
220+ }
221+ if next.IsZero() {
222+ return current, nil
223+ }
224+ return next, nil
225+}
226+
227+// classifyInternalProbe maps one attempt's raw outcome to the wait loop's
228+// next step: ready, not-ready (diagnostic), a stale or vanished candidate
229+// that should be re-read and retried, or an infrastructure failure that
230+// must abort the wait immediately.
231+func classifyInternalProbe(result domain.ContainerNetworkProbeResult, probeErr error) (ready, retryStale bool, diagnostic string, err error) {
232+ switch {
233+ case probeErr == nil:
234+ if result.Ready {
235+ return true, false, "", nil
236+ }
237+ if result.Diagnostic != "" {
238+ return false, false, result.Diagnostic, nil
239+ }
240+ return false, false, "not ready", nil
241+ case errors.Is(probeErr, domain.ErrNetworkProbeCleanup):
242+ // A helper that could not be removed is a broken helper, not a
243+ // stale candidate: an error wrapping both this and a stale or
244+ // vanished sentinel must abort the wait, never be retried.
245+ return false, false, "", fmt.Errorf("deployment: internal readiness probe helper cleanup failed: %w", probeErr)
246+ case errors.Is(probeErr, domain.ErrAppStateConflict), errors.Is(probeErr, domain.ErrContainerNotFound):
247+ return false, true, "target execution changed; retrying", nil
248+ default:
249+ return false, false, "", fmt.Errorf("deployment: internal readiness probe infrastructure error: %w", probeErr)
250+ }
251+}
252+
253+// internalProbeShape selects the probe transport for an internal port:
254+// http readiness maps to a bounded HTTP probe, tcp readiness to a TCP
255+// connection probe.
256+func internalProbeShape(spec domain.AppService) (domain.ProbeProtocol, string) {
257+ if spec.Readiness.Type == domain.AppReadinessHTTP {
258+ path := spec.Readiness.Path
259+ if path == "" {
260+ path = "/"
261+ }
262+ return domain.ProbeProtocolHTTP, path
263+ }
264+ return domain.ProbeProtocolTCP, ""
265+}
266+
81267 // waitServiceReadyWithDeps dispatches readiness by manifest type.
82268 // binds carries the container's 127.0.0.1 backend publishes; L4 probes
83269 // dial them rootless-first and never container IPs (plan D3: loopback-
@@ -222,10 +408,7 @@ func waitLogReadyWithDeps(ctx context.Context, deps ProbeDeps, containerID strin
222408 // the manifest's readiness port when set, else the single TCP-capable
223409 // interface port (validation guarantees unambiguous selection).
224410 func dialPort(spec domain.AppService, binds map[int]int) (int, error) {
225- containerPort := spec.Readiness.Port
226- if containerPort == 0 {
227- containerPort = singleTCPPort(spec)
228- }
411+ containerPort := readinessContainerPort(spec)
229412 hostPort, ok := binds[containerPort]
230413 if !ok || hostPort <= 0 {
231414 return 0, fmt.Errorf("deployment: no 127.0.0.1 backend bind for container port %d", containerPort)
@@ -233,6 +416,43 @@ func dialPort(spec domain.AppService, binds map[int]int) (int, error) {
233416 return hostPort, nil
234417 }
235418
419+// readinessContainerPort resolves the container port a readiness probe
420+// targets: the manifest's readiness port when set, else the single
421+// effective-public HTTP port (the backend the proxy dials), else the
422+// single TCP-capable interface port. Validation guarantees unambiguous
423+// selection for the latter two; preferring the public HTTP port keeps a
424+// public+internal service probeable when it declares no explicit
425+// readiness port.
426+func readinessContainerPort(spec domain.AppService) int {
427+ if spec.Readiness.Port > 0 {
428+ return spec.Readiness.Port
429+ }
430+ if port := singlePublicHTTPPort(spec); port > 0 {
431+ return port
432+ }
433+ return singleTCPPort(spec)
434+}
435+
436+// singlePublicHTTPPort returns the container port when the service
437+// declares exactly one effective-public HTTP interface and no TCP
438+// interface, else 0.
439+func singlePublicHTTPPort(spec domain.AppService) int {
440+ if len(spec.TCP) > 0 {
441+ return 0
442+ }
443+ port := 0
444+ for _, h := range spec.HTTP {
445+ if !h.IsPublic() {
446+ continue
447+ }
448+ if port != 0 && port != h.Port {
449+ return 0
450+ }
451+ port = h.Port
452+ }
453+ return port
454+}
455+
236456 // singleTCPPort returns the container port when exactly one TCP-capable
237457 // interface exists; validation rejects ambiguity (readiness.port
238458 // required) before the engine runs.
A internal/usecase/deployment/restart_http_test.go +314 -0
@@ -0,0 +1,314 @@
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+}
M internal/usecase/deployment/service.go +119 -21
@@ -10,6 +10,7 @@ import (
1010 "maps"
1111 "sort"
1212 "strings"
13+ "sync"
1314 "time"
1415
1516 "github.com/bnema/zerowrap"
@@ -97,6 +98,11 @@ type Service struct {
9798 // generation, so a native restart is detected without restarting
9899 // the workload ourselves.
99100 executions executionTracker
101+ // bindMu guards bindPolicies. A config reload replaces the whole
102+ // map atomically; deploy paths read the current map without holding
103+ // the lock.
104+ bindMu sync.RWMutex
105+ bindPolicies map[string]domain.AppBindPolicy
100106 }
101107
102108 // NewService creates the deployment engine. All deps are required;
@@ -123,6 +129,56 @@ func (s *Service) WithGCBarrier(barrier out.GCBarrier) *Service {
123129 return s
124130 }
125131
132+// WithBindPolicies supplies the initial administrative bind policies.
133+func (s *Service) WithBindPolicies(policies map[string]domain.AppBindPolicy) *Service {
134+ s.SetBindPolicies(policies)
135+ return s
136+}
137+
138+// SetBindPolicies atomically replaces the administrative bind policies used
139+// to authorize service binds. The map is copied so later caller mutation
140+// cannot race an in-flight deploy.
141+func (s *Service) SetBindPolicies(policies map[string]domain.AppBindPolicy) {
142+ copied := make(map[string]domain.AppBindPolicy, len(policies))
143+ for name, policy := range policies {
144+ copied[name] = policy
145+ }
146+ s.bindMu.Lock()
147+ s.bindPolicies = copied
148+ s.bindMu.Unlock()
149+}
150+
151+// snapshotBindPolicies returns the current policy map. SetBindPolicies never
152+// mutates a published map, so the snapshot stays safe after the lock drops.
153+func (s *Service) snapshotBindPolicies() map[string]domain.AppBindPolicy {
154+ s.bindMu.RLock()
155+ defer s.bindMu.RUnlock()
156+ return s.bindPolicies
157+}
158+
159+// resolveServiceBinds resolves every declared bind against the current
160+// administrative policies into ephemeral runtime binds. Errors name only the
161+// app, service, and mount: host source paths never appear.
162+func (s *Service) resolveServiceBinds(app string, svc domain.AppService) ([]domain.ContainerBind, error) {
163+ if len(svc.Binds) == 0 {
164+ return nil, nil
165+ }
166+ policies := s.snapshotBindPolicies()
167+ resolved := make([]domain.ContainerBind, 0, len(svc.Binds))
168+ for _, bind := range svc.Binds {
169+ policy, ok := policies[bind.Name]
170+ if !ok {
171+ return nil, fmt.Errorf("deployment: app %q service %q mount %q: no administrative mount policy configured: %w", app, svc.Name, bind.Name, domain.ErrBindPolicy)
172+ }
173+ r, err := policy.ResolveAppBind(app, svc.Name, bind)
174+ if err != nil {
175+ return nil, fmt.Errorf("deployment: app %q service %q mount %q: refused by administrative mount policy: %w", app, svc.Name, bind.Name, domain.ErrBindPolicy)
176+ }
177+ resolved = append(resolved, domain.ContainerBind(r))
178+ }
179+ return resolved, nil
180+}
181+
126182 // acquireAppContext takes the GC shared lease first and the per-app
127183 // coordinator second, following the documented lock order
128184 // (GC barrier → per-app coordinator → registry lock → bbolt
@@ -256,6 +312,7 @@ type pinnedService struct {
256312 digest string
257313 runtimeImage string
258314 appEnv map[string]string
315+ appNetworks []domain.AppSharedNetwork
259316 // sharedNetworks are the declared shared-network memberships this
260317 // service joins. The private incarnation network is derived from the
261318 // app UUID at create time so recovery and deploy agree.
@@ -303,7 +360,7 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
303360 return nil, nil, false, err
304361 }
305362 if input.Service != "" {
306- if err := s.checkConverged(ctx, input.App, rev); err != nil {
363+ if err := s.checkConverged(ctx, input.App, input.Service, rev); err != nil {
307364 return nil, nil, false, err
308365 }
309366 }
@@ -431,8 +488,10 @@ func (s *Service) requireKnownApp(ctx context.Context, app string) error {
431488 return nil
432489 }
433490
434-// checkConverged refuses service-targeted deploy on desired/effective divergence.
435-func (s *Service) checkConverged(ctx context.Context, app string, rev domain.AppDesiredRevision) error {
491+// checkConverged permits a targeted deploy when every desired/effective
492+// difference belongs to that service. App-wide and other-service changes
493+// must be deployed together so ACTIVE never combines incompatible specs.
494+func (s *Service) checkConverged(ctx context.Context, app, service string, rev domain.AppDesiredRevision) error {
436495 active, ok, err := s.deps.State.LoadActive(ctx, app)
437496 if err != nil {
438497 return err
@@ -440,29 +499,55 @@ func (s *Service) checkConverged(ctx context.Context, app string, rev domain.App
440499 if !ok {
441500 return fmt.Errorf("deployment: app %q was never deployed, targeted deploy refused: %w", app, domain.ErrAppStateConflict)
442501 }
443- if !active.Converged || active.ConvergedRevision != rev.Revision {
444- return fmt.Errorf(
445- "%w: service-targeted deploy refused, desired %s diverges from effective state",
446- domain.ErrAppStateConflict, rev.Revision,
447- )
502+ if active.Converged && active.ConvergedRevision == rev.Revision {
503+ return nil
448504 }
449- return nil
505+ diff := domain.DiffAppSpec(rev.Spec, effectiveAppSpec(active))
506+ prefix := "service/" + service + "/"
507+ if len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) > 0 {
508+ for _, path := range diff.Changed {
509+ if !strings.HasPrefix(path, prefix) {
510+ return targetedDivergenceError(rev.Revision, path)
511+ }
512+ }
513+ return nil
514+ }
515+ return targetedDivergenceError(rev.Revision, "application structure")
450516 }
451517
452-// preflightServices runs the five preflight gates in order. No mutation.
453-func (s *Service) preflightServices(ctx context.Context, app string, rev domain.AppDesiredRevision, onlyService string) ([]pinnedService, error) {
518+func effectiveAppSpec(active domain.AppActive) domain.AppSpec {
519+ spec := domain.AppSpec{Name: active.App, Networks: append([]domain.AppSharedNetwork(nil), active.Networks...)}
520+ for name, service := range active.Services {
521+ effective := service.Spec
522+ effective.Name = name
523+ spec.Services = append(spec.Services, effective)
524+ }
525+ return spec
526+}
527+
528+func targetedDivergenceError(revision, path string) error {
529+ return fmt.Errorf("%w: service-targeted deploy refused, desired %s also changes %s", domain.ErrAppStateConflict, revision, path)
530+}
531+
532+// selectPreflightServices copies and optionally narrows a revision's services.
533+func selectPreflightServices(rev domain.AppDesiredRevision, onlyService string) ([]domain.AppService, error) {
454534 services := append([]domain.AppService(nil), rev.Spec.Services...)
455- if onlyService != "" {
456- filtered := services[:0]
457- for _, svc := range services {
458- if svc.Name == onlyService {
459- filtered = append(filtered, svc)
460- }
461- }
462- if len(filtered) == 0 {
463- return nil, fmt.Errorf("deployment: service %q not in revision %s: %w", onlyService, rev.Revision, domain.ErrAppStateConflict)
535+ if onlyService == "" {
536+ return services, nil
537+ }
538+ for _, svc := range services {
539+ if svc.Name == onlyService {
540+ return []domain.AppService{svc}, nil
464541 }
465- services = filtered
542+ }
543+ return nil, fmt.Errorf("deployment: service %q not in revision %s: %w", onlyService, rev.Revision, domain.ErrAppStateConflict)
544+}
545+
546+// preflightServices runs the five preflight gates in order. No mutation.
547+func (s *Service) preflightServices(ctx context.Context, app string, rev domain.AppDesiredRevision, onlyService string) ([]pinnedService, error) {
548+ services, err := selectPreflightServices(rev, onlyService)
549+ if err != nil {
550+ return nil, err
466551 }
467552 sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
468553
@@ -495,8 +580,16 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
495580 if err := s.checkImageVolumes(ctx, svc, runtimeImage); err != nil {
496581 return nil, err
497582 }
583+ // Bind resolution stays a preflight gate: an unresolvable bind
584+ // must fail before any workload mutation. The successful values
585+ // are discarded because createAndStart re-resolves against the
586+ // current policy immediately before the runtime mutation.
587+ if _, err := s.resolveServiceBinds(app, svc); err != nil {
588+ return nil, err
589+ }
498590 pinned = append(pinned, pinnedService{
499591 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,
592+ appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
500593 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, svc.Name),
501594 })
502595 }
@@ -580,6 +673,11 @@ func (s *Service) checkImageVolumes(ctx context.Context, svc domain.AppService,
580673 for _, vol := range svc.Volumes {
581674 mapped[vol.Path] = struct{}{}
582675 }
676+ // Bind destinations also map image-declared volumes: a bind that
677+ // mounts over an image VOLUME is an explicit operator mapping.
678+ for _, bind := range svc.Binds {
679+ mapped[bind.Path] = struct{}{}
680+ }
583681 for _, path := range declared {
584682 if _, ok := mapped[path]; !ok {
585683 return fmt.Errorf(
M internal/usecase/deployment/service_test.go +60 -0
@@ -234,6 +234,66 @@ func TestPreflight_TargetedRefusesDivergence(t *testing.T) {
234234 require.ErrorIs(t, err, domain.ErrAppStateConflict)
235235 }
236236
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+
237297 func TestPreflight_TargetedConvergedPasses(t *testing.T) {
238298 ctx := context.Background()
239299 state := outmocks.NewMockAppState(t)
M main.go +3 -2
@@ -12,12 +12,13 @@ var (
1212 version = "dev"
1313 commit = "unknown"
1414 date = "unknown"
15+ dirty = "unknown"
1516 )
1617
1718 func main() {
1819 // Set version information globally and for the CLI
19- versionpkg.Set(version, commit, date)
20- cli.SetVersionInfo(version, commit, date)
20+ versionpkg.Set(version, commit, date, dirty)
21+ cli.SetVersionInfo(version, commit, date, dirty)
2122
2223 if err := cli.NewRootCmd().Execute(); err != nil {
2324 os.Exit(1)
M pkg/version/version.go +8 -2
@@ -1,5 +1,6 @@
11 // Package version holds build-time version info for Gordon.
2-// Set via main using Set(), read from anywhere via Version(), Commit(), or BuildDate().
2+// Set via main using Set(), read from anywhere via Version(), Commit(),
3+// BuildDate(), or Dirty().
34 package version
45
56 // Build information, populated by Set() at startup.
@@ -7,13 +8,15 @@ var (
78 version = "dev"
89 commit = "unknown"
910 buildDate = "unknown"
11+ dirty = "unknown"
1012 )
1113
1214 // Set stores build-time version info. Call once from main.
13-func Set(v, c, d string) {
15+func Set(v, c, d, isDirty string) {
1416 version = v
1517 commit = c
1618 buildDate = d
19+ dirty = isDirty
1720 }
1821
1922 // Version returns the build version string.
@@ -24,3 +27,6 @@ func Commit() string { return commit }
2427
2528 // BuildDate returns the build date string.
2629 func BuildDate() string { return buildDate }
30+
31+// Dirty reports whether the source checkout had uncommitted changes.
32+func Dirty() string { return dirty }
A pkg/version/version_test.go +23 -0
@@ -0,0 +1,23 @@
1+package version
2+
3+import "testing"
4+
5+func TestSetStoresAllBuildInfo(t *testing.T) {
6+ previousVersion, previousCommit, previousDate, previousDirty := Version(), Commit(), BuildDate(), Dirty()
7+ t.Cleanup(func() { Set(previousVersion, previousCommit, previousDate, previousDirty) })
8+
9+ Set("v1.2.3", "abc1234", "2026-09-13", "true")
10+
11+ if got := Version(); got != "v1.2.3" {
12+ t.Errorf("Version() = %q, want %q", got, "v1.2.3")
13+ }
14+ if got := Commit(); got != "abc1234" {
15+ t.Errorf("Commit() = %q, want %q", got, "abc1234")
16+ }
17+ if got := BuildDate(); got != "2026-09-13" {
18+ t.Errorf("BuildDate() = %q, want %q", got, "2026-09-13")
19+ }
20+ if got := Dirty(); got != "true" {
21+ t.Errorf("Dirty() = %q, want %q", got, "true")
22+ }
23+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close