detached 48e0691

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"
5555 host = "blog.mydomain.com"
5656 port = 3000
5757
58+[services.web.env] # public env for this service only
59+LOG_LEVEL = "info"
60+
5861 [services.web.secrets] # ENV name -> secret name (values stay in pass)
5962 DATABASE_URL = "database-url"
6063 ```
@@ -63,7 +66,7 @@ DATABASE_URL = "database-url"
6366 - `deploy APP` activates it. `apply --deploy` chains both using exactly the revision accepted by apply.
6467 - `--dry-run` validates and previews without persistence or runtime effects.
6568 - 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]`.
6770 - Staging is an ordinary app in another TOML file. There is no pin, no preview environments, and no historical rollback command.
6871
6972 ## Updates
@@ -150,9 +153,12 @@ App-wide public env is declared in the manifest:
150153 APP_ENV = "production"
151154 ```
152155
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`:
154157
155158 ```toml
159+[services.web.env]
160+LOG_LEVEL = "info"
161+
156162 [services.web.secrets]
157163 DATABASE_URL = "database-url"
158164 ```
@@ -55,6 +55,9 @@ image = "gordon.mydomain.com/blog:1.4.2"
55 host = "blog.mydomain.com"55 host = "blog.mydomain.com"
56 port = 300056 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 ## Updates72 ## 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 ```toml158 ```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 @@
22
33 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>`.
44
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]`.
66
77 ## Minimal Example
88
@@ -48,6 +48,9 @@ tls = "auto" # auto | always | never
4848 visibility = "internal" # public (default) | internal
4949 port = 8080 # required; no host or tls
5050
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+
5154 [services.web.secrets] # ENV name -> secret name (values in pass)
5255 DATABASE_URL = "database-url"
5356
@@ -108,8 +111,8 @@ Image registry names and digest syntax are validated during manifest apply, reso
108111 ## Environment and Secrets
109112
110113 - `[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.
111115 - `[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.
113116
114117 ## Volumes and Databases
115118
@@ -179,7 +182,7 @@ Changing either setting is a service change: it applies on the next deploy.
179182
180183 ## Strictness
181184
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.
183186
184187 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).
185188
@@ -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 Example7 ## Minimal Example
8 8
@@ -48,6 +48,9 @@ tls = "auto" # auto | always | never
48 visibility = "internal" # public (default) | internal48 visibility = "internal" # public (default) | internal
49 port = 8080 # required; no host or tls49 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 Secrets111 ## 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 Databases117 ## 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 ## Strictness183 ## 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
178178
179179 **Solutions:**
180180
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:
182182 ```bash
183183 gordon apps apply --file ./blog.toml
184184 ```
@@ -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 ```bash182 ```bash
183 gordon apps apply --file ./blog.toml183 gordon apps apply --file ./blog.toml
184 ```184 ```
M internal/adapters/in/appmanifest/parse.go +1 -28
@@ -11,7 +11,6 @@ import (
1111 "fmt"
1212 "maps"
1313 "slices"
14- "strconv"
1514 "strings"
1615 "time"
1716
@@ -321,6 +320,7 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
321320 Publish: u.Publish,
322321 })
323322 }
323+ svc.Env = maps.Clone(raw.Env)
324324 for key, value := range raw.Secrets {
325325 svc.Secrets[key] = value
326326 }
@@ -348,9 +348,6 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
348348 Schedule: db.Schedule,
349349 })
350350 }
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- }
354351 return svc, nil
355352 }
356353
@@ -376,30 +373,6 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e
376373 return readiness, nil
377374 }
378375
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-
403376 // normalizeImage records a missing tag as explicit :latest.
404377 func normalizeImage(image string) string {
405378 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] = value325 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, nil351 return svc, nil
355 }352 }
356 353
@@ -376,30 +373,6 @@ func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, e
376 return readiness, nil373 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"
302302 }
303303 }
304304
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) {
306318 cases := []struct {
307319 name string
308320 doc string
309321 wantErr string
310322 }{
311323 {
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",
315332 },
316333 {
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",
320337 },
321338 }
322339 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 string319 name string
308 doc string320 doc string
309 wantErr string321 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 {
102102 HTTP []AppHTTPInterface
103103 TCP []AppTCPInterface
104104 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
108111 // Devices lists logical device names granted by administrative
109112 // [app_devices] policy. Names resolve to CDI device IDs at activation
110113 // time; the logical names (not host resolution) persist in revisions.
@@ -407,7 +410,8 @@ func (s AppSpec) Validate() error {
407410 return s.validateFull()
408411 }
409412
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
411415 // stored spec. Deployment preflight calls it defensively before any
412416 // workload mutation; overlapping keys are invalid with no precedence.
413417 func (s AppSpec) CheckEnvSecretCollisions() error {
@@ -444,18 +448,25 @@ func (s AppSpec) validateFull() error {
444448
445449 // validateEnv checks app-wide public environment values.
446450 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 {
448459 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)
450461 }
451462 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)
453464 }
454465 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)
456467 }
457468 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)
459470 }
460471 }
461472 return nil
@@ -486,13 +497,17 @@ func (s AppSpec) validateServices() error {
486497 return nil
487498 }
488499
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.
490502 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 {
494506 return fmt.Errorf("%w: env key %q collides with a secret key in service %q", ErrInvalidAppSpec, key, svc.Name)
495507 }
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+ }
496511 }
497512 }
498513 return nil
@@ -526,6 +541,9 @@ func (s *AppService) validate() error {
526541 if err := s.validateInterfaces(); err != nil {
527542 return err
528543 }
544+ if err := validatePublicEnv(fmt.Sprintf("service %q ", s.Name), s.Env); err != nil {
545+ return err
546+ }
529547 if err := s.validateSecrets(); err != nil {
530548 return err
531549 }
@@ -1065,6 +1083,9 @@ func diffService(name string, desired, effective AppService) []string {
10651083 } else if interfacesChanged(desired, effective) {
10661084 changed = append(changed, "service/"+name+"/interfaces")
10671085 }
1086+ if !equalStringMaps(desired.Env, effective.Env) {
1087+ changed = append(changed, "service/"+name+"/env")
1088+ }
10681089 if !equalStringMaps(desired.Secrets, effective.Secrets) {
10691090 for path := range secretPathChanges(desired, effective) {
10701091 changed = append(changed, path)
@@ -102,9 +102,12 @@ type AppService struct {
102 HTTP []AppHTTPInterface102 HTTP []AppHTTPInterface
103 TCP []AppTCPInterface103 TCP []AppTCPInterface
104 UDP []AppUDPInterface104 UDP []AppUDPInterface
105- Secrets map[string]string105+ // Env is non-secret per-service environment. It overrides app-wide
106- Volumes []AppVolume106+ // [env] for the same key; its keys must be disjoint from Secrets.
107- Binds []AppBind107+ Env map[string]string
108+ Secrets map[string]string
109+ Volumes []AppVolume
110+ Binds []AppBind
108 // Devices lists logical device names granted by administrative111 // Devices lists logical device names granted by administrative
109 // [app_devices] policy. Names resolve to CDI device IDs at activation112 // [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 a413+// CheckEnvSecretCollisions re-validates [env] and [services.<name>.env]
414+// key disjointness from secrets on a
411 // stored spec. Deployment preflight calls it defensively before any415 // 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 nil472 return nil
@@ -486,13 +497,17 @@ func (s AppSpec) validateServices() error {
486 return nil497 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 nil513 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 err542 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 err548 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) {
4242 {"no services", func(s *domain.AppSpec) { s.Services = nil }, "at least one service"},
4343 {"no image", func(s *domain.AppSpec) { s.Services[0].Image = "" }, "requires an image"},
4444 {"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"},
4552 {"env secret ref", func(s *domain.AppSpec) { s.Env["X"] = "${sops:y}" }, "secret references"},
4653 {"http bad tls", func(s *domain.AppSpec) { s.Services[0].HTTP[0].TLS = "sometimes" }, "auto|always|never"},
4754 {"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) {
177184 func TestDiffAppSpec_ErrorsWrapped(t *testing.T) {
178185 assert.True(t, errors.Is(domain.AppSpec{}.Validate(), domain.ErrInvalidAppSpec))
179186 }
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 (
44 "context"
55 "errors"
66 "fmt"
7+ "maps"
78 "sort"
89 "strings"
910
@@ -267,16 +268,16 @@ func shortOp(opID string) string {
267268 }
268269
269270 // 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
271273 // values (memory only, UUID-keyed paths). Keys are emitted sorted and
272274 // deterministic. Overlapping public/secret keys are invalid with no
273275 // precedence; preflight rejects them before any mutation.
274276 func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) {
275277 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)
280281 keys := make([]string, 0, len(spec.Secrets))
281282 for envKey := range spec.Secrets {
282283 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's270 // serviceEnv resolves the service environment: the captured revision's
270-// app-wide public env on every service, plus that service's own secret271+// 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 and273 // values (memory only, UUID-keyed paths). Keys are emitted sorted and
272 // deterministic. Overlapping public/secret keys are invalid with no274 // 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.spec277 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] = value280+ 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+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close