feat: publish secrets.changed event and debounced redeploy handler
- Add EventPublisher to secrets.Service; publish secrets.changed after Set/Delete/SetAttachment/DeleteAttachment (best-effort, nil-safe) - Implement SecretsChangedHandler with per-domain 60s debounce: each new secret mutation resets the timer; deploy fires once after quiet - Use generation counter to prevent stale timer callbacks from deploying - Wire handler in registerEventHandlers with graceful shutdown cleanup - Pass nil publisher in Kernel and local CLI (no running event bus) Closes #109: gordon secrets set/remove now triggers automatic container recreation after a 60-second debounce window.
- commit
- 868d000900a5b2409b09710a3b76d663a7d1c601
- author
- brice · · 2026-03-14 18:18 UTC
- committer
- brice · · 2026-03-14 18:55 UTC
- parent
- e7424b0
- signature
- unsigned
7 files · +522 -47
M internal/adapters/in/cli/local.go +1 -1
| @@ -90,7 +90,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 90 | 90 | if err != nil { | |
| 91 | 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | |
| 92 | 92 | } | |
| 93 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | |
| 93 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log, nil) | |
| 94 | 94 | ||
| 95 | 95 | return &LocalServices{ | |
| 96 | 96 | configSvc: configSvc, | |
| @@ -90,7 +90,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 90 | if err != nil { | 90 | if err != nil { | ||
| 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | 91 | return nil, fmt.Errorf("failed to create domain secret store: %w", err) | ||
| 92 | } | 92 | } | ||
| 93 | - | secretSvc := secretsSvc.NewService(domainSecretStore, log) | 93 | + | secretSvc := secretsSvc.NewService(domainSecretStore, log, nil) |
| 94 | 94 | ||||
| 95 | return &LocalServices{ | 95 | return &LocalServices{ | ||
| 96 | configSvc: configSvc, | 96 | configSvc: configSvc, | ||
M internal/app/kernel.go +1 -1
| @@ -74,7 +74,7 @@ func NewKernel(configPath string) (*Kernel, error) { | |||
| 74 | 74 | return nil, fmt.Errorf("failed to create local secret store: %w", err) | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | - | secretSvc := secretsusecase.NewService(domainSecretStore, log) | |
| 77 | + | secretSvc := secretsusecase.NewService(domainSecretStore, log, nil) | |
| 78 | 78 | ||
| 79 | 79 | return &Kernel{ | |
| 80 | 80 | authEnabled: cfg.Auth.Enabled, | |
| @@ -74,7 +74,7 @@ func NewKernel(configPath string) (*Kernel, error) { | |||||
| 74 | return nil, fmt.Errorf("failed to create local secret store: %w", err) | 74 | return nil, fmt.Errorf("failed to create local secret store: %w", err) | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | - | secretSvc := secretsusecase.NewService(domainSecretStore, log) | 77 | + | secretSvc := secretsusecase.NewService(domainSecretStore, log, nil) |
| 78 | 78 | ||||
| 79 | return &Kernel{ | 79 | return &Kernel{ | ||
| 80 | authEnabled: cfg.Auth.Enabled, | 80 | authEnabled: cfg.Auth.Enabled, | ||
M internal/app/run.go +25 -12
| @@ -253,9 +253,12 @@ func Run(ctx context.Context, configPath string) error { | |||
| 253 | 253 | } | |
| 254 | 254 | ||
| 255 | 255 | // Register event handlers | |
| 256 | - | if err := registerEventHandlers(ctx, svc, cfg); err != nil { | |
| 256 | + | cleanupHandlers, err := registerEventHandlers(ctx, svc, cfg) | |
| 257 | + | if err != nil { | |
| 257 | 258 | return err | |
| 258 | 259 | } | |
| 260 | + | // cleanupHandlers is passed into runServers so it can stop debounce | |
| 261 | + | // timers before graceful shutdown, preventing deploys during drain. | |
| 259 | 262 | ||
| 260 | 263 | // Set up config hot reload | |
| 261 | 264 | setupConfigHotReload(ctx, v, svc, log) | |
| @@ -270,7 +273,7 @@ func Run(ctx context.Context, configPath string) error { | |||
| 270 | 273 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 271 | 274 | ||
| 272 | 275 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | |
| 273 | - | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log) | |
| 276 | + | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, cleanupHandlers, log) | |
| 274 | 277 | } | |
| 275 | 278 | ||
| 276 | 279 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | |
| @@ -490,7 +493,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 490 | 493 | return nil, err | |
| 491 | 494 | } | |
| 492 | 495 | ||
| 493 | - | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log) | |
| 496 | + | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus) | |
| 494 | 497 | ||
| 495 | 498 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 496 | 499 | return nil, err | |
| @@ -1298,41 +1301,50 @@ func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) { | |||
| 1298 | 1301 | } | |
| 1299 | 1302 | ||
| 1300 | 1303 | // registerEventHandlers registers all event handlers. | |
| 1301 | - | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { | |
| 1304 | + | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) { | |
| 1302 | 1305 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 1303 | 1306 | if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil { | |
| 1304 | - | return fmt.Errorf("failed to subscribe image pushed handler: %w", err) | |
| 1307 | + | return nil, fmt.Errorf("failed to subscribe image pushed handler: %w", err) | |
| 1305 | 1308 | } | |
| 1306 | 1309 | ||
| 1307 | 1310 | // Auto-route handler for creating routes from image labels | |
| 1308 | 1311 | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | |
| 1309 | 1312 | WithEnvExtractor(svc.runtime, svc.envDir) | |
| 1310 | 1313 | if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil { | |
| 1311 | - | return fmt.Errorf("failed to subscribe auto-route handler: %w", err) | |
| 1314 | + | return nil, fmt.Errorf("failed to subscribe auto-route handler: %w", err) | |
| 1312 | 1315 | } | |
| 1313 | 1316 | ||
| 1314 | 1317 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 1315 | 1318 | if err := svc.eventBus.Subscribe(configReloadHandler); err != nil { | |
| 1316 | - | return fmt.Errorf("failed to subscribe config reload handler: %w", err) | |
| 1319 | + | return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err) | |
| 1317 | 1320 | } | |
| 1318 | 1321 | ||
| 1319 | 1322 | manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 1320 | 1323 | if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil { | |
| 1321 | - | return fmt.Errorf("failed to subscribe manual reload handler: %w", err) | |
| 1324 | + | return nil, fmt.Errorf("failed to subscribe manual reload handler: %w", err) | |
| 1322 | 1325 | } | |
| 1323 | 1326 | ||
| 1324 | 1327 | manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc) | |
| 1325 | 1328 | if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil { | |
| 1326 | - | return fmt.Errorf("failed to subscribe manual deploy handler: %w", err) | |
| 1329 | + | return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err) | |
| 1330 | + | } | |
| 1331 | + | ||
| 1332 | + | secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, 60*time.Second) | |
| 1333 | + | if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil { | |
| 1334 | + | return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err) | |
| 1327 | 1335 | } | |
| 1328 | 1336 | ||
| 1329 | 1337 | // Proxy cache invalidation on config reload (clears stale targets for removed routes) | |
| 1330 | 1338 | configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc) | |
| 1331 | 1339 | if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil { | |
| 1332 | - | return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err) | |
| 1340 | + | return nil, fmt.Errorf("failed to subscribe config reload proxy handler: %w", err) | |
| 1333 | 1341 | } | |
| 1334 | 1342 | ||
| 1335 | - | return nil | |
| 1343 | + | cleanup := func() { | |
| 1344 | + | secretsChangedHandler.Stop() | |
| 1345 | + | } | |
| 1346 | + | ||
| 1347 | + | return cleanup, nil | |
| 1336 | 1348 | } | |
| 1337 | 1349 | ||
| 1338 | 1350 | // setupConfigHotReload sets up Viper config hot reload. | |
| @@ -1689,7 +1701,7 @@ func isLoopbackHost(host string) bool { | |||
| 1689 | 1701 | // - SIGUSR2: Triggers manual deploy for a specific route | |
| 1690 | 1702 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 1691 | 1703 | // cleaned up before program exit, preventing signal handler leaks. | |
| 1692 | - | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error { | |
| 1704 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { | |
| 1693 | 1705 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 1694 | 1706 | defer cancel() | |
| 1695 | 1707 | ||
| @@ -1760,6 +1772,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1760 | 1772 | syncAndAutoStart(ctx, svc, log) | |
| 1761 | 1773 | ||
| 1762 | 1774 | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | |
| 1775 | + | cleanupHandlers() // Stop debounce timers before draining containers | |
| 1763 | 1776 | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log) | |
| 1764 | 1777 | return nil | |
| 1765 | 1778 | } | |
| @@ -253,9 +253,12 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 253 | } | 253 | } | ||
| 254 | 254 | ||||
| 255 | // Register event handlers | 255 | // Register event handlers | ||
| 256 | - | if err := registerEventHandlers(ctx, svc, cfg); err != nil { | 256 | + | cleanupHandlers, err := registerEventHandlers(ctx, svc, cfg) |
| 257 | + | if err != nil { | |||
| 257 | return err | 258 | return err | ||
| 258 | } | 259 | } | ||
| 260 | + | // cleanupHandlers is passed into runServers so it can stop debounce | |||
| 261 | + | // timers before graceful shutdown, preventing deploys during drain. | |||
| 259 | 262 | ||||
| 260 | // Set up config hot reload | 263 | // Set up config hot reload | ||
| 261 | setupConfigHotReload(ctx, v, svc, log) | 264 | setupConfigHotReload(ctx, v, svc, log) | ||
| @@ -270,7 +273,7 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 270 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | 273 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | ||
| 271 | 274 | ||||
| 272 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | 275 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | ||
| 273 | - | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log) | 276 | + | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, cleanupHandlers, log) |
| 274 | } | 277 | } | ||
| 275 | 278 | ||||
| 276 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | 279 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | ||
| @@ -490,7 +493,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 490 | return nil, err | 493 | return nil, err | ||
| 491 | } | 494 | } | ||
| 492 | 495 | ||||
| 493 | - | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log) | 496 | + | svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus) |
| 494 | 497 | ||||
| 495 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | 498 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | ||
| 496 | return nil, err | 499 | return nil, err | ||
| @@ -1298,41 +1301,50 @@ func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) { | |||||
| 1298 | } | 1301 | } | ||
| 1299 | 1302 | ||||
| 1300 | // registerEventHandlers registers all event handlers. | 1303 | // registerEventHandlers registers all event handlers. | ||
| 1301 | - | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { | 1304 | + | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) { |
| 1302 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | 1305 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | ||
| 1303 | if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil { | 1306 | if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil { | ||
| 1304 | - | return fmt.Errorf("failed to subscribe image pushed handler: %w", err) | 1307 | + | return nil, fmt.Errorf("failed to subscribe image pushed handler: %w", err) |
| 1305 | } | 1308 | } | ||
| 1306 | 1309 | ||||
| 1307 | // Auto-route handler for creating routes from image labels | 1310 | // Auto-route handler for creating routes from image labels | ||
| 1308 | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | 1311 | autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain). | ||
| 1309 | WithEnvExtractor(svc.runtime, svc.envDir) | 1312 | WithEnvExtractor(svc.runtime, svc.envDir) | ||
| 1310 | if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil { | 1313 | if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil { | ||
| 1311 | - | return fmt.Errorf("failed to subscribe auto-route handler: %w", err) | 1314 | + | return nil, fmt.Errorf("failed to subscribe auto-route handler: %w", err) |
| 1312 | } | 1315 | } | ||
| 1313 | 1316 | ||||
| 1314 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | 1317 | configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc) | ||
| 1315 | if err := svc.eventBus.Subscribe(configReloadHandler); err != nil { | 1318 | if err := svc.eventBus.Subscribe(configReloadHandler); err != nil { | ||
| 1316 | - | return fmt.Errorf("failed to subscribe config reload handler: %w", err) | 1319 | + | return nil, fmt.Errorf("failed to subscribe config reload handler: %w", err) |
| 1317 | } | 1320 | } | ||
| 1318 | 1321 | ||||
| 1319 | manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc) | 1322 | manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc) | ||
| 1320 | if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil { | 1323 | if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil { | ||
| 1321 | - | return fmt.Errorf("failed to subscribe manual reload handler: %w", err) | 1324 | + | return nil, fmt.Errorf("failed to subscribe manual reload handler: %w", err) |
| 1322 | } | 1325 | } | ||
| 1323 | 1326 | ||||
| 1324 | manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc) | 1327 | manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc) | ||
| 1325 | if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil { | 1328 | if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil { | ||
| 1326 | - | return fmt.Errorf("failed to subscribe manual deploy handler: %w", err) | 1329 | + | return nil, fmt.Errorf("failed to subscribe manual deploy handler: %w", err) |
| 1330 | + | } | |||
| 1331 | + | ||||
| 1332 | + | secretsChangedHandler := container.NewSecretsChangedHandler(ctx, svc.containerSvc, svc.configSvc, 60*time.Second) | |||
| 1333 | + | if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil { | |||
| 1334 | + | return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err) | |||
| 1327 | } | 1335 | } | ||
| 1328 | 1336 | ||||
| 1329 | // Proxy cache invalidation on config reload (clears stale targets for removed routes) | 1337 | // Proxy cache invalidation on config reload (clears stale targets for removed routes) | ||
| 1330 | configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc) | 1338 | configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc) | ||
| 1331 | if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil { | 1339 | if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil { | ||
| 1332 | - | return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err) | 1340 | + | return nil, fmt.Errorf("failed to subscribe config reload proxy handler: %w", err) |
| 1333 | } | 1341 | } | ||
| 1334 | 1342 | ||||
| 1335 | - | return nil | 1343 | + | cleanup := func() { |
| 1344 | + | secretsChangedHandler.Stop() | |||
| 1345 | + | } | |||
| 1346 | + | ||||
| 1347 | + | return cleanup, nil | |||
| 1336 | } | 1348 | } | ||
| 1337 | 1349 | ||||
| 1338 | // setupConfigHotReload sets up Viper config hot reload. | 1350 | // setupConfigHotReload sets up Viper config hot reload. | ||
| @@ -1689,7 +1701,7 @@ func isLoopbackHost(host string) bool { | |||||
| 1689 | // - SIGUSR2: Triggers manual deploy for a specific route | 1701 | // - SIGUSR2: Triggers manual deploy for a specific route | ||
| 1690 | // The deferred signal.Stop calls ensure signal handlers are properly | 1702 | // The deferred signal.Stop calls ensure signal handlers are properly | ||
| 1691 | // cleaned up before program exit, preventing signal handler leaks. | 1703 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 1692 | - | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error { | 1704 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, cleanupHandlers func(), log zerowrap.Logger) error { |
| 1693 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 1705 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 1694 | defer cancel() | 1706 | defer cancel() | ||
| 1695 | 1707 | ||||
| @@ -1760,6 +1772,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1760 | syncAndAutoStart(ctx, svc, log) | 1772 | syncAndAutoStart(ctx, svc, log) | ||
| 1761 | 1773 | ||||
| 1762 | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | 1774 | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | ||
| 1775 | + | cleanupHandlers() // Stop debounce timers before draining containers | |||
| 1763 | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log) | 1776 | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log) | ||
| 1764 | return nil | 1777 | return nil | ||
| 1765 | } | 1778 | } | ||
A internal/usecase/container/secrets_handler.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/in" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // SecretsChangedHandler handles secrets.changed events with per-domain | |
| 15 | + | // debouncing. When secrets are modified rapidly (e.g. multiple | |
| 16 | + | // "gordon secrets set" calls), the handler resets a timer on each | |
| 17 | + | // event and only triggers a deploy once the domain is quiet for | |
| 18 | + | // the configured delay. | |
| 19 | + | type SecretsChangedHandler struct { | |
| 20 | + | containerSvc in.ContainerService | |
| 21 | + | configSvc in.ConfigService | |
| 22 | + | ctx context.Context | |
| 23 | + | cancel context.CancelFunc | |
| 24 | + | debounceDelay time.Duration | |
| 25 | + | ||
| 26 | + | mu sync.Mutex | |
| 27 | + | timers map[string]*time.Timer | |
| 28 | + | generations map[string]uint64 | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // NewSecretsChangedHandler creates a new SecretsChangedHandler. | |
| 32 | + | func NewSecretsChangedHandler( | |
| 33 | + | ctx context.Context, | |
| 34 | + | containerSvc in.ContainerService, | |
| 35 | + | configSvc in.ConfigService, | |
| 36 | + | debounceDelay time.Duration, | |
| 37 | + | ) *SecretsChangedHandler { | |
| 38 | + | ctx, cancel := context.WithCancel(ctx) | |
| 39 | + | return &SecretsChangedHandler{ | |
| 40 | + | containerSvc: containerSvc, | |
| 41 | + | configSvc: configSvc, | |
| 42 | + | ctx: ctx, | |
| 43 | + | cancel: cancel, | |
| 44 | + | debounceDelay: debounceDelay, | |
| 45 | + | timers: make(map[string]*time.Timer), | |
| 46 | + | generations: make(map[string]uint64), | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Handle processes a secrets.changed event by resetting the debounce | |
| 51 | + | // timer for the affected domain. Returns immediately - the deploy | |
| 52 | + | // runs asynchronously when the timer fires. | |
| 53 | + | // | |
| 54 | + | // Uses the handler's long-lived context (h.ctx) rather than the event | |
| 55 | + | // context because the debounced deploy fires after this method returns; | |
| 56 | + | // using the event context would cause premature cancellation. | |
| 57 | + | func (h *SecretsChangedHandler) Handle(_ context.Context, event domain.Event) error { | |
| 58 | + | payload, ok := event.Data.(domain.SecretsChangedPayload) | |
| 59 | + | if !ok { | |
| 60 | + | return nil | |
| 61 | + | } | |
| 62 | + | if payload.Domain == "" { | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |
| 67 | + | zerowrap.FieldLayer: "usecase", | |
| 68 | + | zerowrap.FieldHandler: "SecretsChangedHandler", | |
| 69 | + | "domain": payload.Domain, | |
| 70 | + | "operation": payload.Operation, | |
| 71 | + | }) | |
| 72 | + | log := zerowrap.FromCtx(ctx) | |
| 73 | + | ||
| 74 | + | log.Debug(). | |
| 75 | + | Strs("keys", payload.Keys). | |
| 76 | + | Dur("debounce", h.debounceDelay). | |
| 77 | + | Msg("secrets changed, resetting debounce timer") | |
| 78 | + | ||
| 79 | + | h.resetTimer(ctx, payload.Domain) | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // CanHandle returns true for secrets.changed events. | |
| 84 | + | func (h *SecretsChangedHandler) CanHandle(eventType domain.EventType) bool { | |
| 85 | + | return eventType == domain.EventSecretsChanged | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Stop cancels the handler context and all pending timers. | |
| 89 | + | // Call before graceful shutdown to prevent deploys during drain. | |
| 90 | + | func (h *SecretsChangedHandler) Stop() { | |
| 91 | + | h.cancel() | |
| 92 | + | ||
| 93 | + | h.mu.Lock() | |
| 94 | + | defer h.mu.Unlock() | |
| 95 | + | ||
| 96 | + | for domainName, timer := range h.timers { | |
| 97 | + | timer.Stop() | |
| 98 | + | delete(h.timers, domainName) | |
| 99 | + | delete(h.generations, domainName) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // resetTimer replaces any existing timer for the domain with a new one. | |
| 104 | + | func (h *SecretsChangedHandler) resetTimer(ctx context.Context, domainName string) { | |
| 105 | + | h.mu.Lock() | |
| 106 | + | defer h.mu.Unlock() | |
| 107 | + | ||
| 108 | + | if existing, ok := h.timers[domainName]; ok { | |
| 109 | + | existing.Stop() | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | h.generations[domainName]++ | |
| 113 | + | gen := h.generations[domainName] | |
| 114 | + | ||
| 115 | + | h.timers[domainName] = time.AfterFunc(h.debounceDelay, func() { | |
| 116 | + | h.fireDeploy(ctx, domainName, gen) | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // fireDeploy looks up the route and triggers a deploy. | |
| 121 | + | func (h *SecretsChangedHandler) fireDeploy(ctx context.Context, domainName string, generation uint64) { | |
| 122 | + | // Bail if the handler has been stopped (shutdown in progress). | |
| 123 | + | if h.ctx.Err() != nil { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | log := zerowrap.FromCtx(ctx) | |
| 128 | + | ||
| 129 | + | h.mu.Lock() | |
| 130 | + | if h.generations[domainName] != generation { | |
| 131 | + | h.mu.Unlock() | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | delete(h.timers, domainName) | |
| 135 | + | delete(h.generations, domainName) | |
| 136 | + | h.mu.Unlock() | |
| 137 | + | ||
| 138 | + | route, err := h.configSvc.GetRoute(ctx, domainName) | |
| 139 | + | if err != nil { | |
| 140 | + | log.WrapErr(err, "failed to get route for secrets-changed deploy") | |
| 141 | + | return | |
| 142 | + | } | |
| 143 | + | if route == nil { | |
| 144 | + | log.Debug().Msg("no route configured for domain, skipping secrets-changed deploy") | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | log.Info().Str("image", route.Image).Msg("debounce fired, deploying after secrets change") | |
| 149 | + | ||
| 150 | + | if _, err := h.containerSvc.Deploy(ctx, *route); err != nil { | |
| 151 | + | log.WrapErr(err, "secrets-changed deploy failed") | |
| 152 | + | } | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/in" | |||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // SecretsChangedHandler handles secrets.changed events with per-domain | |||
| 15 | + | // debouncing. When secrets are modified rapidly (e.g. multiple | |||
| 16 | + | // "gordon secrets set" calls), the handler resets a timer on each | |||
| 17 | + | // event and only triggers a deploy once the domain is quiet for | |||
| 18 | + | // the configured delay. | |||
| 19 | + | type SecretsChangedHandler struct { | |||
| 20 | + | containerSvc in.ContainerService | |||
| 21 | + | configSvc in.ConfigService | |||
| 22 | + | ctx context.Context | |||
| 23 | + | cancel context.CancelFunc | |||
| 24 | + | debounceDelay time.Duration | |||
| 25 | + | ||||
| 26 | + | mu sync.Mutex | |||
| 27 | + | timers map[string]*time.Timer | |||
| 28 | + | generations map[string]uint64 | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // NewSecretsChangedHandler creates a new SecretsChangedHandler. | |||
| 32 | + | func NewSecretsChangedHandler( | |||
| 33 | + | ctx context.Context, | |||
| 34 | + | containerSvc in.ContainerService, | |||
| 35 | + | configSvc in.ConfigService, | |||
| 36 | + | debounceDelay time.Duration, | |||
| 37 | + | ) *SecretsChangedHandler { | |||
| 38 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 39 | + | return &SecretsChangedHandler{ | |||
| 40 | + | containerSvc: containerSvc, | |||
| 41 | + | configSvc: configSvc, | |||
| 42 | + | ctx: ctx, | |||
| 43 | + | cancel: cancel, | |||
| 44 | + | debounceDelay: debounceDelay, | |||
| 45 | + | timers: make(map[string]*time.Timer), | |||
| 46 | + | generations: make(map[string]uint64), | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Handle processes a secrets.changed event by resetting the debounce | |||
| 51 | + | // timer for the affected domain. Returns immediately - the deploy | |||
| 52 | + | // runs asynchronously when the timer fires. | |||
| 53 | + | // | |||
| 54 | + | // Uses the handler's long-lived context (h.ctx) rather than the event | |||
| 55 | + | // context because the debounced deploy fires after this method returns; | |||
| 56 | + | // using the event context would cause premature cancellation. | |||
| 57 | + | func (h *SecretsChangedHandler) Handle(_ context.Context, event domain.Event) error { | |||
| 58 | + | payload, ok := event.Data.(domain.SecretsChangedPayload) | |||
| 59 | + | if !ok { | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
| 62 | + | if payload.Domain == "" { | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |||
| 67 | + | zerowrap.FieldLayer: "usecase", | |||
| 68 | + | zerowrap.FieldHandler: "SecretsChangedHandler", | |||
| 69 | + | "domain": payload.Domain, | |||
| 70 | + | "operation": payload.Operation, | |||
| 71 | + | }) | |||
| 72 | + | log := zerowrap.FromCtx(ctx) | |||
| 73 | + | ||||
| 74 | + | log.Debug(). | |||
| 75 | + | Strs("keys", payload.Keys). | |||
| 76 | + | Dur("debounce", h.debounceDelay). | |||
| 77 | + | Msg("secrets changed, resetting debounce timer") | |||
| 78 | + | ||||
| 79 | + | h.resetTimer(ctx, payload.Domain) | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // CanHandle returns true for secrets.changed events. | |||
| 84 | + | func (h *SecretsChangedHandler) CanHandle(eventType domain.EventType) bool { | |||
| 85 | + | return eventType == domain.EventSecretsChanged | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Stop cancels the handler context and all pending timers. | |||
| 89 | + | // Call before graceful shutdown to prevent deploys during drain. | |||
| 90 | + | func (h *SecretsChangedHandler) Stop() { | |||
| 91 | + | h.cancel() | |||
| 92 | + | ||||
| 93 | + | h.mu.Lock() | |||
| 94 | + | defer h.mu.Unlock() | |||
| 95 | + | ||||
| 96 | + | for domainName, timer := range h.timers { | |||
| 97 | + | timer.Stop() | |||
| 98 | + | delete(h.timers, domainName) | |||
| 99 | + | delete(h.generations, domainName) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // resetTimer replaces any existing timer for the domain with a new one. | |||
| 104 | + | func (h *SecretsChangedHandler) resetTimer(ctx context.Context, domainName string) { | |||
| 105 | + | h.mu.Lock() | |||
| 106 | + | defer h.mu.Unlock() | |||
| 107 | + | ||||
| 108 | + | if existing, ok := h.timers[domainName]; ok { | |||
| 109 | + | existing.Stop() | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | h.generations[domainName]++ | |||
| 113 | + | gen := h.generations[domainName] | |||
| 114 | + | ||||
| 115 | + | h.timers[domainName] = time.AfterFunc(h.debounceDelay, func() { | |||
| 116 | + | h.fireDeploy(ctx, domainName, gen) | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // fireDeploy looks up the route and triggers a deploy. | |||
| 121 | + | func (h *SecretsChangedHandler) fireDeploy(ctx context.Context, domainName string, generation uint64) { | |||
| 122 | + | // Bail if the handler has been stopped (shutdown in progress). | |||
| 123 | + | if h.ctx.Err() != nil { | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | log := zerowrap.FromCtx(ctx) | |||
| 128 | + | ||||
| 129 | + | h.mu.Lock() | |||
| 130 | + | if h.generations[domainName] != generation { | |||
| 131 | + | h.mu.Unlock() | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | delete(h.timers, domainName) | |||
| 135 | + | delete(h.generations, domainName) | |||
| 136 | + | h.mu.Unlock() | |||
| 137 | + | ||||
| 138 | + | route, err := h.configSvc.GetRoute(ctx, domainName) | |||
| 139 | + | if err != nil { | |||
| 140 | + | log.WrapErr(err, "failed to get route for secrets-changed deploy") | |||
| 141 | + | return | |||
| 142 | + | } | |||
| 143 | + | if route == nil { | |||
| 144 | + | log.Debug().Msg("no route configured for domain, skipping secrets-changed deploy") | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | log.Info().Str("image", route.Image).Msg("debounce fired, deploying after secrets change") | |||
| 149 | + | ||||
| 150 | + | if _, err := h.containerSvc.Deploy(ctx, *route); err != nil { | |||
| 151 | + | log.WrapErr(err, "secrets-changed deploy failed") | |||
| 152 | + | } | |||
| 153 | + | } | |||
A internal/usecase/container/secrets_handler_test.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync/atomic" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | ||
| 12 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestSecretsChangedHandler_CanHandle(t *testing.T) { | |
| 17 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 18 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 19 | + | ||
| 20 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond) | |
| 21 | + | defer h.Stop() | |
| 22 | + | ||
| 23 | + | assert.True(t, h.CanHandle(domain.EventSecretsChanged)) | |
| 24 | + | assert.False(t, h.CanHandle(domain.EventConfigReload)) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) { | |
| 28 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 29 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 30 | + | ||
| 31 | + | debounceDelay := 200 * time.Millisecond | |
| 32 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 33 | + | defer h.Stop() | |
| 34 | + | ||
| 35 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |
| 36 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |
| 37 | + | ||
| 38 | + | var deployCalls atomic.Int32 | |
| 39 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) { | |
| 40 | + | deployCalls.Add(1) | |
| 41 | + | return &domain.Container{ID: "container-1"}, nil | |
| 42 | + | }).Once() | |
| 43 | + | ||
| 44 | + | event := domain.Event{ | |
| 45 | + | Type: domain.EventSecretsChanged, | |
| 46 | + | Data: domain.SecretsChangedPayload{ | |
| 47 | + | Domain: route.Domain, | |
| 48 | + | Operation: "set", | |
| 49 | + | Keys: []string{"A", "B"}, | |
| 50 | + | }, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | for range 5 { | |
| 54 | + | assert.NoError(t, h.Handle(context.Background(), event)) | |
| 55 | + | time.Sleep(10 * time.Millisecond) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |
| 59 | + | assert.Equal(t, int32(1), deployCalls.Load()) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestSecretsChangedHandler_IndependentDomains(t *testing.T) { | |
| 63 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 64 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 65 | + | ||
| 66 | + | debounceDelay := 100 * time.Millisecond | |
| 67 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 68 | + | defer h.Stop() | |
| 69 | + | ||
| 70 | + | routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"} | |
| 71 | + | routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"} | |
| 72 | + | ||
| 73 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once() | |
| 74 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once() | |
| 75 | + | ||
| 76 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).Return(&domain.Container{ID: "container-1"}, nil).Once() | |
| 77 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).Return(&domain.Container{ID: "container-2"}, nil).Once() | |
| 78 | + | ||
| 79 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 80 | + | Type: domain.EventSecretsChanged, | |
| 81 | + | Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}}, | |
| 82 | + | })) | |
| 83 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 84 | + | Type: domain.EventSecretsChanged, | |
| 85 | + | Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}}, | |
| 86 | + | })) | |
| 87 | + | ||
| 88 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) { | |
| 92 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 94 | + | ||
| 95 | + | debounceDelay := 100 * time.Millisecond | |
| 96 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 97 | + | defer h.Stop() | |
| 98 | + | ||
| 99 | + | configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once() | |
| 100 | + | ||
| 101 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 102 | + | Type: domain.EventSecretsChanged, | |
| 103 | + | Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}}, | |
| 104 | + | })) | |
| 105 | + | ||
| 106 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) { | |
| 110 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 111 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 112 | + | ||
| 113 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond) | |
| 114 | + | defer h.Stop() | |
| 115 | + | ||
| 116 | + | err := h.Handle(context.Background(), domain.Event{ | |
| 117 | + | Type: domain.EventSecretsChanged, | |
| 118 | + | Data: "wrong-payload", | |
| 119 | + | }) | |
| 120 | + | ||
| 121 | + | assert.NoError(t, err) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) { | |
| 125 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 126 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 127 | + | ||
| 128 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |
| 129 | + | defer h.Stop() | |
| 130 | + | ||
| 131 | + | const domainName = "app.example.com" | |
| 132 | + | ||
| 133 | + | // White-box: directly set internal state to simulate a stale generation. | |
| 134 | + | // This exercises the generation-based debounce logic that prevents | |
| 135 | + | // already-fired timers from triggering duplicate deploys. | |
| 136 | + | h.mu.Lock() | |
| 137 | + | h.generations[domainName] = 2 | |
| 138 | + | h.timers[domainName] = time.NewTimer(time.Hour) | |
| 139 | + | h.mu.Unlock() | |
| 140 | + | ||
| 141 | + | staleDone := make(chan struct{}) | |
| 142 | + | go func() { | |
| 143 | + | defer close(staleDone) | |
| 144 | + | h.fireDeploy(context.Background(), domainName, 1) | |
| 145 | + | }() | |
| 146 | + | ||
| 147 | + | select { | |
| 148 | + | case <-staleDone: | |
| 149 | + | case <-time.After(2 * time.Second): | |
| 150 | + | t.Fatal("stale fireDeploy did not return") | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | h.mu.Lock() | |
| 154 | + | _, timerExists := h.timers[domainName] | |
| 155 | + | gen := h.generations[domainName] | |
| 156 | + | h.mu.Unlock() | |
| 157 | + | ||
| 158 | + | assert.True(t, timerExists) | |
| 159 | + | assert.Equal(t, uint64(2), gen) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) { | |
| 163 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 164 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 165 | + | ||
| 166 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |
| 167 | + | defer h.Stop() | |
| 168 | + | ||
| 169 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |
| 170 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |
| 171 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once() | |
| 172 | + | ||
| 173 | + | h.mu.Lock() | |
| 174 | + | h.generations[route.Domain] = 1 | |
| 175 | + | h.timers[route.Domain] = time.NewTimer(time.Hour) | |
| 176 | + | h.mu.Unlock() | |
| 177 | + | ||
| 178 | + | h.fireDeploy(context.Background(), route.Domain, 1) | |
| 179 | + | ||
| 180 | + | h.mu.Lock() | |
| 181 | + | _, timerExists := h.timers[route.Domain] | |
| 182 | + | _, generationExists := h.generations[route.Domain] | |
| 183 | + | h.mu.Unlock() | |
| 184 | + | ||
| 185 | + | assert.False(t, timerExists) | |
| 186 | + | assert.False(t, generationExists) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) { | |
| 190 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 191 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 192 | + | ||
| 193 | + | debounceDelay := 50 * time.Millisecond | |
| 194 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 195 | + | ||
| 196 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 197 | + | Type: domain.EventSecretsChanged, | |
| 198 | + | Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}}, | |
| 199 | + | })) | |
| 200 | + | ||
| 201 | + | h.Stop() | |
| 202 | + | time.Sleep(debounceDelay + 50*time.Millisecond) | |
| 203 | + | } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestSecretsChangedHandler_CanHandle(t *testing.T) { | |||
| 17 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 18 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 19 | + | ||||
| 20 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond) | |||
| 21 | + | defer h.Stop() | |||
| 22 | + | ||||
| 23 | + | assert.True(t, h.CanHandle(domain.EventSecretsChanged)) | |||
| 24 | + | assert.False(t, h.CanHandle(domain.EventConfigReload)) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) { | |||
| 28 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 29 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 30 | + | ||||
| 31 | + | debounceDelay := 200 * time.Millisecond | |||
| 32 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 33 | + | defer h.Stop() | |||
| 34 | + | ||||
| 35 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |||
| 36 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |||
| 37 | + | ||||
| 38 | + | var deployCalls atomic.Int32 | |||
| 39 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) { | |||
| 40 | + | deployCalls.Add(1) | |||
| 41 | + | return &domain.Container{ID: "container-1"}, nil | |||
| 42 | + | }).Once() | |||
| 43 | + | ||||
| 44 | + | event := domain.Event{ | |||
| 45 | + | Type: domain.EventSecretsChanged, | |||
| 46 | + | Data: domain.SecretsChangedPayload{ | |||
| 47 | + | Domain: route.Domain, | |||
| 48 | + | Operation: "set", | |||
| 49 | + | Keys: []string{"A", "B"}, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | for range 5 { | |||
| 54 | + | assert.NoError(t, h.Handle(context.Background(), event)) | |||
| 55 | + | time.Sleep(10 * time.Millisecond) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |||
| 59 | + | assert.Equal(t, int32(1), deployCalls.Load()) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestSecretsChangedHandler_IndependentDomains(t *testing.T) { | |||
| 63 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 64 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 65 | + | ||||
| 66 | + | debounceDelay := 100 * time.Millisecond | |||
| 67 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 68 | + | defer h.Stop() | |||
| 69 | + | ||||
| 70 | + | routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"} | |||
| 71 | + | routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"} | |||
| 72 | + | ||||
| 73 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once() | |||
| 74 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once() | |||
| 75 | + | ||||
| 76 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).Return(&domain.Container{ID: "container-1"}, nil).Once() | |||
| 77 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).Return(&domain.Container{ID: "container-2"}, nil).Once() | |||
| 78 | + | ||||
| 79 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 80 | + | Type: domain.EventSecretsChanged, | |||
| 81 | + | Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}}, | |||
| 82 | + | })) | |||
| 83 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 84 | + | Type: domain.EventSecretsChanged, | |||
| 85 | + | Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}}, | |||
| 86 | + | })) | |||
| 87 | + | ||||
| 88 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) { | |||
| 92 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 93 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 94 | + | ||||
| 95 | + | debounceDelay := 100 * time.Millisecond | |||
| 96 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 97 | + | defer h.Stop() | |||
| 98 | + | ||||
| 99 | + | configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once() | |||
| 100 | + | ||||
| 101 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 102 | + | Type: domain.EventSecretsChanged, | |||
| 103 | + | Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}}, | |||
| 104 | + | })) | |||
| 105 | + | ||||
| 106 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) { | |||
| 110 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 111 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 112 | + | ||||
| 113 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond) | |||
| 114 | + | defer h.Stop() | |||
| 115 | + | ||||
| 116 | + | err := h.Handle(context.Background(), domain.Event{ | |||
| 117 | + | Type: domain.EventSecretsChanged, | |||
| 118 | + | Data: "wrong-payload", | |||
| 119 | + | }) | |||
| 120 | + | ||||
| 121 | + | assert.NoError(t, err) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) { | |||
| 125 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 126 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 127 | + | ||||
| 128 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |||
| 129 | + | defer h.Stop() | |||
| 130 | + | ||||
| 131 | + | const domainName = "app.example.com" | |||
| 132 | + | ||||
| 133 | + | // White-box: directly set internal state to simulate a stale generation. | |||
| 134 | + | // This exercises the generation-based debounce logic that prevents | |||
| 135 | + | // already-fired timers from triggering duplicate deploys. | |||
| 136 | + | h.mu.Lock() | |||
| 137 | + | h.generations[domainName] = 2 | |||
| 138 | + | h.timers[domainName] = time.NewTimer(time.Hour) | |||
| 139 | + | h.mu.Unlock() | |||
| 140 | + | ||||
| 141 | + | staleDone := make(chan struct{}) | |||
| 142 | + | go func() { | |||
| 143 | + | defer close(staleDone) | |||
| 144 | + | h.fireDeploy(context.Background(), domainName, 1) | |||
| 145 | + | }() | |||
| 146 | + | ||||
| 147 | + | select { | |||
| 148 | + | case <-staleDone: | |||
| 149 | + | case <-time.After(2 * time.Second): | |||
| 150 | + | t.Fatal("stale fireDeploy did not return") | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | h.mu.Lock() | |||
| 154 | + | _, timerExists := h.timers[domainName] | |||
| 155 | + | gen := h.generations[domainName] | |||
| 156 | + | h.mu.Unlock() | |||
| 157 | + | ||||
| 158 | + | assert.True(t, timerExists) | |||
| 159 | + | assert.Equal(t, uint64(2), gen) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) { | |||
| 163 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 164 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 165 | + | ||||
| 166 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |||
| 167 | + | defer h.Stop() | |||
| 168 | + | ||||
| 169 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |||
| 170 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |||
| 171 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once() | |||
| 172 | + | ||||
| 173 | + | h.mu.Lock() | |||
| 174 | + | h.generations[route.Domain] = 1 | |||
| 175 | + | h.timers[route.Domain] = time.NewTimer(time.Hour) | |||
| 176 | + | h.mu.Unlock() | |||
| 177 | + | ||||
| 178 | + | h.fireDeploy(context.Background(), route.Domain, 1) | |||
| 179 | + | ||||
| 180 | + | h.mu.Lock() | |||
| 181 | + | _, timerExists := h.timers[route.Domain] | |||
| 182 | + | _, generationExists := h.generations[route.Domain] | |||
| 183 | + | h.mu.Unlock() | |||
| 184 | + | ||||
| 185 | + | assert.False(t, timerExists) | |||
| 186 | + | assert.False(t, generationExists) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) { | |||
| 190 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 191 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 192 | + | ||||
| 193 | + | debounceDelay := 50 * time.Millisecond | |||
| 194 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 195 | + | ||||
| 196 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 197 | + | Type: domain.EventSecretsChanged, | |||
| 198 | + | Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}}, | |||
| 199 | + | })) | |||
| 200 | + | ||||
| 201 | + | h.Stop() | |||
| 202 | + | time.Sleep(debounceDelay + 50*time.Millisecond) | |||
| 203 | + | } | |||
M internal/usecase/secrets/service.go +41 -5
| @@ -24,15 +24,35 @@ var ( | |||
| 24 | 24 | ||
| 25 | 25 | // Service implements the SecretService interface. | |
| 26 | 26 | type Service struct { | |
| 27 | - | store out.DomainSecretStore | |
| 28 | - | log zerowrap.Logger | |
| 27 | + | store out.DomainSecretStore | |
| 28 | + | log zerowrap.Logger | |
| 29 | + | eventBus out.EventPublisher | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // NewService creates a new secrets service. | |
| 32 | - | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | |
| 33 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service { | |
| 33 | 34 | return &Service{ | |
| 34 | - | store: store, | |
| 35 | - | log: log, | |
| 35 | + | store: store, | |
| 36 | + | log: log, | |
| 37 | + | eventBus: eventBus, | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // publishSecretsChanged publishes a secrets.changed event. | |
| 42 | + | // Failures are logged but do not fail the mutation - secret storage | |
| 43 | + | // is the source of truth; the event is a best-effort notification. | |
| 44 | + | func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) { | |
| 45 | + | if s.eventBus == nil { | |
| 46 | + | return | |
| 47 | + | } | |
| 48 | + | log := zerowrap.FromCtx(ctx) | |
| 49 | + | payload := domain.SecretsChangedPayload{ | |
| 50 | + | Domain: domainName, | |
| 51 | + | Operation: operation, | |
| 52 | + | Keys: keys, | |
| 53 | + | } | |
| 54 | + | if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil { | |
| 55 | + | log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event") | |
| 36 | 56 | } | |
| 37 | 57 | } | |
| 38 | 58 | ||
| @@ -140,6 +160,12 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str | |||
| 140 | 160 | return err | |
| 141 | 161 | } | |
| 142 | 162 | ||
| 163 | + | keys := make([]string, 0, len(secrets)) | |
| 164 | + | for k := range secrets { | |
| 165 | + | keys = append(keys, k) | |
| 166 | + | } | |
| 167 | + | s.publishSecretsChanged(ctx, domain, "set", keys) | |
| 168 | + | ||
| 143 | 169 | log.Info().Int("count", len(secrets)).Msg("secrets set") | |
| 144 | 170 | return nil | |
| 145 | 171 | } | |
| @@ -164,6 +190,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||
| 164 | 190 | return err | |
| 165 | 191 | } | |
| 166 | 192 | ||
| 193 | + | s.publishSecretsChanged(ctx, domain, "delete", []string{key}) | |
| 194 | + | ||
| 167 | 195 | log.Info().Msg("secret deleted") | |
| 168 | 196 | return nil | |
| 169 | 197 | } | |
| @@ -195,6 +223,12 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string, | |||
| 195 | 223 | return err | |
| 196 | 224 | } | |
| 197 | 225 | ||
| 226 | + | keys := make([]string, 0, len(secrets)) | |
| 227 | + | for k := range secrets { | |
| 228 | + | keys = append(keys, k) | |
| 229 | + | } | |
| 230 | + | s.publishSecretsChanged(ctx, domainName, "set", keys) | |
| 231 | + | ||
| 198 | 232 | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | |
| 199 | 233 | return nil | |
| 200 | 234 | } | |
| @@ -227,6 +261,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key | |||
| 227 | 261 | return err | |
| 228 | 262 | } | |
| 229 | 263 | ||
| 264 | + | s.publishSecretsChanged(ctx, domainName, "delete", []string{key}) | |
| 265 | + | ||
| 230 | 266 | log.Info().Str("container", containerName).Msg("attachment secret deleted") | |
| 231 | 267 | return nil | |
| 232 | 268 | } | |
| @@ -24,15 +24,35 @@ var ( | |||||
| 24 | 24 | ||||
| 25 | // Service implements the SecretService interface. | 25 | // Service implements the SecretService interface. | ||
| 26 | type Service struct { | 26 | type Service struct { | ||
| 27 | - | store out.DomainSecretStore | 27 | + | store out.DomainSecretStore |
| 28 | - | log zerowrap.Logger | 28 | + | log zerowrap.Logger |
| 29 | + | eventBus out.EventPublisher | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | // NewService creates a new secrets service. | 32 | // NewService creates a new secrets service. | ||
| 32 | - | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | 33 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service { |
| 33 | return &Service{ | 34 | return &Service{ | ||
| 34 | - | store: store, | 35 | + | store: store, |
| 35 | - | log: log, | 36 | + | log: log, |
| 37 | + | eventBus: eventBus, | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // publishSecretsChanged publishes a secrets.changed event. | |||
| 42 | + | // Failures are logged but do not fail the mutation - secret storage | |||
| 43 | + | // is the source of truth; the event is a best-effort notification. | |||
| 44 | + | func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) { | |||
| 45 | + | if s.eventBus == nil { | |||
| 46 | + | return | |||
| 47 | + | } | |||
| 48 | + | log := zerowrap.FromCtx(ctx) | |||
| 49 | + | payload := domain.SecretsChangedPayload{ | |||
| 50 | + | Domain: domainName, | |||
| 51 | + | Operation: operation, | |||
| 52 | + | Keys: keys, | |||
| 53 | + | } | |||
| 54 | + | if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil { | |||
| 55 | + | log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event") | |||
| 36 | } | 56 | } | ||
| 37 | } | 57 | } | ||
| 38 | 58 | ||||
| @@ -140,6 +160,12 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str | |||||
| 140 | return err | 160 | return err | ||
| 141 | } | 161 | } | ||
| 142 | 162 | ||||
| 163 | + | keys := make([]string, 0, len(secrets)) | |||
| 164 | + | for k := range secrets { | |||
| 165 | + | keys = append(keys, k) | |||
| 166 | + | } | |||
| 167 | + | s.publishSecretsChanged(ctx, domain, "set", keys) | |||
| 168 | + | ||||
| 143 | log.Info().Int("count", len(secrets)).Msg("secrets set") | 169 | log.Info().Int("count", len(secrets)).Msg("secrets set") | ||
| 144 | return nil | 170 | return nil | ||
| 145 | } | 171 | } | ||
| @@ -164,6 +190,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||||
| 164 | return err | 190 | return err | ||
| 165 | } | 191 | } | ||
| 166 | 192 | ||||
| 193 | + | s.publishSecretsChanged(ctx, domain, "delete", []string{key}) | |||
| 194 | + | ||||
| 167 | log.Info().Msg("secret deleted") | 195 | log.Info().Msg("secret deleted") | ||
| 168 | return nil | 196 | return nil | ||
| 169 | } | 197 | } | ||
| @@ -195,6 +223,12 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string, | |||||
| 195 | return err | 223 | return err | ||
| 196 | } | 224 | } | ||
| 197 | 225 | ||||
| 226 | + | keys := make([]string, 0, len(secrets)) | |||
| 227 | + | for k := range secrets { | |||
| 228 | + | keys = append(keys, k) | |||
| 229 | + | } | |||
| 230 | + | s.publishSecretsChanged(ctx, domainName, "set", keys) | |||
| 231 | + | ||||
| 198 | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | 232 | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | ||
| 199 | return nil | 233 | return nil | ||
| 200 | } | 234 | } | ||
| @@ -227,6 +261,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key | |||||
| 227 | return err | 261 | return err | ||
| 228 | } | 262 | } | ||
| 229 | 263 | ||||
| 264 | + | s.publishSecretsChanged(ctx, domainName, "delete", []string{key}) | |||
| 265 | + | ||||
| 230 | log.Info().Str("container", containerName).Msg("attachment secret deleted") | 266 | log.Info().Str("container", containerName).Msg("attachment secret deleted") | ||
| 231 | return nil | 267 | return nil | ||
| 232 | } | 268 | } | ||
M internal/usecase/secrets/service_test.go +98 -28
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | 13 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | func testLogger() zerowrap.Logger { | |
| @@ -135,7 +136,7 @@ func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) { | |||
| 135 | 136 | ||
| 136 | 137 | func TestService_ListKeys_ValidationErrors(t *testing.T) { | |
| 137 | 138 | store := outmocks.NewMockDomainSecretStore(t) | |
| 138 | - | svc := NewService(store, testLogger()) | |
| 139 | + | svc := NewService(store, testLogger(), nil) | |
| 139 | 140 | ||
| 140 | 141 | tests := []struct { | |
| 141 | 142 | name string | |
| @@ -170,7 +171,7 @@ func TestService_ListKeys_ValidationErrors(t *testing.T) { | |||
| 170 | 171 | ||
| 171 | 172 | func TestService_ListKeys_Success(t *testing.T) { | |
| 172 | 173 | store := outmocks.NewMockDomainSecretStore(t) | |
| 173 | - | svc := NewService(store, testLogger()) | |
| 174 | + | svc := NewService(store, testLogger(), nil) | |
| 174 | 175 | ||
| 175 | 176 | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | |
| 176 | 177 | store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil) | |
| @@ -182,7 +183,7 @@ func TestService_ListKeys_Success(t *testing.T) { | |||
| 182 | 183 | ||
| 183 | 184 | func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) { | |
| 184 | 185 | store := outmocks.NewMockDomainSecretStore(t) | |
| 185 | - | svc := NewService(store, testLogger()) | |
| 186 | + | svc := NewService(store, testLogger(), nil) | |
| 186 | 187 | ||
| 187 | 188 | tests := []struct { | |
| 188 | 189 | name string | |
| @@ -219,7 +220,7 @@ func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) { | |||
| 219 | 220 | func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |
| 220 | 221 | t.Run("returns both domain and attachment secrets", func(t *testing.T) { | |
| 221 | 222 | store := outmocks.NewMockDomainSecretStore(t) | |
| 222 | - | svc := NewService(store, testLogger()) | |
| 223 | + | svc := NewService(store, testLogger(), nil) | |
| 223 | 224 | ||
| 224 | 225 | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | |
| 225 | 226 | expectedAttachments := []out.AttachmentSecrets{ | |
| @@ -238,7 +239,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |||
| 238 | 239 | ||
| 239 | 240 | t.Run("returns empty attachments when none exist", func(t *testing.T) { | |
| 240 | 241 | store := outmocks.NewMockDomainSecretStore(t) | |
| 241 | - | svc := NewService(store, testLogger()) | |
| 242 | + | svc := NewService(store, testLogger(), nil) | |
| 242 | 243 | ||
| 243 | 244 | expectedKeys := []string{"API_KEY"} | |
| 244 | 245 | ||
| @@ -253,7 +254,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |||
| 253 | 254 | ||
| 254 | 255 | t.Run("continues without attachments on attachment error", func(t *testing.T) { | |
| 255 | 256 | store := outmocks.NewMockDomainSecretStore(t) | |
| 256 | - | svc := NewService(store, testLogger()) | |
| 257 | + | svc := NewService(store, testLogger(), nil) | |
| 257 | 258 | ||
| 258 | 259 | expectedKeys := []string{"API_KEY"} | |
| 259 | 260 | attachmentErr := assert.AnError | |
| @@ -271,7 +272,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |||
| 271 | 272 | ||
| 272 | 273 | func TestService_ListKeysWithAttachments_StoreError(t *testing.T) { | |
| 273 | 274 | store := outmocks.NewMockDomainSecretStore(t) | |
| 274 | - | svc := NewService(store, testLogger()) | |
| 275 | + | svc := NewService(store, testLogger(), nil) | |
| 275 | 276 | ||
| 276 | 277 | storeErr := assert.AnError | |
| 277 | 278 | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | |
| @@ -284,7 +285,7 @@ func TestService_ListKeysWithAttachments_StoreError(t *testing.T) { | |||
| 284 | 285 | ||
| 285 | 286 | func TestService_GetAll_ValidationErrors(t *testing.T) { | |
| 286 | 287 | store := outmocks.NewMockDomainSecretStore(t) | |
| 287 | - | svc := NewService(store, testLogger()) | |
| 288 | + | svc := NewService(store, testLogger(), nil) | |
| 288 | 289 | ||
| 289 | 290 | tests := []struct { | |
| 290 | 291 | name string | |
| @@ -314,7 +315,7 @@ func TestService_GetAll_ValidationErrors(t *testing.T) { | |||
| 314 | 315 | ||
| 315 | 316 | func TestService_GetAll_Success(t *testing.T) { | |
| 316 | 317 | store := outmocks.NewMockDomainSecretStore(t) | |
| 317 | - | svc := NewService(store, testLogger()) | |
| 318 | + | svc := NewService(store, testLogger(), nil) | |
| 318 | 319 | ||
| 319 | 320 | expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |
| 320 | 321 | store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil) | |
| @@ -326,7 +327,7 @@ func TestService_GetAll_Success(t *testing.T) { | |||
| 326 | 327 | ||
| 327 | 328 | func TestService_Set_ValidationErrors(t *testing.T) { | |
| 328 | 329 | store := outmocks.NewMockDomainSecretStore(t) | |
| 329 | - | svc := NewService(store, testLogger()) | |
| 330 | + | svc := NewService(store, testLogger(), nil) | |
| 330 | 331 | ||
| 331 | 332 | secrets := map[string]string{"API_KEY": "secret123"} | |
| 332 | 333 | ||
| @@ -357,10 +358,30 @@ func TestService_Set_ValidationErrors(t *testing.T) { | |||
| 357 | 358 | ||
| 358 | 359 | func TestService_Set_Success(t *testing.T) { | |
| 359 | 360 | store := outmocks.NewMockDomainSecretStore(t) | |
| 360 | - | svc := NewService(store, testLogger()) | |
| 361 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 362 | + | svc := NewService(store, testLogger(), eventBus) | |
| 361 | 363 | ||
| 362 | 364 | secrets := map[string]string{"API_KEY": "secret123"} | |
| 363 | 365 | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 366 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |
| 367 | + | ||
| 368 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 369 | + | assert.NoError(t, err) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) { | |
| 373 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 374 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 375 | + | svc := NewService(store, testLogger(), eventBus) | |
| 376 | + | ||
| 377 | + | secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |
| 378 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 379 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |
| 380 | + | assert.Equal(t, "app.example.com", payload.Domain) | |
| 381 | + | assert.Equal(t, "set", payload.Operation) | |
| 382 | + | assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys) | |
| 383 | + | return true | |
| 384 | + | })).Return(nil) | |
| 364 | 385 | ||
| 365 | 386 | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 366 | 387 | assert.NoError(t, err) | |
| @@ -368,7 +389,7 @@ func TestService_Set_Success(t *testing.T) { | |||
| 368 | 389 | ||
| 369 | 390 | func TestService_Delete_ValidationErrors(t *testing.T) { | |
| 370 | 391 | store := outmocks.NewMockDomainSecretStore(t) | |
| 371 | - | svc := NewService(store, testLogger()) | |
| 392 | + | svc := NewService(store, testLogger(), nil) | |
| 372 | 393 | ||
| 373 | 394 | tests := []struct { | |
| 374 | 395 | name string | |
| @@ -397,17 +418,62 @@ func TestService_Delete_ValidationErrors(t *testing.T) { | |||
| 397 | 418 | ||
| 398 | 419 | func TestService_Delete_Success(t *testing.T) { | |
| 399 | 420 | store := outmocks.NewMockDomainSecretStore(t) | |
| 400 | - | svc := NewService(store, testLogger()) | |
| 421 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 422 | + | svc := NewService(store, testLogger(), eventBus) | |
| 401 | 423 | ||
| 402 | 424 | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |
| 425 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |
| 403 | 426 | ||
| 404 | 427 | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |
| 405 | 428 | assert.NoError(t, err) | |
| 406 | 429 | } | |
| 407 | 430 | ||
| 431 | + | func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) { | |
| 432 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 433 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 434 | + | svc := NewService(store, testLogger(), eventBus) | |
| 435 | + | ||
| 436 | + | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |
| 437 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |
| 438 | + | assert.Equal(t, "app.example.com", payload.Domain) | |
| 439 | + | assert.Equal(t, "delete", payload.Operation) | |
| 440 | + | assert.Equal(t, []string{"API_KEY"}, payload.Keys) | |
| 441 | + | return true | |
| 442 | + | })).Return(nil) | |
| 443 | + | ||
| 444 | + | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |
| 445 | + | assert.NoError(t, err) | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) { | |
| 449 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 450 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 451 | + | svc := NewService(store, testLogger(), eventBus) | |
| 452 | + | ||
| 453 | + | secrets := map[string]string{"API_KEY": "secret123"} | |
| 454 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 455 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError) | |
| 456 | + | ||
| 457 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 458 | + | assert.NoError(t, err) | |
| 459 | + | } | |
| 460 | + | ||
| 461 | + | func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) { | |
| 462 | + | store := outmocks.NewMockDomainSecretStore(t) | |
| 463 | + | svc := NewService(store, testLogger(), nil) | |
| 464 | + | ||
| 465 | + | secrets := map[string]string{"API_KEY": "secret123"} | |
| 466 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |
| 467 | + | ||
| 468 | + | assert.NotPanics(t, func() { | |
| 469 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |
| 470 | + | assert.NoError(t, err) | |
| 471 | + | }) | |
| 472 | + | } | |
| 473 | + | ||
| 408 | 474 | func TestService_StoreError_PropagatedCorrectly(t *testing.T) { | |
| 409 | 475 | store := outmocks.NewMockDomainSecretStore(t) | |
| 410 | - | svc := NewService(store, testLogger()) | |
| 476 | + | svc := NewService(store, testLogger(), nil) | |
| 411 | 477 | ||
| 412 | 478 | storeErr := assert.AnError | |
| 413 | 479 | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | |
| @@ -450,7 +516,7 @@ func TestPathTraversalVariants(t *testing.T) { | |||
| 450 | 516 | // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls. | |
| 451 | 517 | func TestService_ValidationHappensBeforeStore(t *testing.T) { | |
| 452 | 518 | store := outmocks.NewMockDomainSecretStore(t) | |
| 453 | - | svc := NewService(store, testLogger()) | |
| 519 | + | svc := NewService(store, testLogger(), nil) | |
| 454 | 520 | ||
| 455 | 521 | // With path traversal domain, store methods should never be called | |
| 456 | 522 | // We don't set up any expectations, so if store is called, the test will fail | |
| @@ -473,10 +539,12 @@ func TestService_ValidationHappensBeforeStore(t *testing.T) { | |||
| 473 | 539 | ||
| 474 | 540 | func TestService_SetAttachment(t *testing.T) { | |
| 475 | 541 | store := outmocks.NewMockDomainSecretStore(t) | |
| 476 | - | svc := NewService(store, testLogger()) | |
| 542 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 543 | + | svc := NewService(store, testLogger(), eventBus) | |
| 477 | 544 | ||
| 478 | 545 | secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"} | |
| 479 | 546 | store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil) | |
| 547 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |
| 480 | 548 | ||
| 481 | 549 | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | |
| 482 | 550 | assert.NoError(t, err) | |
| @@ -484,9 +552,11 @@ func TestService_SetAttachment(t *testing.T) { | |||
| 484 | 552 | ||
| 485 | 553 | func TestService_DeleteAttachment(t *testing.T) { | |
| 486 | 554 | store := outmocks.NewMockDomainSecretStore(t) | |
| 487 | - | svc := NewService(store, testLogger()) | |
| 555 | + | eventBus := outmocks.NewMockEventPublisher(t) | |
| 556 | + | svc := NewService(store, testLogger(), eventBus) | |
| 488 | 557 | ||
| 489 | 558 | store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil) | |
| 559 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |
| 490 | 560 | ||
| 491 | 561 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | |
| 492 | 562 | assert.NoError(t, err) | |
| @@ -494,7 +564,7 @@ func TestService_DeleteAttachment(t *testing.T) { | |||
| 494 | 564 | ||
| 495 | 565 | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |
| 496 | 566 | store := outmocks.NewMockDomainSecretStore(t) | |
| 497 | - | svc := NewService(store, testLogger()) | |
| 567 | + | svc := NewService(store, testLogger(), nil) | |
| 498 | 568 | ||
| 499 | 569 | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"}) | |
| 500 | 570 | assert.ErrorIs(t, err, ErrDomainEmpty) | |
| @@ -502,7 +572,7 @@ func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |||
| 502 | 572 | ||
| 503 | 573 | func TestService_SetAttachment_EmptyService(t *testing.T) { | |
| 504 | 574 | store := outmocks.NewMockDomainSecretStore(t) | |
| 505 | - | svc := NewService(store, testLogger()) | |
| 575 | + | svc := NewService(store, testLogger(), nil) | |
| 506 | 576 | ||
| 507 | 577 | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"}) | |
| 508 | 578 | assert.ErrorIs(t, err, ErrServiceEmpty) | |
| @@ -510,7 +580,7 @@ func TestService_SetAttachment_EmptyService(t *testing.T) { | |||
| 510 | 580 | ||
| 511 | 581 | func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | |
| 512 | 582 | store := outmocks.NewMockDomainSecretStore(t) | |
| 513 | - | svc := NewService(store, testLogger()) | |
| 583 | + | svc := NewService(store, testLogger(), nil) | |
| 514 | 584 | ||
| 515 | 585 | err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY") | |
| 516 | 586 | assert.ErrorIs(t, err, ErrDomainEmpty) | |
| @@ -518,7 +588,7 @@ func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | |||
| 518 | 588 | ||
| 519 | 589 | func TestService_DeleteAttachment_EmptyService(t *testing.T) { | |
| 520 | 590 | store := outmocks.NewMockDomainSecretStore(t) | |
| 521 | - | svc := NewService(store, testLogger()) | |
| 591 | + | svc := NewService(store, testLogger(), nil) | |
| 522 | 592 | ||
| 523 | 593 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY") | |
| 524 | 594 | assert.ErrorIs(t, err, ErrServiceEmpty) | |
| @@ -526,7 +596,7 @@ func TestService_DeleteAttachment_EmptyService(t *testing.T) { | |||
| 526 | 596 | ||
| 527 | 597 | func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | |
| 528 | 598 | store := outmocks.NewMockDomainSecretStore(t) | |
| 529 | - | svc := NewService(store, testLogger()) | |
| 599 | + | svc := NewService(store, testLogger(), nil) | |
| 530 | 600 | ||
| 531 | 601 | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"}) | |
| 532 | 602 | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| @@ -534,7 +604,7 @@ func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | |||
| 534 | 604 | ||
| 535 | 605 | func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | |
| 536 | 606 | store := outmocks.NewMockDomainSecretStore(t) | |
| 537 | - | svc := NewService(store, testLogger()) | |
| 607 | + | svc := NewService(store, testLogger(), nil) | |
| 538 | 608 | ||
| 539 | 609 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY") | |
| 540 | 610 | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| @@ -542,7 +612,7 @@ func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | |||
| 542 | 612 | ||
| 543 | 613 | func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | |
| 544 | 614 | store := outmocks.NewMockDomainSecretStore(t) | |
| 545 | - | svc := NewService(store, testLogger()) | |
| 615 | + | svc := NewService(store, testLogger(), nil) | |
| 546 | 616 | ||
| 547 | 617 | // Service name exceeds DNS label limit of 63 characters | |
| 548 | 618 | longService := strings.Repeat("a", 64) | |
| @@ -552,7 +622,7 @@ func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | |||
| 552 | 622 | ||
| 553 | 623 | func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |
| 554 | 624 | store := outmocks.NewMockDomainSecretStore(t) | |
| 555 | - | svc := NewService(store, testLogger()) | |
| 625 | + | svc := NewService(store, testLogger(), nil) | |
| 556 | 626 | ||
| 557 | 627 | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"}) | |
| 558 | 628 | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| @@ -560,7 +630,7 @@ func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |||
| 560 | 630 | ||
| 561 | 631 | func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | |
| 562 | 632 | store := outmocks.NewMockDomainSecretStore(t) | |
| 563 | - | svc := NewService(store, testLogger()) | |
| 633 | + | svc := NewService(store, testLogger(), nil) | |
| 564 | 634 | ||
| 565 | 635 | // Service name exceeds DNS label limit of 63 characters | |
| 566 | 636 | longService := strings.Repeat("a", 64) | |
| @@ -570,7 +640,7 @@ func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | |||
| 570 | 640 | ||
| 571 | 641 | func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |
| 572 | 642 | store := outmocks.NewMockDomainSecretStore(t) | |
| 573 | - | svc := NewService(store, testLogger()) | |
| 643 | + | svc := NewService(store, testLogger(), nil) | |
| 574 | 644 | ||
| 575 | 645 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY") | |
| 576 | 646 | assert.ErrorIs(t, err, ErrInvalidServiceName) | |
| @@ -578,7 +648,7 @@ func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |||
| 578 | 648 | ||
| 579 | 649 | func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) { | |
| 580 | 650 | store := outmocks.NewMockDomainSecretStore(t) | |
| 581 | - | svc := NewService(store, testLogger()) | |
| 651 | + | svc := NewService(store, testLogger(), nil) | |
| 582 | 652 | ||
| 583 | 653 | // With invalid service name, store methods should never be called | |
| 584 | 654 | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"}) | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | "github.com/bnema/gordon/internal/boundaries/out" | 12 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 13 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | 13 | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| 16 | func testLogger() zerowrap.Logger { | 17 | func testLogger() zerowrap.Logger { | ||
| @@ -135,7 +136,7 @@ func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) { | |||||
| 135 | 136 | ||||
| 136 | func TestService_ListKeys_ValidationErrors(t *testing.T) { | 137 | func TestService_ListKeys_ValidationErrors(t *testing.T) { | ||
| 137 | store := outmocks.NewMockDomainSecretStore(t) | 138 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 138 | - | svc := NewService(store, testLogger()) | 139 | + | svc := NewService(store, testLogger(), nil) |
| 139 | 140 | ||||
| 140 | tests := []struct { | 141 | tests := []struct { | ||
| 141 | name string | 142 | name string | ||
| @@ -170,7 +171,7 @@ func TestService_ListKeys_ValidationErrors(t *testing.T) { | |||||
| 170 | 171 | ||||
| 171 | func TestService_ListKeys_Success(t *testing.T) { | 172 | func TestService_ListKeys_Success(t *testing.T) { | ||
| 172 | store := outmocks.NewMockDomainSecretStore(t) | 173 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 173 | - | svc := NewService(store, testLogger()) | 174 | + | svc := NewService(store, testLogger(), nil) |
| 174 | 175 | ||||
| 175 | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | 176 | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | ||
| 176 | store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil) | 177 | store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil) | ||
| @@ -182,7 +183,7 @@ func TestService_ListKeys_Success(t *testing.T) { | |||||
| 182 | 183 | ||||
| 183 | func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) { | 184 | func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) { | ||
| 184 | store := outmocks.NewMockDomainSecretStore(t) | 185 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 185 | - | svc := NewService(store, testLogger()) | 186 | + | svc := NewService(store, testLogger(), nil) |
| 186 | 187 | ||||
| 187 | tests := []struct { | 188 | tests := []struct { | ||
| 188 | name string | 189 | name string | ||
| @@ -219,7 +220,7 @@ func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) { | |||||
| 219 | func TestService_ListKeysWithAttachments_Success(t *testing.T) { | 220 | func TestService_ListKeysWithAttachments_Success(t *testing.T) { | ||
| 220 | t.Run("returns both domain and attachment secrets", func(t *testing.T) { | 221 | t.Run("returns both domain and attachment secrets", func(t *testing.T) { | ||
| 221 | store := outmocks.NewMockDomainSecretStore(t) | 222 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 222 | - | svc := NewService(store, testLogger()) | 223 | + | svc := NewService(store, testLogger(), nil) |
| 223 | 224 | ||||
| 224 | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | 225 | expectedKeys := []string{"API_KEY", "DB_PASSWORD"} | ||
| 225 | expectedAttachments := []out.AttachmentSecrets{ | 226 | expectedAttachments := []out.AttachmentSecrets{ | ||
| @@ -238,7 +239,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |||||
| 238 | 239 | ||||
| 239 | t.Run("returns empty attachments when none exist", func(t *testing.T) { | 240 | t.Run("returns empty attachments when none exist", func(t *testing.T) { | ||
| 240 | store := outmocks.NewMockDomainSecretStore(t) | 241 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 241 | - | svc := NewService(store, testLogger()) | 242 | + | svc := NewService(store, testLogger(), nil) |
| 242 | 243 | ||||
| 243 | expectedKeys := []string{"API_KEY"} | 244 | expectedKeys := []string{"API_KEY"} | ||
| 244 | 245 | ||||
| @@ -253,7 +254,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |||||
| 253 | 254 | ||||
| 254 | t.Run("continues without attachments on attachment error", func(t *testing.T) { | 255 | t.Run("continues without attachments on attachment error", func(t *testing.T) { | ||
| 255 | store := outmocks.NewMockDomainSecretStore(t) | 256 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 256 | - | svc := NewService(store, testLogger()) | 257 | + | svc := NewService(store, testLogger(), nil) |
| 257 | 258 | ||||
| 258 | expectedKeys := []string{"API_KEY"} | 259 | expectedKeys := []string{"API_KEY"} | ||
| 259 | attachmentErr := assert.AnError | 260 | attachmentErr := assert.AnError | ||
| @@ -271,7 +272,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) { | |||||
| 271 | 272 | ||||
| 272 | func TestService_ListKeysWithAttachments_StoreError(t *testing.T) { | 273 | func TestService_ListKeysWithAttachments_StoreError(t *testing.T) { | ||
| 273 | store := outmocks.NewMockDomainSecretStore(t) | 274 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 274 | - | svc := NewService(store, testLogger()) | 275 | + | svc := NewService(store, testLogger(), nil) |
| 275 | 276 | ||||
| 276 | storeErr := assert.AnError | 277 | storeErr := assert.AnError | ||
| 277 | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | 278 | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | ||
| @@ -284,7 +285,7 @@ func TestService_ListKeysWithAttachments_StoreError(t *testing.T) { | |||||
| 284 | 285 | ||||
| 285 | func TestService_GetAll_ValidationErrors(t *testing.T) { | 286 | func TestService_GetAll_ValidationErrors(t *testing.T) { | ||
| 286 | store := outmocks.NewMockDomainSecretStore(t) | 287 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 287 | - | svc := NewService(store, testLogger()) | 288 | + | svc := NewService(store, testLogger(), nil) |
| 288 | 289 | ||||
| 289 | tests := []struct { | 290 | tests := []struct { | ||
| 290 | name string | 291 | name string | ||
| @@ -314,7 +315,7 @@ func TestService_GetAll_ValidationErrors(t *testing.T) { | |||||
| 314 | 315 | ||||
| 315 | func TestService_GetAll_Success(t *testing.T) { | 316 | func TestService_GetAll_Success(t *testing.T) { | ||
| 316 | store := outmocks.NewMockDomainSecretStore(t) | 317 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 317 | - | svc := NewService(store, testLogger()) | 318 | + | svc := NewService(store, testLogger(), nil) |
| 318 | 319 | ||||
| 319 | expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | 320 | expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | ||
| 320 | store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil) | 321 | store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil) | ||
| @@ -326,7 +327,7 @@ func TestService_GetAll_Success(t *testing.T) { | |||||
| 326 | 327 | ||||
| 327 | func TestService_Set_ValidationErrors(t *testing.T) { | 328 | func TestService_Set_ValidationErrors(t *testing.T) { | ||
| 328 | store := outmocks.NewMockDomainSecretStore(t) | 329 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 329 | - | svc := NewService(store, testLogger()) | 330 | + | svc := NewService(store, testLogger(), nil) |
| 330 | 331 | ||||
| 331 | secrets := map[string]string{"API_KEY": "secret123"} | 332 | secrets := map[string]string{"API_KEY": "secret123"} | ||
| 332 | 333 | ||||
| @@ -357,10 +358,30 @@ func TestService_Set_ValidationErrors(t *testing.T) { | |||||
| 357 | 358 | ||||
| 358 | func TestService_Set_Success(t *testing.T) { | 359 | func TestService_Set_Success(t *testing.T) { | ||
| 359 | store := outmocks.NewMockDomainSecretStore(t) | 360 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 360 | - | svc := NewService(store, testLogger()) | 361 | + | eventBus := outmocks.NewMockEventPublisher(t) |
| 362 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 361 | 363 | ||||
| 362 | secrets := map[string]string{"API_KEY": "secret123"} | 364 | secrets := map[string]string{"API_KEY": "secret123"} | ||
| 363 | store.EXPECT().Set("app.example.com", secrets).Return(nil) | 365 | store.EXPECT().Set("app.example.com", secrets).Return(nil) | ||
| 366 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |||
| 367 | + | ||||
| 368 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 369 | + | assert.NoError(t, err) | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) { | |||
| 373 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 374 | + | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 375 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 376 | + | ||||
| 377 | + | secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"} | |||
| 378 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 379 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |||
| 380 | + | assert.Equal(t, "app.example.com", payload.Domain) | |||
| 381 | + | assert.Equal(t, "set", payload.Operation) | |||
| 382 | + | assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys) | |||
| 383 | + | return true | |||
| 384 | + | })).Return(nil) | |||
| 364 | 385 | ||||
| 365 | err := svc.Set(context.Background(), "app.example.com", secrets) | 386 | err := svc.Set(context.Background(), "app.example.com", secrets) | ||
| 366 | assert.NoError(t, err) | 387 | assert.NoError(t, err) | ||
| @@ -368,7 +389,7 @@ func TestService_Set_Success(t *testing.T) { | |||||
| 368 | 389 | ||||
| 369 | func TestService_Delete_ValidationErrors(t *testing.T) { | 390 | func TestService_Delete_ValidationErrors(t *testing.T) { | ||
| 370 | store := outmocks.NewMockDomainSecretStore(t) | 391 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 371 | - | svc := NewService(store, testLogger()) | 392 | + | svc := NewService(store, testLogger(), nil) |
| 372 | 393 | ||||
| 373 | tests := []struct { | 394 | tests := []struct { | ||
| 374 | name string | 395 | name string | ||
| @@ -397,17 +418,62 @@ func TestService_Delete_ValidationErrors(t *testing.T) { | |||||
| 397 | 418 | ||||
| 398 | func TestService_Delete_Success(t *testing.T) { | 419 | func TestService_Delete_Success(t *testing.T) { | ||
| 399 | store := outmocks.NewMockDomainSecretStore(t) | 420 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 400 | - | svc := NewService(store, testLogger()) | 421 | + | eventBus := outmocks.NewMockEventPublisher(t) |
| 422 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 401 | 423 | ||||
| 402 | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | 424 | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | ||
| 425 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |||
| 403 | 426 | ||||
| 404 | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | 427 | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | ||
| 405 | assert.NoError(t, err) | 428 | assert.NoError(t, err) | ||
| 406 | } | 429 | } | ||
| 407 | 430 | ||||
| 431 | + | func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) { | |||
| 432 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 433 | + | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 434 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 435 | + | ||||
| 436 | + | store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil) | |||
| 437 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool { | |||
| 438 | + | assert.Equal(t, "app.example.com", payload.Domain) | |||
| 439 | + | assert.Equal(t, "delete", payload.Operation) | |||
| 440 | + | assert.Equal(t, []string{"API_KEY"}, payload.Keys) | |||
| 441 | + | return true | |||
| 442 | + | })).Return(nil) | |||
| 443 | + | ||||
| 444 | + | err := svc.Delete(context.Background(), "app.example.com", "API_KEY") | |||
| 445 | + | assert.NoError(t, err) | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) { | |||
| 449 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 450 | + | eventBus := outmocks.NewMockEventPublisher(t) | |||
| 451 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 452 | + | ||||
| 453 | + | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 454 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 455 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError) | |||
| 456 | + | ||||
| 457 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 458 | + | assert.NoError(t, err) | |||
| 459 | + | } | |||
| 460 | + | ||||
| 461 | + | func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) { | |||
| 462 | + | store := outmocks.NewMockDomainSecretStore(t) | |||
| 463 | + | svc := NewService(store, testLogger(), nil) | |||
| 464 | + | ||||
| 465 | + | secrets := map[string]string{"API_KEY": "secret123"} | |||
| 466 | + | store.EXPECT().Set("app.example.com", secrets).Return(nil) | |||
| 467 | + | ||||
| 468 | + | assert.NotPanics(t, func() { | |||
| 469 | + | err := svc.Set(context.Background(), "app.example.com", secrets) | |||
| 470 | + | assert.NoError(t, err) | |||
| 471 | + | }) | |||
| 472 | + | } | |||
| 473 | + | ||||
| 408 | func TestService_StoreError_PropagatedCorrectly(t *testing.T) { | 474 | func TestService_StoreError_PropagatedCorrectly(t *testing.T) { | ||
| 409 | store := outmocks.NewMockDomainSecretStore(t) | 475 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 410 | - | svc := NewService(store, testLogger()) | 476 | + | svc := NewService(store, testLogger(), nil) |
| 411 | 477 | ||||
| 412 | storeErr := assert.AnError | 478 | storeErr := assert.AnError | ||
| 413 | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | 479 | store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr) | ||
| @@ -450,7 +516,7 @@ func TestPathTraversalVariants(t *testing.T) { | |||||
| 450 | // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls. | 516 | // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls. | ||
| 451 | func TestService_ValidationHappensBeforeStore(t *testing.T) { | 517 | func TestService_ValidationHappensBeforeStore(t *testing.T) { | ||
| 452 | store := outmocks.NewMockDomainSecretStore(t) | 518 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 453 | - | svc := NewService(store, testLogger()) | 519 | + | svc := NewService(store, testLogger(), nil) |
| 454 | 520 | ||||
| 455 | // With path traversal domain, store methods should never be called | 521 | // With path traversal domain, store methods should never be called | ||
| 456 | // We don't set up any expectations, so if store is called, the test will fail | 522 | // We don't set up any expectations, so if store is called, the test will fail | ||
| @@ -473,10 +539,12 @@ func TestService_ValidationHappensBeforeStore(t *testing.T) { | |||||
| 473 | 539 | ||||
| 474 | func TestService_SetAttachment(t *testing.T) { | 540 | func TestService_SetAttachment(t *testing.T) { | ||
| 475 | store := outmocks.NewMockDomainSecretStore(t) | 541 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 476 | - | svc := NewService(store, testLogger()) | 542 | + | eventBus := outmocks.NewMockEventPublisher(t) |
| 543 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 477 | 544 | ||||
| 478 | secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"} | 545 | secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"} | ||
| 479 | store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil) | 546 | store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil) | ||
| 547 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |||
| 480 | 548 | ||||
| 481 | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | 549 | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets) | ||
| 482 | assert.NoError(t, err) | 550 | assert.NoError(t, err) | ||
| @@ -484,9 +552,11 @@ func TestService_SetAttachment(t *testing.T) { | |||||
| 484 | 552 | ||||
| 485 | func TestService_DeleteAttachment(t *testing.T) { | 553 | func TestService_DeleteAttachment(t *testing.T) { | ||
| 486 | store := outmocks.NewMockDomainSecretStore(t) | 554 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 487 | - | svc := NewService(store, testLogger()) | 555 | + | eventBus := outmocks.NewMockEventPublisher(t) |
| 556 | + | svc := NewService(store, testLogger(), eventBus) | |||
| 488 | 557 | ||||
| 489 | store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil) | 558 | store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil) | ||
| 559 | + | eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil) | |||
| 490 | 560 | ||||
| 491 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | 561 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY") | ||
| 492 | assert.NoError(t, err) | 562 | assert.NoError(t, err) | ||
| @@ -494,7 +564,7 @@ func TestService_DeleteAttachment(t *testing.T) { | |||||
| 494 | 564 | ||||
| 495 | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | 565 | func TestService_SetAttachment_InvalidDomain(t *testing.T) { | ||
| 496 | store := outmocks.NewMockDomainSecretStore(t) | 566 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 497 | - | svc := NewService(store, testLogger()) | 567 | + | svc := NewService(store, testLogger(), nil) |
| 498 | 568 | ||||
| 499 | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"}) | 569 | err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"}) | ||
| 500 | assert.ErrorIs(t, err, ErrDomainEmpty) | 570 | assert.ErrorIs(t, err, ErrDomainEmpty) | ||
| @@ -502,7 +572,7 @@ func TestService_SetAttachment_InvalidDomain(t *testing.T) { | |||||
| 502 | 572 | ||||
| 503 | func TestService_SetAttachment_EmptyService(t *testing.T) { | 573 | func TestService_SetAttachment_EmptyService(t *testing.T) { | ||
| 504 | store := outmocks.NewMockDomainSecretStore(t) | 574 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 505 | - | svc := NewService(store, testLogger()) | 575 | + | svc := NewService(store, testLogger(), nil) |
| 506 | 576 | ||||
| 507 | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"}) | 577 | err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"}) | ||
| 508 | assert.ErrorIs(t, err, ErrServiceEmpty) | 578 | assert.ErrorIs(t, err, ErrServiceEmpty) | ||
| @@ -510,7 +580,7 @@ func TestService_SetAttachment_EmptyService(t *testing.T) { | |||||
| 510 | 580 | ||||
| 511 | func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | 581 | func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | ||
| 512 | store := outmocks.NewMockDomainSecretStore(t) | 582 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 513 | - | svc := NewService(store, testLogger()) | 583 | + | svc := NewService(store, testLogger(), nil) |
| 514 | 584 | ||||
| 515 | err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY") | 585 | err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY") | ||
| 516 | assert.ErrorIs(t, err, ErrDomainEmpty) | 586 | assert.ErrorIs(t, err, ErrDomainEmpty) | ||
| @@ -518,7 +588,7 @@ func TestService_DeleteAttachment_InvalidDomain(t *testing.T) { | |||||
| 518 | 588 | ||||
| 519 | func TestService_DeleteAttachment_EmptyService(t *testing.T) { | 589 | func TestService_DeleteAttachment_EmptyService(t *testing.T) { | ||
| 520 | store := outmocks.NewMockDomainSecretStore(t) | 590 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 521 | - | svc := NewService(store, testLogger()) | 591 | + | svc := NewService(store, testLogger(), nil) |
| 522 | 592 | ||||
| 523 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY") | 593 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY") | ||
| 524 | assert.ErrorIs(t, err, ErrServiceEmpty) | 594 | assert.ErrorIs(t, err, ErrServiceEmpty) | ||
| @@ -526,7 +596,7 @@ func TestService_DeleteAttachment_EmptyService(t *testing.T) { | |||||
| 526 | 596 | ||||
| 527 | func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | 597 | func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | ||
| 528 | store := outmocks.NewMockDomainSecretStore(t) | 598 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 529 | - | svc := NewService(store, testLogger()) | 599 | + | svc := NewService(store, testLogger(), nil) |
| 530 | 600 | ||||
| 531 | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"}) | 601 | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"}) | ||
| 532 | assert.ErrorIs(t, err, ErrInvalidServiceName) | 602 | assert.ErrorIs(t, err, ErrInvalidServiceName) | ||
| @@ -534,7 +604,7 @@ func TestService_SetAttachment_InvalidServiceName(t *testing.T) { | |||||
| 534 | 604 | ||||
| 535 | func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | 605 | func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | ||
| 536 | store := outmocks.NewMockDomainSecretStore(t) | 606 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 537 | - | svc := NewService(store, testLogger()) | 607 | + | svc := NewService(store, testLogger(), nil) |
| 538 | 608 | ||||
| 539 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY") | 609 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY") | ||
| 540 | assert.ErrorIs(t, err, ErrInvalidServiceName) | 610 | assert.ErrorIs(t, err, ErrInvalidServiceName) | ||
| @@ -542,7 +612,7 @@ func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) { | |||||
| 542 | 612 | ||||
| 543 | func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | 613 | func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | ||
| 544 | store := outmocks.NewMockDomainSecretStore(t) | 614 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 545 | - | svc := NewService(store, testLogger()) | 615 | + | svc := NewService(store, testLogger(), nil) |
| 546 | 616 | ||||
| 547 | // Service name exceeds DNS label limit of 63 characters | 617 | // Service name exceeds DNS label limit of 63 characters | ||
| 548 | longService := strings.Repeat("a", 64) | 618 | longService := strings.Repeat("a", 64) | ||
| @@ -552,7 +622,7 @@ func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) { | |||||
| 552 | 622 | ||||
| 553 | func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | 623 | func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | ||
| 554 | store := outmocks.NewMockDomainSecretStore(t) | 624 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 555 | - | svc := NewService(store, testLogger()) | 625 | + | svc := NewService(store, testLogger(), nil) |
| 556 | 626 | ||||
| 557 | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"}) | 627 | err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"}) | ||
| 558 | assert.ErrorIs(t, err, ErrInvalidServiceName) | 628 | assert.ErrorIs(t, err, ErrInvalidServiceName) | ||
| @@ -560,7 +630,7 @@ func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |||||
| 560 | 630 | ||||
| 561 | func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | 631 | func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | ||
| 562 | store := outmocks.NewMockDomainSecretStore(t) | 632 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 563 | - | svc := NewService(store, testLogger()) | 633 | + | svc := NewService(store, testLogger(), nil) |
| 564 | 634 | ||||
| 565 | // Service name exceeds DNS label limit of 63 characters | 635 | // Service name exceeds DNS label limit of 63 characters | ||
| 566 | longService := strings.Repeat("a", 64) | 636 | longService := strings.Repeat("a", 64) | ||
| @@ -570,7 +640,7 @@ func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) { | |||||
| 570 | 640 | ||||
| 571 | func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | 641 | func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | ||
| 572 | store := outmocks.NewMockDomainSecretStore(t) | 642 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 573 | - | svc := NewService(store, testLogger()) | 643 | + | svc := NewService(store, testLogger(), nil) |
| 574 | 644 | ||||
| 575 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY") | 645 | err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY") | ||
| 576 | assert.ErrorIs(t, err, ErrInvalidServiceName) | 646 | assert.ErrorIs(t, err, ErrInvalidServiceName) | ||
| @@ -578,7 +648,7 @@ func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) { | |||||
| 578 | 648 | ||||
| 579 | func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) { | 649 | func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) { | ||
| 580 | store := outmocks.NewMockDomainSecretStore(t) | 650 | store := outmocks.NewMockDomainSecretStore(t) | ||
| 581 | - | svc := NewService(store, testLogger()) | 651 | + | svc := NewService(store, testLogger(), nil) |
| 582 | 652 | ||||
| 583 | // With invalid service name, store methods should never be called | 653 | // With invalid service name, store methods should never be called | ||
| 584 | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"}) | 654 | err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"}) | ||