feat(apps): support per-service env in app manifests (#9)
Closes #4 ## Summary App manifests accept public per-service environment variables in `[services.<name>.env]`. - `domain.AppService.Env`, validated with the same rules as app-wide `[env]` (shared helper): valid keys, no newlines, 1 byte to 64 KiB, no secret references. - A service env key overrides the same key from `[env]` for that service. It must not collide with that service's secret keys (checked at apply and in deploy preflight). - Changes show as `service/<name>/env` in diffs and trigger replacement on the next deploy. - Container env is merged as app env, then service env, then secrets. - Stored revisions without the field decode to an empty map and diff as unchanged. - Docs (`config/apps.md`, `concepts.md`, troubleshooting) describe the env/secrets split. Values are stored in plain text in revisions, like `[env]`; sensitive values still belong in secrets. ## Checks - `go test ./...` - `golangci-lint run ./...` Reviewed-on: https://git.bnema.dev/bnema/gordon/pulls/9
- commit
- 48e06912425e956a30eb0125035d59d6326e2c1e
- author
- brice · · 2026-10-02 13:30 UTC
- committer
- bnema · · 2026-10-02 13:30 UTC
- parent
- 3a7fcc7
- signature
- unsigned
9 files · +158 -59
M docs/concepts.md +8 -2
| @@ -55,6 +55,9 @@ image = "gordon.mydomain.com/blog:1.4.2" | |||
| 55 | 55 | host = "blog.mydomain.com" | |
| 56 | 56 | port = 3000 | |
| 57 | 57 | ||
| 58 | + | [services.web.env] # public env for this service only | |
| 59 | + | LOG_LEVEL = "info" | |
| 60 | + | ||
| 58 | 61 | [services.web.secrets] # ENV name -> secret name (values stay in pass) | |
| 59 | 62 | DATABASE_URL = "database-url" | |
| 60 | 63 | ``` | |
| @@ -63,7 +66,7 @@ DATABASE_URL = "database-url" | |||
| 63 | 66 | - `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply. | |
| 64 | 67 | - `--dry-run` validates and previews without persistence or runtime effects. | |
| 65 | 68 | - Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content. | |
| 66 | - | - The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential. | |
| 69 | + | - The file is intended for Git: it must never contain secret values. Confidential values use `secrets`; public values use `[env]` or `[services.<name>.env]`. | |
| 67 | 70 | - Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command. | |
| 68 | 71 | ||
| 69 | 72 | ## Updates | |
| @@ -150,9 +153,12 @@ App-wide public env is declared in the manifest: | |||
| 150 | 153 | APP_ENV = "production" | |
| 151 | 154 | ``` | |
| 152 | 155 | ||
| 153 | - | Service-specific values use `secrets` even when non-confidential: | |
| 156 | + | Public values for one service use `[services.<name>.env]` and override `[env]` keys for that service. Confidential values use `secrets`: | |
| 154 | 157 | ||
| 155 | 158 | ```toml | |
| 159 | + | [services.web.env] | |
| 160 | + | LOG_LEVEL = "info" | |
| 161 | + | ||
| 156 | 162 | [services.web.secrets] | |
| 157 | 163 | DATABASE_URL = "database-url" | |
| 158 | 164 | ``` | |
| @@ -55,6 +55,9 @@ image = "gordon.mydomain.com/blog:1.4.2" | |||||
| 55 | host = "blog.mydomain.com" | 55 | host = "blog.mydomain.com" | ||
| 56 | port = 3000 | 56 | port = 3000 | ||
| 57 | 57 | ||||
| 58 | + | [services.web.env] # public env for this service only | |||
| 59 | + | LOG_LEVEL = "info" | |||
| 60 | + | ||||
| 58 | [services.web.secrets] # ENV name -> secret name (values stay in pass) | 61 | [services.web.secrets] # ENV name -> secret name (values stay in pass) | ||
| 59 | DATABASE_URL = "database-url" | 62 | DATABASE_URL = "database-url" | ||
| 60 | ``` | 63 | ``` | ||
| @@ -63,7 +66,7 @@ DATABASE_URL = "database-url" | |||||
| 63 | - `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply. | 66 | - `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply. | ||
| 64 | - `--dry-run` validates and previews without persistence or runtime effects. | 67 | - `--dry-run` validates and previews without persistence or runtime effects. | ||
| 65 | - Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content. | 68 | - Version tags are recommended, not constrained to SemVer. `latest` remains valid; explicit deploy re-resolves mutable tags while restart uses the active pinned content. | ||
| 66 | - | - The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential. | 69 | + | - The file is intended for Git: it must never contain secret values. Confidential values use `secrets`; public values use `[env]` or `[services.<name>.env]`. |
| 67 | - Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command. | 70 | - Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command. | ||
| 68 | 71 | ||||
| 69 | ## Updates | 72 | ## Updates | ||
| @@ -150,9 +153,12 @@ App-wide public env is declared in the manifest: | |||||
| 150 | APP_ENV = "production" | 153 | APP_ENV = "production" | ||
| 151 | ``` | 154 | ``` | ||
| 152 | 155 | ||||
| 153 | - | Service-specific values use `secrets` even when non-confidential: | 156 | + | Public values for one service use `[services.<name>.env]` and override `[env]` keys for that service. Confidential values use `secrets`: |
| 154 | 157 | ||||
| 155 | ```toml | 158 | ```toml | ||
| 159 | + | [services.web.env] | |||
| 160 | + | LOG_LEVEL = "info" | |||
| 161 | + | ||||
| 156 | [services.web.secrets] | 162 | [services.web.secrets] | ||
| 157 | DATABASE_URL = "database-url" | 163 | DATABASE_URL = "database-url" | ||
| 158 | ``` | 164 | ``` | ||
M docs/config/apps.md +6 -3
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | Application workloads are declared in standalone TOML files — one file per app — not in `gordon.toml`. Apply with `gordon apps apply --file <app>.toml`, activate with `gordon apps deploy <app>`. | |
| 4 | 4 | ||
| 5 | - | The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential; values stay in pass. | |
| 5 | + | The file is intended for Git: it must never contain secret values. Confidential values use `secrets`, whose values stay in pass; public values use `[env]` or `[services.<name>.env]`. | |
| 6 | 6 | ||
| 7 | 7 | ## Minimal Example | |
| 8 | 8 | ||
| @@ -48,6 +48,9 @@ tls = "auto" # auto | always | never | |||
| 48 | 48 | visibility = "internal" # public (default) | internal | |
| 49 | 49 | port = 8080 # required; no host or tls | |
| 50 | 50 | ||
| 51 | + | [services.web.env] # optional, non-secret env for this service only | |
| 52 | + | LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name | |
| 53 | + | ||
| 51 | 54 | [services.web.secrets] # ENV name -> secret name (values in pass) | |
| 52 | 55 | DATABASE_URL = "database-url" | |
| 53 | 56 | ||
| @@ -108,8 +111,8 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||
| 108 | 111 | ## Environment and Secrets | |
| 109 | 112 | ||
| 110 | 113 | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | |
| 114 | + | - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy. | |
| 111 | 115 | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | |
| 112 | - | - There is no `[services.<name>.env]` key — service-specific values must use secrets. | |
| 113 | 116 | ||
| 114 | 117 | ## Volumes and Databases | |
| 115 | 118 | ||
| @@ -179,7 +182,7 @@ Changing either setting is a service change: it applies on the next deploy. | |||
| 179 | 182 | ||
| 180 | 183 | ## Strictness | |
| 181 | 184 | ||
| 182 | - | 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. | |
| 185 | + | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions (app-wide or per-service), unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. | |
| 183 | 186 | ||
| 184 | 187 | The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest). | |
| 185 | 188 | ||
| @@ -2,7 +2,7 @@ | |||||
| 2 | 2 | ||||
| 3 | Application workloads are declared in standalone TOML files — one file per app — not in `gordon.toml`. Apply with `gordon apps apply --file <app>.toml`, activate with `gordon apps deploy <app>`. | 3 | Application workloads are declared in standalone TOML files — one file per app — not in `gordon.toml`. Apply with `gordon apps apply --file <app>.toml`, activate with `gordon apps deploy <app>`. | ||
| 4 | 4 | ||||
| 5 | - | The file is intended for Git: it must never contain secret values. Service-specific values use `secrets` even when non-confidential; values stay in pass. | 5 | + | The file is intended for Git: it must never contain secret values. Confidential values use `secrets`, whose values stay in pass; public values use `[env]` or `[services.<name>.env]`. |
| 6 | 6 | ||||
| 7 | ## Minimal Example | 7 | ## Minimal Example | ||
| 8 | 8 | ||||
| @@ -48,6 +48,9 @@ tls = "auto" # auto | always | never | |||||
| 48 | visibility = "internal" # public (default) | internal | 48 | visibility = "internal" # public (default) | internal | ||
| 49 | port = 8080 # required; no host or tls | 49 | port = 8080 # required; no host or tls | ||
| 50 | 50 | ||||
| 51 | + | [services.web.env] # optional, non-secret env for this service only | |||
| 52 | + | LOG_LEVEL = "debug" # overrides an app-wide [env] key of the same name | |||
| 53 | + | ||||
| 51 | [services.web.secrets] # ENV name -> secret name (values in pass) | 54 | [services.web.secrets] # ENV name -> secret name (values in pass) | ||
| 52 | DATABASE_URL = "database-url" | 55 | DATABASE_URL = "database-url" | ||
| 53 | 56 | ||||
| @@ -108,8 +111,8 @@ Image registry names and digest syntax are validated during manifest apply, reso | |||||
| 108 | ## Environment and Secrets | 111 | ## Environment and Secrets | ||
| 109 | 112 | ||||
| 110 | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | 113 | - `[env]` is app-wide public env injected into all services. Each key must be disjoint from every `[services.<name>.secrets]` key in the app. | ||
| 114 | + | - `[services.<name>.env]` is public env for that service only. A key here overrides the same key in `[env]` for this service. Each key must be disjoint from the same service's `[services.<name>.secrets]` keys. Values follow the `[env]` rules: no newlines, 1 byte to 64 KiB, no secret references. Values are stored in revisions and shown in plain text, so use secrets for anything sensitive. A change shows as `service/<name>/env` in diffs and applies on the next deploy. | |||
| 111 | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | 115 | - `[services.<name>.secrets]` maps ENV var name to service-local secret name. Values are written with `gordon apps secrets set` and stay in pass under `gordon/apps/<uuid>/<service>/<name>`. Running containers keep the values they were created with; `gordon apps deploy` applies new values. `restart` does not. | ||
| 112 | - | - There is no `[services.<name>.env]` key — service-specific values must use secrets. | |||
| 113 | 116 | ||||
| 114 | ## Volumes and Databases | 117 | ## Volumes and Databases | ||
| 115 | 118 | ||||
| @@ -179,7 +182,7 @@ Changing either setting is a service change: it applies on the next deploy. | |||||
| 179 | 182 | ||||
| 180 | ## Strictness | 183 | ## Strictness | ||
| 181 | 184 | ||||
| 182 | - | 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. | 185 | + | Unknown fields, duplicate service names, unresolved service/entrypoint/backup refs, secret/env collisions (app-wide or per-service), unsafe identities, and forbidden mounts are hard errors at apply time. Canonical host conflicts (including system domains and external routes) fail before persistence. |
| 183 | 186 | ||||
| 184 | The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest). | 187 | The keyed `[services.<name>]` form is the only accepted app schema: manifests written for an earlier v3 alpha using `[[service]]` with a `name` key and `[service.*]` tables are rejected with a keyed-schema diagnostic, not converted. See [Migrating a v3 alpha app manifest](../upgrading.md#migrating-a-v3-alpha-app-manifest). | ||
| 185 | 188 | ||||
M docs/reference/troubleshooting.md +1 -1
| @@ -178,7 +178,7 @@ gordon apps deploy app | |||
| 178 | 178 | ||
| 179 | 179 | **Solutions:** | |
| 180 | 180 | ||
| 181 | - | 1. Declare public values under `[env]` and service values under `[services.<name>.secrets]` in the app file, then apply it: | |
| 181 | + | 1. Declare app-wide public values under `[env]`, per-service public values under `[services.<name>.env]`, and sensitive values under `[services.<name>.secrets]` in the app file, then apply it: | |
| 182 | 182 | ```bash | |
| 183 | 183 | gordon apps apply --file ./blog.toml | |
| 184 | 184 | ``` | |
| @@ -178,7 +178,7 @@ gordon apps deploy app | |||||
| 178 | 178 | ||||
| 179 | **Solutions:** | 179 | **Solutions:** | ||
| 180 | 180 | ||||
| 181 | - | 1. Declare public values under `[env]` and service values under `[services.<name>.secrets]` in the app file, then apply it: | 181 | + | 1. Declare app-wide public values under `[env]`, per-service public values under `[services.<name>.env]`, and sensitive values under `[services.<name>.secrets]` in the app file, then apply it: |
| 182 | ```bash | 182 | ```bash | ||
| 183 | gordon apps apply --file ./blog.toml | 183 | gordon apps apply --file ./blog.toml | ||
| 184 | ``` | 184 | ``` | ||
M internal/adapters/in/appmanifest/parse.go +1 -28
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "fmt" | |
| 12 | 12 | "maps" | |
| 13 | 13 | "slices" | |
| 14 | - | "strconv" | |
| 15 | 14 | "strings" | |
| 16 | 15 | "time" | |
| 17 | 16 | ||
| @@ -321,6 +320,7 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 321 | 320 | Publish: u.Publish, | |
| 322 | 321 | }) | |
| 323 | 322 | } | |
| 323 | + | svc.Env = maps.Clone(raw.Env) | |
| 324 | 324 | for key, value := range raw.Secrets { | |
| 325 | 325 | svc.Secrets[key] = value | |
| 326 | 326 | } | |
| @@ -348,9 +348,6 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 348 | 348 | Schedule: db.Schedule, | |
| 349 | 349 | }) | |
| 350 | 350 | } | |
| 351 | - | if len(raw.Env) > 0 { | |
| 352 | - | return domain.AppService{}, fmt.Errorf("%w: service %q [services.%s.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, serviceName, tomlKey(serviceName)) | |
| 353 | - | } | |
| 354 | 351 | return svc, nil | |
| 355 | 352 | } | |
| 356 | 353 | ||
| @@ -376,30 +373,6 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e | |||
| 376 | 373 | return readiness, nil | |
| 377 | 374 | } | |
| 378 | 375 | ||
| 379 | - | // tomlKey renders a service name as a TOML key, quoting it when it is | |
| 380 | - | // not a bare key (service names may contain dots, e.g. web.api). | |
| 381 | - | func tomlKey(name string) string { | |
| 382 | - | if isBareTOMLKey(name) { | |
| 383 | - | return name | |
| 384 | - | } | |
| 385 | - | return strconv.Quote(name) | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | // isBareTOMLKey reports whether name is a TOML bare key (A-Za-z0-9_-). | |
| 389 | - | func isBareTOMLKey(name string) bool { | |
| 390 | - | if name == "" { | |
| 391 | - | return false | |
| 392 | - | } | |
| 393 | - | for _, r := range name { | |
| 394 | - | switch { | |
| 395 | - | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': | |
| 396 | - | default: | |
| 397 | - | return false | |
| 398 | - | } | |
| 399 | - | } | |
| 400 | - | return true | |
| 401 | - | } | |
| 402 | - | ||
| 403 | 376 | // normalizeImage records a missing tag as explicit :latest. | |
| 404 | 377 | func normalizeImage(image string) string { | |
| 405 | 378 | image = strings.TrimSpace(image) | |
| @@ -11,7 +11,6 @@ import ( | |||||
| 11 | "fmt" | 11 | "fmt" | ||
| 12 | "maps" | 12 | "maps" | ||
| 13 | "slices" | 13 | "slices" | ||
| 14 | - | "strconv" | |||
| 15 | "strings" | 14 | "strings" | ||
| 16 | "time" | 15 | "time" | ||
| 17 | 16 | ||||
| @@ -321,6 +320,7 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||||
| 321 | Publish: u.Publish, | 320 | Publish: u.Publish, | ||
| 322 | }) | 321 | }) | ||
| 323 | } | 322 | } | ||
| 323 | + | svc.Env = maps.Clone(raw.Env) | |||
| 324 | for key, value := range raw.Secrets { | 324 | for key, value := range raw.Secrets { | ||
| 325 | svc.Secrets[key] = value | 325 | svc.Secrets[key] = value | ||
| 326 | } | 326 | } | ||
| @@ -348,9 +348,6 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||||
| 348 | Schedule: db.Schedule, | 348 | Schedule: db.Schedule, | ||
| 349 | }) | 349 | }) | ||
| 350 | } | 350 | } | ||
| 351 | - | if len(raw.Env) > 0 { | |||
| 352 | - | return domain.AppService{}, fmt.Errorf("%w: service %q [services.%s.env] is not allowed, use secrets", domain.ErrInvalidAppSpec, serviceName, tomlKey(serviceName)) | |||
| 353 | - | } | |||
| 354 | return svc, nil | 351 | return svc, nil | ||
| 355 | } | 352 | } | ||
| 356 | 353 | ||||
| @@ -376,30 +373,6 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e | |||||
| 376 | return readiness, nil | 373 | return readiness, nil | ||
| 377 | } | 374 | } | ||
| 378 | 375 | ||||
| 379 | - | // tomlKey renders a service name as a TOML key, quoting it when it is | |||
| 380 | - | // not a bare key (service names may contain dots, e.g. web.api). | |||
| 381 | - | func tomlKey(name string) string { | |||
| 382 | - | if isBareTOMLKey(name) { | |||
| 383 | - | return name | |||
| 384 | - | } | |||
| 385 | - | return strconv.Quote(name) | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | // isBareTOMLKey reports whether name is a TOML bare key (A-Za-z0-9_-). | |||
| 389 | - | func isBareTOMLKey(name string) bool { | |||
| 390 | - | if name == "" { | |||
| 391 | - | return false | |||
| 392 | - | } | |||
| 393 | - | for _, r := range name { | |||
| 394 | - | switch { | |||
| 395 | - | case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': | |||
| 396 | - | default: | |||
| 397 | - | return false | |||
| 398 | - | } | |||
| 399 | - | } | |||
| 400 | - | return true | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | // normalizeImage records a missing tag as explicit :latest. | 376 | // normalizeImage records a missing tag as explicit :latest. | ||
| 404 | func normalizeImage(image string) string { | 377 | func normalizeImage(image string) string { | ||
| 405 | image = strings.TrimSpace(image) | 378 | image = strings.TrimSpace(image) | ||
M internal/adapters/in/appmanifest/parse_test.go +24 -7
| @@ -302,21 +302,38 @@ image = "img:3" | |||
| 302 | 302 | } | |
| 303 | 303 | } | |
| 304 | 304 | ||
| 305 | - | func TestParse_ServiceEnvRejected(t *testing.T) { | |
| 305 | + | func TestParse_ServiceEnv(t *testing.T) { | |
| 306 | + | doc := "name = \"blog\"\n[env]\nLOG = \"info\"\n[services.web]\nimage = \"img:1\"\n" + | |
| 307 | + | "[services.web.env]\nLOG = \"debug\"\nPORT = \"3000\"\n[services.web.secrets]\nDB = \"db\"\n" + | |
| 308 | + | "[services.worker]\nimage = \"img:1\"\n" | |
| 309 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |
| 310 | + | require.NoError(t, err) | |
| 311 | + | require.Len(t, spec.Services, 2) | |
| 312 | + | assert.Equal(t, map[string]string{"LOG": "info"}, spec.Env) | |
| 313 | + | assert.Equal(t, map[string]string{"LOG": "debug", "PORT": "3000"}, spec.Services[0].Env) | |
| 314 | + | assert.Empty(t, spec.Services[1].Env) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func TestParse_ServiceEnvInvalid(t *testing.T) { | |
| 306 | 318 | cases := []struct { | |
| 307 | 319 | name string | |
| 308 | 320 | doc string | |
| 309 | 321 | wantErr string | |
| 310 | 322 | }{ | |
| 311 | 323 | { | |
| 312 | - | "bare service name", | |
| 313 | - | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"bar\"\n", | |
| 314 | - | "[services.web.env]", | |
| 324 | + | "secret reference", | |
| 325 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"${pass:x}\"\n", | |
| 326 | + | "secret references", | |
| 327 | + | }, | |
| 328 | + | { | |
| 329 | + | "secret key collision", | |
| 330 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nDB = \"x\"\n[services.web.secrets]\nDB = \"db\"\n", | |
| 331 | + | "collides with a secret key", | |
| 315 | 332 | }, | |
| 316 | 333 | { | |
| 317 | - | "dotted service name is quoted", | |
| 318 | - | "name = \"blog\"\n[services.\"web.api\"]\nimage = \"img:1\"\n[services.\"web.api\".env]\nFOO = \"bar\"\n", | |
| 319 | - | `[services."web.api".env]`, | |
| 334 | + | "newline", | |
| 335 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"a\\nb\"\n", | |
| 336 | + | "newlines", | |
| 320 | 337 | }, | |
| 321 | 338 | } | |
| 322 | 339 | for _, tc := range cases { | |
| @@ -302,21 +302,38 @@ image = "img:3" | |||||
| 302 | } | 302 | } | ||
| 303 | } | 303 | } | ||
| 304 | 304 | ||||
| 305 | - | func TestParse_ServiceEnvRejected(t *testing.T) { | 305 | + | func TestParse_ServiceEnv(t *testing.T) { |
| 306 | + | doc := "name = \"blog\"\n[env]\nLOG = \"info\"\n[services.web]\nimage = \"img:1\"\n" + | |||
| 307 | + | "[services.web.env]\nLOG = \"debug\"\nPORT = \"3000\"\n[services.web.secrets]\nDB = \"db\"\n" + | |||
| 308 | + | "[services.worker]\nimage = \"img:1\"\n" | |||
| 309 | + | spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml") | |||
| 310 | + | require.NoError(t, err) | |||
| 311 | + | require.Len(t, spec.Services, 2) | |||
| 312 | + | assert.Equal(t, map[string]string{"LOG": "info"}, spec.Env) | |||
| 313 | + | assert.Equal(t, map[string]string{"LOG": "debug", "PORT": "3000"}, spec.Services[0].Env) | |||
| 314 | + | assert.Empty(t, spec.Services[1].Env) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | func TestParse_ServiceEnvInvalid(t *testing.T) { | |||
| 306 | cases := []struct { | 318 | cases := []struct { | ||
| 307 | name string | 319 | name string | ||
| 308 | doc string | 320 | doc string | ||
| 309 | wantErr string | 321 | wantErr string | ||
| 310 | }{ | 322 | }{ | ||
| 311 | { | 323 | { | ||
| 312 | - | "bare service name", | 324 | + | "secret reference", |
| 313 | - | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"bar\"\n", | 325 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"${pass:x}\"\n", |
| 314 | - | "[services.web.env]", | 326 | + | "secret references", |
| 327 | + | }, | |||
| 328 | + | { | |||
| 329 | + | "secret key collision", | |||
| 330 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nDB = \"x\"\n[services.web.secrets]\nDB = \"db\"\n", | |||
| 331 | + | "collides with a secret key", | |||
| 315 | }, | 332 | }, | ||
| 316 | { | 333 | { | ||
| 317 | - | "dotted service name is quoted", | 334 | + | "newline", |
| 318 | - | "name = \"blog\"\n[services.\"web.api\"]\nimage = \"img:1\"\n[services.\"web.api\".env]\nFOO = \"bar\"\n", | 335 | + | "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"a\\nb\"\n", |
| 319 | - | `[services."web.api".env]`, | 336 | + | "newlines", |
| 320 | }, | 337 | }, | ||
| 321 | } | 338 | } | ||
| 322 | for _, tc := range cases { | 339 | for _, tc := range cases { | ||
M internal/domain/app_spec.go +34 -13
| @@ -102,9 +102,12 @@ type AppService struct { | |||
| 102 | 102 | HTTP []AppHTTPInterface | |
| 103 | 103 | TCP []AppTCPInterface | |
| 104 | 104 | UDP []AppUDPInterface | |
| 105 | - | Secrets map[string]string | |
| 106 | - | Volumes []AppVolume | |
| 107 | - | Binds []AppBind | |
| 105 | + | // Env is non-secret per-service environment. It overrides app-wide | |
| 106 | + | // [env] for the same key; its keys must be disjoint from Secrets. | |
| 107 | + | Env map[string]string | |
| 108 | + | Secrets map[string]string | |
| 109 | + | Volumes []AppVolume | |
| 110 | + | Binds []AppBind | |
| 108 | 111 | // Devices lists logical device names granted by administrative | |
| 109 | 112 | // [app_devices] policy. Names resolve to CDI device IDs at activation | |
| 110 | 113 | // time; the logical names (not host resolution) persist in revisions. | |
| @@ -407,7 +410,8 @@ func (s AppSpec) Validate() error { | |||
| 407 | 410 | return s.validateFull() | |
| 408 | 411 | } | |
| 409 | 412 | ||
| 410 | - | // CheckEnvSecretCollisions re-validates [env]/secret key disjointness on a | |
| 413 | + | // CheckEnvSecretCollisions re-validates [env] and [services.<name>.env] | |
| 414 | + | // key disjointness from secrets on a | |
| 411 | 415 | // stored spec. Deployment preflight calls it defensively before any | |
| 412 | 416 | // workload mutation; overlapping keys are invalid with no precedence. | |
| 413 | 417 | func (s AppSpec) CheckEnvSecretCollisions() error { | |
| @@ -444,18 +448,25 @@ func (s AppSpec) validateFull() error { | |||
| 444 | 448 | ||
| 445 | 449 | // validateEnv checks app-wide public environment values. | |
| 446 | 450 | func (s AppSpec) validateEnv() error { | |
| 447 | - | for key, value := range s.Env { | |
| 451 | + | return validatePublicEnv("", s.Env) | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | // validatePublicEnv checks non-secret environment values. scope prefixes | |
| 455 | + | // error messages ("" for app-wide env, a service label otherwise). Shared | |
| 456 | + | // by app-wide [env] and [services.<name>.env]. | |
| 457 | + | func validatePublicEnv(scope string, env map[string]string) error { | |
| 458 | + | for key, value := range env { | |
| 448 | 459 | if err := ValidateEnvKey(key); err != nil { | |
| 449 | - | return fmt.Errorf("%w: env key %q: %v", ErrInvalidAppSpec, key, err) | |
| 460 | + | return fmt.Errorf("%w: %senv key %q: %v", ErrInvalidAppSpec, scope, key, err) | |
| 450 | 461 | } | |
| 451 | 462 | if strings.Contains(value, "\n") { | |
| 452 | - | return fmt.Errorf("%w: env value for %q must not contain newlines", ErrInvalidAppSpec, key) | |
| 463 | + | return fmt.Errorf("%w: %senv value for %q must not contain newlines", ErrInvalidAppSpec, scope, key) | |
| 453 | 464 | } | |
| 454 | 465 | if len(value) == 0 || len(value) > MaxAppEnvValueLen { | |
| 455 | - | return fmt.Errorf("%w: env value for %q must be 1-%d bytes", ErrInvalidAppSpec, key, MaxAppEnvValueLen) | |
| 466 | + | return fmt.Errorf("%w: %senv value for %q must be 1-%d bytes", ErrInvalidAppSpec, scope, key, MaxAppEnvValueLen) | |
| 456 | 467 | } | |
| 457 | 468 | if ContainsSecretReference(value) { | |
| 458 | - | return fmt.Errorf("%w: env value for %q must not contain secret references", ErrInvalidAppSpec, key) | |
| 469 | + | return fmt.Errorf("%w: %senv value for %q must not contain secret references", ErrInvalidAppSpec, scope, key) | |
| 459 | 470 | } | |
| 460 | 471 | } | |
| 461 | 472 | return nil | |
| @@ -486,13 +497,17 @@ func (s AppSpec) validateServices() error { | |||
| 486 | 497 | return nil | |
| 487 | 498 | } | |
| 488 | 499 | ||
| 489 | - | // checkEnvSecretKeyDisjoint enforces [env] keys disjoint from secret map keys. | |
| 500 | + | // checkEnvSecretKeyDisjoint enforces [env] and [services.<name>.env] keys | |
| 501 | + | // disjoint from the secret map keys of the services they reach. | |
| 490 | 502 | func checkEnvSecretKeyDisjoint(s AppSpec) error { | |
| 491 | - | for key := range s.Env { | |
| 492 | - | for _, svc := range s.Services { | |
| 493 | - | if _, ok := svc.Secrets[key]; ok { | |
| 503 | + | for _, svc := range s.Services { | |
| 504 | + | for key := range svc.Secrets { | |
| 505 | + | if _, ok := s.Env[key]; ok { | |
| 494 | 506 | return fmt.Errorf("%w: env key %q collides with a secret key in service %q", ErrInvalidAppSpec, key, svc.Name) | |
| 495 | 507 | } | |
| 508 | + | if _, ok := svc.Env[key]; ok { | |
| 509 | + | return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key) | |
| 510 | + | } | |
| 496 | 511 | } | |
| 497 | 512 | } | |
| 498 | 513 | return nil | |
| @@ -526,6 +541,9 @@ func (s *AppService) validate() error { | |||
| 526 | 541 | if err := s.validateInterfaces(); err != nil { | |
| 527 | 542 | return err | |
| 528 | 543 | } | |
| 544 | + | if err := validatePublicEnv(fmt.Sprintf("service %q ", s.Name), s.Env); err != nil { | |
| 545 | + | return err | |
| 546 | + | } | |
| 529 | 547 | if err := s.validateSecrets(); err != nil { | |
| 530 | 548 | return err | |
| 531 | 549 | } | |
| @@ -1065,6 +1083,9 @@ func diffService(name string, desired, effective AppService) []string { | |||
| 1065 | 1083 | } else if interfacesChanged(desired, effective) { | |
| 1066 | 1084 | changed = append(changed, "service/"+name+"/interfaces") | |
| 1067 | 1085 | } | |
| 1086 | + | if !equalStringMaps(desired.Env, effective.Env) { | |
| 1087 | + | changed = append(changed, "service/"+name+"/env") | |
| 1088 | + | } | |
| 1068 | 1089 | if !equalStringMaps(desired.Secrets, effective.Secrets) { | |
| 1069 | 1090 | for path := range secretPathChanges(desired, effective) { | |
| 1070 | 1091 | changed = append(changed, path) | |
| @@ -102,9 +102,12 @@ type AppService struct { | |||||
| 102 | HTTP []AppHTTPInterface | 102 | HTTP []AppHTTPInterface | ||
| 103 | TCP []AppTCPInterface | 103 | TCP []AppTCPInterface | ||
| 104 | UDP []AppUDPInterface | 104 | UDP []AppUDPInterface | ||
| 105 | - | Secrets map[string]string | 105 | + | // Env is non-secret per-service environment. It overrides app-wide |
| 106 | - | Volumes []AppVolume | 106 | + | // [env] for the same key; its keys must be disjoint from Secrets. |
| 107 | - | Binds []AppBind | 107 | + | Env map[string]string |
| 108 | + | Secrets map[string]string | |||
| 109 | + | Volumes []AppVolume | |||
| 110 | + | Binds []AppBind | |||
| 108 | // Devices lists logical device names granted by administrative | 111 | // Devices lists logical device names granted by administrative | ||
| 109 | // [app_devices] policy. Names resolve to CDI device IDs at activation | 112 | // [app_devices] policy. Names resolve to CDI device IDs at activation | ||
| 110 | // time; the logical names (not host resolution) persist in revisions. | 113 | // time; the logical names (not host resolution) persist in revisions. | ||
| @@ -407,7 +410,8 @@ func (s AppSpec) Validate() error { | |||||
| 407 | return s.validateFull() | 410 | return s.validateFull() | ||
| 408 | } | 411 | } | ||
| 409 | 412 | ||||
| 410 | - | // CheckEnvSecretCollisions re-validates [env]/secret key disjointness on a | 413 | + | // CheckEnvSecretCollisions re-validates [env] and [services.<name>.env] |
| 414 | + | // key disjointness from secrets on a | |||
| 411 | // stored spec. Deployment preflight calls it defensively before any | 415 | // stored spec. Deployment preflight calls it defensively before any | ||
| 412 | // workload mutation; overlapping keys are invalid with no precedence. | 416 | // workload mutation; overlapping keys are invalid with no precedence. | ||
| 413 | func (s AppSpec) CheckEnvSecretCollisions() error { | 417 | func (s AppSpec) CheckEnvSecretCollisions() error { | ||
| @@ -444,18 +448,25 @@ func (s AppSpec) validateFull() error { | |||||
| 444 | 448 | ||||
| 445 | // validateEnv checks app-wide public environment values. | 449 | // validateEnv checks app-wide public environment values. | ||
| 446 | func (s AppSpec) validateEnv() error { | 450 | func (s AppSpec) validateEnv() error { | ||
| 447 | - | for key, value := range s.Env { | 451 | + | return validatePublicEnv("", s.Env) |
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | // validatePublicEnv checks non-secret environment values. scope prefixes | |||
| 455 | + | // error messages ("" for app-wide env, a service label otherwise). Shared | |||
| 456 | + | // by app-wide [env] and [services.<name>.env]. | |||
| 457 | + | func validatePublicEnv(scope string, env map[string]string) error { | |||
| 458 | + | for key, value := range env { | |||
| 448 | if err := ValidateEnvKey(key); err != nil { | 459 | if err := ValidateEnvKey(key); err != nil { | ||
| 449 | - | return fmt.Errorf("%w: env key %q: %v", ErrInvalidAppSpec, key, err) | 460 | + | return fmt.Errorf("%w: %senv key %q: %v", ErrInvalidAppSpec, scope, key, err) |
| 450 | } | 461 | } | ||
| 451 | if strings.Contains(value, "\n") { | 462 | if strings.Contains(value, "\n") { | ||
| 452 | - | return fmt.Errorf("%w: env value for %q must not contain newlines", ErrInvalidAppSpec, key) | 463 | + | return fmt.Errorf("%w: %senv value for %q must not contain newlines", ErrInvalidAppSpec, scope, key) |
| 453 | } | 464 | } | ||
| 454 | if len(value) == 0 || len(value) > MaxAppEnvValueLen { | 465 | if len(value) == 0 || len(value) > MaxAppEnvValueLen { | ||
| 455 | - | return fmt.Errorf("%w: env value for %q must be 1-%d bytes", ErrInvalidAppSpec, key, MaxAppEnvValueLen) | 466 | + | return fmt.Errorf("%w: %senv value for %q must be 1-%d bytes", ErrInvalidAppSpec, scope, key, MaxAppEnvValueLen) |
| 456 | } | 467 | } | ||
| 457 | if ContainsSecretReference(value) { | 468 | if ContainsSecretReference(value) { | ||
| 458 | - | return fmt.Errorf("%w: env value for %q must not contain secret references", ErrInvalidAppSpec, key) | 469 | + | return fmt.Errorf("%w: %senv value for %q must not contain secret references", ErrInvalidAppSpec, scope, key) |
| 459 | } | 470 | } | ||
| 460 | } | 471 | } | ||
| 461 | return nil | 472 | return nil | ||
| @@ -486,13 +497,17 @@ func (s AppSpec) validateServices() error { | |||||
| 486 | return nil | 497 | return nil | ||
| 487 | } | 498 | } | ||
| 488 | 499 | ||||
| 489 | - | // checkEnvSecretKeyDisjoint enforces [env] keys disjoint from secret map keys. | 500 | + | // checkEnvSecretKeyDisjoint enforces [env] and [services.<name>.env] keys |
| 501 | + | // disjoint from the secret map keys of the services they reach. | |||
| 490 | func checkEnvSecretKeyDisjoint(s AppSpec) error { | 502 | func checkEnvSecretKeyDisjoint(s AppSpec) error { | ||
| 491 | - | for key := range s.Env { | 503 | + | for _, svc := range s.Services { |
| 492 | - | for _, svc := range s.Services { | 504 | + | for key := range svc.Secrets { |
| 493 | - | if _, ok := svc.Secrets[key]; ok { | 505 | + | if _, ok := s.Env[key]; ok { |
| 494 | return fmt.Errorf("%w: env key %q collides with a secret key in service %q", ErrInvalidAppSpec, key, svc.Name) | 506 | return fmt.Errorf("%w: env key %q collides with a secret key in service %q", ErrInvalidAppSpec, key, svc.Name) | ||
| 495 | } | 507 | } | ||
| 508 | + | if _, ok := svc.Env[key]; ok { | |||
| 509 | + | return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key) | |||
| 510 | + | } | |||
| 496 | } | 511 | } | ||
| 497 | } | 512 | } | ||
| 498 | return nil | 513 | return nil | ||
| @@ -526,6 +541,9 @@ func (s *AppService) validate() error { | |||||
| 526 | if err := s.validateInterfaces(); err != nil { | 541 | if err := s.validateInterfaces(); err != nil { | ||
| 527 | return err | 542 | return err | ||
| 528 | } | 543 | } | ||
| 544 | + | if err := validatePublicEnv(fmt.Sprintf("service %q ", s.Name), s.Env); err != nil { | |||
| 545 | + | return err | |||
| 546 | + | } | |||
| 529 | if err := s.validateSecrets(); err != nil { | 547 | if err := s.validateSecrets(); err != nil { | ||
| 530 | return err | 548 | return err | ||
| 531 | } | 549 | } | ||
| @@ -1065,6 +1083,9 @@ func diffService(name string, desired, effective AppService) []string { | |||||
| 1065 | } else if interfacesChanged(desired, effective) { | 1083 | } else if interfacesChanged(desired, effective) { | ||
| 1066 | changed = append(changed, "service/"+name+"/interfaces") | 1084 | changed = append(changed, "service/"+name+"/interfaces") | ||
| 1067 | } | 1085 | } | ||
| 1086 | + | if !equalStringMaps(desired.Env, effective.Env) { | |||
| 1087 | + | changed = append(changed, "service/"+name+"/env") | |||
| 1088 | + | } | |||
| 1068 | if !equalStringMaps(desired.Secrets, effective.Secrets) { | 1089 | if !equalStringMaps(desired.Secrets, effective.Secrets) { | ||
| 1069 | for path := range secretPathChanges(desired, effective) { | 1090 | for path := range secretPathChanges(desired, effective) { | ||
| 1070 | changed = append(changed, path) | 1091 | changed = append(changed, path) | ||
M internal/domain/app_spec_test.go +29 -0
| @@ -42,6 +42,13 @@ func TestAppSpec_ValidateErrors(t *testing.T) { | |||
| 42 | 42 | {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"}, | |
| 43 | 43 | {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"}, | |
| 44 | 44 | {"env newline", func(s *domain.AppSpec) { s.Env["X"] = "a\nb" }, "newlines"}, | |
| 45 | + | {"service env newline", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "a\nb"} }, "newlines"}, | |
| 46 | + | {"service env secret ref", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "${sops:y}"} }, "secret references"}, | |
| 47 | + | {"service env empty value", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": ""} }, "1-"}, | |
| 48 | + | {"service env bad key", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"1bad": "v"} }, "env key"}, | |
| 49 | + | {"service env secret collision", func(s *domain.AppSpec) { | |
| 50 | + | s.Services[0].Env = map[string]string{"DATABASE_URL": "x"} | |
| 51 | + | }, "collides with a secret key"}, | |
| 45 | 52 | {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"}, | |
| 46 | 53 | {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"}, | |
| 47 | 54 | {"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"}, | |
| @@ -177,3 +184,25 @@ func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) { | |||
| 177 | 184 | func TestDiffAppSpec_ErrorsWrapped(t *testing.T) { | |
| 178 | 185 | assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec)) | |
| 179 | 186 | } | |
| 187 | + | ||
| 188 | + | func TestAppSpec_ServiceEnvOverridesAppEnvAllowed(t *testing.T) { | |
| 189 | + | spec := validSpec() | |
| 190 | + | spec.Services[0].Env = map[string]string{"APP_ENV": "staging"} | |
| 191 | + | require.NoError(t, spec.Validate()) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestDiffAppSpec_ServiceEnv(t *testing.T) { | |
| 195 | + | base := validSpec() | |
| 196 | + | changed := validSpec() | |
| 197 | + | changed.Services[0].Env = map[string]string{"LOG": "debug"} | |
| 198 | + | ||
| 199 | + | assert.Equal(t, []string{"service/web/env"}, domain.DiffAppSpec(changed, base).Changed) | |
| 200 | + | assert.Equal(t, []string{"service/web/env"}, domain.DiffAppSpec(base, changed).Changed) | |
| 201 | + | assert.Empty(t, domain.DiffAppSpec(changed, changed).Changed) | |
| 202 | + | assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0])) | |
| 203 | + | ||
| 204 | + | changed.Services[0].Env["LOG"] = "info" | |
| 205 | + | other := validSpec() | |
| 206 | + | other.Services[0].Env = map[string]string{"LOG": "debug"} | |
| 207 | + | assert.Equal(t, []string{"service/web/env"}, domain.DiffAppSpec(changed, other).Changed) | |
| 208 | + | } | |
| @@ -42,6 +42,13 @@ func TestAppSpec_ValidateErrors(t *testing.T) { | |||||
| 42 | {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"}, | 42 | {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"}, | ||
| 43 | {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"}, | 43 | {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"}, | ||
| 44 | {"env newline", func(s *domain.AppSpec) { s.Env["X"] = "a\nb" }, "newlines"}, | 44 | {"env newline", func(s *domain.AppSpec) { s.Env["X"] = "a\nb" }, "newlines"}, | ||
| 45 | + | {"service env newline", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "a\nb"} }, "newlines"}, | |||
| 46 | + | {"service env secret ref", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": "${sops:y}"} }, "secret references"}, | |||
| 47 | + | {"service env empty value", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"X": ""} }, "1-"}, | |||
| 48 | + | {"service env bad key", func(s *domain.AppSpec) { s.Services[0].Env = map[string]string{"1bad": "v"} }, "env key"}, | |||
| 49 | + | {"service env secret collision", func(s *domain.AppSpec) { | |||
| 50 | + | s.Services[0].Env = map[string]string{"DATABASE_URL": "x"} | |||
| 51 | + | }, "collides with a secret key"}, | |||
| 45 | {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"}, | 52 | {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"}, | ||
| 46 | {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"}, | 53 | {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"}, | ||
| 47 | {"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"}, | 54 | {"http readiness authority", func(s *domain.AppSpec) { s.Services[0].Readiness.Path = "@127.0.0.1:9000/private" }, "origin-form path"}, | ||
| @@ -177,3 +184,25 @@ func TestDiffAppSpec_NetworkDetailsAndOrderNormalization(t *testing.T) { | |||||
| 177 | func TestDiffAppSpec_ErrorsWrapped(t *testing.T) { | 184 | func TestDiffAppSpec_ErrorsWrapped(t *testing.T) { | ||
| 178 | assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec)) | 185 | assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec)) | ||
| 179 | } | 186 | } | ||
| 187 | + | ||||
| 188 | + | func TestAppSpec_ServiceEnvOverridesAppEnvAllowed(t *testing.T) { | |||
| 189 | + | spec := validSpec() | |||
| 190 | + | spec.Services[0].Env = map[string]string{"APP_ENV": "staging"} | |||
| 191 | + | require.NoError(t, spec.Validate()) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestDiffAppSpec_ServiceEnv(t *testing.T) { | |||
| 195 | + | base := validSpec() | |||
| 196 | + | changed := validSpec() | |||
| 197 | + | changed.Services[0].Env = map[string]string{"LOG": "debug"} | |||
| 198 | + | ||||
| 199 | + | assert.Equal(t, []string{"service/web/env"}, domain.DiffAppSpec(changed, base).Changed) | |||
| 200 | + | assert.Equal(t, []string{"service/web/env"}, domain.DiffAppSpec(base, changed).Changed) | |||
| 201 | + | assert.Empty(t, domain.DiffAppSpec(changed, changed).Changed) | |||
| 202 | + | assert.False(t, domain.SameAppService(changed.Services[0], base.Services[0])) | |||
| 203 | + | ||||
| 204 | + | changed.Services[0].Env["LOG"] = "info" | |||
| 205 | + | other := validSpec() | |||
| 206 | + | other.Services[0].Env = map[string]string{"LOG": "debug"} | |||
| 207 | + | assert.Equal(t, []string{"service/web/env"}, domain.DiffAppSpec(changed, other).Changed) | |||
| 208 | + | } | |||
M internal/usecase/deployment/candidate.go +6 -5
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | + | "maps" | |
| 7 | 8 | "sort" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| @@ -267,16 +268,16 @@ func shortOp(opID string) string { | |||
| 267 | 268 | } | |
| 268 | 269 | ||
| 269 | 270 | // serviceEnv resolves the service environment: the captured revision's | |
| 270 | - | // app-wide public env on every service, plus that service's own secret | |
| 271 | + | // app-wide public env on every service, overlaid by that service's own | |
| 272 | + | // public env (the service value wins for the same key), plus its secret | |
| 271 | 273 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | |
| 272 | 274 | // deterministic. Overlapping public/secret keys are invalid with no | |
| 273 | 275 | // precedence; preflight rejects them before any mutation. | |
| 274 | 276 | func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) { | |
| 275 | 277 | spec := p.spec | |
| 276 | - | envMap := make(map[string]string, len(p.appEnv)+len(spec.Secrets)) | |
| 277 | - | for key, value := range p.appEnv { | |
| 278 | - | envMap[key] = value | |
| 279 | - | } | |
| 278 | + | envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets)) | |
| 279 | + | maps.Copy(envMap, p.appEnv) | |
| 280 | + | maps.Copy(envMap, spec.Env) | |
| 280 | 281 | keys := make([]string, 0, len(spec.Secrets)) | |
| 281 | 282 | for envKey := range spec.Secrets { | |
| 282 | 283 | keys = append(keys, envKey) | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "maps" | |||
| 7 | "sort" | 8 | "sort" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| @@ -267,16 +268,16 @@ func shortOp(opID string) string { | |||||
| 267 | } | 268 | } | ||
| 268 | 269 | ||||
| 269 | // serviceEnv resolves the service environment: the captured revision's | 270 | // serviceEnv resolves the service environment: the captured revision's | ||
| 270 | - | // app-wide public env on every service, plus that service's own secret | 271 | + | // app-wide public env on every service, overlaid by that service's own |
| 272 | + | // public env (the service value wins for the same key), plus its secret | |||
| 271 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | 273 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | ||
| 272 | // deterministic. Overlapping public/secret keys are invalid with no | 274 | // deterministic. Overlapping public/secret keys are invalid with no | ||
| 273 | // precedence; preflight rejects them before any mutation. | 275 | // precedence; preflight rejects them before any mutation. | ||
| 274 | func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) { | 276 | func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) { | ||
| 275 | spec := p.spec | 277 | spec := p.spec | ||
| 276 | - | envMap := make(map[string]string, len(p.appEnv)+len(spec.Secrets)) | 278 | + | envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets)) |
| 277 | - | for key, value := range p.appEnv { | 279 | + | maps.Copy(envMap, p.appEnv) |
| 278 | - | envMap[key] = value | 280 | + | maps.Copy(envMap, spec.Env) |
| 279 | - | } | |||
| 280 | keys := make([]string, 0, len(spec.Secrets)) | 281 | keys := make([]string, 0, len(spec.Secrets)) | ||
| 281 | for envKey := range spec.Secrets { | 282 | for envKey := range spec.Secrets { | ||
| 282 | keys = append(keys, envKey) | 283 | keys = append(keys, envKey) | ||
A internal/usecase/deployment/service_env_internal_test.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | package deployment | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zerowrap" | |
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // TestServiceEnv_ServiceEnvOverridesAppEnv proves per-service env is merged | |
| 17 | + | // over app-wide env (service wins) and stays scoped to its own service. | |
| 18 | + | func TestServiceEnv_ServiceEnvOverridesAppEnv(t *testing.T) { | |
| 19 | + | svc := NewService(Deps{}, zerowrap.Default()) | |
| 20 | + | appEnv := map[string]string{"LOG": "info", "APP_ENV": "production"} | |
| 21 | + | ||
| 22 | + | web := pinnedService{ | |
| 23 | + | name: "web", appEnv: appEnv, | |
| 24 | + | spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}}, | |
| 25 | + | } | |
| 26 | + | got, err := svc.serviceEnv(context.Background(), "blog", web) | |
| 27 | + | require.NoError(t, err) | |
| 28 | + | assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got) | |
| 29 | + | ||
| 30 | + | worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}} | |
| 31 | + | got, err = svc.serviceEnv(context.Background(), "blog", worker) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got) | |
| 34 | + | assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // TestServiceEnv_ServiceEnvSecretCollisionFails proves a service env key that | |
| 38 | + | // collides with a secret key is refused at deploy time with no precedence. | |
| 39 | + | func TestServiceEnv_ServiceEnvSecretCollisionFails(t *testing.T) { | |
| 40 | + | state := outmocks.NewMockAppState(t) | |
| 41 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe() | |
| 42 | + | svc := NewService(Deps{State: state}, zerowrap.Default()) | |
| 43 | + | p := pinnedService{name: "web", spec: domain.AppService{ | |
| 44 | + | Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"}, | |
| 45 | + | }} | |
| 46 | + | ||
| 47 | + | _, err := svc.serviceEnv(context.Background(), "blog", p) | |
| 48 | + | require.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | package deployment | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zerowrap" | |||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // TestServiceEnv_ServiceEnvOverridesAppEnv proves per-service env is merged | |||
| 17 | + | // over app-wide env (service wins) and stays scoped to its own service. | |||
| 18 | + | func TestServiceEnv_ServiceEnvOverridesAppEnv(t *testing.T) { | |||
| 19 | + | svc := NewService(Deps{}, zerowrap.Default()) | |||
| 20 | + | appEnv := map[string]string{"LOG": "info", "APP_ENV": "production"} | |||
| 21 | + | ||||
| 22 | + | web := pinnedService{ | |||
| 23 | + | name: "web", appEnv: appEnv, | |||
| 24 | + | spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}}, | |||
| 25 | + | } | |||
| 26 | + | got, err := svc.serviceEnv(context.Background(), "blog", web) | |||
| 27 | + | require.NoError(t, err) | |||
| 28 | + | assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got) | |||
| 29 | + | ||||
| 30 | + | worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}} | |||
| 31 | + | got, err = svc.serviceEnv(context.Background(), "blog", worker) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got) | |||
| 34 | + | assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // TestServiceEnv_ServiceEnvSecretCollisionFails proves a service env key that | |||
| 38 | + | // collides with a secret key is refused at deploy time with no precedence. | |||
| 39 | + | func TestServiceEnv_ServiceEnvSecretCollisionFails(t *testing.T) { | |||
| 40 | + | state := outmocks.NewMockAppState(t) | |||
| 41 | + | state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe() | |||
| 42 | + | svc := NewService(Deps{State: state}, zerowrap.Default()) | |||
| 43 | + | p := pinnedService{name: "web", spec: domain.AppService{ | |||
| 44 | + | Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"}, | |||
| 45 | + | }} | |||
| 46 | + | ||||
| 47 | + | _, err := svc.serviceEnv(context.Background(), "blog", p) | |||
| 48 | + | require.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 49 | + | } | |||