detached e683751

feat(deployment): deliver opt-in secrets as read-only files

commit
e683751a8f673f221a274ea3fbd9679dac7f3f36
author
brice · · 2026-10-08 20:26 UTC
parent
b7019c4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +449 -18
M internal/usecase/deployment/candidate.go +81 -10
@@ -5,9 +5,12 @@ import (
55 "errors"
66 "fmt"
77 "maps"
8+ "slices"
89 "sort"
910 "strings"
1011
12+ "github.com/bnema/zerowrap"
13+
1114 "github.com/bnema/gordon/internal/domain"
1215 )
1316
@@ -41,7 +44,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
4144 if err != nil {
4245 return startedCandidate{}, err
4346 }
44- env, err := s.serviceEnv(ctx, app, p)
47+ env, files, err := s.serviceEnv(ctx, app, p)
4548 if err != nil {
4649 return startedCandidate{}, err
4750 }
@@ -102,7 +105,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
102105 NanoCPUs: s.deps.Limits.NanoCPUs,
103106 PidsLimit: s.deps.Limits.PidsLimit,
104107 }
105- created, err := s.createContainer(ctx, p.name, config)
108+ created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)
106109 if err != nil {
107110 return startedCandidate{}, err
108111 }
@@ -158,6 +161,43 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID
158161 return nil
159162 }
160163
164+// createWithSecretFiles writes file-delivered secrets to the host, mounts
165+// each one read-only at /run/secrets/<KEY>, and creates the container.
166+// The files are removed again when the create fails. Values stay in
167+// memory and on the 0444 host files only; nothing is logged.
168+func (s *Service) createWithSecretFiles(ctx context.Context, app, service string, config *domain.ContainerConfig, files map[string]string) (*domain.Container, error) {
169+ if len(files) == 0 {
170+ return s.createContainer(ctx, service, config)
171+ }
172+ if s.deps.SecretFiles == nil {
173+ return nil, fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", service)
174+ }
175+ paths, err := s.deps.SecretFiles.Write(ctx, app, config.Name, files)
176+ if err != nil {
177+ return nil, fmt.Errorf("deployment: write secret files for service %q: %w", service, err)
178+ }
179+ withFiles := *config
180+ withFiles.Binds = append([]domain.ContainerBind(nil), config.Binds...)
181+ for _, key := range slices.Sorted(maps.Keys(files)) {
182+ withFiles.Binds = append(withFiles.Binds, domain.ContainerBind{
183+ Name: "secret-file-" + key,
184+ Source: paths[key],
185+ Destination: domain.SecretFilePath(key),
186+ ReadOnly: true,
187+ })
188+ }
189+ created, err := s.createContainer(ctx, service, &withFiles)
190+ if err != nil {
191+ if rmErr := s.deps.SecretFiles.Remove(ctx, app, config.Name); rmErr != nil {
192+ log := zerowrap.FromCtx(ctx)
193+ log.Warn().Str("app", app).Str("service", service).
194+ Msg("deployment: secret files cleanup after failed create failed")
195+ }
196+ return nil, err
197+ }
198+ return created, nil
199+}
200+
161201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
162202 created, err := s.deps.Runtime.CreateContainer(ctx, config)
163203 if err == nil {
@@ -273,7 +313,10 @@ func shortOp(opID string) string {
273313 // values (memory only, UUID-keyed paths). Keys are emitted sorted and
274314 // deterministic. Overlapping public/secret keys are invalid with no
275315 // precedence; preflight rejects them before any mutation.
276-func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) {
316+//
317+// File-delivered secrets are returned in files (env key -> value, nil
318+// when none) and the env gets <KEY>_FILE=/run/secrets/<KEY> instead.
319+func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, map[string]string, error) {
277320 spec := p.spec
278321 envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets))
279322 maps.Copy(envMap, p.appEnv)
@@ -284,24 +327,52 @@ func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) (
284327 }
285328 sort.Strings(keys)
286329 if len(keys) == 0 {
287- return sortedEnv(envMap), nil
330+ return sortedEnv(envMap), nil, nil
288331 }
289332 id, err := s.appSecretID(ctx, app)
290333 if err != nil {
291- return nil, err
334+ return nil, nil, err
292335 }
336+ var files map[string]string
293337 for _, envKey := range keys {
294- if _, overlap := envMap[envKey]; overlap {
295- return nil, fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)
338+ if err := checkSecretEnvOverlap(envMap, spec, envKey); err != nil {
339+ return nil, nil, err
296340 }
297341 path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey])
298342 value, err := s.deps.Secrets.GetSecret(ctx, path)
299343 if err != nil {
300- return nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)
344+ return nil, nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)
345+ }
346+ if !spec.SecretAsFile(envKey) {
347+ envMap[envKey] = value
348+ continue
349+ }
350+ if files == nil {
351+ files = make(map[string]string, len(spec.SecretFiles))
301352 }
302- envMap[envKey] = value
353+ files[envKey] = value
354+ envMap[domain.SecretFileEnvKey(envKey)] = domain.SecretFilePath(envKey)
355+ }
356+ return sortedEnv(envMap), files, nil
357+}
358+
359+// checkSecretEnvOverlap refuses a secret key (and, for file delivery, its
360+// <KEY>_FILE key) that is already set by public env or another secret.
361+func checkSecretEnvOverlap(envMap map[string]string, spec domain.AppService, envKey string) error {
362+ if _, overlap := envMap[envKey]; overlap {
363+ return fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)
303364 }
304- return sortedEnv(envMap), nil
365+ if !spec.SecretAsFile(envKey) {
366+ return nil
367+ }
368+ fileKey := domain.SecretFileEnvKey(envKey)
369+ if _, overlap := envMap[fileKey]; overlap {
370+ return fmt.Errorf("deployment: env key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
371+ }
372+ if _, overlap := spec.Secrets[fileKey]; overlap {
373+ return fmt.Errorf("deployment: secret key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
374+ }
375+ return nil
305376 }
306377
307378 // sortedEnv renders a KEY=value list in sorted key order. Values stay in
@@ -5,9 +5,12 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "maps"7 "maps"
8+ "slices"
8 "sort"9 "sort"
9 "strings"10 "strings"
10 11
12+ "github.com/bnema/zerowrap"
13+
11 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
12 )15 )
13 16
@@ -41,7 +44,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
41 if err != nil {44 if err != nil {
42 return startedCandidate{}, err45 return startedCandidate{}, err
43 }46 }
44- env, err := s.serviceEnv(ctx, app, p)47+ env, files, err := s.serviceEnv(ctx, app, p)
45 if err != nil {48 if err != nil {
46 return startedCandidate{}, err49 return startedCandidate{}, err
47 }50 }
@@ -102,7 +105,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi
102 NanoCPUs: s.deps.Limits.NanoCPUs,105 NanoCPUs: s.deps.Limits.NanoCPUs,
103 PidsLimit: s.deps.Limits.PidsLimit,106 PidsLimit: s.deps.Limits.PidsLimit,
104 }107 }
105- created, err := s.createContainer(ctx, p.name, config)108+ created, err := s.createWithSecretFiles(ctx, app, p.name, config, files)
106 if err != nil {109 if err != nil {
107 return startedCandidate{}, err110 return startedCandidate{}, err
108 }111 }
@@ -158,6 +161,43 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID
158 return nil161 return nil
159 }162 }
160 163
164+// createWithSecretFiles writes file-delivered secrets to the host, mounts
165+// each one read-only at /run/secrets/<KEY>, and creates the container.
166+// The files are removed again when the create fails. Values stay in
167+// memory and on the 0444 host files only; nothing is logged.
168+func (s *Service) createWithSecretFiles(ctx context.Context, app, service string, config *domain.ContainerConfig, files map[string]string) (*domain.Container, error) {
169+ if len(files) == 0 {
170+ return s.createContainer(ctx, service, config)
171+ }
172+ if s.deps.SecretFiles == nil {
173+ return nil, fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", service)
174+ }
175+ paths, err := s.deps.SecretFiles.Write(ctx, app, config.Name, files)
176+ if err != nil {
177+ return nil, fmt.Errorf("deployment: write secret files for service %q: %w", service, err)
178+ }
179+ withFiles := *config
180+ withFiles.Binds = append([]domain.ContainerBind(nil), config.Binds...)
181+ for _, key := range slices.Sorted(maps.Keys(files)) {
182+ withFiles.Binds = append(withFiles.Binds, domain.ContainerBind{
183+ Name: "secret-file-" + key,
184+ Source: paths[key],
185+ Destination: domain.SecretFilePath(key),
186+ ReadOnly: true,
187+ })
188+ }
189+ created, err := s.createContainer(ctx, service, &withFiles)
190+ if err != nil {
191+ if rmErr := s.deps.SecretFiles.Remove(ctx, app, config.Name); rmErr != nil {
192+ log := zerowrap.FromCtx(ctx)
193+ log.Warn().Str("app", app).Str("service", service).
194+ Msg("deployment: secret files cleanup after failed create failed")
195+ }
196+ return nil, err
197+ }
198+ return created, nil
199+}
200+
161 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {201 func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) {
162 created, err := s.deps.Runtime.CreateContainer(ctx, config)202 created, err := s.deps.Runtime.CreateContainer(ctx, config)
163 if err == nil {203 if err == nil {
@@ -273,7 +313,10 @@ func shortOp(opID string) string {
273 // values (memory only, UUID-keyed paths). Keys are emitted sorted and313 // values (memory only, UUID-keyed paths). Keys are emitted sorted and
274 // deterministic. Overlapping public/secret keys are invalid with no314 // deterministic. Overlapping public/secret keys are invalid with no
275 // precedence; preflight rejects them before any mutation.315 // precedence; preflight rejects them before any mutation.
276-func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, error) {316+//
317+// File-delivered secrets are returned in files (env key -> value, nil
318+// when none) and the env gets <KEY>_FILE=/run/secrets/<KEY> instead.
319+func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ([]string, map[string]string, error) {
277 spec := p.spec320 spec := p.spec
278 envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets))321 envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets))
279 maps.Copy(envMap, p.appEnv)322 maps.Copy(envMap, p.appEnv)
@@ -284,24 +327,52 @@ func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) (
284 }327 }
285 sort.Strings(keys)328 sort.Strings(keys)
286 if len(keys) == 0 {329 if len(keys) == 0 {
287- return sortedEnv(envMap), nil330+ return sortedEnv(envMap), nil, nil
288 }331 }
289 id, err := s.appSecretID(ctx, app)332 id, err := s.appSecretID(ctx, app)
290 if err != nil {333 if err != nil {
291- return nil, err334+ return nil, nil, err
292 }335 }
336+ var files map[string]string
293 for _, envKey := range keys {337 for _, envKey := range keys {
294- if _, overlap := envMap[envKey]; overlap {338+ if err := checkSecretEnvOverlap(envMap, spec, envKey); err != nil {
295- return nil, fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)339+ return nil, nil, err
296 }340 }
297 path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey])341 path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey])
298 value, err := s.deps.Secrets.GetSecret(ctx, path)342 value, err := s.deps.Secrets.GetSecret(ctx, path)
299 if err != nil {343 if err != nil {
300- return nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)344+ return nil, nil, fmt.Errorf("deployment: secret %s (env %s) missing: %w", path, envKey, domain.ErrAppSecretMissing)
345+ }
346+ if !spec.SecretAsFile(envKey) {
347+ envMap[envKey] = value
348+ continue
349+ }
350+ if files == nil {
351+ files = make(map[string]string, len(spec.SecretFiles))
301 }352 }
302- envMap[envKey] = value353+ files[envKey] = value
354+ envMap[domain.SecretFileEnvKey(envKey)] = domain.SecretFilePath(envKey)
355+ }
356+ return sortedEnv(envMap), files, nil
357+}
358+
359+// checkSecretEnvOverlap refuses a secret key (and, for file delivery, its
360+// <KEY>_FILE key) that is already set by public env or another secret.
361+func checkSecretEnvOverlap(envMap map[string]string, spec domain.AppService, envKey string) error {
362+ if _, overlap := envMap[envKey]; overlap {
363+ return fmt.Errorf("deployment: env key %q collides with a secret key in service %q: %w", envKey, spec.Name, domain.ErrInvalidAppSpec)
303 }364 }
304- return sortedEnv(envMap), nil365+ if !spec.SecretAsFile(envKey) {
366+ return nil
367+ }
368+ fileKey := domain.SecretFileEnvKey(envKey)
369+ if _, overlap := envMap[fileKey]; overlap {
370+ return fmt.Errorf("deployment: env key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
371+ }
372+ if _, overlap := spec.Secrets[fileKey]; overlap {
373+ return fmt.Errorf("deployment: secret key %q collides with a secret file key in service %q: %w", fileKey, spec.Name, domain.ErrInvalidAppSpec)
374+ }
375+ return nil
305 }376 }
306 377
307 // sortedEnv renders a KEY=value list in sorted key order. Values stay in378 // sortedEnv renders a KEY=value list in sorted key order. Values stay in
M internal/usecase/deployment/execute.go +24 -5
@@ -445,7 +445,7 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
445445 if err != nil || container.Status != string(domain.ContainerStatusRunning) {
446446 return "container not running"
447447 }
448- if !s.envCurrent(ctx, app, p, container.Env) {
448+ if !s.envCurrent(ctx, app, p, container) {
449449 return "environment or secrets changed"
450450 }
451451 return ""
@@ -455,14 +455,15 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
455455 // environment p would get now, including current secret values. The
456456 // runtime env also holds image-declared keys, so every desired entry must
457457 // be present; removed keys are caught by the spec and app env comparisons.
458+// File-delivered secrets are compared against the container's host files.
458459 // Values are compared in memory only and never logged.
459-func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, running []string) bool {
460- desired, err := s.serviceEnv(ctx, app, p)
460+func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, container *domain.Container) bool {
461+ desired, files, err := s.serviceEnv(ctx, app, p)
461462 if err != nil {
462463 return false
463464 }
464- have := make(map[string]struct{}, len(running))
465- for _, entry := range running {
465+ have := make(map[string]struct{}, len(container.Env))
466+ for _, entry := range container.Env {
466467 have[entry] = struct{}{}
467468 }
468469 for _, entry := range desired {
@@ -470,6 +471,24 @@ func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, r
470471 return false
471472 }
472473 }
474+ return s.secretFilesCurrent(ctx, app, container.Name, files)
475+}
476+
477+// secretFilesCurrent reports whether every host secret file of the
478+// container holds the current value. Values are compared in memory only.
479+func (s *Service) secretFilesCurrent(ctx context.Context, app, container string, files map[string]string) bool {
480+ if len(files) == 0 {
481+ return true
482+ }
483+ if s.deps.SecretFiles == nil {
484+ return false
485+ }
486+ for key, want := range files {
487+ got, err := s.deps.SecretFiles.Read(ctx, app, container, key)
488+ if err != nil || got != want {
489+ return false
490+ }
491+ }
473492 return true
474493 }
475494
@@ -445,7 +445,7 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
445 if err != nil || container.Status != string(domain.ContainerStatusRunning) {445 if err != nil || container.Status != string(domain.ContainerStatusRunning) {
446 return "container not running"446 return "container not running"
447 }447 }
448- if !s.envCurrent(ctx, app, p, container.Env) {448+ if !s.envCurrent(ctx, app, p, container) {
449 return "environment or secrets changed"449 return "environment or secrets changed"
450 }450 }
451 return ""451 return ""
@@ -455,14 +455,15 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ
455 // environment p would get now, including current secret values. The455 // environment p would get now, including current secret values. The
456 // runtime env also holds image-declared keys, so every desired entry must456 // runtime env also holds image-declared keys, so every desired entry must
457 // be present; removed keys are caught by the spec and app env comparisons.457 // be present; removed keys are caught by the spec and app env comparisons.
458+// File-delivered secrets are compared against the container's host files.
458 // Values are compared in memory only and never logged.459 // Values are compared in memory only and never logged.
459-func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, running []string) bool {460+func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, container *domain.Container) bool {
460- desired, err := s.serviceEnv(ctx, app, p)461+ desired, files, err := s.serviceEnv(ctx, app, p)
461 if err != nil {462 if err != nil {
462 return false463 return false
463 }464 }
464- have := make(map[string]struct{}, len(running))465+ have := make(map[string]struct{}, len(container.Env))
465- for _, entry := range running {466+ for _, entry := range container.Env {
466 have[entry] = struct{}{}467 have[entry] = struct{}{}
467 }468 }
468 for _, entry := range desired {469 for _, entry := range desired {
@@ -470,6 +471,24 @@ func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, r
470 return false471 return false
471 }472 }
472 }473 }
474+ return s.secretFilesCurrent(ctx, app, container.Name, files)
475+}
476+
477+// secretFilesCurrent reports whether every host secret file of the
478+// container holds the current value. Values are compared in memory only.
479+func (s *Service) secretFilesCurrent(ctx context.Context, app, container string, files map[string]string) bool {
480+ if len(files) == 0 {
481+ return true
482+ }
483+ if s.deps.SecretFiles == nil {
484+ return false
485+ }
486+ for key, want := range files {
487+ got, err := s.deps.SecretFiles.Read(ctx, app, container, key)
488+ if err != nil || got != want {
489+ return false
490+ }
491+ }
473 return true492 return true
474 }493 }
475 494
M internal/usecase/deployment/retirement.go +11 -0
@@ -6,6 +6,8 @@ import (
66 "sort"
77 "time"
88
9+ "github.com/bnema/zerowrap"
10+
911 "github.com/bnema/gordon/internal/domain"
1012 )
1113
@@ -92,6 +94,15 @@ func (s *Service) retireContainer(ctx context.Context, app string, opts retireOp
9294 Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),
9395 })
9496 }
97+ if err := s.sweepAppSecretFiles(ctx, app); err != nil {
98+ // Fixed detail: the error may name host paths, which must stay
99+ // out of journals and API responses.
100+ log := zerowrap.FromCtx(ctx)
101+ log.Warn().Err(err).Str("app", app).Str("service", opts.Service).Msg("deployment: secret files cleanup failed")
102+ result.Warnings = append(result.Warnings, CleanupWarning{
103+ Service: opts.Service, Leftover: containerID, Detail: "secret files cleanup failed",
104+ })
105+ }
95106 if opts.ClearInhibition {
96107 // Confirmed gone: the marker has nothing left to protect, and a
97108 // stale record would only confuse the next recreation.
@@ -6,6 +6,8 @@ import (
6 "sort"6 "sort"
7 "time"7 "time"
8 8
9+ "github.com/bnema/zerowrap"
10+
9 "github.com/bnema/gordon/internal/domain"11 "github.com/bnema/gordon/internal/domain"
10 )12 )
11 13
@@ -92,6 +94,15 @@ func (s *Service) retireContainer(ctx context.Context, app string, opts retireOp
92 Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),94 Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),
93 })95 })
94 }96 }
97+ if err := s.sweepAppSecretFiles(ctx, app); err != nil {
98+ // Fixed detail: the error may name host paths, which must stay
99+ // out of journals and API responses.
100+ log := zerowrap.FromCtx(ctx)
101+ log.Warn().Err(err).Str("app", app).Str("service", opts.Service).Msg("deployment: secret files cleanup failed")
102+ result.Warnings = append(result.Warnings, CleanupWarning{
103+ Service: opts.Service, Leftover: containerID, Detail: "secret files cleanup failed",
104+ })
105+ }
95 if opts.ClearInhibition {106 if opts.ClearInhibition {
96 // Confirmed gone: the marker has nothing left to protect, and a107 // Confirmed gone: the marker has nothing left to protect, and a
97 // stale record would only confuse the next recreation.108 // stale record would only confuse the next recreation.
A internal/usecase/deployment/secret_files.go +65 -0
@@ -0,0 +1,65 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+)
8+
9+// sweepAppSecretFiles removes the secret file directories of app whose
10+// container no longer exists in the runtime. Callers hold the app lock,
11+// so no candidate of this app can be between file write and create.
12+func (s *Service) sweepAppSecretFiles(ctx context.Context, app string) error {
13+ return s.sweepSecretFiles(ctx, app)
14+}
15+
16+// SweepSecretFiles removes every secret file directory whose container no
17+// longer exists, across all apps. It takes no locks: it runs once at
18+// startup, before any listener (and so any deploy) exists.
19+func (s *Service) SweepSecretFiles(ctx context.Context) error {
20+ return s.sweepSecretFiles(ctx, "")
21+}
22+
23+// sweepSecretFiles removes orphaned directories, limited to one app when
24+// filter is not empty. Directory names are container names because the
25+// files are written before the container ID exists.
26+func (s *Service) sweepSecretFiles(ctx context.Context, filter string) error {
27+ store := s.deps.SecretFiles
28+ if store == nil {
29+ return nil
30+ }
31+ sets, err := store.List(ctx)
32+ if err != nil {
33+ return fmt.Errorf("deployment: list secret files: %w", err)
34+ }
35+ if filter != "" {
36+ kept := sets[:0]
37+ for _, set := range sets {
38+ if set.App == filter {
39+ kept = append(kept, set)
40+ }
41+ }
42+ sets = kept
43+ }
44+ if len(sets) == 0 {
45+ return nil
46+ }
47+ containers, err := s.deps.Runtime.ListContainers(ctx, true)
48+ if err != nil {
49+ return fmt.Errorf("deployment: list containers for secret file sweep: %w", err)
50+ }
51+ live := make(map[string]struct{}, len(containers))
52+ for _, c := range containers {
53+ live[c.Name] = struct{}{}
54+ }
55+ var failures []error
56+ for _, set := range sets {
57+ if _, ok := live[set.Container]; ok {
58+ continue
59+ }
60+ if err := store.Remove(ctx, set.App, set.Container); err != nil {
61+ failures = append(failures, fmt.Errorf("deployment: remove secret files of app %q: %w", set.App, err))
62+ }
63+ }
64+ return errors.Join(failures...)
65+}
@@ -0,0 +1,65 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+)
8+
9+// sweepAppSecretFiles removes the secret file directories of app whose
10+// container no longer exists in the runtime. Callers hold the app lock,
11+// so no candidate of this app can be between file write and create.
12+func (s *Service) sweepAppSecretFiles(ctx context.Context, app string) error {
13+ return s.sweepSecretFiles(ctx, app)
14+}
15+
16+// SweepSecretFiles removes every secret file directory whose container no
17+// longer exists, across all apps. It takes no locks: it runs once at
18+// startup, before any listener (and so any deploy) exists.
19+func (s *Service) SweepSecretFiles(ctx context.Context) error {
20+ return s.sweepSecretFiles(ctx, "")
21+}
22+
23+// sweepSecretFiles removes orphaned directories, limited to one app when
24+// filter is not empty. Directory names are container names because the
25+// files are written before the container ID exists.
26+func (s *Service) sweepSecretFiles(ctx context.Context, filter string) error {
27+ store := s.deps.SecretFiles
28+ if store == nil {
29+ return nil
30+ }
31+ sets, err := store.List(ctx)
32+ if err != nil {
33+ return fmt.Errorf("deployment: list secret files: %w", err)
34+ }
35+ if filter != "" {
36+ kept := sets[:0]
37+ for _, set := range sets {
38+ if set.App == filter {
39+ kept = append(kept, set)
40+ }
41+ }
42+ sets = kept
43+ }
44+ if len(sets) == 0 {
45+ return nil
46+ }
47+ containers, err := s.deps.Runtime.ListContainers(ctx, true)
48+ if err != nil {
49+ return fmt.Errorf("deployment: list containers for secret file sweep: %w", err)
50+ }
51+ live := make(map[string]struct{}, len(containers))
52+ for _, c := range containers {
53+ live[c.Name] = struct{}{}
54+ }
55+ var failures []error
56+ for _, set := range sets {
57+ if _, ok := live[set.Container]; ok {
58+ continue
59+ }
60+ if err := store.Remove(ctx, set.App, set.Container); err != nil {
61+ failures = append(failures, fmt.Errorf("deployment: remove secret files of app %q: %w", set.App, err))
62+ }
63+ }
64+ return errors.Join(failures...)
65+}
A internal/usecase/deployment/secret_files_internal_test.go +257 -0
@@ -0,0 +1,257 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+const (
19+ testFileSecretValue = "file-s3cr3t-value"
20+ testSecretFileBase = "/data/secrets-run"
21+)
22+
23+// fileSecretService returns a service with one env and one file secret,
24+// wired to mocks returning "env-only" and testFileSecretValue.
25+func fileSecretService(t *testing.T, deps Deps) (*Service, pinnedService) {
26+ t.Helper()
27+ state := outmocks.NewMockAppState(t)
28+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe()
29+ secrets := outmocks.NewMockSecretProvider(t)
30+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/a").Return("env-only", nil).Maybe()
31+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/b").Return(testFileSecretValue, nil).Maybe()
32+ deps.State = state
33+ deps.Secrets = secrets
34+ p := pinnedService{name: "web", appEnv: map[string]string{"APP_ENV": "production"}, spec: domain.AppService{
35+ Name: "web",
36+ Secrets: map[string]string{"A": "a", "B": "b"},
37+ SecretFiles: []string{"B"},
38+ }}
39+ return NewService(deps, zerowrap.Default()), p
40+}
41+
42+func TestServiceEnv_SplitsFileSecrets(t *testing.T) {
43+ svc, p := fileSecretService(t, Deps{})
44+
45+ env, files, err := svc.serviceEnv(context.Background(), "blog", p)
46+ require.NoError(t, err)
47+ assert.Equal(t, []string{"A=env-only", "APP_ENV=production", "B_FILE=/run/secrets/B"}, env)
48+ assert.Equal(t, map[string]string{"B": testFileSecretValue}, files)
49+ for _, entry := range env {
50+ assert.NotContains(t, entry, testFileSecretValue)
51+ }
52+}
53+
54+func TestServiceEnv_FileKeyOverlapFails(t *testing.T) {
55+ svc, p := fileSecretService(t, Deps{})
56+ p.appEnv = map[string]string{"B_FILE": "x"}
57+
58+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
59+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
60+}
61+
62+func TestCreateWithSecretFiles_MountsReadOnlyFiles(t *testing.T) {
63+ store := outmocks.NewMockSecretFileStore(t)
64+ runtime := outmocks.NewMockContainerRuntime(t)
65+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
66+ admin := domain.ContainerBind{Name: "config", Source: "/srv/config", Destination: "/etc/app", ReadOnly: true}
67+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Binds: []domain.ContainerBind{admin}}
68+
69+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", map[string]string{"B": testFileSecretValue}).
70+ Return(map[string]string{"B": testSecretFileBase + "/blog/gordon-blog--web--abc/B"}, nil).Once()
71+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(c *domain.ContainerConfig) bool {
72+ return assert.ObjectsAreEqual([]domain.ContainerBind{admin, {
73+ Name: "secret-file-B",
74+ Source: testSecretFileBase + "/blog/gordon-blog--web--abc/B",
75+ Destination: "/run/secrets/B",
76+ ReadOnly: true,
77+ }}, c.Binds)
78+ })).Return(&domain.Container{ID: "c-1"}, nil).Once()
79+
80+ created, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
81+ require.NoError(t, err)
82+ assert.Equal(t, "c-1", created.ID)
83+ assert.Equal(t, []domain.ContainerBind{admin}, config.Binds, "caller config is not mutated")
84+}
85+
86+func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
87+ store := outmocks.NewMockSecretFileStore(t)
88+ runtime := outmocks.NewMockContainerRuntime(t)
89+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
90+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc"}
91+ path := testSecretFileBase + "/blog/gordon-blog--web--abc/B"
92+
93+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
94+ Return(map[string]string{"B": path}, nil).Once()
95+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
96+ Return(nil, errors.New("create failed: mount "+path+" "+testFileSecretValue)).Once()
97+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--abc").Return(nil).Once()
98+
99+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
100+ require.Error(t, err)
101+ assert.NotContains(t, err.Error(), testFileSecretValue)
102+ assert.NotContains(t, err.Error(), path)
103+}
104+
105+func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
106+ runtime := outmocks.NewMockContainerRuntime(t)
107+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
108+
109+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
110+ require.Error(t, err)
111+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
112+}
113+
114+func TestCheckSecrets_FileSecretsNeedStore(t *testing.T) {
115+ secrets := outmocks.NewMockSecretProvider(t)
116+ svc := NewService(Deps{Secrets: secrets}, zerowrap.Default())
117+
118+ err := svc.checkSecrets(context.Background(), "blog", "app-1", domain.AppService{
119+ Name: "web", Secrets: map[string]string{"B": "b"}, SecretFiles: []string{"B"},
120+ })
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "no secret file store")
123+}
124+
125+func TestEnvCurrent_ComparesSecretFiles(t *testing.T) {
126+ running := &domain.Container{
127+ Name: "gordon-blog--web--abc",
128+ Env: []string{"APP_ENV=production", "A=env-only", "B_FILE=/run/secrets/B", "PATH=/bin"},
129+ }
130+ cases := []struct {
131+ name string
132+ read func(*outmocks.MockSecretFileStore)
133+ want bool
134+ }{
135+ {"same content kept", func(m *outmocks.MockSecretFileStore) {
136+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return(testFileSecretValue, nil).Once()
137+ }, true},
138+ {"changed content replaced", func(m *outmocks.MockSecretFileStore) {
139+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("old", nil).Once()
140+ }, false},
141+ {"read error replaced", func(m *outmocks.MockSecretFileStore) {
142+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("", errors.New("gone")).Once()
143+ }, false},
144+ }
145+ for _, tc := range cases {
146+ t.Run(tc.name, func(t *testing.T) {
147+ store := outmocks.NewMockSecretFileStore(t)
148+ tc.read(store)
149+ svc, p := fileSecretService(t, Deps{SecretFiles: store})
150+ assert.Equal(t, tc.want, svc.envCurrent(context.Background(), "blog", p, running))
151+ })
152+ }
153+
154+ t.Run("nil store replaced", func(t *testing.T) {
155+ svc, p := fileSecretService(t, Deps{})
156+ assert.False(t, svc.envCurrent(context.Background(), "blog", p, running))
157+ })
158+}
159+
160+func TestRedactDiagnostics_RedactsFileSecrets(t *testing.T) {
161+ svc, p := fileSecretService(t, Deps{})
162+
163+ got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"loaded " + testFileSecretValue, "ok"})
164+ assert.Equal(t, []string{"loaded [redacted]", "ok"}, got)
165+}
166+
167+func TestSweepSecretFiles(t *testing.T) {
168+ sets := []out.SecretFileSet{
169+ {App: "blog", Container: "gordon-blog--web--a"},
170+ {App: "blog", Container: "gordon-blog--web--b"},
171+ {App: "other", Container: "gordon-other--x--c"},
172+ }
173+ live := []*domain.Container{{Name: "gordon-blog--web--b"}, {Name: "unrelated"}}
174+
175+ t.Run("app scoped", func(t *testing.T) {
176+ store := outmocks.NewMockSecretFileStore(t)
177+ runtime := outmocks.NewMockContainerRuntime(t)
178+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
179+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
180+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
181+
182+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
183+ require.NoError(t, svc.sweepAppSecretFiles(context.Background(), "blog"))
184+ })
185+
186+ t.Run("all apps", func(t *testing.T) {
187+ store := outmocks.NewMockSecretFileStore(t)
188+ runtime := outmocks.NewMockContainerRuntime(t)
189+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
190+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
191+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
192+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
193+
194+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
195+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
196+ })
197+
198+ t.Run("no sets skips runtime", func(t *testing.T) {
199+ store := outmocks.NewMockSecretFileStore(t)
200+ runtime := outmocks.NewMockContainerRuntime(t)
201+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
202+
203+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
204+ require.NoError(t, svc.sweepAppSecretFiles(context.Background(), "ghost"))
205+ runtime.AssertNotCalled(t, "ListContainers", mock.Anything, mock.Anything)
206+ })
207+
208+ t.Run("nil store", func(t *testing.T) {
209+ svc := NewService(Deps{}, zerowrap.Default())
210+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
211+ })
212+}
213+
214+func TestRetireContainer_SweepsSecretFiles(t *testing.T) {
215+ t.Run("gone sweeps", func(t *testing.T) {
216+ state := outmocks.NewMockAppState(t)
217+ runtime := outmocks.NewMockContainerRuntime(t)
218+ store := outmocks.NewMockSecretFileStore(t)
219+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
220+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
221+ store.EXPECT().List(mock.Anything).Return([]out.SecretFileSet{{App: "blog", Container: "gordon-blog--web--a"}}, nil).Once()
222+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, nil).Once()
223+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
224+
225+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
226+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
227+ assert.True(t, retired.Gone)
228+ assert.Empty(t, retired.Warnings)
229+ })
230+
231+ t.Run("sweep failure is a fixed warning", func(t *testing.T) {
232+ state := outmocks.NewMockAppState(t)
233+ runtime := outmocks.NewMockContainerRuntime(t)
234+ store := outmocks.NewMockSecretFileStore(t)
235+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
236+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
237+ store.EXPECT().List(mock.Anything).Return(nil, errors.New("open "+testSecretFileBase+": denied")).Once()
238+
239+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
240+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
241+ assert.True(t, retired.Gone)
242+ require.Len(t, retired.Warnings, 1)
243+ assert.Equal(t, "secret files cleanup failed", retired.Warnings[0].Detail)
244+ })
245+
246+ t.Run("not gone does not sweep", func(t *testing.T) {
247+ state := outmocks.NewMockAppState(t)
248+ runtime := outmocks.NewMockContainerRuntime(t)
249+ store := outmocks.NewMockSecretFileStore(t)
250+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(errors.New("busy")).Once()
251+
252+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
253+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
254+ assert.False(t, retired.Gone)
255+ store.AssertNotCalled(t, "List", mock.Anything)
256+ })
257+}
@@ -0,0 +1,257 @@
1+package deployment
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+const (
19+ testFileSecretValue = "file-s3cr3t-value"
20+ testSecretFileBase = "/data/secrets-run"
21+)
22+
23+// fileSecretService returns a service with one env and one file secret,
24+// wired to mocks returning "env-only" and testFileSecretValue.
25+func fileSecretService(t *testing.T, deps Deps) (*Service, pinnedService) {
26+ t.Helper()
27+ state := outmocks.NewMockAppState(t)
28+ state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe()
29+ secrets := outmocks.NewMockSecretProvider(t)
30+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/a").Return("env-only", nil).Maybe()
31+ secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/b").Return(testFileSecretValue, nil).Maybe()
32+ deps.State = state
33+ deps.Secrets = secrets
34+ p := pinnedService{name: "web", appEnv: map[string]string{"APP_ENV": "production"}, spec: domain.AppService{
35+ Name: "web",
36+ Secrets: map[string]string{"A": "a", "B": "b"},
37+ SecretFiles: []string{"B"},
38+ }}
39+ return NewService(deps, zerowrap.Default()), p
40+}
41+
42+func TestServiceEnv_SplitsFileSecrets(t *testing.T) {
43+ svc, p := fileSecretService(t, Deps{})
44+
45+ env, files, err := svc.serviceEnv(context.Background(), "blog", p)
46+ require.NoError(t, err)
47+ assert.Equal(t, []string{"A=env-only", "APP_ENV=production", "B_FILE=/run/secrets/B"}, env)
48+ assert.Equal(t, map[string]string{"B": testFileSecretValue}, files)
49+ for _, entry := range env {
50+ assert.NotContains(t, entry, testFileSecretValue)
51+ }
52+}
53+
54+func TestServiceEnv_FileKeyOverlapFails(t *testing.T) {
55+ svc, p := fileSecretService(t, Deps{})
56+ p.appEnv = map[string]string{"B_FILE": "x"}
57+
58+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
59+ require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
60+}
61+
62+func TestCreateWithSecretFiles_MountsReadOnlyFiles(t *testing.T) {
63+ store := outmocks.NewMockSecretFileStore(t)
64+ runtime := outmocks.NewMockContainerRuntime(t)
65+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
66+ admin := domain.ContainerBind{Name: "config", Source: "/srv/config", Destination: "/etc/app", ReadOnly: true}
67+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Binds: []domain.ContainerBind{admin}}
68+
69+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", map[string]string{"B": testFileSecretValue}).
70+ Return(map[string]string{"B": testSecretFileBase + "/blog/gordon-blog--web--abc/B"}, nil).Once()
71+ runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(c *domain.ContainerConfig) bool {
72+ return assert.ObjectsAreEqual([]domain.ContainerBind{admin, {
73+ Name: "secret-file-B",
74+ Source: testSecretFileBase + "/blog/gordon-blog--web--abc/B",
75+ Destination: "/run/secrets/B",
76+ ReadOnly: true,
77+ }}, c.Binds)
78+ })).Return(&domain.Container{ID: "c-1"}, nil).Once()
79+
80+ created, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
81+ require.NoError(t, err)
82+ assert.Equal(t, "c-1", created.ID)
83+ assert.Equal(t, []domain.ContainerBind{admin}, config.Binds, "caller config is not mutated")
84+}
85+
86+func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
87+ store := outmocks.NewMockSecretFileStore(t)
88+ runtime := outmocks.NewMockContainerRuntime(t)
89+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
90+ config := &domain.ContainerConfig{Name: "gordon-blog--web--abc"}
91+ path := testSecretFileBase + "/blog/gordon-blog--web--abc/B"
92+
93+ store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
94+ Return(map[string]string{"B": path}, nil).Once()
95+ runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
96+ Return(nil, errors.New("create failed: mount "+path+" "+testFileSecretValue)).Once()
97+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--abc").Return(nil).Once()
98+
99+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
100+ require.Error(t, err)
101+ assert.NotContains(t, err.Error(), testFileSecretValue)
102+ assert.NotContains(t, err.Error(), path)
103+}
104+
105+func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
106+ runtime := outmocks.NewMockContainerRuntime(t)
107+ svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
108+
109+ _, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
110+ require.Error(t, err)
111+ runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
112+}
113+
114+func TestCheckSecrets_FileSecretsNeedStore(t *testing.T) {
115+ secrets := outmocks.NewMockSecretProvider(t)
116+ svc := NewService(Deps{Secrets: secrets}, zerowrap.Default())
117+
118+ err := svc.checkSecrets(context.Background(), "blog", "app-1", domain.AppService{
119+ Name: "web", Secrets: map[string]string{"B": "b"}, SecretFiles: []string{"B"},
120+ })
121+ require.Error(t, err)
122+ assert.Contains(t, err.Error(), "no secret file store")
123+}
124+
125+func TestEnvCurrent_ComparesSecretFiles(t *testing.T) {
126+ running := &domain.Container{
127+ Name: "gordon-blog--web--abc",
128+ Env: []string{"APP_ENV=production", "A=env-only", "B_FILE=/run/secrets/B", "PATH=/bin"},
129+ }
130+ cases := []struct {
131+ name string
132+ read func(*outmocks.MockSecretFileStore)
133+ want bool
134+ }{
135+ {"same content kept", func(m *outmocks.MockSecretFileStore) {
136+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return(testFileSecretValue, nil).Once()
137+ }, true},
138+ {"changed content replaced", func(m *outmocks.MockSecretFileStore) {
139+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("old", nil).Once()
140+ }, false},
141+ {"read error replaced", func(m *outmocks.MockSecretFileStore) {
142+ m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("", errors.New("gone")).Once()
143+ }, false},
144+ }
145+ for _, tc := range cases {
146+ t.Run(tc.name, func(t *testing.T) {
147+ store := outmocks.NewMockSecretFileStore(t)
148+ tc.read(store)
149+ svc, p := fileSecretService(t, Deps{SecretFiles: store})
150+ assert.Equal(t, tc.want, svc.envCurrent(context.Background(), "blog", p, running))
151+ })
152+ }
153+
154+ t.Run("nil store replaced", func(t *testing.T) {
155+ svc, p := fileSecretService(t, Deps{})
156+ assert.False(t, svc.envCurrent(context.Background(), "blog", p, running))
157+ })
158+}
159+
160+func TestRedactDiagnostics_RedactsFileSecrets(t *testing.T) {
161+ svc, p := fileSecretService(t, Deps{})
162+
163+ got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"loaded " + testFileSecretValue, "ok"})
164+ assert.Equal(t, []string{"loaded [redacted]", "ok"}, got)
165+}
166+
167+func TestSweepSecretFiles(t *testing.T) {
168+ sets := []out.SecretFileSet{
169+ {App: "blog", Container: "gordon-blog--web--a"},
170+ {App: "blog", Container: "gordon-blog--web--b"},
171+ {App: "other", Container: "gordon-other--x--c"},
172+ }
173+ live := []*domain.Container{{Name: "gordon-blog--web--b"}, {Name: "unrelated"}}
174+
175+ t.Run("app scoped", func(t *testing.T) {
176+ store := outmocks.NewMockSecretFileStore(t)
177+ runtime := outmocks.NewMockContainerRuntime(t)
178+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
179+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
180+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
181+
182+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
183+ require.NoError(t, svc.sweepAppSecretFiles(context.Background(), "blog"))
184+ })
185+
186+ t.Run("all apps", func(t *testing.T) {
187+ store := outmocks.NewMockSecretFileStore(t)
188+ runtime := outmocks.NewMockContainerRuntime(t)
189+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
190+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
191+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
192+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
193+
194+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
195+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
196+ })
197+
198+ t.Run("no sets skips runtime", func(t *testing.T) {
199+ store := outmocks.NewMockSecretFileStore(t)
200+ runtime := outmocks.NewMockContainerRuntime(t)
201+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
202+
203+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
204+ require.NoError(t, svc.sweepAppSecretFiles(context.Background(), "ghost"))
205+ runtime.AssertNotCalled(t, "ListContainers", mock.Anything, mock.Anything)
206+ })
207+
208+ t.Run("nil store", func(t *testing.T) {
209+ svc := NewService(Deps{}, zerowrap.Default())
210+ require.NoError(t, svc.SweepSecretFiles(context.Background()))
211+ })
212+}
213+
214+func TestRetireContainer_SweepsSecretFiles(t *testing.T) {
215+ t.Run("gone sweeps", func(t *testing.T) {
216+ state := outmocks.NewMockAppState(t)
217+ runtime := outmocks.NewMockContainerRuntime(t)
218+ store := outmocks.NewMockSecretFileStore(t)
219+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
220+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
221+ store.EXPECT().List(mock.Anything).Return([]out.SecretFileSet{{App: "blog", Container: "gordon-blog--web--a"}}, nil).Once()
222+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, nil).Once()
223+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
224+
225+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
226+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
227+ assert.True(t, retired.Gone)
228+ assert.Empty(t, retired.Warnings)
229+ })
230+
231+ t.Run("sweep failure is a fixed warning", func(t *testing.T) {
232+ state := outmocks.NewMockAppState(t)
233+ runtime := outmocks.NewMockContainerRuntime(t)
234+ store := outmocks.NewMockSecretFileStore(t)
235+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
236+ state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
237+ store.EXPECT().List(mock.Anything).Return(nil, errors.New("open "+testSecretFileBase+": denied")).Once()
238+
239+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
240+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
241+ assert.True(t, retired.Gone)
242+ require.Len(t, retired.Warnings, 1)
243+ assert.Equal(t, "secret files cleanup failed", retired.Warnings[0].Detail)
244+ })
245+
246+ t.Run("not gone does not sweep", func(t *testing.T) {
247+ state := outmocks.NewMockAppState(t)
248+ runtime := outmocks.NewMockContainerRuntime(t)
249+ store := outmocks.NewMockSecretFileStore(t)
250+ runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(errors.New("busy")).Once()
251+
252+ svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
253+ retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
254+ assert.False(t, retired.Gone)
255+ store.AssertNotCalled(t, "List", mock.Anything)
256+ })
257+}
M internal/usecase/deployment/service.go +7 -0
@@ -47,6 +47,10 @@ type Deps struct {
4747 // fail-closed before touching a workload. Nil in tests that assert
4848 // state effects only. Failures are reported to the caller.
4949 Traffic out.AppTrafficRefresher
50+ // SecretFiles stores file-delivered secrets on the host. Optional:
51+ // nil is valid only while no service delivers secrets as files;
52+ // preflight refuses such services otherwise.
53+ SecretFiles out.SecretFileStore
5054 }
5155
5256 // RegistryConfig carries the installation registry identity for pulls.
@@ -796,6 +800,9 @@ func devicesEngineChecked(pinned []pinnedService) bool {
796800 // Paths are UUID-keyed so a removed app's secrets are never adopted
797801 // by a new app reusing the name.
798802 func (s *Service) checkSecrets(ctx context.Context, app, appID string, svc domain.AppService) error {
803+ if len(svc.SecretFiles) > 0 && s.deps.SecretFiles == nil {
804+ return fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", svc.Name)
805+ }
799806 if s.deps.Secrets == nil {
800807 return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing)
801808 }
@@ -47,6 +47,10 @@ type Deps struct {
47 // fail-closed before touching a workload. Nil in tests that assert47 // fail-closed before touching a workload. Nil in tests that assert
48 // state effects only. Failures are reported to the caller.48 // state effects only. Failures are reported to the caller.
49 Traffic out.AppTrafficRefresher49 Traffic out.AppTrafficRefresher
50+ // SecretFiles stores file-delivered secrets on the host. Optional:
51+ // nil is valid only while no service delivers secrets as files;
52+ // preflight refuses such services otherwise.
53+ SecretFiles out.SecretFileStore
50 }54 }
51 55
52 // RegistryConfig carries the installation registry identity for pulls.56 // RegistryConfig carries the installation registry identity for pulls.
@@ -796,6 +800,9 @@ func devicesEngineChecked(pinned []pinnedService) bool {
796 // Paths are UUID-keyed so a removed app's secrets are never adopted800 // Paths are UUID-keyed so a removed app's secrets are never adopted
797 // by a new app reusing the name.801 // by a new app reusing the name.
798 func (s *Service) checkSecrets(ctx context.Context, app, appID string, svc domain.AppService) error {802 func (s *Service) checkSecrets(ctx context.Context, app, appID string, svc domain.AppService) error {
803+ if len(svc.SecretFiles) > 0 && s.deps.SecretFiles == nil {
804+ return fmt.Errorf("deployment: service %q delivers secrets as files but no secret file store is configured", svc.Name)
805+ }
799 if s.deps.Secrets == nil {806 if s.deps.Secrets == nil {
800 return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing)807 return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing)
801 }808 }
M internal/usecase/deployment/service_env_internal_test.go +4 -3
@@ -23,12 +23,13 @@ func TestServiceEnv_ServiceEnvOverridesAppEnv(t *testing.T) {
2323 name: "web", appEnv: appEnv,
2424 spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}},
2525 }
26- got, err := svc.serviceEnv(context.Background(), "blog", web)
26+ got, files, err := svc.serviceEnv(context.Background(), "blog", web)
2727 require.NoError(t, err)
28+ assert.Nil(t, files)
2829 assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got)
2930
3031 worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}}
31- got, err = svc.serviceEnv(context.Background(), "blog", worker)
32+ got, _, err = svc.serviceEnv(context.Background(), "blog", worker)
3233 require.NoError(t, err)
3334 assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got)
3435 assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated")
@@ -44,6 +45,6 @@ func TestServiceEnv_ServiceEnvSecretCollisionFails(t *testing.T) {
4445 Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"},
4546 }}
4647
47- _, err := svc.serviceEnv(context.Background(), "blog", p)
48+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
4849 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
4950 }
@@ -23,12 +23,13 @@ func TestServiceEnv_ServiceEnvOverridesAppEnv(t *testing.T) {
23 name: "web", appEnv: appEnv,23 name: "web", appEnv: appEnv,
24 spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}},24 spec: domain.AppService{Name: "web", Env: map[string]string{"LOG": "debug", "PORT": "3000"}},
25 }25 }
26- got, err := svc.serviceEnv(context.Background(), "blog", web)26+ got, files, err := svc.serviceEnv(context.Background(), "blog", web)
27 require.NoError(t, err)27 require.NoError(t, err)
28+ assert.Nil(t, files)
28 assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got)29 assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got)
29 30
30 worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}}31 worker := pinnedService{name: "worker", appEnv: appEnv, spec: domain.AppService{Name: "worker"}}
31- got, err = svc.serviceEnv(context.Background(), "blog", worker)32+ got, _, err = svc.serviceEnv(context.Background(), "blog", worker)
32 require.NoError(t, err)33 require.NoError(t, err)
33 assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got)34 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 assert.Equal(t, map[string]string{"LOG": "info", "APP_ENV": "production"}, appEnv, "app env is not mutated")
@@ -44,6 +45,6 @@ func TestServiceEnv_ServiceEnvSecretCollisionFails(t *testing.T) {
44 Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"},45 Name: "web", Env: map[string]string{"DB": "x"}, Secrets: map[string]string{"DB": "db"},
45 }}46 }}
46 47
47- _, err := svc.serviceEnv(context.Background(), "blog", p)48+ _, _, err := svc.serviceEnv(context.Background(), "blog", p)
48 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)49 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
49 }50 }

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