detached e55fb3b

feat(apps): parse and validate file-delivered secrets

commit
e55fb3b03e6d4adc3ff10d15d3820260bd43dc29
author
brice · · 2026-10-08 20:21 UTC
parent
7ec1d0a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5252 HTTP []rawHTTP `toml:"http"`
5353 TCP []rawTCP `toml:"tcp"`
5454 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"`
5860 // Devices lists logical device names from `devices = [...]`.
5961 Devices []string `toml:"devices"`
6062 Databases []rawDatabase `toml:"database"`
@@ -321,9 +323,12 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
321323 })
322324 }
323325 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
326329 }
330+ svc.Secrets = secrets
331+ svc.SecretFiles = secretFiles
327332 for _, v := range raw.Volumes {
328333 svc.Volumes = append(svc.Volumes, domain.AppVolume{
329334 Name: v.Name,
@@ -351,6 +356,67 @@ func toDomainService(serviceName string, raw rawService) (domain.AppService, err
351356 return svc, nil
352357 }
353358
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+
354420 // toDomainReadiness maps readiness with defaults.
355421 func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {
356422 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] = value327+ 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, nil356 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 {
106106 // [env] for the same key; its keys must be disjoint from Secrets.
107107 Env map[string]string
108108 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
111115 // Devices lists logical device names granted by administrative
112116 // [app_devices] policy. Names resolve to CDI device IDs at activation
113117 // time; the logical names (not host resolution) persist in revisions.
@@ -509,6 +513,15 @@ func checkEnvSecretKeyDisjoint(s AppSpec) error {
509513 return fmt.Errorf("%w: service %q env key %q collides with a secret key", ErrInvalidAppSpec, svc.Name, key)
510514 }
511515 }
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+ }
512525 }
513526 return nil
514527 }
@@ -624,7 +637,7 @@ func (s *AppService) validateSecrets() error {
624637 return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err)
625638 }
626639 }
627- return nil
640+ return s.validateSecretFiles()
628641 }
629642
630643 // validateVolumes checks volume declarations and returns known names.
@@ -1086,11 +1099,7 @@ func diffService(name string, desired, effective AppService) []string {
10861099 if !equalStringMaps(desired.Env, effective.Env) {
10871100 changed = append(changed, "service/"+name+"/env")
10881101 }
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)
10941103 if !equalVolumes(desired.Volumes, effective.Volumes) {
10951104 changed = append(changed, "service/"+name+"/volumes")
10961105 }
@@ -1116,16 +1125,31 @@ func appendOperationChanges(changed []string, name string, desired, effective Ap
11161125 return changed
11171126 }
11181127
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.
11201144 func secretPathChanges(desired, effective AppService) map[string]struct{} {
11211145 paths := map[string]struct{}{}
11221146 for envKey, secretName := range desired.Secrets {
1123- if effective.Secrets[envKey] != secretName {
1147+ if effective.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
11241148 paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{}
11251149 }
11261150 }
11271151 for envKey, secretName := range effective.Secrets {
1128- if desired.Secrets[envKey] != secretName {
1152+ if desired.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
11291153 paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{}
11301154 }
11311155 }
@@ -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]string107 Env map[string]string
108 Secrets map[string]string108 Secrets map[string]string
109- Volumes []AppVolume109+ // SecretFiles lists the Secrets env keys delivered as read-only files
110- Binds []AppBind110+ // 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 administrative115 // Devices lists logical device names granted by administrative
112 // [app_devices] policy. Names resolve to CDI device IDs at activation116 // [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 nil526 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 nil640+ 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 changed1125 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 }

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