feat(deployment): deliver opt-in secrets as read-only files
- commit
- e683751a8f673f221a274ea3fbd9679dac7f3f36
- author
- brice · · 2026-10-08 20:26 UTC
- parent
- b7019c4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +449 -18
M internal/usecase/deployment/candidate.go +81 -10
| @@ -5,9 +5,12 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "maps" | |
| 8 | + | "slices" | |
| 8 | 9 | "sort" | |
| 9 | 10 | "strings" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 11 | 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 | 44 | if err != nil { | |
| 42 | 45 | return startedCandidate{}, err | |
| 43 | 46 | } | |
| 44 | - | env, err := s.serviceEnv(ctx, app, p) | |
| 47 | + | env, files, err := s.serviceEnv(ctx, app, p) | |
| 45 | 48 | if err != nil { | |
| 46 | 49 | return startedCandidate{}, err | |
| 47 | 50 | } | |
| @@ -102,7 +105,7 @@ func (s *Service) createAndStart(ctx context.Context, app, revision string, p pi | |||
| 102 | 105 | NanoCPUs: s.deps.Limits.NanoCPUs, | |
| 103 | 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 | 109 | if err != nil { | |
| 107 | 110 | return startedCandidate{}, err | |
| 108 | 111 | } | |
| @@ -158,6 +161,43 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID | |||
| 158 | 161 | 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 | 201 | func (s *Service) createContainer(ctx context.Context, service string, config *domain.ContainerConfig) (*domain.Container, error) { | |
| 162 | 202 | created, err := s.deps.Runtime.CreateContainer(ctx, config) | |
| 163 | 203 | if err == nil { | |
| @@ -273,7 +313,10 @@ func shortOp(opID string) string { | |||
| 273 | 313 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | |
| 274 | 314 | // deterministic. Overlapping public/secret keys are invalid with no | |
| 275 | 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 | 320 | spec := p.spec | |
| 278 | 321 | envMap := make(map[string]string, len(p.appEnv)+len(spec.Env)+len(spec.Secrets)) | |
| 279 | 322 | maps.Copy(envMap, p.appEnv) | |
| @@ -284,24 +327,52 @@ func (s *Service) serviceEnv(ctx context.Context, app string, p pinnedService) ( | |||
| 284 | 327 | } | |
| 285 | 328 | sort.Strings(keys) | |
| 286 | 329 | if len(keys) == 0 { | |
| 287 | - | return sortedEnv(envMap), nil | |
| 330 | + | return sortedEnv(envMap), nil, nil | |
| 288 | 331 | } | |
| 289 | 332 | id, err := s.appSecretID(ctx, app) | |
| 290 | 333 | if err != nil { | |
| 291 | - | return nil, err | |
| 334 | + | return nil, nil, err | |
| 292 | 335 | } | |
| 336 | + | var files map[string]string | |
| 293 | 337 | 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 | |
| 296 | 340 | } | |
| 297 | 341 | path := domain.AppSecretPathForID(id, app, spec.Name, spec.Secrets[envKey]) | |
| 298 | 342 | value, err := s.deps.Secrets.GetSecret(ctx, path) | |
| 299 | 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] = 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) | |
| 303 | 364 | } | |
| 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 | |
| 305 | 376 | } | |
| 306 | 377 | ||
| 307 | 378 | // 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{}, err | 45 | 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{}, err | 49 | 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{}, err | 110 | return startedCandidate{}, err | ||
| 108 | } | 111 | } | ||
| @@ -158,6 +161,43 @@ func (s *Service) recordCandidate(ctx context.Context, app, service, containerID | |||||
| 158 | return nil | 161 | 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 and | 313 | // values (memory only, UUID-keyed paths). Keys are emitted sorted and | ||
| 274 | // deterministic. Overlapping public/secret keys are invalid with no | 314 | // 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.spec | 320 | 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), nil | 330 | + | 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, err | 334 | + | 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] = 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) | |||
| 303 | } | 364 | } | ||
| 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 | |||
| 305 | } | 376 | } | ||
| 306 | 377 | ||||
| 307 | // sortedEnv renders a KEY=value list in sorted key order. Values stay in | 378 | // 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 | |||
| 445 | 445 | if err != nil || container.Status != string(domain.ContainerStatusRunning) { | |
| 446 | 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 | 449 | return "environment or secrets changed" | |
| 450 | 450 | } | |
| 451 | 451 | return "" | |
| @@ -455,14 +455,15 @@ func (s *Service) unchangedRefusal(ctx context.Context, app string, p pinnedServ | |||
| 455 | 455 | // environment p would get now, including current secret values. The | |
| 456 | 456 | // runtime env also holds image-declared keys, so every desired entry must | |
| 457 | 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 | 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 | - | 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) | |
| 461 | 462 | if err != nil { | |
| 462 | 463 | return false | |
| 463 | 464 | } | |
| 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 { | |
| 466 | 467 | have[entry] = struct{}{} | |
| 467 | 468 | } | |
| 468 | 469 | for _, entry := range desired { | |
| @@ -470,6 +471,24 @@ func (s *Service) envCurrent(ctx context.Context, app string, p pinnedService, r | |||
| 470 | 471 | 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 | 492 | return true | |
| 474 | 493 | } | |
| 475 | 494 | ||
| @@ -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. The | 455 | // environment p would get now, including current secret values. The | ||
| 456 | // runtime env also holds image-declared keys, so every desired entry must | 456 | // 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 false | 463 | 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 false | 471 | 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 true | 492 | return true | ||
| 474 | } | 493 | } | ||
| 475 | 494 | ||||
M internal/usecase/deployment/retirement.go +11 -0
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "sort" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | ||
| 9 | 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 | 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 | 106 | if opts.ClearInhibition { | |
| 96 | 107 | // Confirmed gone: the marker has nothing left to protect, and a | |
| 97 | 108 | // 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 a | 107 | // 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 { | |||
| 47 | 47 | // fail-closed before touching a workload. Nil in tests that assert | |
| 48 | 48 | // state effects only. Failures are reported to the caller. | |
| 49 | 49 | 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 | 56 | // RegistryConfig carries the installation registry identity for pulls. | |
| @@ -796,6 +800,9 @@ func devicesEngineChecked(pinned []pinnedService) bool { | |||
| 796 | 800 | // Paths are UUID-keyed so a removed app's secrets are never adopted | |
| 797 | 801 | // by a new app reusing the name. | |
| 798 | 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 | 806 | if s.deps.Secrets == nil { | |
| 800 | 807 | return fmt.Errorf("deployment: secret provider unavailable: %w", domain.ErrAppSecretMissing) | |
| 801 | 808 | } | |
| @@ -47,6 +47,10 @@ type Deps struct { | |||||
| 47 | // fail-closed before touching a workload. Nil in tests that assert | 47 | // 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.AppTrafficRefresher | 49 | 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 adopted | 800 | // 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) { | |||
| 23 | 23 | name: "web", appEnv: appEnv, | |
| 24 | 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 | 27 | require.NoError(t, err) | |
| 28 | + | assert.Nil(t, files) | |
| 28 | 29 | assert.Equal(t, []string{"APP_ENV=production", "LOG=debug", "PORT=3000"}, got) | |
| 29 | 30 | ||
| 30 | 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 | 33 | require.NoError(t, err) | |
| 33 | 34 | assert.Equal(t, []string{"APP_ENV=production", "LOG=info"}, got) | |
| 34 | 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 | 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 | 49 | require.ErrorIs(t, err, domain.ErrInvalidAppSpec) | |
| 49 | 50 | } | |
| @@ -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 | } | ||