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:
23 env:23 env:
24 - CGO_ENABLED=024 - CGO_ENABLED=0
25 ldflags:25 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}}
27 27
28 # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup)28 # Docker builds disabled for v2.0.0 release (will add back with proper buildx setup)
29 # dockers:29 # dockers:
M Dockerfile +7 -1
@@ -5,6 +5,11 @@ ARG BUILDPLATFORM
5 ARG VERSION=dev5 ARG VERSION=dev
6 ARG COMMIT=unknown6 ARG COMMIT=unknown
7 ARG BUILD_DATE=unknown7 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
8 13
9 FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder14 FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder
10 15
@@ -13,6 +18,7 @@ ARG TARGETARCH=amd64
13 ARG VERSION18 ARG VERSION
14 ARG COMMIT19 ARG COMMIT
15 ARG BUILD_DATE20 ARG BUILD_DATE
21+ARG DIRTY
16 22
17 RUN apk add --no-cache git ca-certificates tzdata23 RUN apk add --no-cache git ca-certificates tzdata
18 24
@@ -25,7 +31,7 @@ COPY . .
25 31
26 RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \32 RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \
27 -trimpath \33 -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}" \
29 -o /gordon ./main.go35 -o /gordon ./main.go
30 36
31 FROM alpine:3.2237 FROM alpine:3.22
M Makefile +8 -2
@@ -10,12 +10,17 @@ ENGINE := podman
10 VERSION := $(shell git describe --tags --always --dirty)10 VERSION := $(shell git describe --tags --always --dirty)
11 COMMIT := $(shell git rev-parse --short HEAD)11 COMMIT := $(shell git rev-parse --short HEAD)
12 BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p')12 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)
13 17
14 # Build flags18 # Build flags
15 LDFLAGS := -s -w \19 LDFLAGS := -s -w \
16 -X main.version=$(VERSION) \20 -X main.version=$(VERSION) \
17 -X main.commit=$(COMMIT) \21 -X main.commit=$(COMMIT) \
18- -X main.date=$(BUILD_DATE)22+ -X main.date=$(BUILD_DATE) \
23+ -X main.dirty=$(DIRTY)
19 24
20 # Architectures25 # Architectures
21 ARCHS := amd64 arm6426 ARCHS := amd64 arm64
@@ -83,7 +88,7 @@ build: ## Build binaries for linux (amd64 and arm64)
83 @echo "Building Go binaries..."88 @echo "Building Go binaries..."
84 @mkdir -p $(DIST_DIR)89 @mkdir -p $(DIST_DIR)
85 @rm -f $(DIST_DIR)/*90 @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)"
87 @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go92 @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go
88 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go93 @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go
89 @echo "Go binaries built successfully"94 @echo "Go binaries built successfully"
@@ -105,6 +110,7 @@ build-push: ## Build and push Docker images
105 --build-arg VERSION="$(VERSION)" \110 --build-arg VERSION="$(VERSION)" \
106 --build-arg COMMIT="$(COMMIT)" \111 --build-arg COMMIT="$(COMMIT)" \
107 --build-arg BUILD_DATE="$(BUILD_DATE)" \112 --build-arg BUILD_DATE="$(BUILD_DATE)" \
113+ --build-arg DIRTY="$(DIRTY)" \
108 -t $(REPO):$(TAG)-$$arch .; \114 -t $(REPO):$(TAG)-$$arch .; \
109 $(ENGINE) push $(REPO):$(TAG)-$$arch; \115 $(ENGINE) push $(REPO):$(TAG)-$$arch; \
110 done116 done
M docs/cli/apps.md +94 -5
@@ -29,6 +29,7 @@ See [Local-only Mode](../config/auth.md#local-only-mode).
29 | Subcommand | Description |29 | Subcommand | Description |
30 |------------|-------------|30 |------------|-------------|
31 | `apply` | Validate and persist an app manifest |31 | `apply` | Validate and persist an app manifest |
32+| `operations` | Inspect app operation journals |
32 | `list` | List applications |33 | `list` | List applications |
33 | `show` | Show desired and active state for an app |34 | `show` | Show desired and active state for an app |
34 | `diff` | Show the normalized desired-vs-active diff |35 | `diff` | Show the normalized desired-vs-active diff |
@@ -81,6 +82,26 @@ stopped intent, and the last operation with its outcome.
81 82
82 ---83 ---
83 84
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+
84 ## gordon apps diff105 ## gordon apps diff
85 106
86 ```bash107 ```bash
@@ -93,16 +114,84 @@ Shows the normalized desired-vs-active diff (`added`, `removed`, `changed`).
93 114
94 ## gordon apps secrets115 ## gordon apps secrets
95 116
96-Values are accepted via `KEY=VALUE` arguments (discouraged: shell history),117+Values are accepted via `KEY=VALUE` arguments (discouraged: shell history) or
97-`--stdin` (preferred), or an interactive prompt. Names must already exist in118+stdin. Names must already exist in desired or active state. Only key names are
98-desired or active state. Only key names are ever echoed back — never values.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
99 182
100 ```bash183 ```bash
101-gordon apps secrets set APP --service SVC KEY=VALUE… [--stdin] [--json]
102 gordon apps secrets delete APP KEY --service SVC [--json]184 gordon apps secrets delete APP KEY --service SVC [--json]
103 ```185 ```
104 186
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 |
106 195
107 ---196 ---
108 197
M docs/cli/backup.md +4 -0
@@ -32,6 +32,10 @@ volume = ["data"]
32 A declared database or volume that the service's backup declaration does not32 A declared database or volume that the service's backup declaration does not
33 reference is not a backup target.33 reference is not a backup target.
34 34
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+
35 ## gordon backups39 ## gordon backups
36 40
37 Database backups are logical PostgreSQL backups made with `pg_dump`.41 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).
12 | Subcommand | Description |12 | Subcommand | Description |
13 |------------|-------------|13 |------------|-------------|
14 | `show` | Show server configuration |14 | `show` | Show server configuration |
15+| `validate` | Statically validate a local configuration file |
15 16
16 ---17 ---
17 18
@@ -59,6 +60,51 @@ gordon config show --remote https://gordon.mydomain.com --token $TOKEN
59 60
60 External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.61 External route targets and `server.data_dir` are intentionally omitted from the default admin config response because they reveal internal network and filesystem layout.
61 62
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+
62 ## Related108 ## Related
63 109
64 - [CLI Overview](./index.md)110 - [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
24 24
25 | Command | Description | Documentation |25 | Command | Description | Documentation |
26 |---------|-------------|---------------|26 |---------|-------------|---------------|
27-| `gordon apps` | Manage applications (apply, deploy, lifecycle) | [apps](./apps.md) |27+| `gordon apps` | Manage applications, operations, and app secrets | [apps](./apps.md) |
28 | `gordon backups` | Manage declared app database and volume backups | [backup](./backup.md) |28 | `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) |
30 | `gordon images` | List and prune images | [images](./images.md) |30 | `gordon images` | List and prune images | [images](./images.md) |
31 | `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) |31 | `gordon logs` | Display Gordon process logs | [serve](./serve.md#gordon-logs) |
32 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |32 | `gordon networks list` | List Gordon-managed Docker networks | [networks](./networks.md) |
@@ -67,6 +67,9 @@ gordon apps restart blog
67 gordon apps stop blog67 gordon apps stop blog
68 gordon apps start blog68 gordon apps start blog
69 gordon apps remove blog69 gordon apps remove blog
70+gordon apps operations show blog --key <operation-key>
71+gordon apps secrets list blog
72+gordon config validate --file /path/to/gordon.toml
70 73
71 # Push an image (OCI transfer only; deploy separately)74 # Push an image (OCI transfer only; deploy separately)
72 gordon push myapp --build --remote prod75 gordon push myapp --build --remote prod
M docs/config/apps.md +43 -3
@@ -30,7 +30,7 @@ APP_ENV = "production"
30 name = "web"30 name = "web"
31 image = "gordon.mydomain.com/blog:1.4.2"31 image = "gordon.mydomain.com/blog:1.4.2"
32 command = ["node", "server.js"] # optional override32 command = ["node", "server.js"] # optional override
33-stop_grace = "10s" # optional, default 10s33+stop_grace = "30s" # optional, default 30s
34 34
35 [service.readiness] # optional explicit readiness35 [service.readiness] # optional explicit readiness
36 type = "http"36 type = "http"
@@ -43,6 +43,10 @@ host = "blog.mydomain.com"
43 port = 300043 port = 3000
44 tls = "auto" # auto | always | never44 tls = "auto" # auto | always | never
45 45
46+[[service.http]] # optional private interface
47+visibility = "internal" # public (default) | internal
48+port = 8080 # required; no host or tls
49+
46 [service.secrets] # ENV name -> secret name (values in pass)50 [service.secrets] # ENV name -> secret name (values in pass)
47 DATABASE_URL = "database-url"51 DATABASE_URL = "database-url"
48 52
@@ -51,6 +55,11 @@ name = "web-data"
51 path = "/data"55 path = "/data"
52 readonly = false56 readonly = false
53 57
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+
54 [[service.database]] # explicit database declarations63 [[service.database]] # explicit database declarations
55 name = "main"64 name = "main"
56 type = "postgres"65 type = "postgres"
@@ -69,6 +78,7 @@ services = ["web"]
69 78
70 - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness.79 - App name: DNS label (lowercase alphanumerics and hyphens, max 63), must not contain `--`, reserved: `gordon`, `registry`, `admin`, `localhost`. Case-insensitive uniqueness.
71 - Service name: `[a-z0-9_.-]`, max 63, unique within the app.80 - 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.
72 - 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.82 - 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.
73 83
74 ## Services84 ## Services
@@ -101,17 +111,45 @@ Image registry names and digest syntax are validated during manifest apply, reso
101 111
102 ## Volumes and Databases112 ## Volumes and Databases
103 113
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.
105 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.119 - A volume declared `readonly = true` is mounted read-only in the container; the service cannot modify protected data.
106 - `[[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`).120 - `[[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`).
107 - `[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.121 - `[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.
108 122
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+
109 ## Readiness145 ## Readiness
110 146
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.
112 148
113 ## TLS149 ## TLS
114 150
151+TLS applies to public interfaces only; an internal interface never declares `tls`.
152+
115 - `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled.153 - `auto` keeps the host eligible for HTTP and HTTPS; plaintext is redirected when an HTTPS endpoint exists and redirects are enabled.
116 - `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.154 - `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.
117 - `never` stays plaintext and is never issued an app certificate.155 - `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
120 158
121 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.159 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.
122 160
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+
123 ## Strictness163 ## Strictness
124 164
125 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.165 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 @@
3 Gordon has two backup flows:3 Gordon has two backup flows:
4 4
5 - **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.5 - **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.
7 7
8 Backups are identified by app, service, and the declared database or volume8 Backups are identified by app, service, and the declared database or volume
9 (see [Apps](../config/apps.md)). Domains are routing addresses and are never a9 (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
21 21
22 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.22 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.
23 23
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+
24 ## Inspecting Networks32 ## Inspecting Networks
25 33
26 View Gordon-managed networks:34 View Gordon-managed networks:
M docs/config/reference.md +33 -0
@@ -150,6 +150,21 @@ auto_create = true # Auto-create volumes from Dockerfi
150 prefix = "gordon" # Volume name prefix150 prefix = "gordon" # Volume name prefix
151 preserve = true # Keep volumes when containers are removed151 preserve = true # Keep volumes when containers are removed
152 152
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+
153 # REMOVED in v2.50: [routes], [attachments], [network_groups],168 # REMOVED in v2.50: [routes], [attachments], [network_groups],
154 # [[services]]-as-apps, [service_routes], [auto_route], [previews].169 # [[services]]-as-apps, [service_routes], [auto_route], [previews].
155 # Declare apps in standalone files (see ./apps.md).170 # Declare apps in standalone files (see ./apps.md).
@@ -286,6 +301,11 @@ keep_last = 3 # Keep N newest tags per repository
286 | `volumes.auto_create` | `true` | Auto-create volumes |301 | `volumes.auto_create` | `true` | Auto-create volumes |
287 | `volumes.prefix` | `"gordon"` | Volume prefix |302 | `volumes.prefix` | `"gordon"` | Volume prefix |
288 | `volumes.preserve` | `true` | Keep volumes |303 | `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 |
289 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |309 | `services[].name` | none | Standalone service name used by `service:<service>:<port-name>` traffic refs |
290 | `services[].image` | none | Container image for enabled standalone services |310 | `services[].image` | none | Container image for enabled standalone services |
291 | `services[].enabled` | `false` | Whether Gordon creates, starts, and reconciles the service container |311 | `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
322 342
323 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.343 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
324 344
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+
325 ## Environment Variables358 ## Environment Variables
326 359
327 All configuration options can be set via environment variables using the pattern:360 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:
40 - `admin:volumes:read` for listing volumes.40 - `admin:volumes:read` for listing volumes.
41 - `admin:volumes:write` for prune operations.41 - `admin:volumes:write` for prune operations.
42 42
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+
43 ## Pass import plaintext handling66 ## Pass import plaintext handling
44 67
45 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.68 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
102 125
103 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.126 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.
104 127
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+
105 ## Registry exposure140 ## Registry exposure
106 141
107 - With `auth.enabled = true`, registry requests are authenticated and repository-scoped; the public proxy forwards registry domains to the internal registry.142 - 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"
12 path = "/var/lib/postgresql/data"12 path = "/var/lib/postgresql/data"
13 ```13 ```
14 14
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.
16 16
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.
18 67
19 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.68 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.
20 69
@@ -25,10 +74,31 @@ Ordinary app operations retain data:
25 - deploy and restart reuse the service's volumes;74 - deploy and restart reuse the service's volumes;
26 - removing a service retains its volumes;75 - removing a service retains its volumes;
27 - `gordon apps remove` retains volumes under the removed app's internal UUID;76 - `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.
29 79
30 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.80 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.
31 81
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+
32 ## Pruning102 ## Pruning
33 103
34 Use Gordon's ownership-aware command:104 Use Gordon's ownership-aware command:
@@ -44,7 +114,7 @@ A volume is eligible only when all of these requirements hold:
44 2. Runtime labels agree with the record's app, app incarnation UUID, and service.114 2. Runtime labels agree with the record's app, app incarnation UUID, and service.
45 3. No container mounts the volume.115 3. No container mounts the volume.
46 116
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.
48 118
49 > **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.119 > **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.
50 120
M gordon.toml.example +14 -0
@@ -117,6 +117,20 @@ max_age = 28
117 exclude_health_checks = true117 exclude_health_checks = true
118 syslog_identifier = "gordon-access"118 syslog_identifier = "gordon-access"
119 119
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+
120 [telemetry]134 [telemetry]
121 enabled = false135 enabled = false
122 # endpoint = "http://localhost:4318"136 # endpoint = "http://localhost:4318"
M install.sh +3 -1
@@ -311,8 +311,10 @@ install_next() {
311 NEXT_VERSION="next-${COMMIT}"311 NEXT_VERSION="next-${COMMIT}"
312 BINARY="$TMP_DIR/gordon"312 BINARY="$TMP_DIR/gordon"
313 echo "Building ${NEXT_VERSION} for ${OS}/${ARCH}..."313 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.
314 if ! (cd "$SOURCE_DIR" && CGO_ENABLED=0 GOOS="$OS" GOARCH="$ARCH" GOTOOLCHAIN=local \316 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" \
316 -o "$BINARY" .); then318 -o "$BINARY" .); then
317 echo "Error: Failed to build Gordon from source"319 echo "Error: Failed to build Gordon from source"
318 exit 1320 exit 1
M internal/adapters/dto/admin_apps.go +9 -0
@@ -94,6 +94,15 @@ type AppRetainedDTO struct {
94 Images []string `json:"images"`94 Images []string `json:"images"`
95 }95 }
96 96
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+
97 // AppDeployResponse reports deploy outcome with terminal results.106 // AppDeployResponse reports deploy outcome with terminal results.
98 // Effective and Retained are filled from current app state when the app107 // Effective and Retained are filled from current app state when the app
99 // still exists; they are omitted when it does not.108 // still exists; they are omitted when it does not.
M internal/adapters/in/appmanifest/parse.go +74 -16
@@ -7,6 +7,7 @@ package appmanifest
7 7
8 import (8 import (
9 "bytes"9 "bytes"
10+ "errors"
10 "fmt"11 "fmt"
11 "strings"12 "strings"
12 "time"13 "time"
@@ -18,10 +19,15 @@ import (
18 19
19 // rawManifest mirrors the frozen TOML schema for strict decoding.20 // rawManifest mirrors the frozen TOML schema for strict decoding.
20 type rawManifest struct {21 type rawManifest struct {
21- Name string `toml:"name"`22+ Name string `toml:"name"`
22- Env map[string]string `toml:"env"`23+ Env map[string]string `toml:"env"`
23- Services []rawService `toml:"service"`24+ Services []rawService `toml:"service"`
24- Networks []rawSharedNetwork `toml:"network.shared"`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"`
25 }31 }
26 32
27 // rawService mirrors one [[service]] table.33 // rawService mirrors one [[service]] table.
@@ -37,6 +43,7 @@ type rawService struct {
37 UDP []rawUDP `toml:"udp"`43 UDP []rawUDP `toml:"udp"`
38 Secrets map[string]string `toml:"secrets"`44 Secrets map[string]string `toml:"secrets"`
39 Volumes []rawVolume `toml:"volume"`45 Volumes []rawVolume `toml:"volume"`
46+ Binds []rawBind `toml:"bind"`
40 Databases []rawDatabase `toml:"database"`47 Databases []rawDatabase `toml:"database"`
41 Backup rawBackup `toml:"backup"`48 Backup rawBackup `toml:"backup"`
42 }49 }
@@ -54,7 +61,11 @@ type rawReadiness struct {
54 type rawHTTP struct {61 type rawHTTP struct {
55 Host string `toml:"host"`62 Host string `toml:"host"`
56 Port int `toml:"port"`63 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"`
58 }69 }
59 70
60 // rawTCP mirrors [[service.tcp]].71 // rawTCP mirrors [[service.tcp]].
@@ -78,6 +89,13 @@ type rawVolume struct {
78 ReadOnly bool `toml:"readonly"`89 ReadOnly bool `toml:"readonly"`
79 }90 }
80 91
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+
81 // rawDatabase mirrors [[service.database]].99 // rawDatabase mirrors [[service.database]].
82 type rawDatabase struct {100 type rawDatabase struct {
83 Name string `toml:"name"`101 Name string `toml:"name"`
@@ -104,7 +122,7 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
104 var raw rawManifest122 var raw rawManifest
105 decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()123 decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
106 if err := decoder.Decode(&raw); err != nil {124 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))
108 }126 }
109 spec, err := toDomain(raw)127 spec, err := toDomain(raw)
110 if err != nil {128 if err != nil {
@@ -120,13 +138,31 @@ func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
120 return spec, warnings, nil138 return spec, warnings, nil
121 }139 }
122 140
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+
123 // toDomain maps raw TOML onto domain types with normalization and defaults.159 // toDomain maps raw TOML onto domain types with normalization and defaults.
124 func toDomain(raw rawManifest) (domain.AppSpec, error) {160 func toDomain(raw rawManifest) (domain.AppSpec, error) {
125 spec := domain.AppSpec{161 spec := domain.AppSpec{
126 Name: raw.Name,162 Name: raw.Name,
127 Env: map[string]string{},163 Env: map[string]string{},
128 Services: make([]domain.AppService, 0, len(raw.Services)),164 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)),
130 }166 }
131 for key, value := range raw.Env {167 for key, value := range raw.Env {
132 spec.Env[key] = value168 spec.Env[key] = value
@@ -138,7 +174,7 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
138 }174 }
139 spec.Services = append(spec.Services, svc)175 spec.Services = append(spec.Services, svc)
140 }176 }
141- for _, net := range raw.Networks {177+ for _, net := range raw.Network.Shared {
142 spec.Networks = append(spec.Networks, domain.AppSharedNetwork{178 spec.Networks = append(spec.Networks, domain.AppSharedNetwork{
143 Network: net.Network,179 Network: net.Network,
144 Services: append([]string(nil), net.Services...),180 Services: append([]string(nil), net.Services...),
@@ -148,6 +184,25 @@ func toDomain(raw rawManifest) (domain.AppSpec, error) {
148 return spec, nil184 return spec, nil
149 }185 }
150 186
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+
151 // toDomainService maps one raw service with defaults.206 // toDomainService maps one raw service with defaults.
152 func toDomainService(raw rawService) (domain.AppService, error) {207 func toDomainService(raw rawService) (domain.AppService, error) {
153 svc := domain.AppService{208 svc := domain.AppService{
@@ -175,15 +230,11 @@ func toDomainService(raw rawService) (domain.AppService, error) {
175 }230 }
176 svc.Readiness = readiness231 svc.Readiness = readiness
177 for _, h := range raw.HTTP {232 for _, h := range raw.HTTP {
178- tls := h.TLS233+ iface, err := toDomainHTTP(raw.Name, h)
179- if tls == "" {234+ if err != nil {
180- tls = domain.AppTLSAuto235+ return domain.AppService{}, err
181 }236 }
182- svc.HTTP = append(svc.HTTP, domain.AppHTTPInterface{237+ svc.HTTP = append(svc.HTTP, iface)
183- Host: domain.CanonicalHTTPHost(h.Host),
184- Port: h.Port,
185- TLS: tls,
186- })
187 }238 }
188 for _, t := range raw.TCP {239 for _, t := range raw.TCP {
189 svc.TCP = append(svc.TCP, domain.AppTCPInterface{240 svc.TCP = append(svc.TCP, domain.AppTCPInterface{
@@ -209,6 +260,13 @@ func toDomainService(raw rawService) (domain.AppService, error) {
209 ReadOnly: v.ReadOnly,260 ReadOnly: v.ReadOnly,
210 })261 })
211 }262 }
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+ }
212 for _, db := range raw.Databases {270 for _, db := range raw.Databases {
213 svc.Databases = append(svc.Databases, domain.AppDatabase{271 svc.Databases = append(svc.Databases, domain.AppDatabase{
214 Name: db.Name,272 Name: db.Name,
M internal/adapters/in/appmanifest/parse_test.go +60 -2
@@ -8,6 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 9
10 "github.com/bnema/gordon/internal/adapters/in/appmanifest"10 "github.com/bnema/gordon/internal/adapters/in/appmanifest"
11+ "github.com/bnema/gordon/internal/domain"
11 )12 )
12 13
13 const validWeb = `14 const validWeb = `
@@ -46,6 +47,27 @@ func TestParse_ValidWeb(t *testing.T) {
46 assert.Equal(t, "http", svc.Readiness.Type)47 assert.Equal(t, "http", svc.Readiness.Type)
47 }48 }
48 49
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+
49 func TestParse_FileNameMismatchIsWarning(t *testing.T) {71 func TestParse_FileNameMismatchIsWarning(t *testing.T) {
50 _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml")72 _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml")
51 require.NoError(t, err)73 require.NoError(t, err)
@@ -112,7 +134,7 @@ func TestParse_Names(t *testing.T) {
112 {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"},134 {"dup service", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service]]\nname = \"web\"\nimage = \"img:2\"\n", "duplicate service"},
113 {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"},135 {"dup service case", "name = \"blog\"\n[[service]]\nname = \"Web\"\nimage = \"img:1\"\n", "must match"},
114 {"normalized collision", "name = \"blog\"\n[[service]]\nname = \"a.b\"\nimage = \"img:1\"\n[[service]]\nname = \"a-b\"\nimage = \"img:2\"\n", "same runtime identifier"},136 {"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"},
116 {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},138 {"volume double dash", "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n[[service.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},
117 {"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"},139 {"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"},
118 {"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"},140 {"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) {
156 }{178 }{
157 {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},179 {"tcp needs entrypoint", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},
158 {"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"},180 {"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"},
160 {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},182 {"bad host", "name = \"b\"\n[[service]]\nname = \"w\"\nimage = \"i:1\"\n[[service.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},
161 }183 }
162 for _, tc := range cases {184 for _, tc := range cases {
@@ -252,3 +274,39 @@ func TestParse_NoFileEnvSecretReads(t *testing.T) {
252 require.NoError(t, err)274 require.NoError(t, err)
253 assert.Equal(t, "blog", spec.Name)275 assert.Equal(t, "blog", spec.Name)
254 }276 }
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 (
19 19
20 "github.com/bnema/gordon/internal/adapters/dto"20 "github.com/bnema/gordon/internal/adapters/dto"
21 "github.com/bnema/gordon/internal/adapters/in/cli/remote"21 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
22+ "github.com/bnema/gordon/internal/domain"
22 )23 )
23 24
24 // appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2).25 // appApplyMaxBytes bounds the apply manifest body (05-api-cli.md §2).
@@ -70,8 +71,8 @@ func appMutationError(op, app, key string, err error) error {
70 var unknown *remote.OutcomeUnknownError71 var unknown *remote.OutcomeUnknownError
71 if errors.As(err, &unknown) {72 if errors.As(err, &unknown) {
72 return fmt.Errorf(73 return fmt.Errorf(
73- "outcome-unknown: %s %s may have executed; query "+74+ "outcome-unknown: %s %s may have executed; run the by-key lookup "+
74- "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w",75+ "gordon apps operations show %s --key %s before retrying with the same key: %w",
75 op, app, app, key, err)76 op, app, app, key, err)
76 }77 }
77 return err78 return err
@@ -95,8 +96,8 @@ func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut
95 return rerr, true96 return rerr, true
96 }97 }
97 return fmt.Errorf(98 return fmt.Errorf(
98- "%s of %s did not succeed (outcome %s, op %s); journal rendered above; query "+99+ "%s of %s did not succeed (outcome %s, op %s); journal rendered above; run the by-key lookup "+
99- "GET /admin/apps/%s/operations/by-key/%s before retrying (same key): %w",100+ "gordon apps operations show %s --key %s before retrying with the same key: %w",
100 op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true101 op, app, conflict.Response.Outcome, conflict.Response.Op, app, key, err), true
101 }102 }
102 103
@@ -118,6 +119,7 @@ Examples:
118 }119 }
119 cmd.AddCommand(120 cmd.AddCommand(
120 newAppsApplyCmd(),121 newAppsApplyCmd(),
122+ newAppsOperationsCmd(),
121 newAppsListCmd(),123 newAppsListCmd(),
122 newAppsShowCmd(),124 newAppsShowCmd(),
123 newAppsDiffCmd(),125 newAppsDiffCmd(),
@@ -470,16 +472,55 @@ func newAppsSecretsCmd() *cobra.Command {
470 Use: "secrets",472 Use: "secrets",
471 Short: "Manage app secret values",473 Short: "Manage app secret values",
472 Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),474 Long: `Values are accepted via KEY=VALUE arguments (discouraged: shell history),
473---stdin (preferred), or an interactive prompt. Names must already exist in475+--stdin with KEY=VALUE lines, or --stdin --key KEY for one raw value. Names
474-desired or active state. Only key names are ever echoed back — never values.`,476+must already exist in desired or active state. Only key names are ever echoed
477+back — never values.`,
475 }478 }
476- cmd.AddCommand(newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())479+ cmd.AddCommand(newAppsSecretsListCmd(), newAppsSecretsSetCmd(), newAppsSecretsDeleteCmd())
477 return cmd480 return cmd
478 }481 }
479 482
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+
480 // newAppsSecretsSetCmd creates `apps secrets set`.520 // newAppsSecretsSetCmd creates `apps secrets set`.
481 func newAppsSecretsSetCmd() *cobra.Command {521 func newAppsSecretsSetCmd() *cobra.Command {
482 var service string522 var service string
523+ var key string
483 var fromStdin bool524 var fromStdin bool
484 var jsonOut bool525 var jsonOut bool
485 cmd := &cobra.Command{526 cmd := &cobra.Command{
@@ -496,23 +537,37 @@ func newAppsSecretsSetCmd() *cobra.Command {
496 if len(args) == 0 {537 if len(args) == 0 {
497 return fmt.Errorf("missing APP argument")538 return fmt.Errorf("missing APP argument")
498 }539 }
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)
500 },541 },
501 }542 }
502 cmd.Flags().StringVar(&service, "service", "", "Service the secrets belong to (required)")543 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")
504 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")546 cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
505 return cmd547 return cmd
506 }548 }
507 549
508 func runAppsSecretsSet(ctx context.Context, plane ControlPlane, stdin io.Reader, out io.Writer, app string, pairs []string, service string, fromStdin, jsonOut bool) error {550 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 {
509 if app == "" {555 if app == "" {
510 return fmt.Errorf("missing APP argument")556 return fmt.Errorf("missing APP argument")
511 }557 }
512 if service == "" {558 if service == "" {
513 return fmt.Errorf("missing required flag --service: secrets are service-scoped")559 return fmt.Errorf("missing required flag --service: secrets are service-scoped")
514 }560 }
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 {
516 stdinPairs, err := readSecretStdin(stdin)571 stdinPairs, err := readSecretStdin(stdin)
517 if err != nil {572 if err != nil {
518 return err573 return err
@@ -579,30 +634,68 @@ func runAppsSecretsDelete(ctx context.Context, plane ControlPlane, out io.Writer
579 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))634 return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Deleted secret %s for %s/%s", key, app, service)))
580 }635 }
581 636
582-// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty;637+// parseSecretPairs parses KEY=VALUE arguments. Keys must be non-empty and
583-// values may be empty (explicit empty secret).638+// each value must pass validateSecretValue (1-MaxAppEnvValueLen bytes on a
639+// single line); an empty value is rejected.
584 func parseSecretPairs(pairs []string) (map[string]string, error) {640 func parseSecretPairs(pairs []string) (map[string]string, error) {
585 values := make(map[string]string, len(pairs))641 values := make(map[string]string, len(pairs))
586- for _, pair := range pairs {642+ for index, pair := range pairs {
587 key, value, ok := strings.Cut(pair, "=")643 key, value, ok := strings.Cut(pair, "=")
588 if !ok || key == "" {644 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)
590 }649 }
591 values[key] = value650 values[key] = value
592 }651 }
593 return values, nil652 return values, nil
594 }653 }
595 654
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.
597 func readSecretStdin(in io.Reader) ([]string, error) {686 func readSecretStdin(in io.Reader) ([]string, error) {
598- data, err := io.ReadAll(in)687+ data, err := io.ReadAll(io.LimitReader(in, maxSecretStdinBytes+1))
599 if err != nil {688 if err != nil {
600 return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)689 return nil, fmt.Errorf("failed to read secrets from stdin: %w", err)
601 }690 }
691+ if len(data) > maxSecretStdinBytes {
692+ return nil, fmt.Errorf("secret input exceeds %d bytes", maxSecretStdinBytes)
693+ }
602 var pairs []string694 var pairs []string
603 for _, line := range strings.Split(string(data), "\n") {695 for _, line := range strings.Split(string(data), "\n") {
604- if trimmed := strings.TrimSpace(line); trimmed != "" {696+ line = strings.TrimSuffix(line, "\r")
605- pairs = append(pairs, trimmed)697+ if strings.TrimSpace(line) != "" {
698+ pairs = append(pairs, line)
606 }699 }
607 }700 }
608 return pairs, nil701 return pairs, nil
@@ -776,9 +869,42 @@ func renderAppDeployResponse(out io.Writer, resp *dto.AppDeployResponse) error {
776 if err := renderDeployServices(out, resp); err != nil {869 if err := renderDeployServices(out, resp); err != nil {
777 return err870 return err
778 }871 }
872+ if err := renderDeploySteps(out, resp.Steps); err != nil {
873+ return err
874+ }
779 return renderDeploySummaries(out, resp)875 return renderDeploySummaries(out, resp)
780 }876 }
781 877
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+
782 func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {908 func renderDeployHeadline(out io.Writer, resp *dto.AppDeployResponse) error {
783 headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)909 headline := fmt.Sprintf("%s %s: %s", resp.Op, resp.App, resp.Outcome)
784 if resp.Outcome == "success" {910 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) {
185 plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).185 plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).
186 Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()186 Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()
187 var out bytes.Buffer187 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")
189 require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,189 require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,
190 &out, "blog", []string{"from_flag=flag-value"}, "web", true, false))190 &out, "blog", []string{"from_flag=flag-value"}, "web", true, false))
191 assert.Equal(t, map[string]string{191 assert.Equal(t, map[string]string{
192 "from_stdin": "stdin-value",192 "from_stdin": "stdin-value",
193- "empty_val": "",193+ "spaced": " preserved ",
194 "from_flag": "flag-value",194 "from_flag": "flag-value",
195 }, gotReq.Secrets)195 }, gotReq.Secrets)
196 196
@@ -529,3 +529,21 @@ func TestRunAppLogs_TextJSONFollow(t *testing.T) {
529 require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))529 require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))
530 assert.Equal(t, "s1\n", out.String())530 assert.Equal(t, "s1\n", out.String())
531 }531 }
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 {
17 Short: "Inspect Gordon configuration",17 Short: "Inspect Gordon configuration",
18 }18 }
19 19
20- cmd.AddCommand(newConfigShowCmd())20+ cmd.AddCommand(newConfigShowCmd(), newConfigValidateCmd())
21 21
22 return cmd22 return cmd
23 }23 }
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 {
23 RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error)23 RestartApp(ctx context.Context, app, service string) (*dto.AppDeployResponse, string, error)
24 RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)24 RemoveApp(ctx context.Context, app string) (*dto.AppDeployResponse, string, error)
25 OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)25 OperationByKey(ctx context.Context, app, key string) (*dto.AppDeployResponse, error)
26+ ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error)
26 SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error27 SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error
27 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error28 DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error
28 29
M internal/adapters/in/cli/controlplane_remote.go +4 -0
@@ -60,6 +60,10 @@ func (r *remoteControlPlane) OperationByKey(ctx context.Context, app, key string
60 return r.client.OperationByKey(ctx, app, key)60 return r.client.OperationByKey(ctx, app, key)
61 }61 }
62 62
63+func (r *remoteControlPlane) ListAppSecrets(ctx context.Context, app, service string) ([]dto.AppSecretMetadataDTO, error) {
64+ return r.client.ListAppSecrets(ctx, app, service)
65+}
66+
63 func (r *remoteControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {67 func (r *remoteControlPlane) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
64 return r.client.SetAppSecrets(ctx, app, req)68 return r.client.SetAppSecrets(ctx, app, req)
65 }69 }
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
760 return _c760 return _c
761 }761 }
762 762
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+
763 // ListApps provides a mock function for the type MockControlPlane837 // ListApps provides a mock function for the type MockControlPlane
764 func (_mock *MockControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {838 func (_mock *MockControlPlane) ListApps(ctx context.Context) ([]dto.AppSummaryDTO, error) {
765 ret := _mock.Called(ctx)839 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
260 return &result, nil260 return &result, nil
261 }261 }
262 262
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+
263 // SetAppSecrets writes secret values for pre-registered names.280 // SetAppSecrets writes secret values for pre-registered names.
264 func (c *Client) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {281 func (c *Client) SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error {
265 resp, err := c.mutationPost(ctx, "/apps/"+url.PathEscape(app)+"/secrets/set", newIdempotencyKey(), req)282 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
171 require.ErrorAs(t, err, &httpErr)171 require.ErrorAs(t, err, &httpErr)
172 assert.Equal(t, http.StatusConflict, httpErr.StatusCode)172 assert.Equal(t, http.StatusConflict, httpErr.StatusCode)
173 }173 }
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 {
373 msg := string(body)373 msg := string(body)
374 structured := false374 structured := false
375 var errResp struct {375 var errResp struct {
376- Error string `json:"error"`376+ Error string `json:"error"`
377- Cause string `json:"cause"`377+ Message string `json:"message"`
378- Hint string `json:"hint"`378+ Cause string `json:"cause"`
379- Logs []string `json:"logs"`379+ Hint string `json:"hint"`
380+ Logs []string `json:"logs"`
380 }381 }
381 if err := json.Unmarshal(body, &errResp); err == nil {382 if err := json.Unmarshal(body, &errResp); err == nil {
382 structured = errResp.Cause != "" || errResp.Hint != "" || len(errResp.Logs) > 0383 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 != "" {
384 msg = errResp.Error387 msg = errResp.Error
385 }388 }
386 }389 }
M internal/adapters/in/cli/remote/client_test.go +10 -0
@@ -379,6 +379,16 @@ func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
379 assert.Equal(t, "insufficient scope", httpErr.Body)379 assert.Equal(t, "insufficient scope", httpErr.Body)
380 }380 }
381 381
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+
382 func TestParseErrorResponse_DeployFailureFields(t *testing.T) {392 func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
383 resp := &http.Response{393 resp := &http.Response{
384 StatusCode: 500,394 StatusCode: 500,
M internal/adapters/in/cli/root.go +6 -1
@@ -20,6 +20,7 @@ var (
20 Version = "dev"20 Version = "dev"
21 Commit = "unknown"21 Commit = "unknown"
22 BuildDate = "unknown"22 BuildDate = "unknown"
23+ Dirty = "unknown"
23 24
24 // Global flags for remote targeting25 // Global flags for remote targeting
25 remoteFlag string26 remoteFlag string
@@ -229,6 +230,9 @@ func newVersionCmd() *cobra.Command {
229 if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {230 if err := cliWriteLine(out, cliRenderMeta("Build Date:", BuildDate)); err != nil {
230 return err231 return err
231 }232 }
233+ if err := cliWriteLine(out, cliRenderMeta("Dirty:", Dirty)); err != nil {
234+ return err
235+ }
232 return nil236 return nil
233 },237 },
234 }238 }
@@ -312,8 +316,9 @@ func runProcessLogs(ctx context.Context, cp ControlPlane, follow bool, lines int
312 }316 }
313 317
314 // SetVersionInfo sets the version information for the CLI.318 // SetVersionInfo sets the version information for the CLI.
315-func SetVersionInfo(version, commit, date string) {319+func SetVersionInfo(version, commit, date, dirty string) {
316 Version = version320 Version = version
317 Commit = commit321 Commit = commit
318 BuildDate = date322 BuildDate = date
323+ Dirty = dirty
319 }324 }
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
67 return67 return
68 }68 }
69 if len(parts) == 2 {69 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+ }
71 return75 return
72 }76 }
73 if len(parts) == 3 && parts[1] == "secrets" {77 if len(parts) == 3 && parts[1] == "secrets" {
@@ -416,6 +420,28 @@ func (h *Handler) handleAppOpLookup(w http.ResponseWriter, r *http.Request, app,
416 h.sendJSON(w, http.StatusOK, toAppDeployResponse(app, op, includeDiagnostics))420 h.sendJSON(w, http.StatusOK, toAppDeployResponse(app, op, includeDiagnostics))
417 }421 }
418 422
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+
419 // handleAppSecretsSet writes secret values for pre-registered names.445 // handleAppSecretsSet writes secret values for pre-registered names.
420 func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, app string) {446 func (h *Handler) handleAppSecretsSet(w http.ResponseWriter, r *http.Request, app string) {
421 ctx := r.Context()447 ctx := r.Context()
@@ -478,6 +504,7 @@ func isMappedPreflightError(err error, op *domain.AppOperation) bool {
478 }504 }
479 }505 }
480 return errors.Is(err, domain.ErrInvalidAppSpec) ||506 return errors.Is(err, domain.ErrInvalidAppSpec) ||
507+ errors.Is(err, domain.ErrBindPolicy) ||
481 errors.Is(err, domain.ErrAppNotFound) ||508 errors.Is(err, domain.ErrAppNotFound) ||
482 errors.Is(err, domain.ErrAppReservationConflict) ||509 errors.Is(err, domain.ErrAppReservationConflict) ||
483 errors.Is(err, domain.ErrAppImageUnresolvable) ||510 errors.Is(err, domain.ErrAppImageUnresolvable) ||
@@ -494,6 +521,8 @@ func (h *Handler) sendAppOpError(w http.ResponseWriter, err error) {
494 switch {521 switch {
495 case errors.Is(err, domain.ErrInvalidAppSpec):522 case errors.Is(err, domain.ErrInvalidAppSpec):
496 h.sendAppError(w, http.StatusBadRequest, "invalid-manifest", err.Error(), "", "")523 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")
497 case errors.Is(err, domain.ErrAppReservationConflict):526 case errors.Is(err, domain.ErrAppReservationConflict):
498 h.sendAppError(w, http.StatusConflict, "reservation-conflict", err.Error(), "", "")527 h.sendAppError(w, http.StatusConflict, "reservation-conflict", err.Error(), "", "")
499 case errors.Is(err, domain.ErrAppImageUnresolvable):528 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) {
377 assert.Empty(t, resp.ResultingRevision)377 assert.Empty(t, resp.ResultingRevision)
378 assert.NotContains(t, rec.Body.String(), "resulting_revision")378 assert.NotContains(t, rec.Body.String(), "resulting_revision")
379 }379 }
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 {
116 "{app}/stop": http.MethodPost,116 "{app}/stop": http.MethodPost,
117 "{app}/start": http.MethodPost,117 "{app}/start": http.MethodPost,
118 "{app}/remove": http.MethodPost,118 "{app}/remove": http.MethodPost,
119+ "{app}/secrets": http.MethodGet,
119 "{app}/secrets/set": http.MethodPost,120 "{app}/secrets/set": http.MethodPost,
120 "{app}/secrets/delete": http.MethodPost,121 "{app}/secrets/delete": http.MethodPost,
121 "{app}/operations/by-key/{key}": http.MethodGet,122 "{app}/operations/by-key/{key}": http.MethodGet,
M internal/adapters/in/traffic/manager.go +8 -0
@@ -3,6 +3,7 @@ package traffic
3 3
4 import (4 import (
5 "context"5 "context"
6+ "errors"
6 "fmt"7 "fmt"
7 "maps"8 "maps"
8 "net"9 "net"
@@ -10,6 +11,7 @@ import (
10 "strings"11 "strings"
11 "sync"12 "sync"
12 "sync/atomic"13 "sync/atomic"
14+ "syscall"
13 "time"15 "time"
14 16
15 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
@@ -337,6 +339,9 @@ func conflictingTCPRuntime(current map[string]*entryPointRuntime, entryPoint dom
337 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {339 func (m *Manager) bindTCPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*entryPointRuntime, error) {
338 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)340 listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", entryPoint.Address)
339 if err != nil {341 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+ }
340 return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)345 return nil, fmt.Errorf("bind tcp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
341 }346 }
342 trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)347 trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)
@@ -405,6 +410,9 @@ func conflictingUDPRuntime(current map[string]*udpEntryPointRuntime, entryPoint
405 func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {410 func (m *Manager) bindUDPEntryPoint(ctx context.Context, entryPoint domain.EntryPoint) (*udpEntryPointRuntime, error) {
406 packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)411 packetConn, err := (&net.ListenConfig{}).ListenPacket(ctx, "udp", entryPoint.Address)
407 if err != nil {412 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+ }
408 return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)416 return nil, fmt.Errorf("bind udp entrypoint %q on %s: %w", entryPoint.Name, entryPoint.Address, err)
409 }417 }
410 trusted, err := parseTrustedCIDRs(entryPoint.TrustedCIDRs)418 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
49 created, err := runtime.CreateContainer(ctx, config)49 created, err := runtime.CreateContainer(ctx, config)
50 require.NoError(t, err)50 require.NoError(t, err)
51 t.Cleanup(func() {51 t.Cleanup(func() {
52- cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)52+ cleanup, cancel := context.WithTimeout(context.Background(), 60*time.Second)
53 defer cancel()53 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.
55 _ = runtime.RemoveContainer(cleanup, created.ID, true)57 _ = runtime.RemoveContainer(cleanup, created.ID, true)
56 })58 })
57 require.NoError(t, runtime.StartContainer(ctx, created.ID))59 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 (
13 "io"13 "io"
14 "maps"14 "maps"
15 "math"15 "math"
16+ "net"
17+ "net/http"
16 "net/netip"18 "net/netip"
19+ "net/url"
17 "os"20 "os"
18 "path/filepath"21 "path/filepath"
19 "sort"22 "sort"
@@ -39,7 +42,8 @@ import (
39 42
40 // Runtime implements the ContainerRuntime interface using Docker API.43 // Runtime implements the ContainerRuntime interface using Docker API.
41 type Runtime struct {44 type Runtime struct {
42- client *client.Client45+ client *client.Client
46+ runtimeName string
43 }47 }
44 48
45 var _ out.ContainerRuntime = (*Runtime)(nil)49 var _ out.ContainerRuntime = (*Runtime)(nil)
@@ -114,7 +118,7 @@ func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
114 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)118 return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
115 }119 }
116 120
117- return &Runtime{client: cli}, nil121+ return &Runtime{client: cli, runtimeName: guessRuntimeName(socketPath)}, nil
118 }122 }
119 123
120 // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).124 // 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
140 return nil, err144 return nil, err
141 }145 }
142 binds := buildVolumeBinds(config, log)146 binds := buildVolumeBinds(config, log)
147+ mounts, err := buildBindMounts(config)
148+ if err != nil {
149+ return nil, err
150+ }
143 151
144 // Create container configuration152 // Create container configuration
145 containerConfig := &container.Config{153 containerConfig := &container.Config{
@@ -176,6 +184,7 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
176 PortBindings: portBindings,184 PortBindings: portBindings,
177 AutoRemove: config.AutoRemove,185 AutoRemove: config.AutoRemove,
178 Binds: binds,186 Binds: binds,
187+ Mounts: mounts,
179 NetworkMode: container.NetworkMode(config.NetworkMode),188 NetworkMode: container.NetworkMode(config.NetworkMode),
180 Resources: resources,189 Resources: resources,
181 SecurityOpt: []string{"no-new-privileges:true"},190 SecurityOpt: []string{"no-new-privileges:true"},
@@ -288,6 +297,43 @@ func buildVolumeBinds(config *domain.ContainerConfig, log zerowrap.Logger) []str
288 return binds297 return binds
289 }298 }
290 299
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+
291 // StartContainer starts a container.337 // StartContainer starts a container.
292 func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {338 func (r *Runtime) StartContainer(ctx context.Context, containerID string) error {
293 ctx = zerowrap.CtxWithFields(ctx, map[string]any{339 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -628,6 +674,70 @@ func (r *Runtime) GetContainerLogs(ctx context.Context, containerID string, foll
628 return logs, nil674 return logs, nil
629 }675 }
630 676
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+
631 // PullImage pulls an image.741 // PullImage pulls an image.
632 func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {742 func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
633 ctx = zerowrap.CtxWithFields(ctx, map[string]any{743 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -646,10 +756,8 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error {
646 }756 }
647 defer reader.Close()757 defer reader.Close()
648 758
649- // Read the response to completion (this is required for the pull to complete)759+ if err := reader.Wait(ctx); err != nil {
650- _, err = io.Copy(io.Discard, reader)760+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete image pull")
651- if err != nil {
652- return log.WrapErr(err, "failed to read pull response")
653 }761 }
654 762
655 log.Info().Msg("image pulled successfully")763 log.Info().Msg("image pulled successfully")
@@ -706,10 +814,8 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
706 }814 }
707 defer reader.Close()815 defer reader.Close()
708 816
709- // Read the response to completion (this is required for the pull to complete)817+ if err := reader.Wait(ctx); err != nil {
710- _, err = io.Copy(io.Discard, reader)818+ return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to complete authenticated image pull")
711- if err != nil {
712- return log.WrapErr(err, "failed to read pull response")
713 }819 }
714 820
715 log.Info().Msg("image pulled successfully with authentication")821 log.Info().Msg("image pulled successfully with authentication")
@@ -1584,6 +1690,19 @@ func (r *Runtime) GetImageLabels(ctx context.Context, imageRef string) (map[stri
1584 }1690 }
1585 1691
1586 // GetImageID returns the unique image ID (sha256 digest) for the given image reference.1692 // 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+
1587 func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {1706 func (r *Runtime) GetImageID(ctx context.Context, imageRef string) (string, error) {
1588 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1707 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1589 zerowrap.FieldLayer: "adapter",1708 zerowrap.FieldLayer: "adapter",
M internal/adapters/out/docker/runtime_create_test.go +84 -0
@@ -171,3 +171,87 @@ func TestRuntime_CreateContainerAppliesNetworkAndResourceLimits(t *testing.T) {
171 require.True(t, ok)171 require.True(t, ok)
172 assert.Contains(t, endpoints, "gordon-app-abc123")172 assert.Contains(t, endpoints, "gordon-app-abc123")
173 }173 }
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
4 import (4 import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7+ "strings"
7 8
8 "github.com/spf13/viper"9 "github.com/spf13/viper"
9 )10 )
@@ -23,6 +24,16 @@ func DefaultDataDir() string {
23 func ConfigureViper(v *viper.Viper, configPath string) {24 func ConfigureViper(v *viper.Viper, configPath string) {
24 if configPath != "" {25 if configPath != "" {
25 v.SetConfigFile(configPath)26 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+ }
26 } else {37 } else {
27 v.SetConfigName("gordon")38 v.SetConfigName("gordon")
28 v.SetConfigType("toml")39 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 {
128 RevisionRetention int `mapstructure:"revision_retention"`128 RevisionRetention int `mapstructure:"revision_retention"`
129 } `mapstructure:"apps"`129 } `mapstructure:"apps"`
130 130
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+
131 Logging struct {135 Logging struct {
132 Level string `mapstructure:"level"`136 Level string `mapstructure:"level"`
133 Format string `mapstructure:"format"`137 Format string `mapstructure:"format"`
@@ -320,6 +324,7 @@ type services struct {
320 gcBarrier out.GCBarrier324 gcBarrier out.GCBarrier
321 appDeploySvc *deployment.Service325 appDeploySvc *deployment.Service
322 appSvc in.AppService326 appSvc in.AppService
327+ appSvcImpl *apps.AppServiceImpl
323 appActivator *apptraffic.Activator328 appActivator *apptraffic.Activator
324 appHostIndex *apptraffic.HostIndex329 appHostIndex *apptraffic.HostIndex
325 appTrafficPublisher *appTrafficPublisher330 appTrafficPublisher *appTrafficPublisher
@@ -440,6 +445,9 @@ func initConfig(configPath string) (*viper.Viper, Config, error) {
440 if err := validateRetiredAppConfig(v); err != nil {445 if err := validateRetiredAppConfig(v); err != nil {
441 return nil, Config{}, err446 return nil, Config{}, err
442 }447 }
448+ if _, err := buildAppMountPolicies(cfg); err != nil {
449+ return nil, Config{}, err
450+ }
443 451
444 return v, cfg, nil452 return v, cfg, nil
445 }453 }
@@ -459,7 +467,6 @@ var retiredAppConfigKeys = []struct {
459 {"auto", "feature removed; declare explicit interfaces"},467 {"auto", "feature removed; declare explicit interfaces"},
460 {"auto_route", "feature removed; declare explicit interfaces"},468 {"auto_route", "feature removed; declare explicit interfaces"},
461 {"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},469 {"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"},
463 {"previews", "staging is an ordinary app file"},470 {"previews", "staging is an ordinary app file"},
464 }471 }
465 472
@@ -468,11 +475,15 @@ var retiredAppConfigKeys = []struct {
468 // (not IsSet) targets explicit file keys only — installation defaults475 // (not IsSet) targets explicit file keys only — installation defaults
469 // registered via SetDefault must not trip the rejection.476 // registered via SetDefault must not trip the rejection.
470 func validateRetiredAppConfig(v *viper.Viper) error {477 func validateRetiredAppConfig(v *viper.Viper) error {
478+ diagnostics := make([]string, 0, len(retiredAppConfigKeys))
471 for _, retired := range retiredAppConfigKeys {479 for _, retired := range retiredAppConfigKeys {
472 if v.InConfig(retired.key) {480 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))
474 }482 }
475 }483 }
484+ if len(diagnostics) > 0 {
485+ return fmt.Errorf("config-retired: %s", strings.Join(diagnostics, "; "))
486+ }
476 return nil487 return nil
477 }488 }
478 489
@@ -893,10 +904,18 @@ func (si *serviceInit) initApps() error {
893 Traffic: si.svc.appTrafficPublisher,904 Traffic: si.svc.appTrafficPublisher,
894 }, si.log).WithGCBarrier(si.svc.gcBarrier)905 }, si.log).WithGCBarrier(si.svc.gcBarrier)
895 si.svc.appActivator = apptraffic.NewActivator(si.log)906 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).
897 WithEntrypoints(appEntrypointListeners(si.cfg)).913 WithEntrypoints(appEntrypointListeners(si.cfg)).
898 WithGCBarrier(si.svc.gcBarrier).914 WithGCBarrier(si.svc.gcBarrier).
899- WithImagePolicy(imagePolicy)915+ WithImagePolicy(imagePolicy).
916+ WithBindPolicies(mountPolicies)
917+ si.svc.appSvcImpl = appSvcImpl
918+ si.svc.appSvc = appSvcImpl
900 // Health checks resolve from ACTIVE state (loopback backends).919 // Health checks resolve from ACTIVE state (loopback backends).
901 si.svc.healthSvc = health.NewService(store, si.svc.runtime, httpprober.New(), si.log)920 si.svc.healthSvc = health.NewService(store, si.svc.runtime, httpprober.New(), si.log)
902 // ACTIVE-derived host index for the proxy: rebuilt after every921 // ACTIVE-derived host index for the proxy: rebuilt after every
@@ -1035,7 +1054,23 @@ func (si *serviceInit) initRuntimeAndProxy() error {
1035 1054
1036 // initHandlers creates the auth, health, log, preview, and admin handlers.1055 // initHandlers creates the auth, health, log, preview, and admin handlers.
1037 func (si *serviceInit) registerReloadCoordinatorHooks() {1056 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 {
1039 return1074 return
1040 }1075 }
1041 1076
@@ -1836,14 +1871,25 @@ type loadedConfigApplier interface {
1836 }1871 }
1837 1872
1838 type reloadCoordinator struct {1873 type reloadCoordinator struct {
1839- mu sync.Mutex1874+ mu sync.Mutex
1840- lastRun time.Time1875+ lastRun time.Time
1841- debounce time.Duration1876+ 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
1842 1887
1843 configSvc configReloader1888 configSvc configReloader
1844 v *viper.Viper1889 v *viper.Viper
1845 proxySvc proxyConfigUpdater1890 proxySvc proxyConfigUpdater
1846 applyContainerConfig func(context.Context, Config) error1891 applyContainerConfig func(context.Context, Config) error
1892+ applyAppMountPolicy func(context.Context, Config) error
1847 registryLimits interface {1893 registryLimits interface {
1848 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)1894 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1849 }1895 }
@@ -1881,27 +1927,93 @@ func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context
1881 c.applyContainerConfig = apply1927 c.applyContainerConfig = apply
1882 }1928 }
1883 1929
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.
1884 func (c *reloadCoordinator) Trigger(ctx context.Context) error {1939 func (c *reloadCoordinator) Trigger(ctx context.Context) error {
1885 c.mu.Lock()1940 c.mu.Lock()
1886 defer c.mu.Unlock()1941 defer c.mu.Unlock()
1887 1942
1888- return c.reloadLocked(ctx, true)1943+ return c.reloadDebouncedLocked(ctx, true)
1889 }1944 }
1890 1945
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.
1891 func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {1949 func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {
1892 c.mu.Lock()1950 c.mu.Lock()
1893 defer c.mu.Unlock()1951 defer c.mu.Unlock()
1894 1952
1895- return c.reloadLocked(ctx, false)1953+ return c.reloadDebouncedLocked(ctx, false)
1896 }1954 }
1897 1955
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 {
1899 now := time.Now()1960 now := time.Now()
1900 if !c.lastRun.IsZero() && now.Sub(c.lastRun) < c.debounce {1961 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)
1902 return nil1963 return nil
1903 }1964 }
1904 1965
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()
1905 if loadConfig {2017 if loadConfig {
1906 if err := c.configSvc.Reload(ctx); err != nil {2018 if err := c.configSvc.Reload(ctx); err != nil {
1907 c.log.Error().Err(err).Msg("failed to reload config")2019 c.log.Error().Err(err).Msg("failed to reload config")
@@ -1928,6 +2040,9 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1928 if err := validateRetiredAppConfig(c.v); err != nil {2040 if err := validateRetiredAppConfig(c.v); err != nil {
1929 return err2041 return err
1930 }2042 }
2043+ if _, err := buildAppMountPolicies(reloadCfg); err != nil {
2044+ return err
2045+ }
1931 2046
1932 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)2047 reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
1933 if err != nil {2048 if err != nil {
@@ -1941,6 +2056,14 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1941 return fmt.Errorf("failed to apply container config on reload: %w", err)2056 return fmt.Errorf("failed to apply container config on reload: %w", err)
1942 }2057 }
1943 }2058 }
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+ }
1944 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)2067 c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1945 if c.registryLimits != nil {2068 if c.registryLimits != nil {
1946 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)2069 c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
@@ -2329,6 +2452,9 @@ func registerEventHandlers(ctx context.Context, svc *services) (func(), error) {
2329 }2452 }
2330 2453
2331 cleanup := func() {2454 cleanup := func() {
2455+ if svc.reloadCoordinator != nil {
2456+ svc.reloadCoordinator.Stop()
2457+ }
2332 }2458 }
2333 2459
2334 return cleanup, nil2460 return cleanup, nil
@@ -2901,18 +3027,38 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2901 return err3027 return err
2902 }3028 }
2903 3029
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+
2904 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)3037 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
2905 svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)3038 svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
2906 3039
2907 // Wait for both registry listeners and the HTTP proxy before applying traffic.3040 // Wait for both registry listeners and the HTTP proxy before applying traffic.
2908- if err := waitForServerReady(internalRegistryReady, errChan); err != nil {3041+ if err := waitForServerReady(ctx, internalRegistryReady, errChan); err != nil {
2909- closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)3042+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
2910 return err3043 return err
2911 }3044 }
2912- if err := waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, registryReady, proxyReady, errChan); err != nil {3045+ if err := waitForCoreProxyReady(ctx, registryReady, proxyReady, errChan); err != nil {
2913- closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)3046+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
2914 return err3047 return err
2915 }3048 }
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)
2916 3062
2917 logEvent := log.Info().3063 logEvent := log.Info().
2918 Int("proxy_port", cfg.Server.Port).3064 Int("proxy_port", cfg.Server.Port).
@@ -2926,22 +3072,13 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2926 3072
2927 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)3073 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
2928 if err != nil {3074 if err != nil {
2929- shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log)3075+ cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
2930- closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
2931 return err3076 return err
2932 }3077 }
2933 if schedulerCleanup != nil {3078 if schedulerCleanup != nil {
2934 defer schedulerCleanup()3079 defer schedulerCleanup()
2935 }3080 }
2936 3081
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-
2945 waitForShutdown(ctx, errChan, reloadChan, reload, svc.eventBus, log)3082 waitForShutdown(ctx, errChan, reloadChan, reload, svc.eventBus, log)
2946 cleanupHandlers() // Stop debounce timers before draining containers3083 cleanupHandlers() // Stop debounce timers before draining containers
2947 // Stop app administration before tearing down traffic/runtime dependencies,3084 // Stop app administration before tearing down traffic/runtime dependencies,
@@ -2980,17 +3117,37 @@ func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime,
2980 }3117 }
2981 }3118 }
2982 3119
2983-func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *services, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {3120+func waitForCoreProxyReady(ctx context.Context, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {
2984- if err := waitForServerReady(registryReady, errChan); err != nil {3121+ if err := waitForServerReady(ctx, registryReady, errChan); err != nil {
2985 return err3122 return err
2986 }3123 }
2987- if err := waitForServerReady(proxyReady, errChan); err != nil {3124+ return waitForServerReady(ctx, proxyReady, errChan)
2988- return err3125+}
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+ }
2989 }3138 }
2990- if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc, svc.appHostIndex); err != nil {3139+}
2991- return err3140+
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()
2992 }3148 }
2993- return nil3149+ shutdownStartedServers(servers, log)
3150+ shutdownTrafficManagerForStartupCleanup(trafficManager, log)
2994 }3151 }
2995 3152
2996 func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {3153 func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {
@@ -3004,7 +3161,7 @@ func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, lo
3004 }3161 }
3005 }3162 }
3006 3163
3007-func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {3164+func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-chan error) error {
3008 if ready == nil {3165 if ready == nil {
3009 return nil3166 return nil
3010 }3167 }
@@ -3013,6 +3170,8 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
3013 return nil3170 return nil
3014 case err := <-errChan:3171 case err := <-errChan:
3015 return err3172 return err
3173+ case <-ctx.Done():
3174+ return ctx.Err()
3016 }3175 }
3017 }3176 }
3018 3177
M internal/app/run_reload_test.go +190 -31
@@ -101,27 +101,57 @@ func (r *reloadRecorder) Calls() int {
101 }101 }
102 102
103 type proxyRecorder struct {103 type proxyRecorder struct {
104+ mu sync.Mutex
104 calls int105 calls int
105 config proxy.Config106 config proxy.Config
106 }107 }
107 108
108 func (p *proxyRecorder) UpdateConfig(config proxy.Config) {109 func (p *proxyRecorder) UpdateConfig(config proxy.Config) {
110+ p.mu.Lock()
111+ defer p.mu.Unlock()
109 p.calls++112 p.calls++
110 p.config = config113 p.config = config
111 }114 }
112 115
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+
113 type registryLimitsRecorder struct {128 type registryLimitsRecorder struct {
129+ mu sync.Mutex
114 calls int130 calls int
115 maxBlobChunkSize int64131 maxBlobChunkSize int64
116 maxBlobSize int64132 maxBlobSize int64
117 }133 }
118 134
119 func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {135 func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) {
136+ r.mu.Lock()
137+ defer r.mu.Unlock()
120 r.calls++138 r.calls++
121 r.maxBlobChunkSize = maxBlobChunkSize139 r.maxBlobChunkSize = maxBlobChunkSize
122 r.maxBlobSize = maxBlobSize140 r.maxBlobSize = maxBlobSize
123 }141 }
124 142
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+
125 type containerConfigApplyRecorder struct {155 type containerConfigApplyRecorder struct {
126 calls int156 calls int
127 ctx context.Context157 ctx context.Context
@@ -168,6 +198,12 @@ func (e *eventBusRecorder) Calls() int {
168 return e.calls198 return e.calls
169 }199 }
170 200
201+func (e *eventBusRecorder) Type() domain.EventType {
202+ e.mu.Lock()
203+ defer e.mu.Unlock()
204+ return e.eventType
205+}
206+
171 func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {207 func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
172 ctx := context.Background()208 ctx := context.Background()
173 configSvc := &watchRecorder{}209 configSvc := &watchRecorder{}
@@ -225,13 +261,14 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
225 require.NoError(t, coord.ApplyLoadedConfig(ctx))261 require.NoError(t, coord.ApplyLoadedConfig(ctx))
226 262
227 require.Equal(t, 0, reloadSvc.Calls())263 require.Equal(t, 0, reloadSvc.Calls())
228- require.Equal(t, 1, proxySvc.calls)264+ require.Equal(t, 1, proxySvc.Calls())
229 require.Equal(t, 1, events.Calls())265 require.Equal(t, 1, events.Calls())
230- require.Equal(t, 1, registryLimits.calls)266+ require.Equal(t, 1, registryLimits.Calls())
231 require.Equal(t, 1, containerCfg.calls)267 require.Equal(t, 1, containerCfg.calls)
232- require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)268+ rlChunk, rlSize := registryLimits.Limits()
233- require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)269+ require.Equal(t, int64(6<<20), rlChunk)
234- require.Equal(t, domain.EventConfigReload, events.eventType)270+ require.Equal(t, int64(8<<20), rlSize)
271+ require.Equal(t, domain.EventConfigReload, events.Type())
235 require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)272 require.Equal(t, "reload.example.com", containerCfg.cfg.Server.GordonDomain)
236 require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)273 require.Equal(t, []string{"old.example.com"}, containerCfg.cfg.Server.LegacyRegistryDomains)
237 require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)274 require.Equal(t, []string{"docker.io"}, containerCfg.cfg.Images.AllowedRegistries)
@@ -241,7 +278,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven
241 MaxBodySize: 5 << 20,278 MaxBodySize: 5 << 20,
242 MaxResponseSize: 7 << 20,279 MaxResponseSize: 7 << 20,
243 MaxConcurrentConns: 99,280 MaxConcurrentConns: 99,
244- }, proxySvc.config)281+ }, proxySvc.Config())
245 }282 }
246 283
247 // newTestServices wires the serialized traffic publisher the reload hook284 // newTestServices wires the serialized traffic publisher the reload hook
@@ -412,24 +449,52 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_AppliesTraffic(t *testing.T)
412 assert.NotEmpty(t, manager.Status().EntryPoints)449 assert.NotEmpty(t, manager.Status().EntryPoints)
413 }450 }
414 451
415-func TestWaitForCoreProxyReadyAndApplyTraffic_AppliesTraffic(t *testing.T) {452+func TestWaitForCoreProxyReady_WaitsWithoutPublishingEmptyTraffic(t *testing.T) {
416 ctx := context.Background()453 ctx := context.Background()
417- v := viper.New()
418- configSvc := cfgusecase.NewService(v, nil)
419- require.NoError(t, configSvc.Load(ctx))
420 manager := trafficadapter.NewManager()454 manager := trafficadapter.NewManager()
421 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()455 defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
422 456
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-
428 ready := make(chan struct{})457 ready := make(chan struct{})
429 close(ready)458 close(ready)
430- svc := &services{configSvc: configSvc, trafficManager: manager}459+ require.NoError(t, waitForCoreProxyReady(ctx, ready, ready, nil))
431- require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil))460+ assert.Empty(t, manager.Status().EntryPoints)
432- assert.NotEmpty(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")
433 }498 }
434 499
435 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {500 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
@@ -449,23 +514,115 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
449 514
450 registryLimits := &registryLimitsRecorder{}515 registryLimits := &registryLimitsRecorder{}
451 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())516 coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default())
517+ coord.debounce = 20 * time.Millisecond
518+ require.NoError(t, coord.Trigger(ctx))
452 require.NoError(t, coord.Trigger(ctx))519 require.NoError(t, coord.Trigger(ctx))
453 require.NoError(t, coord.Trigger(ctx))520 require.NoError(t, coord.Trigger(ctx))
454 521
455- require.Equal(t, 1, reloadSvc.Calls())522+ // Observe the trailing reload through the recorders' own locks instead of
456- require.Equal(t, 1, proxySvc.calls)523+ // locking the coordinator: the reload event is published last, so waiting
457- require.Equal(t, 1, events.Calls())524+ // for all recorders proves the trailing reload finished applying.
458- require.Equal(t, 1, registryLimits.calls)525+ require.Eventually(t, func() bool {
459- require.Equal(t, int64(6<<20), registryLimits.maxBlobChunkSize)526+ return reloadSvc.Calls() == 2 && proxySvc.Calls() == 2 &&
460- require.Equal(t, int64(8<<20), registryLimits.maxBlobSize)527+ events.Calls() == 2 && registryLimits.Calls() == 2
461- require.Equal(t, domain.EventConfigReload, events.eventType)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())
462 require.Equal(t, proxy.Config{540 require.Equal(t, proxy.Config{
463 RegistryDomain: "new.example.com",541 RegistryDomain: "new.example.com",
464 RegistryPort: 5000,542 RegistryPort: 5000,
465 MaxBodySize: 5 << 20,543 MaxBodySize: 5 << 20,
466 MaxResponseSize: 7 << 20,544 MaxResponseSize: 7 << 20,
467 MaxConcurrentConns: 99,545 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)
469 }626 }
470 627
471 func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {628 func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
@@ -492,7 +649,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) {
492 649
493 require.NoError(t, coord.Trigger(ctx))650 require.NoError(t, coord.Trigger(ctx))
494 require.Equal(t, 2, reloadSvc.Calls())651 require.Equal(t, 2, reloadSvc.Calls())
495- assert.Equal(t, 1, proxySvc.calls)652+ assert.Equal(t, 1, proxySvc.Calls())
496 }653 }
497 654
498 func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) {655 func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) {
@@ -520,10 +677,10 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T)
520 require.ErrorIs(t, err, publishErr)677 require.ErrorIs(t, err, publishErr)
521 678
522 require.Equal(t, 2, reloadSvc.Calls())679 require.Equal(t, 2, reloadSvc.Calls())
523- require.Equal(t, 2, proxySvc.calls)680+ require.Equal(t, 2, proxySvc.Calls())
524 require.Equal(t, 2, events.Calls())681 require.Equal(t, 2, events.Calls())
525 require.Equal(t, 2, publicTLS.calls)682 require.Equal(t, 2, publicTLS.calls)
526- require.Equal(t, domain.EventConfigReload, events.eventType)683+ require.Equal(t, domain.EventConfigReload, events.Type())
527 }684 }
528 685
529 func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {686 func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
@@ -545,6 +702,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
545 }}702 }}
546 proxySvc := &proxyRecorder{}703 proxySvc := &proxyRecorder{}
547 coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())704 coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default())
705+ coord.debounce = 20 * time.Millisecond
548 706
549 firstDone := make(chan struct{})707 firstDone := make(chan struct{})
550 go func() {708 go func() {
@@ -585,6 +743,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) {
585 t.Fatal("second reload did not finish")743 t.Fatal("second reload did not finish")
586 }744 }
587 745
588- require.Equal(t, 1, reloadSvc.Calls())746+ require.Eventually(t, func() bool { return proxySvc.Calls() == 2 }, time.Second, 5*time.Millisecond)
589- assert.Equal(t, 1, proxySvc.calls)747+ require.Never(t, func() bool { return proxySvc.Calls() > 2 }, 50*time.Millisecond, 5*time.Millisecond)
748+ assert.Equal(t, 2, proxySvc.Calls())
590 }749 }
M internal/app/start_proxy_servers_test.go +44 -2
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "net"9 "net"
10 "net/http"10 "net/http"
11+ "path/filepath"
11 "strconv"12 "strconv"
12 "testing"13 "testing"
13 "time"14 "time"
@@ -16,6 +17,7 @@ import (
16 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
17 18
18 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"19 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
20+ "github.com/bnema/gordon/internal/adapters/localadmin"
19 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"21 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
20 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"22 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
21 "github.com/bnema/gordon/internal/domain"23 "github.com/bnema/gordon/internal/domain"
@@ -28,7 +30,7 @@ func TestWaitForServerReady_NilReadyReturnsImmediately(t *testing.T) {
28 done := make(chan error, 1)30 done := make(chan error, 1)
29 31
30 go func() {32 go func() {
31- done <- waitForServerReady(nil, errChan)33+ done <- waitForServerReady(context.Background(), nil, errChan)
32 }()34 }()
33 35
34 select {36 select {
@@ -44,10 +46,50 @@ func TestWaitForServerReady_NonNilReadyPreservesErrorBehavior(t *testing.T) {
44 errChan := make(chan error, 1)46 errChan := make(chan error, 1)
45 errChan <- expected47 errChan <- expected
46 48
47- err := waitForServerReady(make(chan struct{}), errChan)49+ err := waitForServerReady(context.Background(), make(chan struct{}), errChan)
48 require.ErrorIs(t, err, expected)50 require.ErrorIs(t, err, expected)
49 }51 }
50 52
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+
51 func TestStartProxyServers_DoesNotStartLegacyHTTPListenerFromServerPort(t *testing.T) {93 func TestStartProxyServers_DoesNotStartLegacyHTTPListenerFromServerPort(t *testing.T) {
52 cfg := Config{}94 cfg := Config{}
53 cfg.Server.Port = freeTCPPort(t)95 cfg.Server.Port = freeTCPPort(t)
M internal/boundaries/in/apps.go +12 -0
@@ -56,6 +56,9 @@ type AppService interface {
56 // client-supplied idempotency key.56 // client-supplied idempotency key.
57 OperationByKey(ctx context.Context, app, key string) (*domain.AppOperation, error)57 OperationByKey(ctx context.Context, app, key string) (*domain.AppOperation, error)
58 58
59+ // ListSecrets returns metadata-only desired and active registrations.
60+ ListSecrets(ctx context.Context, app, service string) ([]AppSecretMetadata, error)
61+
59 // SetSecrets writes secret values for pre-registered names in one62 // SetSecrets writes secret values for pre-registered names in one
60 // service. service is required; every key must already exist in63 // service. service is required; every key must already exist in
61 // desired or active state.64 // desired or active state.
@@ -66,6 +69,15 @@ type AppService interface {
66 }69 }
67 70
68 // AppApplyResult describes one accepted (or no-op) apply.71 // 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+
69 type AppApplyResult struct {81 type AppApplyResult struct {
70 App string82 App string
71 FormerRevision string83 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) (
410 return _c410 return _c
411 }411 }
412 412
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+
413 // OperationByKey provides a mock function for the type MockAppService487 // OperationByKey provides a mock function for the type MockAppService
414 func (_mock *MockAppService) OperationByKey(ctx context.Context, app string, key string) (*domain.AppOperation, error) {488 func (_mock *MockAppService) OperationByKey(ctx context.Context, app string, key string) (*domain.AppOperation, error) {
415 ret := _mock.Called(ctx, app, key)489 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
1858 return _c1858 return _c
1859 }1859 }
1860 1860
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+
1861 // PullImage provides a mock function for the type MockContainerRuntime1927 // PullImage provides a mock function for the type MockContainerRuntime
1862 func (_mock *MockContainerRuntime) PullImage(ctx context.Context, image string) error {1928 func (_mock *MockContainerRuntime) PullImage(ctx context.Context, image string) error {
1863 ret := _mock.Called(ctx, image)1929 ret := _mock.Called(ctx, image)
@@ -1984,6 +2050,63 @@ func (_c *MockContainerRuntime_PullImageWithAuth_Call) RunAndReturn(run func(ctx
1984 return _c2050 return _c
1985 }2051 }
1986 2052
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+
1987 // RemoveContainer provides a mock function for the type MockContainerRuntime2110 // RemoveContainer provides a mock function for the type MockContainerRuntime
1988 func (_mock *MockContainerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {2111 func (_mock *MockContainerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
1989 ret := _mock.Called(ctx, containerID, force)2112 ret := _mock.Called(ctx, containerID, force)
@@ -2596,6 +2719,69 @@ func (_c *MockContainerRuntime_UntagImage_Call) RunAndReturn(run func(ctx contex
2596 return _c2719 return _c
2597 }2720 }
2598 2721
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+
2599 // Version provides a mock function for the type MockContainerRuntime2785 // Version provides a mock function for the type MockContainerRuntime
2600 func (_mock *MockContainerRuntime) Version(ctx context.Context) (string, error) {2786 func (_mock *MockContainerRuntime) Version(ctx context.Context) (string, error) {
2601 ret := _mock.Called(ctx)2787 ret := _mock.Called(ctx)
M internal/boundaries/out/runtime.go +13 -0
@@ -40,6 +40,7 @@ type ContainerRuntime interface {
40 40
41 // Image operations41 // Image operations
42 PullImage(ctx context.Context, image string) error42 PullImage(ctx context.Context, image string) error
43+ PullImageWithOptions(ctx context.Context, request domain.ImagePullRequest) error
43 PullImageWithAuth(ctx context.Context, image, username, password string) error44 PullImageWithAuth(ctx context.Context, image, username, password string) error
44 TagImage(ctx context.Context, sourceRef, targetRef string) error45 TagImage(ctx context.Context, sourceRef, targetRef string) error
45 UntagImage(ctx context.Context, imageRef string) error46 UntagImage(ctx context.Context, imageRef string) error
@@ -81,6 +82,7 @@ type ContainerRuntime interface {
81 82
82 // Image identity83 // Image identity
83 GetImageID(ctx context.Context, imageRef string) (string, error)84 GetImageID(ctx context.Context, imageRef string) (string, error)
85+ VerifyImageDigest(ctx context.Context, imageRef, digest string) error
84 86
85 // In-container operations87 // In-container operations
86 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)88 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
@@ -93,6 +95,17 @@ type ContainerRuntime interface {
93 NetworkExists(ctx context.Context, name string) (bool, error)95 NetworkExists(ctx context.Context, name string) (bool, error)
94 ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error96 ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
95 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error97 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)
96 }109 }
97 110
98 // ContainerLister is the subset of ContainerRuntime needed by the orphan GC.111 // 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) {
27 "/healthz\x00",27 "/healthz\x00",
28 "/healthz\x7f",28 "/healthz\x7f",
29 "/he\talthz",29 "/he\talthz",
30+ " /healthz",
31+ "/healthz ",
32+ "\t/healthz",
33+ "/healthz\n",
30 }34 }
31 for _, path := range invalid {35 for _, path := range invalid {
32 assert.Error(t, domain.ValidateReadinessPath(path), "path %q should be rejected", path)36 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 (
7 7
8 // ReservationsFor computes the global listener claims for a normalized8 // ReservationsFor computes the global listener claims for a normalized
9 // spec. It is the single owner of reservation derivation; the apps and9 // 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.
11 func ReservationsFor(spec AppSpec) []AppListenerReservation {13 func ReservationsFor(spec AppSpec) []AppListenerReservation {
12 var reservations []AppListenerReservation14 var reservations []AppListenerReservation
13 for _, svc := range spec.Services {15 for _, svc := range spec.Services {
14 for _, h := range svc.HTTP {16 for _, h := range svc.HTTP {
17+ if !h.IsPublic() {
18+ continue
19+ }
15 reservations = append(reservations, AppListenerReservation{20 reservations = append(reservations, AppListenerReservation{
16 Proto: "http",21 Proto: "http",
17 Host: h.Host,22 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
3 import (3 import (
4 "fmt"4 "fmt"
5 "net"5 "net"
6+ "path"
6 "reflect"7 "reflect"
7 "regexp"8 "regexp"
8 "sort"9 "sort"
@@ -44,6 +45,13 @@ const (
44 AppTLSNever = "never"45 AppTLSNever = "never"
45 )46 )
46 47
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+
47 // App database engines. Only postgres in v2.50.55 // App database engines. Only postgres in v2.50.
48 const (56 const (
49 AppDBPostgres = "postgres"57 AppDBPostgres = "postgres"
@@ -68,7 +76,7 @@ const (
68 // AppMaxReadinessTimeout bounds readiness timeout above.76 // AppMaxReadinessTimeout bounds readiness timeout above.
69 AppMaxReadinessTimeout = 10 * time.Minute77 AppMaxReadinessTimeout = 10 * time.Minute
70 // AppDefaultStopGrace applies when stop_grace is unset.78 // AppDefaultStopGrace applies when stop_grace is unset.
71- AppDefaultStopGrace = 10 * time.Second79+ AppDefaultStopGrace = 30 * time.Second
72 // AppMaxStopGrace caps stop_grace.80 // AppMaxStopGrace caps stop_grace.
73 AppMaxStopGrace = 5 * time.Minute81 AppMaxStopGrace = 5 * time.Minute
74 )82 )
@@ -95,6 +103,7 @@ type AppService struct {
95 UDP []AppUDPInterface103 UDP []AppUDPInterface
96 Secrets map[string]string104 Secrets map[string]string
97 Volumes []AppVolume105 Volumes []AppVolume
106+ Binds []AppBind
98 Databases []AppDatabase107 Databases []AppDatabase
99 Backup AppBackup108 Backup AppBackup
100 }109 }
@@ -108,11 +117,71 @@ type AppReadiness struct {
108 Timeout time.Duration117 Timeout time.Duration
109 }118 }
110 119
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.
112 type AppHTTPInterface struct {124 type AppHTTPInterface struct {
113 Host string125 Host string
114 Port int126 Port int
115 TLS string127 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
116 }185 }
117 186
118 // AppTCPInterface is one TCP service interface.187 // AppTCPInterface is one TCP service interface.
@@ -136,6 +205,15 @@ type AppVolume struct {
136 ReadOnly bool205 ReadOnly bool
137 }206 }
138 207
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+
139 // AppDatabase is one explicit database declaration.217 // AppDatabase is one explicit database declaration.
140 type AppDatabase struct {218 type AppDatabase struct {
141 Name string219 Name string
@@ -194,6 +272,61 @@ func ValidateSecretName(name string) error {
194 return ValidateServiceName(name)272 return ValidateServiceName(name)
195 }273 }
196 274
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+
197 // NormalizeServiceName applies the runtime-identifier normalization.330 // NormalizeServiceName applies the runtime-identifier normalization.
198 func NormalizeServiceName(name string) string {331 func NormalizeServiceName(name string) string {
199 return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)332 return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
@@ -389,13 +522,16 @@ func (s *AppService) validate() error {
389 if err != nil {522 if err != nil {
390 return err523 return err
391 }524 }
525+ if err := s.validateBinds(); err != nil {
526+ return err
527+ }
392 if err := s.validateDatabases(volumes); err != nil {528 if err := s.validateDatabases(volumes); err != nil {
393 return err529 return err
394 }530 }
395 return nil531 return nil
396 }532 }
397 533
398-// validateInterfaces checks HTTP/TCP/UDP entries.534+// validateInterfaces checks HTTP/TCP/UDP entries and their port ownership.
399 func (s *AppService) validateInterfaces() error {535 func (s *AppService) validateInterfaces() error {
400 for i := range s.HTTP {536 for i := range s.HTTP {
401 if err := s.HTTP[i].validate(s.Name); err != nil {537 if err := s.HTTP[i].validate(s.Name); err != nil {
@@ -412,6 +548,37 @@ func (s *AppService) validateInterfaces() error {
412 return err548 return err
413 }549 }
414 }550 }
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+ }
415 return nil582 return nil
416 }583 }
417 584
@@ -440,13 +607,44 @@ func (s *AppService) validateVolumes() (map[string]struct{}, error) {
440 return nil, fmt.Errorf("%w: service %q duplicate volume %q", ErrInvalidAppSpec, s.Name, vol.Name)607 return nil, fmt.Errorf("%w: service %q duplicate volume %q", ErrInvalidAppSpec, s.Name, vol.Name)
441 }608 }
442 seenVolumes[vol.Name] = struct{}{}609 seenVolumes[vol.Name] = struct{}{}
443- if !strings.HasPrefix(vol.Path, "/") || strings.Contains(vol.Path, "..") {610+ if !path.IsAbs(vol.Path) || path.Clean(vol.Path) != vol.Path {
444- return nil, fmt.Errorf("%w: service %q volume %q path must be absolute without dot-dot", ErrInvalidAppSpec, s.Name, vol.Name)611+ return nil, fmt.Errorf("%w: service %q volume %q path must be absolute and normalized", ErrInvalidAppSpec, s.Name, vol.Name)
445 }612 }
446 }613 }
447 return seenVolumes, nil614 return seenVolumes, nil
448 }615 }
449 616
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+
450 // validateDatabases checks database declarations and backup references.648 // validateDatabases checks database declarations and backup references.
451 func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {649 func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {
452 seenDBs := map[string]struct{}{}650 seenDBs := map[string]struct{}{}
@@ -515,8 +713,8 @@ func (r AppReadiness) validateL4(svc *AppService) error {
515 if hasUDPOnly(svc) {713 if hasUDPOnly(svc) {
516 return fmt.Errorf("%w: service %q with UDP-only interfaces must use none or log readiness", ErrInvalidAppSpec, svc.Name)714 return fmt.Errorf("%w: service %q with UDP-only interfaces must use none or log readiness", ErrInvalidAppSpec, svc.Name)
517 }715 }
518- if countTCPInterfaces(svc) > 1 && r.Port == 0 {716+ if r.Port == 0 && readinessPortRequired(svc) {
519- return fmt.Errorf("%w: service %q has multiple TCP-capable interfaces, readiness.port is required", ErrInvalidAppSpec, svc.Name)717+ return fmt.Errorf("%w: service %q interfaces do not select one readiness port, readiness.port is required", ErrInvalidAppSpec, svc.Name)
520 }718 }
521 if r.Port != 0 && !hasTCPContainerPort(svc, r.Port) {719 if r.Port != 0 && !hasTCPContainerPort(svc, r.Port) {
522 return fmt.Errorf("%w: service %q readiness.port %d matches no declared container port", ErrInvalidAppSpec, svc.Name, r.Port)720 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 {
533 // leading slash, no scheme or authority, and no control characters. The731 // leading slash, no scheme or authority, and no control characters. The
534 // path is appended to an immutable loopback URL, so rejecting everything732 // path is appended to an immutable loopback URL, so rejecting everything
535 // that could be read as a different authority keeps the probe on the733 // 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.
537 func ValidateReadinessPath(path string) error {737 func ValidateReadinessPath(path string) error {
538- path = strings.TrimSpace(path)738+ if strings.TrimSpace(path) == "" {
539- if path == "" {
540 return fmt.Errorf("http readiness requires path")739 return fmt.Errorf("http readiness requires path")
541 }740 }
741+ if path != strings.TrimSpace(path) {
742+ return fmt.Errorf("http readiness path must not have leading or trailing whitespace")
743+ }
542 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {744 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
543 return fmt.Errorf("http readiness path must be an origin-form path beginning with one slash")745 return fmt.Errorf("http readiness path must be an origin-form path beginning with one slash")
544 }746 }
@@ -558,6 +760,43 @@ func hasUDPOnly(svc *AppService) bool {
558 return len(svc.HTTP) == 0 && len(svc.TCP) == 0760 return len(svc.HTTP) == 0 && len(svc.TCP) == 0
559 }761 }
560 762
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+
561 // countTCPInterfaces counts http/tcp container ports.800 // countTCPInterfaces counts http/tcp container ports.
562 func countTCPInterfaces(svc *AppService) int {801 func countTCPInterfaces(svc *AppService) int {
563 return len(svc.HTTP) + len(svc.TCP)802 return len(svc.HTTP) + len(svc.TCP)
@@ -578,21 +817,38 @@ func hasTCPContainerPort(svc *AppService, port int) bool {
578 return false817 return false
579 }818 }
580 819
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.
582 func (h AppHTTPInterface) validate(service string) error {823 func (h AppHTTPInterface) validate(service string) error {
583- if h.Host == "" {824+ switch h.EffectiveVisibility() {
584- return fmt.Errorf("%w: service %q http host is required", ErrInvalidAppSpec, service)825+ case AppVisibilityPublic:
585- }826+ if h.Host == "" {
586- if _, ok := CanonicalRouteDomain(h.Host); !ok {827+ return fmt.Errorf("%w: service %q http host is required", ErrInvalidAppSpec, service)
587- return fmt.Errorf("%w: service %q http host %q is not a valid public hostname", ErrInvalidAppSpec, service, h.Host)828+ }
588- }829+ if _, ok := CanonicalRouteDomain(h.Host); !ok {
589- if h.Port < 1 || h.Port > 65535 {830+ return fmt.Errorf("%w: service %q http host %q is not a valid public hostname", ErrInvalidAppSpec, service, h.Host)
590- return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)831+ }
591- }832+ if h.Port < 1 || h.Port > 65535 {
592- switch h.TLS {833+ return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)
593- case AppTLSAuto, AppTLSAlways, AppTLSNever: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+ }
594 default:850 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)
596 }852 }
597 return nil853 return nil
598 }854 }
@@ -697,15 +953,60 @@ func DiffAppSpec(desired, effective AppSpec) AppDiff {
697 if !equalStringMaps(desired.Env, effective.Env) {953 if !equalStringMaps(desired.Env, effective.Env) {
698 diff.Changed = append(diff.Changed, "env")954 diff.Changed = append(diff.Changed, "env")
699 }955 }
700- if !reflect.DeepEqual(desired.Networks, effective.Networks) {956+ diff.Changed = append(diff.Changed, diffNetworks(desired.Networks, effective.Networks)...)
701- diff.Changed = append(diff.Changed, "networks")
702- }
703 sort.Strings(diff.Added)957 sort.Strings(diff.Added)
704 sort.Strings(diff.Removed)958 sort.Strings(diff.Removed)
705 sort.Strings(diff.Changed)959 sort.Strings(diff.Changed)
706 return diff960 return diff
707 }961 }
708 962
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+
709 // diffService compares two services field by field.1010 // diffService compares two services field by field.
710 func diffService(name string, desired, effective AppService) []string {1011 func diffService(name string, desired, effective AppService) []string {
711 var changed []string1012 var changed []string
@@ -735,6 +1036,9 @@ func diffService(name string, desired, effective AppService) []string {
735 if !equalVolumes(desired.Volumes, effective.Volumes) {1036 if !equalVolumes(desired.Volumes, effective.Volumes) {
736 changed = append(changed, "service/"+name+"/volumes")1037 changed = append(changed, "service/"+name+"/volumes")
737 }1038 }
1039+ if !equalBinds(desired.Binds, effective.Binds) {
1040+ changed = append(changed, "service/"+name+"/binds")
1041+ }
738 if !reflect.DeepEqual(desired.Databases, effective.Databases) {1042 if !reflect.DeepEqual(desired.Databases, effective.Databases) {
739 changed = append(changed, "service/"+name+"/databases")1043 changed = append(changed, "service/"+name+"/databases")
740 }1044 }
@@ -763,7 +1067,7 @@ func secretPathChanges(desired, effective AppService) map[string]struct{} {
763 // interfacesChanged compares interface slices.1067 // interfacesChanged compares interface slices.
764 func interfacesChanged(desired, effective AppService) bool {1068 func interfacesChanged(desired, effective AppService) bool {
765 for i := range desired.HTTP {1069 for i := range desired.HTTP {
766- if desired.HTTP[i] != effective.HTTP[i] {1070+ if !httpInterfacesEqual(desired.HTTP[i], effective.HTTP[i]) {
767 return true1071 return true
768 }1072 }
769 }1073 }
@@ -780,6 +1084,18 @@ func interfacesChanged(desired, effective AppService) bool {
780 return false1084 return false
781 }1085 }
782 1086
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+
783 // equalStringMaps compares string maps.1099 // equalStringMaps compares string maps.
784 func equalStringMaps(a, b map[string]string) bool {1100 func equalStringMaps(a, b map[string]string) bool {
785 if len(a) != len(b) {1101 if len(a) != len(b) {
@@ -805,3 +1121,16 @@ func equalVolumes(a, b []AppVolume) bool {
805 }1121 }
806 return true1122 return true
807 }1123 }
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) {
131 assert.Contains(t, diff.Changed, "service/web/stop_grace")131 assert.Contains(t, diff.Changed, "service/web/stop_grace")
132 assert.Contains(t, diff.Changed, "service/web/databases")132 assert.Contains(t, diff.Changed, "service/web/databases")
133 assert.Contains(t, diff.Changed, "service/web/backup")133 assert.Contains(t, diff.Changed, "service/web/backup")
134- assert.Contains(t, diff.Changed, "networks")134+ assert.Contains(t, diff.Changed, "network/shared/added")
135 assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2")135 assert.Contains(t, diff.Changed, "service/web/secret/database-url-v2")
136 assert.Contains(t, diff.Changed, "service/web/secret/database-url")136 assert.Contains(t, diff.Changed, "service/web/secret/database-url")
137 assert.Contains(t, diff.Changed, "env")137 assert.Contains(t, diff.Changed, "env")
@@ -153,6 +153,27 @@ func TestDiffAppSpec_DeterministicAndRedacted(t *testing.T) {
153 assert.Equal(t, []string{"service/web"}, removed.Removed)153 assert.Equal(t, []string{"service/web"}, removed.Removed)
154 }154 }
155 155
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+
156 func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {177 func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {
157 assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))178 assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))
158 }179 }
M internal/domain/app_state.go +4 -0
@@ -151,6 +151,7 @@ type AppActive struct {
151 ConvergedRevision string `json:"converged_revision"`151 ConvergedRevision string `json:"converged_revision"`
152 Converged bool `json:"converged"`152 Converged bool `json:"converged"`
153 Services map[string]AppEffectiveService `json:"services"`153 Services map[string]AppEffectiveService `json:"services"`
154+ Networks []AppSharedNetwork `json:"networks,omitempty"`
154 StopIntent bool `json:"stop_intent"`155 StopIntent bool `json:"stop_intent"`
155 }156 }
156 157
@@ -302,6 +303,9 @@ const (
302 // AppInhibitReplacementPending marks the generation being replaced303 // AppInhibitReplacementPending marks the generation being replaced
303 // while a volume-owning candidate may already be writing.304 // while a volume-owning candidate may already be writing.
304 AppInhibitReplacementPending = "replacement-pending"305 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"
305 )309 )
306 310
307 // AppOwnership is the ownership record for one app.311 // 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 {
90 Protocol NetworkProtocol90 Protocol NetworkProtocol
91 }91 }
92 92
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+
93 // ContainerConfig holds configuration for creating a container.103 // ContainerConfig holds configuration for creating a container.
94 type ContainerConfig struct {104 type ContainerConfig struct {
95 Image string105 Image string
@@ -105,6 +115,7 @@ type ContainerConfig struct {
105 RestartPolicy string115 RestartPolicy string
106 Volumes map[string]string // map[containerPath]volumeName116 Volumes map[string]string // map[containerPath]volumeName
107 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)117 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
118+ Binds []ContainerBind // ephemeral resolved host binds, keyed by destination
108 NetworkMode string // Network to join119 NetworkMode string // Network to join
109 Hostname string // Container hostname for DNS120 Hostname string // Container hostname for DNS
110 Aliases []string // Additional network aliases121 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 (
64 64
65 // App manifest errors65 // App manifest errors
66 ErrInvalidAppSpec = errors.New("invalid app manifest")66 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")
67 78
68 // App state errors79 // App state errors
69 ErrAppStateIO = errors.New("app state storage failure")80 ErrAppStateIO = errors.New("app state storage failure")
M internal/domain/image_policy.go +24 -0
@@ -11,6 +11,30 @@ import (
11 11
12 var defaultImageRegistries = []string{"docker.io", "ghcr.io", "quay.io"}12 var defaultImageRegistries = []string{"docker.io", "ghcr.io", "quay.io"}
13 13
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+
14 // ImageSourcePolicy is the installation policy for every image reference38 // ImageSourcePolicy is the installation policy for every image reference
15 // the daemon validates, resolves, pulls, or runs.39 // the daemon validates, resolves, pulls, or runs.
16 type ImageSourcePolicy struct {40 type ImageSourcePolicy struct {
M internal/domain/image_policy_test.go +21 -0
@@ -94,6 +94,27 @@ func TestImageSourcePolicy_RejectsMalformedReferences(t *testing.T) {
94 }94 }
95 }95 }
96 96
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+
97 func TestIsLocalOrPrivateHost(t *testing.T) {118 func TestIsLocalOrPrivateHost(t *testing.T) {
98 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"}119 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"}
99 for _, host := range local {120 for _, host := range local {
M internal/domain/labels.go +11 -0
@@ -52,4 +52,15 @@ const (
52 LabelPort = "gordon.port"52 LabelPort = "gordon.port"
53 // LabelEnvFile specifies the path to .env file inside the image.53 // LabelEnvFile specifies the path to .env file inside the image.
54 LabelEnvFile = "gordon.env-file"54 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"
55 )66 )
M internal/usecase/apps/app_service.go +68 -0
@@ -66,6 +66,18 @@ func (s *AppServiceImpl) WithImagePolicy(policy domain.ImageSourcePolicy) *AppSe
66 return s66 return s
67 }67 }
68 68
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+
69 var _ in.AppService = (*AppServiceImpl)(nil)81 var _ in.AppService = (*AppServiceImpl)(nil)
70 82
71 // Apply implements in.AppService.83 // Apply implements in.AppService.
@@ -339,6 +351,62 @@ func (s *AppServiceImpl) OperationByKey(ctx context.Context, app, key string) (*
339 return &op, nil351 return &op, nil
340 }352 }
341 353
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+
342 // SetSecrets implements in.AppService.410 // SetSecrets implements in.AppService.
343 func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, values map[string]string) error {411 func (s *AppServiceImpl) SetSecrets(ctx context.Context, app, service string, values map[string]string) error {
344 if service == "" {412 if service == "" {
M internal/usecase/apps/app_service_test.go +36 -0
@@ -2,6 +2,7 @@ package apps_test
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -88,3 +89,38 @@ func TestAppServiceImpl_OperationByKey(t *testing.T) {
88 _, err = svc.OperationByKey(ctx, "blog", "missing")89 _, err = svc.OperationByKey(ctx, "blog", "missing")
89 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)90 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
90 }91 }
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 (
9 "crypto/sha256"9 "crypto/sha256"
10 "encoding/hex"10 "encoding/hex"
11 "fmt"11 "fmt"
12+ "sync"
12 "time"13 "time"
13 14
14 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -28,6 +29,10 @@ type Service struct {
28 // never snapshot between a staged intent and its materialization.29 // never snapshot between a staged intent and its materialization.
29 barrier out.GCBarrier30 barrier out.GCBarrier
30 imagePolicy domain.ImageSourcePolicy31 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
31 }36 }
32 37
33 // NewService creates the apps use case over an AppState store.38 // NewService creates the apps use case over an AppState store.
@@ -49,6 +54,33 @@ func (s *Service) WithImagePolicy(policy domain.ImageSourcePolicy) *Service {
49 return s54 return s
50 }55 }
51 56
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+
52 // noopLease is a GC lease for an unwired barrier.84 // noopLease is a GC lease for an unwired barrier.
53 type noopLease struct{}85 type noopLease struct{}
54 86
@@ -139,6 +171,9 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte,
139 return nil, nil, fmt.Errorf("apps: service %q image %q: %w", service.Name, service.Image, err)171 return nil, nil, fmt.Errorf("apps: service %q image %q: %w", service.Name, service.Image, err)
140 }172 }
141 }173 }
174+ if err := s.validateBindPolicies(spec); err != nil {
175+ return nil, nil, err
176+ }
142 if err := s.validateEntrypointCompatibility(spec); err != nil {177 if err := s.validateEntrypointCompatibility(spec); err != nil {
143 return nil, nil, err178 return nil, nil, err
144 }179 }
@@ -173,6 +208,26 @@ func (s *Service) Apply(ctx context.Context, spec domain.AppSpec, source []byte,
173 return s.persistApply(ctx, spec, source, prepared)208 return s.persistApply(ctx, spec, source, prepared)
174 }209 }
175 210
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+
176 // validateEntrypointCompatibility checks every declared TCP/UDP interface231 // validateEntrypointCompatibility checks every declared TCP/UDP interface
177 // against the installation entrypoint listeners before any persistence or232 // against the installation entrypoint listeners before any persistence or
178 // workload effect. The runtime binds the entrypoint address, so a publish233 // 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
285 340
286 // activeSpec rebuilds an AppSpec view from effective definitions for diffing.341 // activeSpec rebuilds an AppSpec view from effective definitions for diffing.
287 func activeSpec(active domain.AppActive) domain.AppSpec {342 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...)}
289 for name, svc := range active.Services {344 for name, svc := range active.Services {
290 svcSpec := svc.Spec345 svcSpec := svc.Spec
291 svcSpec.Name = name346 svcSpec.Name = name
M internal/usecase/apps/service_test.go +27 -0
@@ -177,6 +177,33 @@ func TestApply_DryRunWritesNothing(t *testing.T) {
177 store.AssertNotCalled(t, "StageApply", mock.Anything, mock.Anything)177 store.AssertNotCalled(t, "StageApply", mock.Anything, mock.Anything)
178 }178 }
179 179
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+
180 func TestApply_RejectsCrossAppConflict(t *testing.T) {207 func TestApply_RejectsCrossAppConflict(t *testing.T) {
181 ctx := context.Background()208 ctx := context.Background()
182 store := outmocks.NewMockAppState(t)209 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
88 spec.Name = name88 spec.Name = name
89 var entries []RouteEntry89 var entries []RouteEntry
90 for _, h := range spec.HTTP {90 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+ }
91 entries = append(entries, RouteEntry{97 entries = append(entries, RouteEntry{
92 Kind: "http",98 Kind: "http",
93 RouterName: "app-" + app + "--" + spec.Name + "--http-" + sanitizeHost(h.Host),99 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) {
102 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)102 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
103 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)103 images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
104 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)104 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()
106 runtime.EXPECT().InspectImageVolumes(mock.Anything, "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, nil)108 runtime.EXPECT().InspectImageVolumes(mock.Anything, "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, nil)
107 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)109 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
108 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)110 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
21 const failLogTailTimeout = 10 * time.Second21 const failLogTailTimeout = 10 * time.Second
22 22
23 // Deploy executes a preflighted revision: fail-fast across services in23 // Deploy executes a preflighted revision: fail-fast across services in
24-// sorted name order. HTTP services without volumes keep the old container24+// sorted name order. HTTP-only services whose interfaces are all public,
25-// serving until the replacement passes readiness, then switch with a25+// with no volumes and no binds, keep the old container serving until the
26-// bounded drain and retire the old container by exact ID. TCP/UDP, mixed,26+// replacement passes readiness, then switch with a bounded drain and
27-// and volume-owning services use replacement with interruption. Volumes27+// retire the old container by exact ID. TCP/UDP, any internal HTTP
28-// are never deleted: no RemoveVolume call, no volume-deletion flags on28+// interface, and bind- or volume-owning services use replacement with
29-// container removal.29+// interruption. Volumes are never deleted: no RemoveVolume call, no
30+// volume-deletion flags on container removal.
30 func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) {31 func (s *Service) Deploy(ctx context.Context, input DeployInput) (*DeployResult, error) {
31 release, err := s.acquireAppContext(ctx, input.App)32 release, err := s.acquireAppContext(ctx, input.App)
32 if err != nil {33 if err != nil {
@@ -71,12 +72,12 @@ func (s *Service) deployLocked(ctx context.Context, input DeployInput) (*DeployR
71 Revision: rev.Revision,72 Revision: rev.Revision,
72 Services: map[string]ServiceResult{},73 Services: map[string]ServiceResult{},
73 }74 }
74- // Reconcile services the new desired state no longer declares BEFORE75+ // A targeted deploy is intentionally a partial plan: services omitted
75- // publishing anything: a service the operator removed must stop being76+ // from pinned remain active. Full deploys reconcile actual removals.
76- // reachable, and its exact container must be stopped and removed77+ if input.Service == "" {
77- // while its data is retained.78+ if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, op, result, log); err != nil {
78- if err := s.reconcileRemovalsForDeploy(ctx, input.App, active, pinned, op, result, log); err != nil {79+ return result, err
79- return result, err80+ }
80 }81 }
81 for i, p := range pinned {82 for i, p := range pinned {
82 if err := s.runServiceStep(ctx, input.App, rev.Revision, i, p, op, active, result, log); err != nil {83 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
340 return s.deployInterrupted(ctx, app, revision, p, opID, before, beforeGrace)341 return s.deployInterrupted(ctx, app, revision, p, opID, before, beforeGrace)
341 }342 }
342 343
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.
344 func httpEligible(spec domain.AppService) bool {355 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 {
346 return false357 return false
347 }358 }
359+ for _, h := range spec.HTTP {
360+ if !h.IsPublic() {
361+ return false
362+ }
363+ }
348 return true364 return true
349 }365 }
350 366
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+
351 // deployHTTP keeps the old container serving until the replacement passes374 // deployHTTP keeps the old container serving until the replacement passes
352 // readiness AND the new effective state is published: the proxy switches375 // readiness AND the new effective state is published: the proxy switches
353 // to the new loopback binds at publication, the old container drains,376 // 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
358 if err != nil {381 if err != nil {
359 return s.failResult(revision, before, "", err), false382 return s.failResult(revision, before, "", err), false
360 }383 }
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 {
362 // Old version keeps serving. Capture redacted diagnostics while385 // Old version keeps serving. Capture redacted diagnostics while
363 // the failed replacement still exists, then remove only that386 // the failed replacement still exists, then remove only that
364 // candidate.387 // candidate.
@@ -419,13 +442,19 @@ func retireContainerID(before, after string) string {
419 // the replacement. Volume-owning failures never restart the old image:442 // the replacement. Volume-owning failures never restart the old image:
420 // the replacement may already have written data.443 // the replacement may already have written data.
421 func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {444 func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p pinnedService, opID, before string, beforeGrace time.Duration) (ServiceResult, bool) {
445+ // Revalidate the current authorization and source before inhibiting or
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+ }
422 // A volume-owning replacement may write while the old generation451 // A volume-owning replacement may write while the old generation
423 // still exists and could be revived by native restart policy.452 // still exists and could be revived by native restart policy.
424 // Inhibit that generation durably BEFORE the write can happen, so453 // Inhibit that generation durably BEFORE the write can happen, so
425 // boot or periodic recovery can never restart the old writer on454 // boot or periodic recovery can never restart the old writer on
426 // top of the new one. Cleared once the safe generation is455 // top of the new one. Cleared once the safe generation is
427 // published (publishService) or the operator removes the app.456 // published (publishService) or the operator removes the app.
428- inhibited := before != "" && len(p.spec.Volumes) > 0457+ inhibited := before != "" && singleWriterRequired(p.spec)
429 if inhibited {458 if inhibited {
430 if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil {459 if err := s.inhibitRecovery(ctx, app, p.name, before, domain.AppInhibitReplacementPending, opID); err != nil {
431 return s.failResult(revision, before, "", err), true460 return s.failResult(revision, before, "", err), true
@@ -447,7 +476,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
447 if err != nil {476 if err != nil {
448 return s.failResult(revision, before, "", err), true477 return s.failResult(revision, before, "", err), true
449 }478 }
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 {
451 // Capture redacted diagnostics while the candidate still exists,480 // Capture redacted diagnostics while the candidate still exists,
452 // then remove only that candidate.481 // then remove only that candidate.
453 tail := s.redactDiagnostics(ctx, app, p, s.logTail(ctx, created.ID))482 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
457 EffectiveRevision: revision,486 EffectiveRevision: revision,
458 Before: before,487 Before: before,
459 After: created.ID,488 After: created.ID,
460- RestartUnsafe: len(p.spec.Volumes) > 0,489+ RestartUnsafe: singleWriterRequired(p.spec),
461 Error: err.Error(),490 Error: err.Error(),
462 Diagnostics: tail,491 Diagnostics: tail,
463 CleanupWarnings: cleanup,492 CleanupWarnings: cleanup,
@@ -468,7 +497,7 @@ func (s *Service) deployInterrupted(ctx context.Context, app, revision string, p
468 EffectiveRevision: revision,497 EffectiveRevision: revision,
469 Before: before,498 Before: before,
470 After: created.ID,499 After: created.ID,
471- RestartUnsafe: len(p.spec.Volumes) > 0,500+ RestartUnsafe: singleWriterRequired(p.spec),
472 BackendBinds: binds,501 BackendBinds: binds,
473 UDPBackendBinds: udpBinds,502 UDPBackendBinds: udpBinds,
474 }, true503 }, true
@@ -540,6 +569,13 @@ func volumeProvenanceLabels(app, appID, service, revision string) map[string]str
540 // reconciles intent at boot and in the monitor (accepted decision:569 // reconciles intent at boot and in the monitor (accepted decision:
541 // native runtime restarts plus daemon reconciliation).570 // native runtime restarts plus daemon reconciliation).
542 func (s *Service) createAndStart(ctx context.Context, app, revision string, p pinnedService, opID string) (*domain.Container, map[int]int, map[int]int, error) {571 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+ }
543 env, err := s.serviceEnv(ctx, app, p)579 env, err := s.serviceEnv(ctx, app, p)
544 if err != nil {580 if err != nil {
545 return nil, nil, nil, err581 return nil, nil, nil, err
@@ -594,6 +630,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
594 Entrypoint: append([]string(nil), p.spec.Command...),630 Entrypoint: append([]string(nil), p.spec.Command...),
595 Volumes: volumes,631 Volumes: volumes,
596 ReadOnlyVolumes: readOnlyVolumes,632 ReadOnlyVolumes: readOnlyVolumes,
633+ Binds: resolvedBinds,
597 Labels: appLabels(app, p.spec.Name, revision),634 Labels: appLabels(app, p.spec.Name, revision),
598 AutoRemove: false,635 AutoRemove: false,
599 RestartPolicy: domain.RestartPolicyAlways,636 RestartPolicy: domain.RestartPolicyAlways,
@@ -605,9 +642,9 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
605 NanoCPUs: s.deps.Limits.NanoCPUs,642 NanoCPUs: s.deps.Limits.NanoCPUs,
606 PidsLimit: s.deps.Limits.PidsLimit,643 PidsLimit: s.deps.Limits.PidsLimit,
607 }644 }
608- created, err := s.deps.Runtime.CreateContainer(ctx, config)645+ created, err := s.createContainer(ctx, p.name, config)
609 if err != nil {646 if err != nil {
610- return nil, nil, nil, fmt.Errorf("deployment: create container: %w", err)647+ return nil, nil, nil, err
611 }648 }
612 if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil {649 if err := s.connectSharedNetworks(ctx, created.ID, nets); err != nil {
613 s.retireCandidate(ctx, app, p.spec.Name, created.ID)650 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
625 return created, binds, udpBinds, nil662 return created, binds, udpBinds, nil
626 }663 }
627 664
628-// backendPublishes collects every interface container port for loopback665+func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
629-// publication: HTTP + TCP interfaces on tcp plus UDP interfaces on udp,666+ created, err := s.deps.Runtime.CreateContainer(ctx, config)
630-// plus an explicit TCP readiness port. Each publishes on 127.0.0.1667+ if err == nil {
631-// ephemeral (never public). Deduplicated by (protocol, container port).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).
632 func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {687 func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
633 seen := map[domain.ContainerBackendPort]struct{}{}688 seen := map[domain.ContainerBackendPort]struct{}{}
634 var ports []domain.ContainerBackendPort689 var ports []domain.ContainerBackendPort
@@ -643,6 +698,9 @@ func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
643 }698 }
644 }699 }
645 for _, h := range spec.HTTP {700 for _, h := range spec.HTTP {
701+ if !h.IsPublic() {
702+ continue
703+ }
646 add(h.Port, domain.NetworkProtocolTCP)704 add(h.Port, domain.NetworkProtocolTCP)
647 }705 }
648 for _, t := range spec.TCP {706 for _, t := range spec.TCP {
@@ -653,8 +711,12 @@ func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
653 }711 }
654 // Readiness probes are TCP-only (validation rejects UDP-only712 // Readiness probes are TCP-only (validation rejects UDP-only
655 // services with tcp/http readiness); the readiness port matches a713 // services with tcp/http readiness); the readiness port matches a
656- // declared TCP container port.714+ // declared TCP container port. An internal-only port is never
657- add(spec.Readiness.Port, domain.NetworkProtocolTCP)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+ }
658 sort.Slice(ports, func(i, j int) bool {720 sort.Slice(ports, func(i, j int) bool {
659 if ports[i].Protocol != ports[j].Protocol {721 if ports[i].Protocol != ports[j].Protocol {
660 return ports[i].Protocol < ports[j].Protocol722 return ports[i].Protocol < ports[j].Protocol
@@ -788,25 +850,24 @@ func (s *Service) pullImage(ctx context.Context, image string) (string, error) {
788 if s.deps.Registry.Domain == "" {850 if s.deps.Registry.Domain == "" {
789 return image, nil851 return image, nil
790 }852 }
791- host, _, _ := strings.Cut(image, "/")853+ if !s.deps.ImagePolicy.IsInstallationImage(image) {
792- if host != s.deps.Registry.Domain {
793 if err := s.deps.Runtime.PullImage(ctx, image); err != nil {854 if err := s.deps.Runtime.PullImage(ctx, image); err != nil {
794 return "", fmt.Errorf("deployment: pull image %q: %w", image, err)855 return "", fmt.Errorf("deployment: pull image %q: %w", image, err)
795 }856 }
796 return image, nil857 return image, nil
797 }858 }
798- if s.deps.Registry.Username == "" {859+ _, remainder, ok := strings.Cut(image, "/")
799- if err := s.deps.Runtime.PullImage(ctx, image); err != nil {860+ if !ok || !strings.Contains(remainder, "@sha256:") {
800- return "", fmt.Errorf("deployment: pull image %q: %w", image, err)861+ return "", fmt.Errorf("deployment: installation image must include an exact sha256 digest: %w", domain.ErrAppImageNotAllowed)
801- }
802- return image, nil
803 }862 }
804- pullImage := image863+ pullImage := s.deps.Registry.PullAddress + "/" + remainder
805- if s.deps.Registry.PullAddress != "" {864+ request := domain.ImagePullRequest{Reference: pullImage, Username: s.deps.Registry.Username, Password: s.deps.Registry.Password, Transport: domain.ImagePullTransportHTTP}
806- pullImage = s.deps.Registry.PullAddress + strings.TrimPrefix(image, s.deps.Registry.Domain)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)
807 }867 }
808- if err := s.deps.Runtime.PullImageWithAuth(ctx, pullImage, s.deps.Registry.Username, s.deps.Registry.Password); err != nil {868+ digest := remainder[strings.LastIndex(remainder, "@")+1:]
809- return "", fmt.Errorf("deployment: pull image %q: %w", image, err)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)
810 }871 }
811 return pullImage, nil872 return pullImage, nil
812 }873 }
@@ -901,6 +962,9 @@ func (s *Service) publishService(ctx context.Context, app, revision string, p pi
901 }962 }
902 }963 }
903 active.Converged = converged964 active.Converged = converged
965+ if converged {
966+ active.Networks = append([]domain.AppSharedNetwork(nil), p.appNetworks...)
967+ }
904 if err := s.deps.State.SaveActive(ctx, active); err != nil {968 if err := s.deps.State.SaveActive(ctx, active); err != nil {
905 return fmt.Errorf("deployment: publish active: %w", err)969 return fmt.Errorf("deployment: publish active: %w", err)
906 }970 }
@@ -976,7 +1040,7 @@ func (s *Service) recordOwnership(ctx context.Context, app string, p pinnedServi
976 if ownership.Services == nil {1040 if ownership.Services == nil {
977 ownership.Services = map[string]domain.AppServiceRecovery{}1041 ownership.Services = map[string]domain.AppServiceRecovery{}
978 }1042 }
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)}
980 if err := s.deps.State.SaveOwnership(ctx, ownership); err != nil {1044 if err := s.deps.State.SaveOwnership(ctx, ownership); err != nil {
981 return fmt.Errorf("deployment: record ownership: %w", err)1045 return fmt.Errorf("deployment: record ownership: %w", err)
982 }1046 }
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
196 }196 }
197 197
198 // Restart restarts from pinned digests without re-resolution. Empty198 // 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.
200 func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) {203 func (s *Service) Restart(ctx context.Context, app, service, opID string) (*LifecycleResult, error) {
201 release, err := s.acquireAppContext(ctx, app)204 release, err := s.acquireAppContext(ctx, app)
202 if err != nil {205 if err != nil {
@@ -241,7 +244,13 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
241 var failures []string244 var failures []string
242 for _, name := range names {245 for _, name := range names {
243 eff := active.Services[name]246 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+ }
245 op.Steps = append(op.Steps, step)254 op.Steps = append(op.Steps, step)
246 result.Services[name] = svcResult255 result.Services[name] = svcResult
247 if svcResult.Result != "deployed" {256 if svcResult.Result != "deployed" {
@@ -263,6 +272,129 @@ func (s *Service) restartLocked(ctx context.Context, app, service, opID string)
263 return result, nil272 return result, nil
264 }273 }
265 274
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+
266 // restartOneService withdraws one service, restarts its exact container,398 // restartOneService withdraws one service, restarts its exact container,
267 // re-inspects its binds, and verifies readiness before it may be published399 // re-inspects its binds, and verifies readiness before it may be published
268 // again. Every failure leaves the service withdrawn with its recorded binds400 // 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
280 result.After = ""412 result.After = ""
281 return fail("no active container")413 return fail("no active container")
282 }414 }
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+ }
283 if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {420 if err := s.refuseInhibitedRestart(ctx, app, name, eff.Container); err != nil {
284 _ = s.withdrawForRecovery(ctx, app, name)421 _ = s.withdrawForRecovery(ctx, app, name)
285 return fail(err.Error())422 return fail(err.Error())
@@ -296,12 +433,12 @@ func (s *Service) restartOneService(ctx context.Context, app, name string, eff d
296 // Ephemeral loopback binds are not guaranteed stable across a runtime433 // Ephemeral loopback binds are not guaranteed stable across a runtime
297 // restart: re-inspect before probing so the proxy never dials a stale434 // restart: re-inspect before probing so the proxy never dials a stale
298 // bind.435 // 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)
300 if err != nil {437 if err != nil {
301 s.clearServiceBinds(ctx, app, name)438 s.clearServiceBinds(ctx, app, name)
302 return fail(err.Error())439 return fail(err.Error())
303 }440 }
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 {
305 s.clearServiceBinds(ctx, app, name)442 s.clearServiceBinds(ctx, app, name)
306 return fail(err.Error())443 return fail(err.Error())
307 }444 }
@@ -538,6 +675,12 @@ func (s *Service) reconcileBootApp(ctx context.Context, app string) error {
538 // step inline (false, reserved for future fallible publishes).675 // step inline (false, reserved for future fallible publishes).
539 func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool {676 func (s *Service) ensureServiceRunning(ctx context.Context, app, opID, name string, eff domain.AppEffectiveService, step *domain.AppOperationStep, result *LifecycleResult) bool {
540 if eff.Container != "" {677 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+ }
541 // Boot recovery refuses a generation whose recovery is durably684 // Boot recovery refuses a generation whose recovery is durably
542 // inhibited: a replacement may already have written to its685 // inhibited: a replacement may already have written to its
543 // volume, so reviving this ID could corrupt the newer data.686 // 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
574 result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}717 result.Services[name] = ServiceResult{Result: "failed", Before: eff.Container, Error: err.Error()}
575 return true718 return true
576 }719 }
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+ }
577 pinned := pinnedService{726 pinned := pinnedService{
578 name: name, spec: eff.Spec, digest: eff.Digest, runtimeImage: runtimeImage, appEnv: maps.Clone(rev.Spec.Env),727 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...),
579 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),729 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, name),
580 }730 }
581 svcResult, _ := s.deployService(ctx, app, rev.Revision, pinned, opID, eff.Container, serviceStopGrace(eff))731 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
629 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())779 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
630 return true780 return true
631 }781 }
632- binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff)782+ binds, udpBinds, err := s.refreshBackendBinds(ctx, app, name, eff, s.deps.Traffic != nil)
633 if err != nil {783 if err != nil {
634 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())784 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
635 return true785 return true
636 }786 }
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 {
638 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())788 s.failServiceStep(ctx, app, name, eff, step, result, err.Error())
639 return true789 return true
640 }790 }
@@ -648,12 +798,12 @@ func (s *Service) verifyRunningService(ctx context.Context, app, name string, ef
648 }798 }
649 799
650 // refreshBackendBinds re-inspects the loopback publishes of a restarted800 // 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.
652 // Ephemeral binds may shift across runtime restarts; the proxy must802 // Ephemeral binds may shift across runtime restarts; the proxy must
653 // never dial the stale recorded bind. Uses the non-destructive803 // never dial the stale recorded bind. Uses the non-destructive
654 // inspectBackendBinds: an existing ACTIVE container is never stopped804 // inspectBackendBinds: an existing ACTIVE container is never stopped
655 // or removed by re-inspection.805 // 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) {
657 ports := backendPorts(eff.Spec)807 ports := backendPorts(eff.Spec)
658 if len(ports) == 0 {808 if len(ports) == 0 {
659 return nil, nil, nil809 return nil, nil, nil
@@ -662,24 +812,42 @@ func (s *Service) refreshBackendBinds(ctx context.Context, app, name string, eff
662 if err != nil {812 if err != nil {
663 return nil, nil, err813 return nil, nil, err
664 }814 }
665- if !equalBinds(binds, eff.BackendBinds) || !equalBinds(udpBinds, eff.UDPBackendBinds) {815+ if !forcePersist && equalBinds(binds, eff.BackendBinds) && equalBinds(udpBinds, eff.UDPBackendBinds) {
666- active, ok, err := s.deps.State.LoadActive(ctx, app)816+ return binds, udpBinds, nil
667- if err != nil {817+ }
668- return nil, nil, fmt.Errorf("deployment: reload active for bind refresh: %w", err)818+ active, ok, err := s.deps.State.LoadActive(ctx, app)
669- }819+ if err != nil {
670- if !ok {820+ return nil, nil, fmt.Errorf("deployment: reload active for bind refresh: %w", err)
671- return nil, nil, fmt.Errorf("deployment: app %q has no active state: %w", app, domain.ErrAppStateConflict)821+ }
672- }822+ if !ok {
673- eff.BackendBinds = binds823+ return nil, nil, fmt.Errorf("deployment: app %q has no active state: %w", app, domain.ErrAppStateConflict)
674- eff.UDPBackendBinds = udpBinds824+ }
675- active.Services[name] = eff825+ if err := s.persistBackendBinds(ctx, active, name, eff, binds, udpBinds, forcePersist); err != nil {
676- if err := s.deps.State.SaveActive(ctx, active); err != nil {826+ return nil, nil, err
677- return nil, nil, fmt.Errorf("deployment: persist refreshed binds: %w", err)
678- }
679 }827 }
680 return binds, udpBinds, nil828 return binds, udpBinds, nil
681 }829 }
682 830
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+
683 // failServiceStep records a failed service step and withdraws its851 // failServiceStep records a failed service step and withdraws its
684 // recorded backend binds from ACTIVE: a bind that failed verification852 // recorded backend binds from ACTIVE: a bind that failed verification
685 // (or could not be refreshed) must never be served again — the853 // (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 {
358 if err != nil {358 if err != nil {
359 return fmt.Errorf("deployment: reconcile %q: load recovery inhibitions: %w", app, err)359 return fmt.Errorf("deployment: reconcile %q: load recovery inhibitions: %w", app, err)
360 }360 }
361- inhibited := map[recoveryKey]struct{}{}361+ inhibited, failures := s.processRecoveryInhibitions(ctx, app, inhibitions)
362- for _, inhibition := range inhibitions {
363- inhibited[recoveryKey{app: app, service: inhibition.Service, container: inhibition.ContainerID}] = struct{}{}
364- }
365- var failures []error
366 for _, name := range sortedServiceNames(active) {362 for _, name := range sortedServiceNames(active) {
367 eff := active.Services[name]363 eff := active.Services[name]
368 if intent.Stopped {364 if intent.Stopped {
@@ -389,6 +385,22 @@ func (s *Service) reconcileAppRunning(ctx context.Context, app string) error {
389 return errors.Join(failures...)385 return errors.Join(failures...)
390 }386 }
391 387
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+
392 // convergeStoppedService enforces durable stopped intent: it never404 // convergeStoppedService enforces durable stopped intent: it never
393 // starts or publishes, and it stops a verified running exact ACTIVE ID405 // starts or publishes, and it stops a verified running exact ACTIVE ID
394 // without deleting the container or its volumes.406 // without deleting the container or its volumes.
@@ -598,7 +610,7 @@ func (s *Service) publishRecoveredService(ctx context.Context, app, name string,
598 return errors.Join(s.withdrawForRecovery(ctx, app, name), err)610 return errors.Join(s.withdrawForRecovery(ctx, app, name), err)
599 }611 }
600 if started || restarted || fresh {612 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 {
602 s.backoff.recordFailure(key)614 s.backoff.recordFailure(key)
603 return errors.Join(s.withdrawForRecovery(ctx, app, name), err)615 return errors.Join(s.withdrawForRecovery(ctx, app, name), err)
604 }616 }
@@ -621,6 +633,11 @@ func (s *Service) publishRecoveredService(ctx context.Context, app, name string,
621 // re-inspected: if native restart policy already revived it, no failure633 // re-inspected: if native restart policy already revived it, no failure
622 // is charged and no second restart is issued.634 // is charged and no second restart is issued.
623 func (s *Service) startRecovered(serviceCtx context.Context, app, name string, eff domain.AppEffectiveService, key recoveryKey) (bool, error) {635 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+ }
624 if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil {641 if err := s.deps.Runtime.StartContainer(serviceCtx, eff.Container); err != nil {
625 if errors.Is(err, domain.ErrContainerNotFound) {642 if errors.Is(err, domain.ErrContainerNotFound) {
626 s.backoff.forget(key)643 s.backoff.forget(key)
@@ -668,6 +685,11 @@ func (s *Service) recoverHealth(serviceCtx context.Context, app, name string, ef
668 if !s.backoff.allow(key) {685 if !s.backoff.allow(key) {
669 return false, false, nil686 return false, false, nil
670 }687 }
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+ }
671 if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil {693 if err := s.deps.Runtime.RestartContainer(serviceCtx, eff.Container, serviceStopGrace(eff)); err != nil {
672 s.backoff.recordFailure(key)694 s.backoff.recordFailure(key)
673 return false, false, fmt.Errorf("deployment: app %q service %q restart unhealthy %s: %w", app, name, eff.Container, err)695 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) {
42 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(42 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-1", mock.Anything).Return(
43 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()43 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
44 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()44 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()
46 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).47 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default()).
47 WithProbeDeps(NewTestProbeDeps(runtime,48 WithProbeDeps(NewTestProbeDeps(runtime,
48 func(context.Context, string) (int, error) { return 500, nil },49 func(context.Context, string) (int, error) { return 500, nil },
@@ -59,7 +60,7 @@ func TestVerifyRunningService_NotReadyWithdrawsAndClearsBinds(t *testing.T) {
59 // the canonical boundary; the fail-closed ACTIVE write itself is60 // the canonical boundary; the fail-closed ACTIVE write itself is
60 // covered by the publisher tests in internal/app.61 // covered by the publisher tests in internal/app.
61 assert.Equal(t, []string{"blog/web", "blog/web"}, traffic.withdrawn)62 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)
63 }64 }
64 65
65 // TestVerifyRunningService_ReadyPublishesBinds proves a ready generation is66 // TestVerifyRunningService_ReadyPublishesBinds proves a ready generation is
M internal/usecase/deployment/readiness.go +224 -4
@@ -2,6 +2,7 @@ package deployment
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "net"8 "net"
@@ -21,6 +22,10 @@ type ProbeDeps struct {
21 logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error)22 logStream func(ctx context.Context, containerID string, since time.Time) (io.ReadCloser, error)
22 httpGet func(ctx context.Context, url string) (int, error)23 httpGet func(ctx context.Context, url string) (int, error)
23 tcpDial func(ctx context.Context, addr string) error24 tcpDial func(ctx context.Context, addr string) error
25+ // networkProbe runs one bounded session against an internal port 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)
24 }29 }
25 30
26 // ProbeRuntime is the runtime subset readiness probing needs. It mirrors31 // ProbeRuntime is the runtime subset readiness probing needs. It mirrors
@@ -30,6 +35,7 @@ type ProbeRuntime interface {
30 GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)35 GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error)
31 GetContainerLogsSince(ctx context.Context, containerID string, since time.Time, follow bool) (io.ReadCloser, error)36 GetContainerLogsSince(ctx context.Context, containerID string, since time.Time, follow bool) (io.ReadCloser, error)
32 InspectContainer(ctx context.Context, containerID string) (*domain.Container, error)37 InspectContainer(ctx context.Context, containerID string) (*domain.Container, error)
38+ ProbeContainerNetwork(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error)
33 }39 }
34 40
35 // NewProbeDeps wires readiness probes to a runtime implementation.41 // NewProbeDeps wires readiness probes to a runtime implementation.
@@ -52,6 +58,9 @@ func NewProbeDeps(runtime ProbeRuntime) ProbeDeps {
52 },58 },
53 httpGet: httpGetOnce,59 httpGet: httpGetOnce,
54 tcpDial: tcpDialOnce,60 tcpDial: tcpDialOnce,
61+ networkProbe: func(ctx context.Context, request domain.ContainerNetworkProbeRequest) (domain.ContainerNetworkProbeResult, error) {
62+ return runtime.ProbeContainerNetwork(ctx, request)
63+ },
55 }64 }
56 }65 }
57 66
@@ -78,6 +87,183 @@ func (s *Service) probeDeps() ProbeDeps {
78 return s.defaultProbeDeps()87 return s.defaultProbeDeps()
79 }88 }
80 89
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+
81 // waitServiceReadyWithDeps dispatches readiness by manifest type.267 // waitServiceReadyWithDeps dispatches readiness by manifest type.
82 // binds carries the container's 127.0.0.1 backend publishes; L4 probes268 // binds carries the container's 127.0.0.1 backend publishes; L4 probes
83 // dial them rootless-first and never container IPs (plan D3: loopback-269 // dial them rootless-first and never container IPs (plan D3: loopback-
@@ -222,10 +408,7 @@ func waitLogReadyWithDeps(ctx context.Context, deps ProbeDeps, containerID strin
222 // the manifest's readiness port when set, else the single TCP-capable408 // the manifest's readiness port when set, else the single TCP-capable
223 // interface port (validation guarantees unambiguous selection).409 // interface port (validation guarantees unambiguous selection).
224 func dialPort(spec domain.AppService, binds map[int]int) (int, error) {410 func dialPort(spec domain.AppService, binds map[int]int) (int, error) {
225- containerPort := spec.Readiness.Port411+ containerPort := readinessContainerPort(spec)
226- if containerPort == 0 {
227- containerPort = singleTCPPort(spec)
228- }
229 hostPort, ok := binds[containerPort]412 hostPort, ok := binds[containerPort]
230 if !ok || hostPort <= 0 {413 if !ok || hostPort <= 0 {
231 return 0, fmt.Errorf("deployment: no 127.0.0.1 backend bind for container port %d", containerPort)414 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) {
233 return hostPort, nil416 return hostPort, nil
234 }417 }
235 418
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+
236 // singleTCPPort returns the container port when exactly one TCP-capable456 // singleTCPPort returns the container port when exactly one TCP-capable
237 // interface exists; validation rejects ambiguity (readiness.port457 // interface exists; validation rejects ambiguity (readiness.port
238 // required) before the engine runs.458 // 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 (
10 "maps"10 "maps"
11 "sort"11 "sort"
12 "strings"12 "strings"
13+ "sync"
13 "time"14 "time"
14 15
15 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
@@ -97,6 +98,11 @@ type Service struct {
97 // generation, so a native restart is detected without restarting98 // generation, so a native restart is detected without restarting
98 // the workload ourselves.99 // the workload ourselves.
99 executions executionTracker100 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
100 }106 }
101 107
102 // NewService creates the deployment engine. All deps are required;108 // NewService creates the deployment engine. All deps are required;
@@ -123,6 +129,56 @@ func (s *Service) WithGCBarrier(barrier out.GCBarrier) *Service {
123 return s129 return s
124 }130 }
125 131
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+
126 // acquireAppContext takes the GC shared lease first and the per-app182 // acquireAppContext takes the GC shared lease first and the per-app
127 // coordinator second, following the documented lock order183 // coordinator second, following the documented lock order
128 // (GC barrier → per-app coordinator → registry lock → bbolt184 // (GC barrier → per-app coordinator → registry lock → bbolt
@@ -256,6 +312,7 @@ type pinnedService struct {
256 digest string312 digest string
257 runtimeImage string313 runtimeImage string
258 appEnv map[string]string314 appEnv map[string]string
315+ appNetworks []domain.AppSharedNetwork
259 // sharedNetworks are the declared shared-network memberships this316 // sharedNetworks are the declared shared-network memberships this
260 // service joins. The private incarnation network is derived from the317 // service joins. The private incarnation network is derived from the
261 // app UUID at create time so recovery and deploy agree.318 // app UUID at create time so recovery and deploy agree.
@@ -303,7 +360,7 @@ func (s *Service) preflightLocked(ctx context.Context, input DeployInput) ([]pin
303 return nil, nil, false, err360 return nil, nil, false, err
304 }361 }
305 if input.Service != "" {362 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 {
307 return nil, nil, false, err364 return nil, nil, false, err
308 }365 }
309 }366 }
@@ -431,8 +488,10 @@ func (s *Service) requireKnownApp(ctx context.Context, app string) error {
431 return nil488 return nil
432 }489 }
433 490
434-// checkConverged refuses service-targeted deploy on desired/effective divergence.491+// checkConverged permits a targeted deploy when every desired/effective
435-func (s *Service) checkConverged(ctx context.Context, app string, rev domain.AppDesiredRevision) error {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 {
436 active, ok, err := s.deps.State.LoadActive(ctx, app)495 active, ok, err := s.deps.State.LoadActive(ctx, app)
437 if err != nil {496 if err != nil {
438 return err497 return err
@@ -440,29 +499,55 @@ func (s *Service) checkConverged(ctx context.Context, app string, rev domain.App
440 if !ok {499 if !ok {
441 return fmt.Errorf("deployment: app %q was never deployed, targeted deploy refused: %w", app, domain.ErrAppStateConflict)500 return fmt.Errorf("deployment: app %q was never deployed, targeted deploy refused: %w", app, domain.ErrAppStateConflict)
442 }501 }
443- if !active.Converged || active.ConvergedRevision != rev.Revision {502+ if active.Converged && active.ConvergedRevision == rev.Revision {
444- return fmt.Errorf(503+ return nil
445- "%w: service-targeted deploy refused, desired %s diverges from effective state",
446- domain.ErrAppStateConflict, rev.Revision,
447- )
448 }504 }
449- return nil505+ 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")
450 }516 }
451 517
452-// preflightServices runs the five preflight gates in order. No mutation.518+func effectiveAppSpec(active domain.AppActive) domain.AppSpec {
453-func (s *Service) preflightServices(ctx context.Context, app string, rev domain.AppDesiredRevision, onlyService string) ([]pinnedService, error) {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) {
454 services := append([]domain.AppService(nil), rev.Spec.Services...)534 services := append([]domain.AppService(nil), rev.Spec.Services...)
455- if onlyService != "" {535+ if onlyService == "" {
456- filtered := services[:0]536+ return services, nil
457- for _, svc := range services {537+ }
458- if svc.Name == onlyService {538+ for _, svc := range services {
459- filtered = append(filtered, svc)539+ if svc.Name == onlyService {
460- }540+ return []domain.AppService{svc}, nil
461- }
462- if len(filtered) == 0 {
463- return nil, fmt.Errorf("deployment: service %q not in revision %s: %w", onlyService, rev.Revision, domain.ErrAppStateConflict)
464 }541 }
465- services = filtered542+ }
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
466 }551 }
467 sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })552 sort.Slice(services, func(i, j int) bool { return services[i].Name < services[j].Name })
468 553
@@ -495,8 +580,16 @@ func (s *Service) preflightServices(ctx context.Context, app string, rev domain.
495 if err := s.checkImageVolumes(ctx, svc, runtimeImage); err != nil {580 if err := s.checkImageVolumes(ctx, svc, runtimeImage); err != nil {
496 return nil, err581 return nil, err
497 }582 }
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+ }
498 pinned = append(pinned, pinnedService{590 pinned = append(pinned, pinnedService{
499 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,591 name: svc.Name, spec: svc, digest: digest, runtimeImage: runtimeImage, appEnv: appEnv,
592+ appNetworks: append([]domain.AppSharedNetwork(nil), rev.Spec.Networks...),
500 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, svc.Name),593 sharedNetworks: domain.AppServiceSharedNetworks(rev.Spec, svc.Name),
501 })594 })
502 }595 }
@@ -580,6 +673,11 @@ func (s *Service) checkImageVolumes(ctx context.Context, svc domain.AppService,
580 for _, vol := range svc.Volumes {673 for _, vol := range svc.Volumes {
581 mapped[vol.Path] = struct{}{}674 mapped[vol.Path] = struct{}{}
582 }675 }
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+ }
583 for _, path := range declared {681 for _, path := range declared {
584 if _, ok := mapped[path]; !ok {682 if _, ok := mapped[path]; !ok {
585 return fmt.Errorf(683 return fmt.Errorf(
M internal/usecase/deployment/service_test.go +60 -0
@@ -234,6 +234,66 @@ func TestPreflight_TargetedRefusesDivergence(t *testing.T) {
234 require.ErrorIs(t, err, domain.ErrAppStateConflict)234 require.ErrorIs(t, err, domain.ErrAppStateConflict)
235 }235 }
236 236
237+func TestPreflight_TargetedDiffPolicy(t *testing.T) {
238+ baseService := webService()
239+ worker := webService()
240+ worker.Name = "worker"
241+ worker.HTTP[0].Host = "worker.example.com"
242+ base := domain.AppActive{
243+ App: "blog", ConvergedRevision: "rev-1", Converged: true,
244+ Networks: []domain.AppSharedNetwork{{Network: "shared", Services: []string{"web"}}},
245+ Services: map[string]domain.AppEffectiveService{
246+ "web": {Spec: baseService},
247+ "worker": {Spec: worker},
248+ },
249+ }
250+
251+ tests := []struct {
252+ name string
253+ mutate func(*domain.AppSpec)
254+ service string
255+ wantErr bool
256+ }{
257+ {name: "requested service image", service: "web", mutate: func(spec *domain.AppSpec) { spec.Services[0].Image = "img:2" }},
258+ {name: "other service", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Services[1].Image = "img:2" }},
259+ {name: "environment", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Env = map[string]string{"MODE": "prod"} }},
260+ {name: "network", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) { spec.Networks[0].Aliases = []string{"peer"} }},
261+ {name: "service added", service: "web", wantErr: true, mutate: func(spec *domain.AppSpec) {
262+ spec.Services = append(spec.Services, domain.AppService{Name: "job", Image: "img:1", Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second}})
263+ }},
264+ }
265+ for _, tc := range tests {
266+ t.Run(tc.name, func(t *testing.T) {
267+ spec := domain.AppSpec{Name: "blog", Networks: append([]domain.AppSharedNetwork(nil), base.Networks...), Services: []domain.AppService{baseService, worker}}
268+ spec.Networks[0].Aliases = append([]string(nil), base.Networks[0].Aliases...)
269+ tc.mutate(&spec)
270+ state := outmocks.NewMockAppState(t)
271+ runtime := outmocks.NewMockContainerRuntime(t)
272+ images := outmocks.NewMockImageResolver(t)
273+ secrets := outmocks.NewMockSecretProvider(t)
274+ rev := domain.AppDesiredRevision{App: "blog", Revision: "rev-2", Spec: spec}
275+ state.EXPECT().Recover(mock.Anything).Return(nil).Once()
276+ state.EXPECT().LoadRevision(mock.Anything, "blog", "rev-2").Return(rev, nil).Once()
277+ state.EXPECT().LoadActive(mock.Anything, "blog").Return(base, true, nil).Once()
278+ if !tc.wantErr {
279+ images.EXPECT().ResolveDigest(mock.Anything, mock.Anything).Return("sha256:"+strings.Repeat("a", 64), nil).Once()
280+ secrets.EXPECT().GetSecret(mock.Anything, mock.Anything).Return("x", nil).Once()
281+ runtime.EXPECT().InspectImageVolumes(mock.Anything, mock.Anything).Return(nil, nil).Once()
282+ state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
283+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil).Once()
284+ state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Times(2)
285+ }
286+ svc := preflightService(t, state, runtime, images, secrets)
287+ _, _, err := svc.Preflight(context.Background(), deployment.DeployInput{App: "blog", Revision: "rev-2", Service: tc.service})
288+ if tc.wantErr {
289+ require.ErrorIs(t, err, domain.ErrAppStateConflict)
290+ } else {
291+ require.NoError(t, err)
292+ }
293+ })
294+ }
295+}
296+
237 func TestPreflight_TargetedConvergedPasses(t *testing.T) {297 func TestPreflight_TargetedConvergedPasses(t *testing.T) {
238 ctx := context.Background()298 ctx := context.Background()
239 state := outmocks.NewMockAppState(t)299 state := outmocks.NewMockAppState(t)
M main.go +3 -2
@@ -12,12 +12,13 @@ var (
12 version = "dev"12 version = "dev"
13 commit = "unknown"13 commit = "unknown"
14 date = "unknown"14 date = "unknown"
15+ dirty = "unknown"
15 )16 )
16 17
17 func main() {18 func main() {
18 // Set version information globally and for the CLI19 // Set version information globally and for the CLI
19- versionpkg.Set(version, commit, date)20+ versionpkg.Set(version, commit, date, dirty)
20- cli.SetVersionInfo(version, commit, date)21+ cli.SetVersionInfo(version, commit, date, dirty)
21 22
22 if err := cli.NewRootCmd().Execute(); err != nil {23 if err := cli.NewRootCmd().Execute(); err != nil {
23 os.Exit(1)24 os.Exit(1)
M pkg/version/version.go +8 -2
@@ -1,5 +1,6 @@
1 // Package version holds build-time version info for Gordon.1 // 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().
3 package version4 package version
4 5
5 // Build information, populated by Set() at startup.6 // Build information, populated by Set() at startup.
@@ -7,13 +8,15 @@ var (
7 version = "dev"8 version = "dev"
8 commit = "unknown"9 commit = "unknown"
9 buildDate = "unknown"10 buildDate = "unknown"
11+ dirty = "unknown"
10 )12 )
11 13
12 // Set stores build-time version info. Call once from main.14 // 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) {
14 version = v16 version = v
15 commit = c17 commit = c
16 buildDate = d18 buildDate = d
19+ dirty = isDirty
17 }20 }
18 21
19 // Version returns the build version string.22 // Version returns the build version string.
@@ -24,3 +27,6 @@ func Commit() string { return commit }
24 27
25 // BuildDate returns the build date string.28 // BuildDate returns the build date string.
26 func BuildDate() string { return buildDate }29 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