feat(apps): parse and validate file-delivered secrets
- commit
- e55fb3b03e6d4adc3ff10d15d3820260bd43dc29
- author
- brice · · 2026-10-08 20:21 UTC
- parent
- 7ec1d0a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +337 -16
M internal/adapters/in/appmanifest/parse.go +71 -5
| @@ -52,9 +52,11 @@ type rawService struct { | |||
| 52 | 52 | HTTP []rawHTTP `toml:"http"` | |
| 53 | 53 | TCP []rawTCP `toml:"tcp"` | |
| 54 | 54 | UDP []rawUDP `toml:"udp"` | |
| 55 | - | Secrets map[string]string `toml:"secrets"` | |
| 56 | - | Volumes []rawVolume `toml:"volume"` | |
| 57 | - | Binds []rawBind `toml:"bind"` | |
| 55 | + | // Secrets maps env keys to a secret name string or to a table | |
| 56 | + | // { name = "...", as = "env"|"file" }; see toDomainSecrets. | |
| 57 | + | Secrets map[string]any `toml:"secrets"` | |
| 58 | + | Volumes []rawVolume `toml:"volume"` | |
| 59 | + | Binds []rawBind `toml:"bind"` | |
| 58 | 60 | // Devices lists logical device names from `devices = [...]`. | |
| 59 | 61 | Devices []string `toml:"devices"` | |
| 60 | 62 | Databases []rawDatabase `toml:"database"` | |
| @@ -321,9 +323,12 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 321 | 323 | }) | |
| 322 | 324 | } | |
| 323 | 325 | svc.Env = maps.Clone(raw.Env) | |
| 324 | - | for key, value := range raw.Secrets { | |
| 325 | - | svc.Secrets[key] = value | |
| 326 | + | secrets, secretFiles, err := toDomainSecrets(serviceName, raw.Secrets) | |
| 327 | + | if err != nil { | |
| 328 | + | return domain.AppService{}, err | |
| 326 | 329 | } | |
| 330 | + | svc.Secrets = secrets | |
| 331 | + | svc.SecretFiles = secretFiles | |
| 327 | 332 | for _, v := range raw.Volumes { | |
| 328 | 333 | svc.Volumes = append(svc.Volumes, domain.AppVolume{ | |
| 329 | 334 | Name: v.Name, | |
| @@ -351,6 +356,67 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||
| 351 | 356 | return svc, nil | |
| 352 | 357 | } | |
| 353 | 358 | ||
| 359 | + | // Secret delivery modes accepted in a secret table's `as` key. | |
| 360 | + | const ( | |
| 361 | + | secretAsEnv = "env" | |
| 362 | + | secretAsFile = "file" | |
| 363 | + | ) | |
| 364 | + | ||
| 365 | + | // toDomainSecrets maps [services.<name>.secrets]. Each value is either a | |
| 366 | + | // secret name string (env delivery) or a table with a required `name` | |
| 367 | + | // and an optional `as` ("env" or "file", default "env"). It returns the | |
| 368 | + | // env key -> secret name map and the sorted file-delivered keys (nil | |
| 369 | + | // when none). | |
| 370 | + | func toDomainSecrets(service string, raw map[string]any) (map[string]string, []string, error) { | |
| 371 | + | secrets := make(map[string]string, len(raw)) | |
| 372 | + | var files []string | |
| 373 | + | for _, key := range slices.Sorted(maps.Keys(raw)) { | |
| 374 | + | name, asFile, err := toDomainSecret(raw[key]) | |
| 375 | + | if err != nil { | |
| 376 | + | return nil, nil, fmt.Errorf("%w: service %q secret %q: %s", domain.ErrInvalidAppSpec, service, key, err) | |
| 377 | + | } | |
| 378 | + | secrets[key] = name | |
| 379 | + | if asFile { | |
| 380 | + | files = append(files, key) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | return secrets, files, nil | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // toDomainSecret decodes one secret value and reports whether it is | |
| 387 | + | // delivered as a file. | |
| 388 | + | func toDomainSecret(value any) (string, bool, error) { | |
| 389 | + | switch v := value.(type) { | |
| 390 | + | case string: | |
| 391 | + | return v, false, nil | |
| 392 | + | case map[string]any: | |
| 393 | + | return toDomainSecretTable(v) | |
| 394 | + | default: | |
| 395 | + | return "", false, errors.New("value must be a secret name string or a { name, as } table") | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func toDomainSecretTable(table map[string]any) (string, bool, error) { | |
| 400 | + | for _, key := range slices.Sorted(maps.Keys(table)) { | |
| 401 | + | if key != "name" && key != "as" { | |
| 402 | + | return "", false, fmt.Errorf("unknown key %q (allowed: name, as)", key) | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | name, ok := table["name"].(string) | |
| 406 | + | if !ok || name == "" { | |
| 407 | + | return "", false, errors.New("name must be a non-empty string") | |
| 408 | + | } | |
| 409 | + | rawAs, present := table["as"] | |
| 410 | + | if !present { | |
| 411 | + | return name, false, nil | |
| 412 | + | } | |
| 413 | + | as, ok := rawAs.(string) | |
| 414 | + | if !ok || (as != secretAsEnv && as != secretAsFile) { | |
| 415 | + | return "", false, fmt.Errorf("as must be %q or %q", secretAsEnv, secretAsFile) | |
| 416 | + | } | |
| 417 | + | return name, as == secretAsFile, nil | |
| 418 | + | } | |
| 419 | + | ||
| 354 | 420 | // toDomainReadiness maps readiness with defaults. | |
| 355 | 421 | func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) { | |
| 356 | 422 | readiness := domain.AppReadiness{ | |
| @@ -52,9 +52,11 @@ type rawService struct { | |||||
| 52 | HTTP []rawHTTP `toml:"http"` | 52 | HTTP []rawHTTP `toml:"http"` | ||
| 53 | TCP []rawTCP `toml:"tcp"` | 53 | TCP []rawTCP `toml:"tcp"` | ||
| 54 | UDP []rawUDP `toml:"udp"` | 54 | UDP []rawUDP `toml:"udp"` | ||
| 55 | - | Secrets map[string]string `toml:"secrets"` | 55 | + | // Secrets maps env keys to a secret name string or to a table |
| 56 | - | Volumes []rawVolume `toml:"volume"` | 56 | + | // { name = "...", as = "env"|"file" }; see toDomainSecrets. |
| 57 | - | Binds []rawBind `toml:"bind"` | 57 | + | Secrets map[string]any `toml:"secrets"` |
| 58 | + | Volumes []rawVolume `toml:"volume"` | |||
| 59 | + | Binds []rawBind `toml:"bind"` | |||
| 58 | // Devices lists logical device names from `devices = [...]`. | 60 | // Devices lists logical device names from `devices = [...]`. | ||
| 59 | Devices []string `toml:"devices"` | 61 | Devices []string `toml:"devices"` | ||
| 60 | Databases []rawDatabase `toml:"database"` | 62 | Databases []rawDatabase `toml:"database"` | ||
| @@ -321,9 +323,12 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||||
| 321 | }) | 323 | }) | ||
| 322 | } | 324 | } | ||
| 323 | svc.Env = maps.Clone(raw.Env) | 325 | svc.Env = maps.Clone(raw.Env) | ||
| 324 | - | for key, value := range raw.Secrets { | 326 | + | secrets, secretFiles, err := toDomainSecrets(serviceName, raw.Secrets) |
| 325 | - | svc.Secrets[key] = value | 327 | + | if err != nil { |
| 328 | + | return domain.AppService{}, err | |||
| 326 | } | 329 | } | ||
| 330 | + | svc.Secrets = secrets | |||
| 331 | + | svc.SecretFiles = secretFiles | |||
| 327 | for _, v := range raw.Volumes { | 332 | for _, v := range raw.Volumes { | ||
| 328 | svc.Volumes = append(svc.Volumes, domain.AppVolume{ | 333 | svc.Volumes = append(svc.Volumes, domain.AppVolume{ | ||
| 329 | Name: v.Name, | 334 | Name: v.Name, | ||
| @@ -351,6 +356,67 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err | |||||
| 351 | return svc, nil | 356 | return svc, nil | ||
| 352 | } | 357 | } | ||
| 353 | 358 | ||||
| 359 | + | // Secret delivery modes accepted in a secret table's `as` key. | |||
| 360 | + | const ( | |||
| 361 | + | secretAsEnv = "env" | |||
| 362 | + | secretAsFile = "file" | |||
| 363 | + | ) | |||
| 364 | + | ||||
| 365 | + | // toDomainSecrets maps [services.<name>.secrets]. Each value is either a | |||
| 366 | + | // secret name string (env delivery) or a table with a required `name` | |||
| 367 | + | // and an optional `as` ("env" or "file", default "env"). It returns the | |||
| 368 | + | // env key -> secret name map and the sorted file-delivered keys (nil | |||
| 369 | + | // when none). | |||
| 370 | + | func toDomainSecrets(service string, raw map[string]any) (map[string]string, []string, error) { | |||
| 371 | + | secrets := make(map[string]string, len(raw)) | |||
| 372 | + | var files []string | |||
| 373 | + | for _, key := range slices.Sorted(maps.Keys(raw)) { | |||
| 374 | + | name, asFile, err := toDomainSecret(raw[key]) | |||
| 375 | + | if err != nil { | |||
| 376 | + | return nil, nil, fmt.Errorf("%w: service %q secret %q: %s", domain.ErrInvalidAppSpec, service, key, err) | |||
| 377 | + | } | |||
| 378 | + | secrets[key] = name | |||
| 379 | + | if asFile { | |||
| 380 | + | files = append(files, key) | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | return secrets, files, nil | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // toDomainSecret decodes one secret value and reports whether it is | |||
| 387 | + | // delivered as a file. | |||
| 388 | + | func toDomainSecret(value any) (string, bool, error) { | |||
| 389 | + | switch v := value.(type) { | |||
| 390 | + | case string: | |||
| 391 | + | return v, false, nil | |||
| 392 | + | case map[string]any: | |||
| 393 | + | return toDomainSecretTable(v) | |||
| 394 | + | default: | |||
| 395 | + | return "", false, errors.New("value must be a secret name string or a { name, as } table") | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func toDomainSecretTable(table map[string]any) (string, bool, error) { | |||
| 400 | + | for _, key := range slices.Sorted(maps.Keys(table)) { | |||
| 401 | + | if key != "name" && key != "as" { | |||
| 402 | + | return "", false, fmt.Errorf("unknown key %q (allowed: name, as)", key) | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | name, ok := table["name"].(string) | |||
| 406 | + | if !ok || name == "" { | |||
| 407 | + | return "", false, errors.New("name must be a non-empty string") | |||
| 408 | + | } | |||
| 409 | + | rawAs, present := table["as"] | |||
| 410 | + | if !present { | |||
| 411 | + | return name, false, nil | |||
| 412 | + | } | |||
| 413 | + | as, ok := rawAs.(string) | |||
| 414 | + | if !ok || (as != secretAsEnv && as != secretAsFile) { | |||
| 415 | + | return "", false, fmt.Errorf("as must be %q or %q", secretAsEnv, secretAsFile) | |||
| 416 | + | } | |||
| 417 | + | return name, as == secretAsFile, nil | |||
| 418 | + | } | |||
| 419 | + | ||||
| 354 | // toDomainReadiness maps readiness with defaults. | 420 | // toDomainReadiness maps readiness with defaults. | ||
| 355 | func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) { | 421 | func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) { | ||
| 356 | readiness := domain.AppReadiness{ | 422 | readiness := domain.AppReadiness{ | ||
A internal/adapters/in/appmanifest/parse_secrets_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package appmanifest_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/appmanifest" | |
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const secretsHead = "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n" | |
| 14 | + | ||
| 15 | + | func TestParse_SecretForms(t *testing.T) { | |
| 16 | + | cases := []struct { | |
| 17 | + | name string | |
| 18 | + | doc string | |
| 19 | + | wantMap map[string]string | |
| 20 | + | wantFiles []string | |
| 21 | + | }{ | |
| 22 | + | {"short form", "[services.web.secrets]\nDB = \"db\"\n", map[string]string{"DB": "db"}, nil}, | |
| 23 | + | {"table name only", "[services.web.secrets]\nDB = { name = \"db\" }\n", map[string]string{"DB": "db"}, nil}, | |
| 24 | + | {"table env", "[services.web.secrets]\nDB = { name = \"db\", as = \"env\" }\n", map[string]string{"DB": "db"}, nil}, | |
| 25 | + | {"table file", "[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", map[string]string{"DB": "db"}, []string{"DB"}}, | |
| 26 | + | {"sub-table", "[services.web.secrets.DB]\nname = \"db\"\nas = \"file\"\n", map[string]string{"DB": "db"}, []string{"DB"}}, | |
| 27 | + | { | |
| 28 | + | "mixed", | |
| 29 | + | "[services.web.secrets]\nZ = { name = \"z\", as = \"file\" }\nA = { name = \"a\", as = \"file\" }\nM = \"m\"\nE = { name = \"e\", as = \"env\" }\n", | |
| 30 | + | map[string]string{"Z": "z", "A": "a", "M": "m", "E": "e"}, | |
| 31 | + | []string{"A", "Z"}, | |
| 32 | + | }, | |
| 33 | + | } | |
| 34 | + | for _, tc := range cases { | |
| 35 | + | t.Run(tc.name, func(t *testing.T) { | |
| 36 | + | spec, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml") | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Len(t, spec.Services, 1) | |
| 39 | + | assert.Equal(t, tc.wantMap, spec.Services[0].Secrets) | |
| 40 | + | assert.Equal(t, tc.wantFiles, spec.Services[0].SecretFiles) | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestParse_NoSecretsKeepsEmptyMap(t *testing.T) { | |
| 46 | + | spec, _, err := appmanifest.Parse([]byte(secretsHead), "blog.toml") | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | assert.NotNil(t, spec.Services[0].Secrets) | |
| 49 | + | assert.Empty(t, spec.Services[0].Secrets) | |
| 50 | + | assert.Nil(t, spec.Services[0].SecretFiles) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestParse_SecretFormsInvalid(t *testing.T) { | |
| 54 | + | cases := []struct { | |
| 55 | + | name string | |
| 56 | + | doc string | |
| 57 | + | wantErr string | |
| 58 | + | }{ | |
| 59 | + | {"bad as", "[services.web.secrets]\nDB = { name = \"db\", as = \"volume\" }\n", "as must be"}, | |
| 60 | + | {"empty as", "[services.web.secrets]\nDB = { name = \"db\", as = \"\" }\n", "as must be"}, | |
| 61 | + | {"as not string", "[services.web.secrets]\nDB = { name = \"db\", as = 1 }\n", "as must be"}, | |
| 62 | + | {"missing name", "[services.web.secrets]\nDB = { as = \"file\" }\n", "name must be"}, | |
| 63 | + | {"name not string", "[services.web.secrets]\nDB = { name = 1 }\n", "name must be"}, | |
| 64 | + | {"extra key", "[services.web.secrets]\nDB = { name = \"db\", mode = \"x\" }\n", "unknown key \"mode\""}, | |
| 65 | + | {"integer value", "[services.web.secrets]\nDB = 42\n", "secret name string"}, | |
| 66 | + | {"array value", "[services.web.secrets]\nDB = [\"db\"]\n", "secret name string"}, | |
| 67 | + | {"file key collision", "[services.web.env]\nDB_FILE = \"x\"\n[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", "collides with a secret file key"}, | |
| 68 | + | } | |
| 69 | + | for _, tc := range cases { | |
| 70 | + | t.Run(tc.name, func(t *testing.T) { | |
| 71 | + | _, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml") | |
| 72 | + | require.Error(t, err) | |
| 73 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 74 | + | assert.ErrorContains(t, err, tc.wantErr) | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package appmanifest_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/adapters/in/appmanifest" | |||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const secretsHead = "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n" | |||
| 14 | + | ||||
| 15 | + | func TestParse_SecretForms(t *testing.T) { | |||
| 16 | + | cases := []struct { | |||
| 17 | + | name string | |||
| 18 | + | doc string | |||
| 19 | + | wantMap map[string]string | |||
| 20 | + | wantFiles []string | |||
| 21 | + | }{ | |||
| 22 | + | {"short form", "[services.web.secrets]\nDB = \"db\"\n", map[string]string{"DB": "db"}, nil}, | |||
| 23 | + | {"table name only", "[services.web.secrets]\nDB = { name = \"db\" }\n", map[string]string{"DB": "db"}, nil}, | |||
| 24 | + | {"table env", "[services.web.secrets]\nDB = { name = \"db\", as = \"env\" }\n", map[string]string{"DB": "db"}, nil}, | |||
| 25 | + | {"table file", "[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", map[string]string{"DB": "db"}, []string{"DB"}}, | |||
| 26 | + | {"sub-table", "[services.web.secrets.DB]\nname = \"db\"\nas = \"file\"\n", map[string]string{"DB": "db"}, []string{"DB"}}, | |||
| 27 | + | { | |||
| 28 | + | "mixed", | |||
| 29 | + | "[services.web.secrets]\nZ = { name = \"z\", as = \"file\" }\nA = { name = \"a\", as = \"file\" }\nM = \"m\"\nE = { name = \"e\", as = \"env\" }\n", | |||
| 30 | + | map[string]string{"Z": "z", "A": "a", "M": "m", "E": "e"}, | |||
| 31 | + | []string{"A", "Z"}, | |||
| 32 | + | }, | |||
| 33 | + | } | |||
| 34 | + | for _, tc := range cases { | |||
| 35 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 36 | + | spec, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml") | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.Len(t, spec.Services, 1) | |||
| 39 | + | assert.Equal(t, tc.wantMap, spec.Services[0].Secrets) | |||
| 40 | + | assert.Equal(t, tc.wantFiles, spec.Services[0].SecretFiles) | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestParse_NoSecretsKeepsEmptyMap(t *testing.T) { | |||
| 46 | + | spec, _, err := appmanifest.Parse([]byte(secretsHead), "blog.toml") | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | assert.NotNil(t, spec.Services[0].Secrets) | |||
| 49 | + | assert.Empty(t, spec.Services[0].Secrets) | |||
| 50 | + | assert.Nil(t, spec.Services[0].SecretFiles) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestParse_SecretFormsInvalid(t *testing.T) { | |||
| 54 | + | cases := []struct { | |||
| 55 | + | name string | |||
| 56 | + | doc string | |||
| 57 | + | wantErr string | |||
| 58 | + | }{ | |||
| 59 | + | {"bad as", "[services.web.secrets]\nDB = { name = \"db\", as = \"volume\" }\n", "as must be"}, | |||
| 60 | + | {"empty as", "[services.web.secrets]\nDB = { name = \"db\", as = \"\" }\n", "as must be"}, | |||
| 61 | + | {"as not string", "[services.web.secrets]\nDB = { name = \"db\", as = 1 }\n", "as must be"}, | |||
| 62 | + | {"missing name", "[services.web.secrets]\nDB = { as = \"file\" }\n", "name must be"}, | |||
| 63 | + | {"name not string", "[services.web.secrets]\nDB = { name = 1 }\n", "name must be"}, | |||
| 64 | + | {"extra key", "[services.web.secrets]\nDB = { name = \"db\", mode = \"x\" }\n", "unknown key \"mode\""}, | |||
| 65 | + | {"integer value", "[services.web.secrets]\nDB = 42\n", "secret name string"}, | |||
| 66 | + | {"array value", "[services.web.secrets]\nDB = [\"db\"]\n", "secret name string"}, | |||
| 67 | + | {"file key collision", "[services.web.env]\nDB_FILE = \"x\"\n[services.web.secrets]\nDB = { name = \"db\", as = \"file\" }\n", "collides with a secret file key"}, | |||
| 68 | + | } | |||
| 69 | + | for _, tc := range cases { | |||
| 70 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 71 | + | _, _, err := appmanifest.Parse([]byte(secretsHead+tc.doc), "blog.toml") | |||
| 72 | + | require.Error(t, err) | |||
| 73 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 74 | + | assert.ErrorContains(t, err, tc.wantErr) | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
A internal/domain/app_secret_files.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "path" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // AppSecretFileDir is the container directory holding file-delivered secrets. | |
| 11 | + | const AppSecretFileDir = "/run/secrets" | |
| 12 | + | ||
| 13 | + | // SecretFileEnvKey returns the env key that points to a file-delivered secret. | |
| 14 | + | func SecretFileEnvKey(key string) string { return key + "_FILE" } | |
| 15 | + | ||
| 16 | + | // SecretFilePath returns the container path of a file-delivered secret. | |
| 17 | + | func SecretFilePath(key string) string { return AppSecretFileDir + "/" + key } | |
| 18 | + | ||
| 19 | + | // SecretAsFile reports whether the secret env key is delivered as a file. | |
| 20 | + | func (s AppService) SecretAsFile(key string) bool { | |
| 21 | + | return slices.Contains(s.SecretFiles, key) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // validateSecretFiles checks the file-delivered secret keys of one service. | |
| 25 | + | func (s *AppService) validateSecretFiles() error { | |
| 26 | + | seen := make(map[string]struct{}, len(s.SecretFiles)) | |
| 27 | + | for _, key := range s.SecretFiles { | |
| 28 | + | if _, ok := s.Secrets[key]; !ok { | |
| 29 | + | return fmt.Errorf("%w: service %q secret file key %q is not a declared secret", ErrInvalidAppSpec, s.Name, key) | |
| 30 | + | } | |
| 31 | + | if _, dup := seen[key]; dup { | |
| 32 | + | return fmt.Errorf("%w: service %q secret file key %q is duplicated", ErrInvalidAppSpec, s.Name, key) | |
| 33 | + | } | |
| 34 | + | seen[key] = struct{}{} | |
| 35 | + | fileKey := SecretFileEnvKey(key) | |
| 36 | + | if _, ok := s.Secrets[fileKey]; ok { | |
| 37 | + | return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey) | |
| 38 | + | } | |
| 39 | + | if _, ok := s.Env[fileKey]; ok { | |
| 40 | + | return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | if len(s.SecretFiles) == 0 { | |
| 44 | + | return nil | |
| 45 | + | } | |
| 46 | + | for _, b := range s.Binds { | |
| 47 | + | if underSecretFileDir(b.Path) { | |
| 48 | + | return fmt.Errorf("%w: service %q bind %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, b.Name, AppSecretFileDir) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | for _, v := range s.Volumes { | |
| 52 | + | if underSecretFileDir(v.Path) { | |
| 53 | + | return fmt.Errorf("%w: service %q volume %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, v.Name, AppSecretFileDir) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func underSecretFileDir(p string) bool { | |
| 60 | + | p = path.Clean(p) | |
| 61 | + | return p == AppSecretFileDir || strings.HasPrefix(p, AppSecretFileDir+"/") | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "path" | |||
| 6 | + | "slices" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // AppSecretFileDir is the container directory holding file-delivered secrets. | |||
| 11 | + | const AppSecretFileDir = "/run/secrets" | |||
| 12 | + | ||||
| 13 | + | // SecretFileEnvKey returns the env key that points to a file-delivered secret. | |||
| 14 | + | func SecretFileEnvKey(key string) string { return key + "_FILE" } | |||
| 15 | + | ||||
| 16 | + | // SecretFilePath returns the container path of a file-delivered secret. | |||
| 17 | + | func SecretFilePath(key string) string { return AppSecretFileDir + "/" + key } | |||
| 18 | + | ||||
| 19 | + | // SecretAsFile reports whether the secret env key is delivered as a file. | |||
| 20 | + | func (s AppService) SecretAsFile(key string) bool { | |||
| 21 | + | return slices.Contains(s.SecretFiles, key) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // validateSecretFiles checks the file-delivered secret keys of one service. | |||
| 25 | + | func (s *AppService) validateSecretFiles() error { | |||
| 26 | + | seen := make(map[string]struct{}, len(s.SecretFiles)) | |||
| 27 | + | for _, key := range s.SecretFiles { | |||
| 28 | + | if _, ok := s.Secrets[key]; !ok { | |||
| 29 | + | return fmt.Errorf("%w: service %q secret file key %q is not a declared secret", ErrInvalidAppSpec, s.Name, key) | |||
| 30 | + | } | |||
| 31 | + | if _, dup := seen[key]; dup { | |||
| 32 | + | return fmt.Errorf("%w: service %q secret file key %q is duplicated", ErrInvalidAppSpec, s.Name, key) | |||
| 33 | + | } | |||
| 34 | + | seen[key] = struct{}{} | |||
| 35 | + | fileKey := SecretFileEnvKey(key) | |||
| 36 | + | if _, ok := s.Secrets[fileKey]; ok { | |||
| 37 | + | return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey) | |||
| 38 | + | } | |||
| 39 | + | if _, ok := s.Env[fileKey]; ok { | |||
| 40 | + | return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | if len(s.SecretFiles) == 0 { | |||
| 44 | + | return nil | |||
| 45 | + | } | |||
| 46 | + | for _, b := range s.Binds { | |||
| 47 | + | if underSecretFileDir(b.Path) { | |||
| 48 | + | return fmt.Errorf("%w: service %q bind %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, b.Name, AppSecretFileDir) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | for _, v := range s.Volumes { | |||
| 52 | + | if underSecretFileDir(v.Path) { | |||
| 53 | + | return fmt.Errorf("%w: service %q volume %q must not mount at or under %s when secrets are delivered as files", ErrInvalidAppSpec, s.Name, v.Name, AppSecretFileDir) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func underSecretFileDir(p string) bool { | |||
| 60 | + | p = path.Clean(p) | |||
| 61 | + | return p == AppSecretFileDir || strings.HasPrefix(p, AppSecretFileDir+"/") | |||
| 62 | + | } | |||
A internal/domain/app_secret_files_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package domain_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func fileSecretSpec() domain.AppSpec { | |
| 13 | + | spec := validSpec() | |
| 14 | + | spec.Services[0].SecretFiles = []string{"DATABASE_URL"} | |
| 15 | + | return spec | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestAppSpec_ValidateSecretFilesOK(t *testing.T) { | |
| 19 | + | require.NoError(t, fileSecretSpec().Validate()) | |
| 20 | + | assert.True(t, fileSecretSpec().Services[0].SecretAsFile("DATABASE_URL")) | |
| 21 | + | assert.False(t, validSpec().Services[0].SecretAsFile("DATABASE_URL")) | |
| 22 | + | assert.Equal(t, "DATABASE_URL_FILE", domain.SecretFileEnvKey("DATABASE_URL")) | |
| 23 | + | assert.Equal(t, "/run/secrets/DATABASE_URL", domain.SecretFilePath("DATABASE_URL")) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestAppSpec_ValidateSecretFilesErrors(t *testing.T) { | |
| 27 | + | cases := []struct { | |
| 28 | + | name string | |
| 29 | + | mutate func(*domain.AppSpec) | |
| 30 | + | wantErr string | |
| 31 | + | }{ | |
| 32 | + | {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"}, | |
| 33 | + | {"duplicate", func(s *domain.AppSpec) { | |
| 34 | + | s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"} | |
| 35 | + | }, "duplicated"}, | |
| 36 | + | {"file key collides with secret", func(s *domain.AppSpec) { | |
| 37 | + | s.Services[0].Secrets["DATABASE_URL_FILE"] = "other" | |
| 38 | + | }, "collides with a secret file key"}, | |
| 39 | + | {"file key collides with service env", func(s *domain.AppSpec) { | |
| 40 | + | s.Services[0].Env = map[string]string{"DATABASE_URL_FILE": "x"} | |
| 41 | + | }, "collides with a secret file key"}, | |
| 42 | + | {"file key collides with app env", func(s *domain.AppSpec) { | |
| 43 | + | s.Env["DATABASE_URL_FILE"] = "x" | |
| 44 | + | }, "collides with a secret file key"}, | |
| 45 | + | {"bind at secret dir", func(s *domain.AppSpec) { | |
| 46 | + | s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}} | |
| 47 | + | }, "/run/secrets"}, | |
| 48 | + | {"bind under secret dir", func(s *domain.AppSpec) { | |
| 49 | + | s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets/x", ReadOnly: true}} | |
| 50 | + | }, "/run/secrets"}, | |
| 51 | + | {"volume under secret dir", func(s *domain.AppSpec) { | |
| 52 | + | s.Services[0].Volumes = []domain.AppVolume{{Name: "data", Path: "/run/secrets/data"}} | |
| 53 | + | }, "/run/secrets"}, | |
| 54 | + | } | |
| 55 | + | for _, tc := range cases { | |
| 56 | + | t.Run(tc.name, func(t *testing.T) { | |
| 57 | + | spec := fileSecretSpec() | |
| 58 | + | tc.mutate(&spec) | |
| 59 | + | err := spec.Validate() | |
| 60 | + | require.Error(t, err) | |
| 61 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 62 | + | assert.Contains(t, err.Error(), tc.wantErr) | |
| 63 | + | }) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestAppSpec_SecretDirBindWithoutFileSecretsOK(t *testing.T) { | |
| 68 | + | spec := validSpec() | |
| 69 | + | spec.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}} | |
| 70 | + | require.NoError(t, spec.Validate()) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestDiffAppSpec_SecretFileModeSwitch(t *testing.T) { | |
| 74 | + | envSpec := validSpec() | |
| 75 | + | fileSpec := fileSecretSpec() | |
| 76 | + | ||
| 77 | + | diff := domain.DiffAppSpec(fileSpec, envSpec) | |
| 78 | + | assert.Equal(t, []string{"service/web/secret/database-url"}, diff.Changed) | |
| 79 | + | assert.False(t, domain.SameAppService(fileSpec.Services[0], envSpec.Services[0])) | |
| 80 | + | ||
| 81 | + | back := domain.DiffAppSpec(envSpec, fileSpec) | |
| 82 | + | assert.Equal(t, []string{"service/web/secret/database-url"}, back.Changed) | |
| 83 | + | ||
| 84 | + | same := domain.DiffAppSpec(fileSpec, fileSecretSpec()) | |
| 85 | + | assert.Empty(t, same.Changed) | |
| 86 | + | assert.True(t, domain.SameAppService(fileSpec.Services[0], fileSecretSpec().Services[0])) | |
| 87 | + | ||
| 88 | + | old := validSpec() | |
| 89 | + | require.Nil(t, old.Services[0].SecretFiles) | |
| 90 | + | assert.Empty(t, domain.DiffAppSpec(old, validSpec()).Changed) | |
| 91 | + | assert.True(t, domain.SameAppService(old.Services[0], validSpec().Services[0])) | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package domain_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func fileSecretSpec() domain.AppSpec { | |||
| 13 | + | spec := validSpec() | |||
| 14 | + | spec.Services[0].SecretFiles = []string{"DATABASE_URL"} | |||
| 15 | + | return spec | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestAppSpec_ValidateSecretFilesOK(t *testing.T) { | |||
| 19 | + | require.NoError(t, fileSecretSpec().Validate()) | |||
| 20 | + | assert.True(t, fileSecretSpec().Services[0].SecretAsFile("DATABASE_URL")) | |||
| 21 | + | assert.False(t, validSpec().Services[0].SecretAsFile("DATABASE_URL")) | |||
| 22 | + | assert.Equal(t, "DATABASE_URL_FILE", domain.SecretFileEnvKey("DATABASE_URL")) | |||
| 23 | + | assert.Equal(t, "/run/secrets/DATABASE_URL", domain.SecretFilePath("DATABASE_URL")) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestAppSpec_ValidateSecretFilesErrors(t *testing.T) { | |||
| 27 | + | cases := []struct { | |||
| 28 | + | name string | |||
| 29 | + | mutate func(*domain.AppSpec) | |||
| 30 | + | wantErr string | |||
| 31 | + | }{ | |||
| 32 | + | {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"}, | |||
| 33 | + | {"duplicate", func(s *domain.AppSpec) { | |||
| 34 | + | s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"} | |||
| 35 | + | }, "duplicated"}, | |||
| 36 | + | {"file key collides with secret", func(s *domain.AppSpec) { | |||
| 37 | + | s.Services[0].Secrets["DATABASE_URL_FILE"] = "other" | |||
| 38 | + | }, "collides with a secret file key"}, | |||
| 39 | + | {"file key collides with service env", func(s *domain.AppSpec) { | |||
| 40 | + | s.Services[0].Env = map[string]string{"DATABASE_URL_FILE": "x"} | |||
| 41 | + | }, "collides with a secret file key"}, | |||
| 42 | + | {"file key collides with app env", func(s *domain.AppSpec) { | |||
| 43 | + | s.Env["DATABASE_URL_FILE"] = "x" | |||
| 44 | + | }, "collides with a secret file key"}, | |||
| 45 | + | {"bind at secret dir", func(s *domain.AppSpec) { | |||
| 46 | + | s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}} | |||
| 47 | + | }, "/run/secrets"}, | |||
| 48 | + | {"bind under secret dir", func(s *domain.AppSpec) { | |||
| 49 | + | s.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets/x", ReadOnly: true}} | |||
| 50 | + | }, "/run/secrets"}, | |||
| 51 | + | {"volume under secret dir", func(s *domain.AppSpec) { | |||
| 52 | + | s.Services[0].Volumes = []domain.AppVolume{{Name: "data", Path: "/run/secrets/data"}} | |||
| 53 | + | }, "/run/secrets"}, | |||
| 54 | + | } | |||
| 55 | + | for _, tc := range cases { | |||
| 56 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 57 | + | spec := fileSecretSpec() | |||
| 58 | + | tc.mutate(&spec) | |||
| 59 | + | err := spec.Validate() | |||
| 60 | + | require.Error(t, err) | |||
| 61 | + | assert.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |||
| 62 | + | assert.Contains(t, err.Error(), tc.wantErr) | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestAppSpec_SecretDirBindWithoutFileSecretsOK(t *testing.T) { | |||
| 68 | + | spec := validSpec() | |||
| 69 | + | spec.Services[0].Binds = []domain.AppBind{{Name: "conf", Path: "/run/secrets", ReadOnly: true}} | |||
| 70 | + | require.NoError(t, spec.Validate()) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestDiffAppSpec_SecretFileModeSwitch(t *testing.T) { | |||
| 74 | + | envSpec := validSpec() | |||
| 75 | + | fileSpec := fileSecretSpec() | |||
| 76 | + | ||||
| 77 | + | diff := domain.DiffAppSpec(fileSpec, envSpec) | |||
| 78 | + | assert.Equal(t, []string{"service/web/secret/database-url"}, diff.Changed) | |||
| 79 | + | assert.False(t, domain.SameAppService(fileSpec.Services[0], envSpec.Services[0])) | |||
| 80 | + | ||||
| 81 | + | back := domain.DiffAppSpec(envSpec, fileSpec) | |||
| 82 | + | assert.Equal(t, []string{"service/web/secret/database-url"}, back.Changed) | |||
| 83 | + | ||||
| 84 | + | same := domain.DiffAppSpec(fileSpec, fileSecretSpec()) | |||
| 85 | + | assert.Empty(t, same.Changed) | |||
| 86 | + | assert.True(t, domain.SameAppService(fileSpec.Services[0], fileSecretSpec().Services[0])) | |||
| 87 | + | ||||
| 88 | + | old := validSpec() | |||
| 89 | + | require.Nil(t, old.Services[0].SecretFiles) | |||
| 90 | + | assert.Empty(t, domain.DiffAppSpec(old, validSpec()).Changed) | |||
| 91 | + | assert.True(t, domain.SameAppService(old.Services[0], validSpec().Services[0])) | |||
| 92 | + | } | |||
M internal/domain/app_spec.go +35 -11
| @@ -106,8 +106,12 @@ type AppService struct { | |||
| 106 | 106 | // [env] for the same key; its keys must be disjoint from Secrets. | |
| 107 | 107 | Env map[string]string | |
| 108 | 108 | Secrets map[string]string | |
| 109 | - | Volumes []AppVolume | |
| 110 | - | Binds []AppBind | |
| 109 | + | // SecretFiles lists the Secrets env keys delivered as read-only files | |
| 110 | + | // under /run/secrets instead of env values. Sorted, nil when empty; | |
| 111 | + | // keys absent from it are delivered as env. | |
| 112 | + | SecretFiles []string | |
| 113 | + | Volumes []AppVolume | |
| 114 | + | Binds []AppBind | |
| 111 | 115 | // Devices lists logical device names granted by administrative | |
| 112 | 116 | // [app_devices] policy. Names resolve to CDI device IDs at activation | |
| 113 | 117 | // time; the logical names (not host resolution) persist in revisions. | |
| @@ -509,6 +513,15 @@ func checkEnvSecretKeyDisjoint(s AppSpec) error { | |||
| 509 | 513 | return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key) | |
| 510 | 514 | } | |
| 511 | 515 | } | |
| 516 | + | for _, key := range svc.SecretFiles { | |
| 517 | + | fileKey := SecretFileEnvKey(key) | |
| 518 | + | if _, ok := s.Env[fileKey]; ok { | |
| 519 | + | return fmt.Errorf("%w: env key %q collides with a secret file key in service %q", ErrInvalidAppSpec, fileKey, svc.Name) | |
| 520 | + | } | |
| 521 | + | if _, ok := svc.Env[fileKey]; ok { | |
| 522 | + | return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, svc.Name, fileKey) | |
| 523 | + | } | |
| 524 | + | } | |
| 512 | 525 | } | |
| 513 | 526 | return nil | |
| 514 | 527 | } | |
| @@ -624,7 +637,7 @@ func (s *AppService) validateSecrets() error { | |||
| 624 | 637 | return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err) | |
| 625 | 638 | } | |
| 626 | 639 | } | |
| 627 | - | return nil | |
| 640 | + | return s.validateSecretFiles() | |
| 628 | 641 | } | |
| 629 | 642 | ||
| 630 | 643 | // validateVolumes checks volume declarations and returns known names. | |
| @@ -1086,11 +1099,7 @@ func diffService(name string, desired, effective AppService) []string { | |||
| 1086 | 1099 | if !equalStringMaps(desired.Env, effective.Env) { | |
| 1087 | 1100 | changed = append(changed, "service/"+name+"/env") | |
| 1088 | 1101 | } | |
| 1089 | - | if !equalStringMaps(desired.Secrets, effective.Secrets) { | |
| 1090 | - | for path := range secretPathChanges(desired, effective) { | |
| 1091 | - | changed = append(changed, path) | |
| 1092 | - | } | |
| 1093 | - | } | |
| 1102 | + | changed = appendSecretChanges(changed, desired, effective) | |
| 1094 | 1103 | if !equalVolumes(desired.Volumes, effective.Volumes) { | |
| 1095 | 1104 | changed = append(changed, "service/"+name+"/volumes") | |
| 1096 | 1105 | } | |
| @@ -1116,16 +1125,31 @@ func appendOperationChanges(changed []string, name string, desired, effective Ap | |||
| 1116 | 1125 | return changed | |
| 1117 | 1126 | } | |
| 1118 | 1127 | ||
| 1119 | - | // secretPathChanges lists secret PATH changes (never values). | |
| 1128 | + | // appendSecretChanges appends sorted secret path changes when the secret | |
| 1129 | + | // names or their delivery modes differ. | |
| 1130 | + | func appendSecretChanges(changed []string, desired, effective AppService) []string { | |
| 1131 | + | if equalStringMaps(desired.Secrets, effective.Secrets) && slices.Equal(desired.SecretFiles, effective.SecretFiles) { | |
| 1132 | + | return changed | |
| 1133 | + | } | |
| 1134 | + | paths := make([]string, 0) | |
| 1135 | + | for path := range secretPathChanges(desired, effective) { | |
| 1136 | + | paths = append(paths, path) | |
| 1137 | + | } | |
| 1138 | + | sort.Strings(paths) | |
| 1139 | + | return append(changed, paths...) | |
| 1140 | + | } | |
| 1141 | + | ||
| 1142 | + | // secretPathChanges lists secret PATH changes (never values). A key whose | |
| 1143 | + | // delivery mode (env or file) changed counts as changed. | |
| 1120 | 1144 | func secretPathChanges(desired, effective AppService) map[string]struct{} { | |
| 1121 | 1145 | paths := map[string]struct{}{} | |
| 1122 | 1146 | for envKey, secretName := range desired.Secrets { | |
| 1123 | - | if effective.Secrets[envKey] != secretName { | |
| 1147 | + | if effective.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) { | |
| 1124 | 1148 | paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{} | |
| 1125 | 1149 | } | |
| 1126 | 1150 | } | |
| 1127 | 1151 | for envKey, secretName := range effective.Secrets { | |
| 1128 | - | if desired.Secrets[envKey] != secretName { | |
| 1152 | + | if desired.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) { | |
| 1129 | 1153 | paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{} | |
| 1130 | 1154 | } | |
| 1131 | 1155 | } | |
| @@ -106,8 +106,12 @@ type AppService struct { | |||||
| 106 | // [env] for the same key; its keys must be disjoint from Secrets. | 106 | // [env] for the same key; its keys must be disjoint from Secrets. | ||
| 107 | Env map[string]string | 107 | Env map[string]string | ||
| 108 | Secrets map[string]string | 108 | Secrets map[string]string | ||
| 109 | - | Volumes []AppVolume | 109 | + | // SecretFiles lists the Secrets env keys delivered as read-only files |
| 110 | - | Binds []AppBind | 110 | + | // under /run/secrets instead of env values. Sorted, nil when empty; |
| 111 | + | // keys absent from it are delivered as env. | |||
| 112 | + | SecretFiles []string | |||
| 113 | + | Volumes []AppVolume | |||
| 114 | + | Binds []AppBind | |||
| 111 | // Devices lists logical device names granted by administrative | 115 | // Devices lists logical device names granted by administrative | ||
| 112 | // [app_devices] policy. Names resolve to CDI device IDs at activation | 116 | // [app_devices] policy. Names resolve to CDI device IDs at activation | ||
| 113 | // time; the logical names (not host resolution) persist in revisions. | 117 | // time; the logical names (not host resolution) persist in revisions. | ||
| @@ -509,6 +513,15 @@ func checkEnvSecretKeyDisjoint(s AppSpec) error { | |||||
| 509 | return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key) | 513 | return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key) | ||
| 510 | } | 514 | } | ||
| 511 | } | 515 | } | ||
| 516 | + | for _, key := range svc.SecretFiles { | |||
| 517 | + | fileKey := SecretFileEnvKey(key) | |||
| 518 | + | if _, ok := s.Env[fileKey]; ok { | |||
| 519 | + | return fmt.Errorf("%w: env key %q collides with a secret file key in service %q", ErrInvalidAppSpec, fileKey, svc.Name) | |||
| 520 | + | } | |||
| 521 | + | if _, ok := svc.Env[fileKey]; ok { | |||
| 522 | + | return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, svc.Name, fileKey) | |||
| 523 | + | } | |||
| 524 | + | } | |||
| 512 | } | 525 | } | ||
| 513 | return nil | 526 | return nil | ||
| 514 | } | 527 | } | ||
| @@ -624,7 +637,7 @@ func (s *AppService) validateSecrets() error { | |||||
| 624 | return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err) | 637 | return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err) | ||
| 625 | } | 638 | } | ||
| 626 | } | 639 | } | ||
| 627 | - | return nil | 640 | + | return s.validateSecretFiles() |
| 628 | } | 641 | } | ||
| 629 | 642 | ||||
| 630 | // validateVolumes checks volume declarations and returns known names. | 643 | // validateVolumes checks volume declarations and returns known names. | ||
| @@ -1086,11 +1099,7 @@ func diffService(name string, desired, effective AppService) []string { | |||||
| 1086 | if !equalStringMaps(desired.Env, effective.Env) { | 1099 | if !equalStringMaps(desired.Env, effective.Env) { | ||
| 1087 | changed = append(changed, "service/"+name+"/env") | 1100 | changed = append(changed, "service/"+name+"/env") | ||
| 1088 | } | 1101 | } | ||
| 1089 | - | if !equalStringMaps(desired.Secrets, effective.Secrets) { | 1102 | + | changed = appendSecretChanges(changed, desired, effective) |
| 1090 | - | for path := range secretPathChanges(desired, effective) { | |||
| 1091 | - | changed = append(changed, path) | |||
| 1092 | - | } | |||
| 1093 | - | } | |||
| 1094 | if !equalVolumes(desired.Volumes, effective.Volumes) { | 1103 | if !equalVolumes(desired.Volumes, effective.Volumes) { | ||
| 1095 | changed = append(changed, "service/"+name+"/volumes") | 1104 | changed = append(changed, "service/"+name+"/volumes") | ||
| 1096 | } | 1105 | } | ||
| @@ -1116,16 +1125,31 @@ func appendOperationChanges(changed []string, name string, desired, effective Ap | |||||
| 1116 | return changed | 1125 | return changed | ||
| 1117 | } | 1126 | } | ||
| 1118 | 1127 | ||||
| 1119 | - | // secretPathChanges lists secret PATH changes (never values). | 1128 | + | // appendSecretChanges appends sorted secret path changes when the secret |
| 1129 | + | // names or their delivery modes differ. | |||
| 1130 | + | func appendSecretChanges(changed []string, desired, effective AppService) []string { | |||
| 1131 | + | if equalStringMaps(desired.Secrets, effective.Secrets) && slices.Equal(desired.SecretFiles, effective.SecretFiles) { | |||
| 1132 | + | return changed | |||
| 1133 | + | } | |||
| 1134 | + | paths := make([]string, 0) | |||
| 1135 | + | for path := range secretPathChanges(desired, effective) { | |||
| 1136 | + | paths = append(paths, path) | |||
| 1137 | + | } | |||
| 1138 | + | sort.Strings(paths) | |||
| 1139 | + | return append(changed, paths...) | |||
| 1140 | + | } | |||
| 1141 | + | ||||
| 1142 | + | // secretPathChanges lists secret PATH changes (never values). A key whose | |||
| 1143 | + | // delivery mode (env or file) changed counts as changed. | |||
| 1120 | func secretPathChanges(desired, effective AppService) map[string]struct{} { | 1144 | func secretPathChanges(desired, effective AppService) map[string]struct{} { | ||
| 1121 | paths := map[string]struct{}{} | 1145 | paths := map[string]struct{}{} | ||
| 1122 | for envKey, secretName := range desired.Secrets { | 1146 | for envKey, secretName := range desired.Secrets { | ||
| 1123 | - | if effective.Secrets[envKey] != secretName { | 1147 | + | if effective.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) { |
| 1124 | paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{} | 1148 | paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{} | ||
| 1125 | } | 1149 | } | ||
| 1126 | } | 1150 | } | ||
| 1127 | for envKey, secretName := range effective.Secrets { | 1151 | for envKey, secretName := range effective.Secrets { | ||
| 1128 | - | if desired.Secrets[envKey] != secretName { | 1152 | + | if desired.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) { |
| 1129 | paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{} | 1153 | paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{} | ||
| 1130 | } | 1154 | } | ||
| 1131 | } | 1155 | } | ||