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 Unverified
B5690EEEBB952194The key is unknown
94 files · +6830 -275
M .goreleaser.yaml +1 -1
| @@ -23,7 +23,7 @@ builds: | |||||
| 23 | env: | 23 | env: | ||
| 24 | - CGO_ENABLED=0 | 24 | - 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=dev | 5 | ARG VERSION=dev | ||
| 6 | ARG COMMIT=unknown | 6 | ARG COMMIT=unknown | ||
| 7 | ARG BUILD_DATE=unknown | 7 | 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 builder | 14 | FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.22 AS builder | ||
| 10 | 15 | ||||
| @@ -13,6 +18,7 @@ ARG TARGETARCH=amd64 | |||||
| 13 | ARG VERSION | 18 | ARG VERSION | ||
| 14 | ARG COMMIT | 19 | ARG COMMIT | ||
| 15 | ARG BUILD_DATE | 20 | ARG BUILD_DATE | ||
| 21 | + | ARG DIRTY | |||
| 16 | 22 | ||||
| 17 | RUN apk add --no-cache git ca-certificates tzdata | 23 | 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.go | 35 | -o /gordon ./main.go | ||
| 30 | 36 | ||||
| 31 | FROM alpine:3.22 | 37 | 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 flags | 18 | # 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 | # Architectures | 25 | # Architectures | ||
| 21 | ARCHS := amd64 arm64 | 26 | 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.go | 92 | @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.go | 93 | @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 | done | 116 | 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 diff | 105 | ## gordon apps diff | ||
| 85 | 106 | ||||
| 86 | ```bash | 107 | ```bash | ||
| @@ -93,16 +114,84 @@ Shows the normalized desired-vs-active diff (`added`, `removed`, `changed`). | |||||
| 93 | 114 | ||||
| 94 | ## gordon apps secrets | 115 | ## 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 in | 118 | + | 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 | ```bash | 183 | ```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 not | 32 | 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 backups | 39 | ## 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 | ## Related | 108 | ## 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 blog | 67 | gordon apps stop blog | ||
| 68 | gordon apps start blog | 68 | gordon apps start blog | ||
| 69 | gordon apps remove blog | 69 | gordon apps remove blog | ||
| 70 | + | gordon apps operations show blog --key <operation-key> | |||
| 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 prod | 75 | 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 override | 32 | command = ["node", "server.js"] # optional override | ||
| 33 | - | stop_grace = "10s" # optional, default 10s | 33 | + | stop_grace = "30s" # optional, default 30s |
| 34 | 34 | ||||
| 35 | [service.readiness] # optional explicit readiness | 35 | [service.readiness] # optional explicit readiness | ||
| 36 | type = "http" | 36 | type = "http" | ||
| @@ -43,6 +43,10 @@ host = "blog.mydomain.com" | |||||
| 43 | port = 3000 | 43 | port = 3000 | ||
| 44 | tls = "auto" # auto | always | never | 44 | 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 = false | 56 | 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 declarations | 63 | [[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 | ## Services | 84 | ## Services | ||
| @@ -101,17 +111,45 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||||
| 101 | 111 | ||||
| 102 | ## Volumes and Databases | 112 | ## 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 | ## Readiness | 145 | ## 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 | ## TLS | 149 | ## 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 | ## Strictness | 163 | ## 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 volume | 8 | 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 a | 9 | (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 Networks | 32 | ## 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 prefix | 150 | prefix = "gordon" # Volume name prefix | ||
| 151 | preserve = true # Keep volumes when containers are removed | 151 | 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 Variables | 358 | ## 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 handling | 66 | ## 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 exposure | 140 | ## 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 | ## Pruning | 102 | ## 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 = true | 117 | 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 = false | 135 | 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" .); then | 318 | -o "$BINARY" .); then | ||
| 317 | echo "Error: Failed to build Gordon from source" | 319 | echo "Error: Failed to build Gordon from source" | ||
| 318 | exit 1 | 320 | 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 app | 107 | // 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 rawManifest | 122 | 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, nil | 138 | 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] = value | 168 | 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, nil | 184 | 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 = readiness | 231 | svc.Readiness = readiness | ||
| 177 | for _, h := range raw.HTTP { | 232 | for _, h := range raw.HTTP { | ||
| 178 | - | tls := h.TLS | 233 | + | iface, err := toDomainHTTP(raw.Name, h) |
| 179 | - | if tls == "" { | 234 | + | if err != nil { |
| 180 | - | tls = domain.AppTLSAuto | 235 | + | 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.OutcomeUnknownError | 71 | 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 err | 78 | return err | ||
| @@ -95,8 +96,8 @@ func renderAppOpConflict(out io.Writer, op, app, key string, err error, jsonOut | |||||
| 95 | return rerr, true | 96 | 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), true | 101 | 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 in | 475 | + | --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 cmd | 480 | 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 string | 522 | var service string | ||
| 523 | + | var key string | |||
| 483 | var fromStdin bool | 524 | var fromStdin bool | ||
| 484 | var jsonOut bool | 525 | 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 cmd | 547 | 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 err | 573 | 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] = value | 650 | values[key] = value | ||
| 592 | } | 651 | } | ||
| 593 | return values, nil | 652 | 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 []string | 694 | 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, nil | 701 | 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 err | 870 | 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.Buffer | 187 | 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 cmd | 22 | 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) error | 27 | SetAppSecrets(ctx context.Context, app string, req dto.AppSecretSetRequest) error | ||
| 27 | DeleteAppSecret(ctx context.Context, app string, req dto.AppSecretDeleteRequest) error | 28 | 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 _c | 760 | 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 MockControlPlane | 837 | // 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, nil | 260 | 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 := false | 374 | 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) > 0 | 383 | 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.Error | 387 | 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 targeting | 25 | // Global flags for remote targeting | ||
| 25 | remoteFlag string | 26 | 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 err | 231 | return err | ||
| 231 | } | 232 | } | ||
| 233 | + | if err := cliWriteLine(out, cliRenderMeta("Dirty:", Dirty)); err != nil { | |||
| 234 | + | return err | |||
| 235 | + | } | |||
| 232 | return nil | 236 | 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 = version | 320 | Version = version | ||
| 317 | Commit = commit | 321 | Commit = commit | ||
| 318 | BuildDate = date | 322 | 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 | return | 67 | 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 | return | 75 | 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.Client | 45 | + | 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}, nil | 121 | + | 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, err | 144 | 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 configuration | 152 | // 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 binds | 297 | 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, nil | 674 | 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.GCBarrier | 324 | gcBarrier out.GCBarrier | ||
| 321 | appDeploySvc *deployment.Service | 325 | appDeploySvc *deployment.Service | ||
| 322 | appSvc in.AppService | 326 | appSvc in.AppService | ||
| 327 | + | appSvcImpl *apps.AppServiceImpl | |||
| 323 | appActivator *apptraffic.Activator | 328 | appActivator *apptraffic.Activator | ||
| 324 | appHostIndex *apptraffic.HostIndex | 329 | appHostIndex *apptraffic.HostIndex | ||
| 325 | appTrafficPublisher *appTrafficPublisher | 330 | 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{}, err | 446 | 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, nil | 452 | 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 defaults | 475 | // (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 nil | 487 | 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 every | 921 | // 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 | return | 1074 | 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.Mutex | 1874 | + | mu sync.Mutex |
| 1840 | - | lastRun time.Time | 1875 | + | lastRun time.Time |
| 1841 | - | debounce time.Duration | 1876 | + | debounce time.Duration |
| 1877 | + | trailingTimer *time.Timer | |||
| 1878 | + | trailingGeneration uint64 | |||
| 1879 | + | stopped bool | |||
| 1880 | + | ||||
| 1881 | + | // lifecycleCtx owns the context used by debounced trailing reloads. Its | |||
| 1882 | + | // base is detached from any caller's cancellation so a short-lived request | |||
| 1883 | + | // context cannot abort a coalesced apply, while lifecycleCancel lets Stop | |||
| 1884 | + | // tear the coordinator's own lifecycle down explicitly. | |||
| 1885 | + | lifecycleCtx context.Context | |||
| 1886 | + | lifecycleCancel context.CancelFunc | |||
| 1842 | 1887 | ||||
| 1843 | configSvc configReloader | 1888 | configSvc configReloader | ||
| 1844 | v *viper.Viper | 1889 | v *viper.Viper | ||
| 1845 | proxySvc proxyConfigUpdater | 1890 | proxySvc proxyConfigUpdater | ||
| 1846 | applyContainerConfig func(context.Context, Config) error | 1891 | 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 = apply | 1927 | 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 nil | 1963 | 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 err | 2041 | 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, nil | 2460 | return cleanup, nil | ||
| @@ -2901,18 +3027,38 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2901 | return err | 3027 | 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 err | 3043 | 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 err | 3047 | 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 err | 3076 | 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 containers | 3083 | 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 err | 3122 | return err | ||
| 2986 | } | 3123 | } | ||
| 2987 | - | if err := waitForServerReady(proxyReady, errChan); err != nil { | 3124 | + | return waitForServerReady(ctx, proxyReady, errChan) |
| 2988 | - | return err | 3125 | + | } |
| 3126 | + | ||||
| 3127 | + | // shutdownStartedServers gracefully shuts down listeners that bound before a | |||
| 3128 | + | // partial startup failure so an error return does not leak them. | |||
| 3129 | + | func shutdownStartedServers(servers []*http.Server, log zerowrap.Logger) { | |||
| 3130 | + | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 3131 | + | defer shutdownCancel() | |||
| 3132 | + | for _, srv := range servers { | |||
| 3133 | + | if srv != nil { | |||
| 3134 | + | if err := srv.Shutdown(shutdownCtx); err != nil { | |||
| 3135 | + | log.Error().Err(err).Msg("failed to shut down server during startup cleanup") | |||
| 3136 | + | } | |||
| 3137 | + | } | |||
| 2989 | } | 3138 | } | ||
| 2990 | - | if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc, svc.appHostIndex); err != nil { | 3139 | + | } |
| 2991 | - | return err | 3140 | + | |
| 3141 | + | // cleanupStartupResources releases every resource a partial startup may have | |||
| 3142 | + | // acquired once the local admin socket is serving: the admin socket first (no | |||
| 3143 | + | // further mutations can begin), then the bound servers, then the traffic | |||
| 3144 | + | // manager that startProxyServers may already have populated. | |||
| 3145 | + | func cleanupStartupResources(localAdmin *localAdminServer, trafficManager *trafficadapter.Manager, log zerowrap.Logger, servers ...*http.Server) { | |||
| 3146 | + | if localAdmin != nil { | |||
| 3147 | + | localAdmin.Close() | |||
| 2992 | } | 3148 | } | ||
| 2993 | - | return nil | 3149 | + | 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 nil | 3166 | return nil | ||
| 3010 | } | 3167 | } | ||
| @@ -3013,6 +3170,8 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | |||||
| 3013 | return nil | 3170 | return nil | ||
| 3014 | case err := <-errChan: | 3171 | case err := <-errChan: | ||
| 3015 | return err | 3172 | 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 int | 105 | calls int | ||
| 105 | config proxy.Config | 106 | 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 = config | 113 | 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 int | 130 | calls int | ||
| 115 | maxBlobChunkSize int64 | 131 | maxBlobChunkSize int64 | ||
| 116 | maxBlobSize int64 | 132 | 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 = maxBlobChunkSize | 139 | r.maxBlobChunkSize = maxBlobChunkSize | ||
| 122 | r.maxBlobSize = maxBlobSize | 140 | 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 int | 156 | calls int | ||
| 127 | ctx context.Context | 157 | ctx context.Context | ||
| @@ -168,6 +198,12 @@ func (e *eventBusRecorder) Calls() int { | |||||
| 168 | return e.calls | 198 | 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 hook | 284 | // 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 := ®istryLimitsRecorder{} | 515 | registryLimits := ®istryLimitsRecorder{} | ||
| 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 <- expected | 47 | 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 one | 62 | // SetSecrets writes secret values for pre-registered names in one | ||
| 60 | // service. service is required; every key must already exist in | 63 | // 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 string | 82 | App string | ||
| 71 | FormerRevision string | 83 | 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 _c | 410 | 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 MockAppService | 487 | // 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 _c | 1858 | 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 MockContainerRuntime | 1927 | // 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 _c | 2050 | 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 MockContainerRuntime | 2110 | // 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 _c | 2719 | 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 MockContainerRuntime | 2785 | // 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 operations | 41 | // Image operations | ||
| 42 | PullImage(ctx context.Context, image string) error | 42 | 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) error | 44 | PullImageWithAuth(ctx context.Context, image, username, password string) error | ||
| 44 | TagImage(ctx context.Context, sourceRef, targetRef string) error | 45 | TagImage(ctx context.Context, sourceRef, targetRef string) error | ||
| 45 | UntagImage(ctx context.Context, imageRef string) error | 46 | UntagImage(ctx context.Context, imageRef string) error | ||
| @@ -81,6 +82,7 @@ type ContainerRuntime interface { | |||||
| 81 | 82 | ||||
| 82 | // Image identity | 83 | // 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 operations | 87 | // 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) error | 96 | ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error | ||
| 95 | DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error | 97 | 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 normalized | 8 | // ReservationsFor computes the global listener claims for a normalized | ||
| 9 | // spec. It is the single owner of reservation derivation; the apps and | 9 | // 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 []AppListenerReservation | 14 | 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.Minute | 77 | AppMaxReadinessTimeout = 10 * time.Minute | ||
| 70 | // AppDefaultStopGrace applies when stop_grace is unset. | 78 | // AppDefaultStopGrace applies when stop_grace is unset. | ||
| 71 | - | AppDefaultStopGrace = 10 * time.Second | 79 | + | AppDefaultStopGrace = 30 * time.Second |
| 72 | // AppMaxStopGrace caps stop_grace. | 80 | // AppMaxStopGrace caps stop_grace. | ||
| 73 | AppMaxStopGrace = 5 * time.Minute | 81 | AppMaxStopGrace = 5 * time.Minute | ||
| 74 | ) | 82 | ) | ||
| @@ -95,6 +103,7 @@ type AppService struct { | |||||
| 95 | UDP []AppUDPInterface | 103 | UDP []AppUDPInterface | ||
| 96 | Secrets map[string]string | 104 | Secrets map[string]string | ||
| 97 | Volumes []AppVolume | 105 | Volumes []AppVolume | ||
| 106 | + | Binds []AppBind | |||
| 98 | Databases []AppDatabase | 107 | Databases []AppDatabase | ||
| 99 | Backup AppBackup | 108 | Backup AppBackup | ||
| 100 | } | 109 | } | ||
| @@ -108,11 +117,71 @@ type AppReadiness struct { | |||||
| 108 | Timeout time.Duration | 117 | 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 string | 125 | Host string | ||
| 114 | Port int | 126 | Port int | ||
| 115 | TLS string | 127 | 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 bool | 205 | 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 string | 219 | 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 err | 523 | 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 err | 529 | return err | ||
| 394 | } | 530 | } | ||
| 395 | return nil | 531 | 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 err | 548 | 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 nil | 582 | 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, nil | 614 | 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. The | 731 | // leading slash, no scheme or authority, and no control characters. The | ||
| 534 | // path is appended to an immutable loopback URL, so rejecting everything | 732 | // path is appended to an immutable loopback URL, so rejecting everything | ||
| 535 | // that could be read as a different authority keeps the probe on the | 733 | // 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) == 0 | 760 | 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 false | 817 | 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 nil | 853 | 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 diff | 960 | 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 []string | 1012 | 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 true | 1071 | return true | ||
| 768 | } | 1072 | } | ||
| 769 | } | 1073 | } | ||
| @@ -780,6 +1084,18 @@ func interfacesChanged(desired, effective AppService) bool { | |||||
| 780 | return false | 1084 | 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 true | 1122 | 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 replaced | 303 | // 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 NetworkProtocol | 90 | 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 string | 105 | Image string | ||
| @@ -105,6 +115,7 @@ type ContainerConfig struct { | |||||
| 105 | RestartPolicy string | 115 | RestartPolicy string | ||
| 106 | Volumes map[string]string // map[containerPath]volumeName | 116 | 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 join | 119 | NetworkMode string // Network to join | ||
| 109 | Hostname string // Container hostname for DNS | 120 | Hostname string // Container hostname for DNS | ||
| 110 | Aliases []string // Additional network aliases | 121 | 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 errors | 65 | // 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 errors | 79 | // 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 reference | 38 | // 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 s | 66 | 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, nil | 351 | 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.GCBarrier | 30 | barrier out.GCBarrier | ||
| 30 | imagePolicy domain.ImageSourcePolicy | 31 | 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 s | 54 | 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, err | 178 | 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 interface | 231 | // validateEntrypointCompatibility checks every declared TCP/UDP interface | ||
| 177 | // against the installation entrypoint listeners before any persistence or | 232 | // against the installation entrypoint listeners before any persistence or | ||
| 178 | // workload effect. The runtime binds the entrypoint address, so a publish | 233 | // 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.Spec | 345 | svcSpec := svc.Spec | ||
| 291 | svcSpec.Name = name | 346 | 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 = name | 88 | spec.Name = name | ||
| 89 | var entries []RouteEntry | 89 | 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.Second | 21 | const failLogTailTimeout = 10 * time.Second | ||
| 22 | 22 | ||||
| 23 | // Deploy executes a preflighted revision: fail-fast across services in | 23 | // Deploy executes a preflighted revision: fail-fast across services in | ||
| 24 | - | // sorted name order. HTTP services without volumes keep the old container | 24 | + | // sorted name order. HTTP-only services whose interfaces are all public, |
| 25 | - | // serving until the replacement passes readiness, then switch with a | 25 | + | // 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. Volumes | 27 | + | // retire the old container by exact ID. TCP/UDP, any internal HTTP |
| 28 | - | // are never deleted: no RemoveVolume call, no volume-deletion flags on | 28 | + | // 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 BEFORE | 75 | + | // A targeted deploy is intentionally a partial plan: services omitted |
| 75 | - | // publishing anything: a service the operator removed must stop being | 76 | + | // from pinned remain active. Full deploys reconcile actual removals. |
| 76 | - | // reachable, and its exact container must be stopped and removed | 77 | + | 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, err | 80 | + | } |
| 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 false | 357 | return false | ||
| 347 | } | 358 | } | ||
| 359 | + | for _, h := range spec.HTTP { | |||
| 360 | + | if !h.IsPublic() { | |||
| 361 | + | return false | |||
| 362 | + | } | |||
| 363 | + | } | |||
| 348 | return true | 364 | 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 passes | 374 | // deployHTTP keeps the old container serving until the replacement passes | ||
| 352 | // readiness AND the new effective state is published: the proxy switches | 375 | // 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), false | 382 | 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 while | 385 | // Old version keeps serving. Capture redacted diagnostics while | ||
| 363 | // the failed replacement still exists, then remove only that | 386 | // 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 generation | 451 | // 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, so | 453 | // Inhibit that generation durably BEFORE the write can happen, so | ||
| 425 | // boot or periodic recovery can never restart the old writer on | 454 | // boot or periodic recovery can never restart the old writer on | ||
| 426 | // top of the new one. Cleared once the safe generation is | 455 | // 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) > 0 | 457 | + | 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), true | 460 | 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), true | 477 | 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 | }, true | 503 | }, 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, err | 581 | 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, nil | 662 | return created, binds, udpBinds, nil | ||
| 626 | } | 663 | } | ||
| 627 | 664 | ||||
| 628 | - | // backendPublishes collects every interface container port for loopback | 665 | + | 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.1 | 667 | + | 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.ContainerBackendPort | 689 | 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-only | 712 | // Readiness probes are TCP-only (validation rejects UDP-only | ||
| 655 | // services with tcp/http readiness); the readiness port matches a | 713 | // 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].Protocol | 722 | 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, nil | 851 | 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, nil | 857 | 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 := image | 863 | + | 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, nil | 872 | 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 = converged | 964 | 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. Empty | 198 | // 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 []string | 244 | 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] = svcResult | 255 | 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, nil | 272 | 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 published | 399 | // re-inspects its binds, and verifies readiness before it may be published | ||
| 268 | // again. Every failure leaves the service withdrawn with its recorded binds | 400 | // 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 runtime | 433 | // Ephemeral loopback binds are not guaranteed stable across a runtime | ||
| 297 | // restart: re-inspect before probing so the proxy never dials a stale | 434 | // 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 durably | 684 | // Boot recovery refuses a generation whose recovery is durably | ||
| 542 | // inhibited: a replacement may already have written to its | 685 | // 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 true | 718 | 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 true | 780 | 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 true | 785 | 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 true | 789 | 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 restarted | 800 | // 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 must | 802 | // Ephemeral binds may shift across runtime restarts; the proxy must | ||
| 653 | // never dial the stale recorded bind. Uses the non-destructive | 803 | // never dial the stale recorded bind. Uses the non-destructive | ||
| 654 | // inspectBackendBinds: an existing ACTIVE container is never stopped | 804 | // 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, nil | 809 | 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, err | 813 | 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 = binds | 823 | + | return nil, nil, fmt.Errorf("deployment: app %q has no active state: %w", app, domain.ErrAppStateConflict) |
| 674 | - | eff.UDPBackendBinds = udpBinds | 824 | + | } |
| 675 | - | active.Services[name] = eff | 825 | + | 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, nil | 828 | 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 its | 851 | // failServiceStep records a failed service step and withdraws its | ||
| 684 | // recorded backend binds from ACTIVE: a bind that failed verification | 852 | // recorded backend binds from ACTIVE: a bind that failed verification | ||
| 685 | // (or could not be refreshed) must never be served again — the | 853 | // (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 never | 404 | // convergeStoppedService enforces durable stopped intent: it never | ||
| 393 | // starts or publishes, and it stops a verified running exact ACTIVE ID | 405 | // 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 failure | 633 | // 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, nil | 686 | 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 is | 60 | // 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 is | 66 | // 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) error | 24 | 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 mirrors | 31 | // 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 probes | 268 | // 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-capable | 408 | // 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.Port | 411 | + | 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, nil | 416 | 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-capable | 456 | // singleTCPPort returns the container port when exactly one TCP-capable | ||
| 237 | // interface exists; validation rejects ambiguity (readiness.port | 457 | // 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 restarting | 98 | // generation, so a native restart is detected without restarting | ||
| 98 | // the workload ourselves. | 99 | // the workload ourselves. | ||
| 99 | executions executionTracker | 100 | 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 s | 129 | 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-app | 182 | // acquireAppContext takes the GC shared lease first and the per-app | ||
| 127 | // coordinator second, following the documented lock order | 183 | // coordinator second, following the documented lock order | ||
| 128 | // (GC barrier → per-app coordinator → registry lock → bbolt | 184 | // (GC barrier → per-app coordinator → registry lock → bbolt | ||
| @@ -256,6 +312,7 @@ type pinnedService struct { | |||||
| 256 | digest string | 312 | digest string | ||
| 257 | runtimeImage string | 313 | runtimeImage string | ||
| 258 | appEnv map[string]string | 314 | appEnv map[string]string | ||
| 315 | + | appNetworks []domain.AppSharedNetwork | |||
| 259 | // sharedNetworks are the declared shared-network memberships this | 316 | // sharedNetworks are the declared shared-network memberships this | ||
| 260 | // service joins. The private incarnation network is derived from the | 317 | // 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, err | 360 | 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, err | 364 | 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 nil | 488 | 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 err | 497 | 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 nil | 505 | + | diff := domain.DiffAppSpec(rev.Spec, effectiveAppSpec(active)) |
| 506 | + | prefix := "service/" + service + "/" | |||
| 507 | + | if len(diff.Added) == 0 && len(diff.Removed) == 0 && len(diff.Changed) > 0 { | |||
| 508 | + | for _, path := range diff.Changed { | |||
| 509 | + | if !strings.HasPrefix(path, prefix) { | |||
| 510 | + | return targetedDivergenceError(rev.Revision, path) | |||
| 511 | + | } | |||
| 512 | + | } | |||
| 513 | + | return nil | |||
| 514 | + | } | |||
| 515 | + | return targetedDivergenceError(rev.Revision, "application structure") | |||
| 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 = filtered | 542 | + | } |
| 543 | + | return nil, fmt.Errorf("deployment: service %q not in revision %s: %w", onlyService, rev.Revision, domain.ErrAppStateConflict) | |||
| 544 | + | } | |||
| 545 | + | ||||
| 546 | + | // preflightServices runs the five preflight gates in order. No mutation. | |||
| 547 | + | func (s *Service) preflightServices(ctx context.Context, app string, rev domain.AppDesiredRevision, onlyService string) ([]pinnedService, error) { | |||
| 548 | + | services, err := selectPreflightServices(rev, onlyService) | |||
| 549 | + | if err != nil { | |||
| 550 | + | return nil, err | |||
| 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, err | 581 | 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 CLI | 19 | // 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 version | 4 | 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 = v | 16 | version = v | ||
| 15 | commit = c | 17 | commit = c | ||
| 16 | buildDate = d | 18 | 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 | + | } | |||