Merge pull request #110 from bnema/fix/redeploy-on-env-change
feat: redeploy containers when route secrets or env change
13 files · +964 -79
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 +27 -14
| @@ -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, 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, container.DefaultSecretsDebounce) | |
| 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, 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 | ||
| @@ -1759,8 +1771,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1759 | 1771 | // Auto-start after servers are listening (registry port is now bound). | |
| 1760 | 1772 | syncAndAutoStart(ctx, svc, log) | |
| 1761 | 1773 | ||
| 1762 | - | waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log) | |
| 1763 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log) | |
| 1774 | + | waitForShutdown(ctx, errChan, reloadChan, deployChan, svc.eventBus, log) | |
| 1775 | + | cleanupHandlers() // Stop debounce timers before draining containers | |
| 1776 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, log) | |
| 1764 | 1777 | return nil | |
| 1765 | 1778 | } | |
| 1766 | 1779 | ||
| @@ -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, 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, container.DefaultSecretsDebounce) | |||
| 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, 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 | ||||
| @@ -1759,8 +1771,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1759 | // Auto-start after servers are listening (registry port is now bound). | 1771 | // Auto-start after servers are listening (registry port is now bound). | ||
| 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, svc.eventBus, log) |
| 1763 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log) | 1775 | + | cleanupHandlers() // Stop debounce timers before draining containers |
| 1776 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, log) | |||
| 1764 | return nil | 1777 | return nil | ||
| 1765 | } | 1778 | } | ||
| 1766 | 1779 | ||||
M internal/domain/event.go +8 -0
| @@ -18,6 +18,7 @@ const ( | |||
| 18 | 18 | EventContainerStart EventType = "container.start" | |
| 19 | 19 | EventContainerHealthCheck EventType = "container.health_check" | |
| 20 | 20 | EventContainerDeployed EventType = "container.deployed" | |
| 21 | + | EventSecretsChanged EventType = "secrets.changed" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // Event represents a domain event that occurred in the system. | |
| @@ -61,6 +62,13 @@ type ManualDeployPayload struct { | |||
| 61 | 62 | Domain string `json:"domain"` | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 65 | + | // SecretsChangedPayload contains data for secrets.changed events. | |
| 66 | + | type SecretsChangedPayload struct { | |
| 67 | + | Domain string // Route domain whose secrets changed | |
| 68 | + | Operation string // "set" or "delete" | |
| 69 | + | Keys []string // Secret key names (not values) | |
| 70 | + | } | |
| 71 | + | ||
| 64 | 72 | // Context keys for domain-level concerns. | |
| 65 | 73 | type contextKey string | |
| 66 | 74 | ||
| @@ -18,6 +18,7 @@ const ( | |||||
| 18 | EventContainerStart EventType = "container.start" | 18 | EventContainerStart EventType = "container.start" | ||
| 19 | EventContainerHealthCheck EventType = "container.health_check" | 19 | EventContainerHealthCheck EventType = "container.health_check" | ||
| 20 | EventContainerDeployed EventType = "container.deployed" | 20 | EventContainerDeployed EventType = "container.deployed" | ||
| 21 | + | EventSecretsChanged EventType = "secrets.changed" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // Event represents a domain event that occurred in the system. | 24 | // Event represents a domain event that occurred in the system. | ||
| @@ -61,6 +62,13 @@ type ManualDeployPayload struct { | |||||
| 61 | Domain string `json:"domain"` | 62 | Domain string `json:"domain"` | ||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 65 | + | // SecretsChangedPayload contains data for secrets.changed events. | |||
| 66 | + | type SecretsChangedPayload struct { | |||
| 67 | + | Domain string // Route domain whose secrets changed | |||
| 68 | + | Operation string // "set" or "delete" | |||
| 69 | + | Keys []string // Secret key names (not values) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 64 | // Context keys for domain-level concerns. | 72 | // Context keys for domain-level concerns. | ||
| 65 | type contextKey string | 73 | type contextKey string | ||
| 66 | 74 | ||||
M internal/domain/labels.go +4 -0
| @@ -10,6 +10,10 @@ const ( | |||
| 10 | 10 | LabelAttachment = "gordon.attachment" | |
| 11 | 11 | LabelAttachedTo = "gordon.attached-to" | |
| 12 | 12 | LabelCreated = "gordon.created" | |
| 13 | + | // LabelEnvHash stores a SHA-256 hash of the effective environment | |
| 14 | + | // variables at deploy time, used to detect env drift without | |
| 15 | + | // exposing secret values. | |
| 16 | + | LabelEnvHash = "gordon.env-hash" | |
| 13 | 17 | ||
| 14 | 18 | // Image labels (set in Dockerfile) | |
| 15 | 19 | LabelProxyPort = "gordon.proxy.port" | |
| @@ -10,6 +10,10 @@ const ( | |||||
| 10 | LabelAttachment = "gordon.attachment" | 10 | LabelAttachment = "gordon.attachment" | ||
| 11 | LabelAttachedTo = "gordon.attached-to" | 11 | LabelAttachedTo = "gordon.attached-to" | ||
| 12 | LabelCreated = "gordon.created" | 12 | LabelCreated = "gordon.created" | ||
| 13 | + | // LabelEnvHash stores a SHA-256 hash of the effective environment | |||
| 14 | + | // variables at deploy time, used to detect env drift without | |||
| 15 | + | // exposing secret values. | |||
| 16 | + | LabelEnvHash = "gordon.env-hash" | |||
| 13 | 17 | ||||
| 14 | // Image labels (set in Dockerfile) | 18 | // Image labels (set in Dockerfile) | ||
| 15 | LabelProxyPort = "gordon.proxy.port" | 19 | LabelProxyPort = "gordon.proxy.port" | ||
A internal/usecase/container/env_hash.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "fmt" | |
| 6 | + | "slices" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // hashEnvironment computes a stable SHA-256 hash of a set of KEY=VALUE pairs. | |
| 10 | + | // The input is sorted by key before hashing so map iteration order does not | |
| 11 | + | // affect the result. The returned string is the hex-encoded digest. | |
| 12 | + | func hashEnvironment(env []string) string { | |
| 13 | + | sorted := make([]string, len(env)) | |
| 14 | + | copy(sorted, env) | |
| 15 | + | slices.Sort(sorted) | |
| 16 | + | ||
| 17 | + | h := sha256.New() | |
| 18 | + | for _, kv := range sorted { | |
| 19 | + | // Length-prefix each entry so embedded newlines cannot collide. | |
| 20 | + | _, _ = fmt.Fprintf(h, "%d:%s\n", len(kv), kv) | |
| 21 | + | } | |
| 22 | + | return fmt.Sprintf("%x", h.Sum(nil)) | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "fmt" | |||
| 6 | + | "slices" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // hashEnvironment computes a stable SHA-256 hash of a set of KEY=VALUE pairs. | |||
| 10 | + | // The input is sorted by key before hashing so map iteration order does not | |||
| 11 | + | // affect the result. The returned string is the hex-encoded digest. | |||
| 12 | + | func hashEnvironment(env []string) string { | |||
| 13 | + | sorted := make([]string, len(env)) | |||
| 14 | + | copy(sorted, env) | |||
| 15 | + | slices.Sort(sorted) | |||
| 16 | + | ||||
| 17 | + | h := sha256.New() | |||
| 18 | + | for _, kv := range sorted { | |||
| 19 | + | // Length-prefix each entry so embedded newlines cannot collide. | |||
| 20 | + | _, _ = fmt.Fprintf(h, "%d:%s\n", len(kv), kv) | |||
| 21 | + | } | |||
| 22 | + | return fmt.Sprintf("%x", h.Sum(nil)) | |||
| 23 | + | } | |||
A internal/usecase/container/env_hash_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHashEnvironment_Deterministic(t *testing.T) { | |
| 11 | + | env1 := []string{"B=2", "A=1", "C=3"} | |
| 12 | + | env2 := []string{"A=1", "C=3", "B=2"} | |
| 13 | + | assert.Equal(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestHashEnvironment_DifferentValues(t *testing.T) { | |
| 17 | + | env1 := []string{"A=1", "B=2"} | |
| 18 | + | env2 := []string{"A=1", "B=3"} | |
| 19 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestHashEnvironment_DifferentKeys(t *testing.T) { | |
| 23 | + | env1 := []string{"A=1", "B=2"} | |
| 24 | + | env2 := []string{"A=1", "C=2"} | |
| 25 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestHashEnvironment_Empty(t *testing.T) { | |
| 29 | + | hash := hashEnvironment(nil) | |
| 30 | + | require.NotEmpty(t, hash) | |
| 31 | + | assert.Equal(t, hash, hashEnvironment([]string{})) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestHashEnvironment_ExtraKey(t *testing.T) { | |
| 35 | + | env1 := []string{"A=1"} | |
| 36 | + | env2 := []string{"A=1", "B=2"} | |
| 37 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestHashEnvironment_NewlineInValue(t *testing.T) { | |
| 41 | + | env1 := []string{"A=1\nB=2"} | |
| 42 | + | env2 := []string{"A=1", "B=2"} | |
| 43 | + | ||
| 44 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestMergeEnvironmentVariables_Sorted(t *testing.T) { | |
| 48 | + | dockerfile := []string{"Z=26", "A=1"} | |
| 49 | + | user := []string{"M=13", "B=2"} | |
| 50 | + | result := mergeEnvironmentVariables(dockerfile, user) | |
| 51 | + | assert.Equal(t, []string{"A=1", "B=2", "M=13", "Z=26"}, result) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestMergeEnvironmentVariables_UserOverridesDockerfile(t *testing.T) { | |
| 55 | + | dockerfile := []string{"A=old"} | |
| 56 | + | user := []string{"A=new"} | |
| 57 | + | result := mergeEnvironmentVariables(dockerfile, user) | |
| 58 | + | assert.Equal(t, []string{"A=new"}, result) | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHashEnvironment_Deterministic(t *testing.T) { | |||
| 11 | + | env1 := []string{"B=2", "A=1", "C=3"} | |||
| 12 | + | env2 := []string{"A=1", "C=3", "B=2"} | |||
| 13 | + | assert.Equal(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestHashEnvironment_DifferentValues(t *testing.T) { | |||
| 17 | + | env1 := []string{"A=1", "B=2"} | |||
| 18 | + | env2 := []string{"A=1", "B=3"} | |||
| 19 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestHashEnvironment_DifferentKeys(t *testing.T) { | |||
| 23 | + | env1 := []string{"A=1", "B=2"} | |||
| 24 | + | env2 := []string{"A=1", "C=2"} | |||
| 25 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestHashEnvironment_Empty(t *testing.T) { | |||
| 29 | + | hash := hashEnvironment(nil) | |||
| 30 | + | require.NotEmpty(t, hash) | |||
| 31 | + | assert.Equal(t, hash, hashEnvironment([]string{})) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestHashEnvironment_ExtraKey(t *testing.T) { | |||
| 35 | + | env1 := []string{"A=1"} | |||
| 36 | + | env2 := []string{"A=1", "B=2"} | |||
| 37 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestHashEnvironment_NewlineInValue(t *testing.T) { | |||
| 41 | + | env1 := []string{"A=1\nB=2"} | |||
| 42 | + | env2 := []string{"A=1", "B=2"} | |||
| 43 | + | ||||
| 44 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestMergeEnvironmentVariables_Sorted(t *testing.T) { | |||
| 48 | + | dockerfile := []string{"Z=26", "A=1"} | |||
| 49 | + | user := []string{"M=13", "B=2"} | |||
| 50 | + | result := mergeEnvironmentVariables(dockerfile, user) | |||
| 51 | + | assert.Equal(t, []string{"A=1", "B=2", "M=13", "Z=26"}, result) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestMergeEnvironmentVariables_UserOverridesDockerfile(t *testing.T) { | |||
| 55 | + | dockerfile := []string{"A=old"} | |||
| 56 | + | user := []string{"A=new"} | |||
| 57 | + | result := mergeEnvironmentVariables(dockerfile, user) | |||
| 58 | + | assert.Equal(t, []string{"A=new"}, result) | |||
| 59 | + | } | |||
A internal/usecase/container/secrets_handler.go +155 -0
| @@ -0,0 +1,155 @@ | |||
| 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 | + | const DefaultSecretsDebounce = 60 * time.Second | |
| 15 | + | ||
| 16 | + | // SecretsChangedHandler handles secrets.changed events with per-domain | |
| 17 | + | // debouncing. When secrets are modified rapidly (e.g. multiple | |
| 18 | + | // "gordon secrets set" calls), the handler resets a timer on each | |
| 19 | + | // event and only triggers a deploy once the domain is quiet for | |
| 20 | + | // the configured delay. | |
| 21 | + | type SecretsChangedHandler struct { | |
| 22 | + | containerSvc in.ContainerService | |
| 23 | + | configSvc in.ConfigService | |
| 24 | + | ctx context.Context | |
| 25 | + | cancel context.CancelFunc | |
| 26 | + | debounceDelay time.Duration | |
| 27 | + | ||
| 28 | + | mu sync.Mutex | |
| 29 | + | timers map[string]*time.Timer | |
| 30 | + | generations map[string]uint64 | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewSecretsChangedHandler creates a new SecretsChangedHandler. | |
| 34 | + | func NewSecretsChangedHandler( | |
| 35 | + | ctx context.Context, | |
| 36 | + | containerSvc in.ContainerService, | |
| 37 | + | configSvc in.ConfigService, | |
| 38 | + | debounceDelay time.Duration, | |
| 39 | + | ) *SecretsChangedHandler { | |
| 40 | + | ctx, cancel := context.WithCancel(ctx) | |
| 41 | + | return &SecretsChangedHandler{ | |
| 42 | + | containerSvc: containerSvc, | |
| 43 | + | configSvc: configSvc, | |
| 44 | + | ctx: ctx, | |
| 45 | + | cancel: cancel, | |
| 46 | + | debounceDelay: debounceDelay, | |
| 47 | + | timers: make(map[string]*time.Timer), | |
| 48 | + | generations: make(map[string]uint64), | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Handle processes a secrets.changed event by resetting the debounce | |
| 53 | + | // timer for the affected domain. Returns immediately - the deploy | |
| 54 | + | // runs asynchronously when the timer fires. | |
| 55 | + | // | |
| 56 | + | // Uses the handler's long-lived context (h.ctx) rather than the event | |
| 57 | + | // context because the debounced deploy fires after this method returns; | |
| 58 | + | // using the event context would cause premature cancellation. | |
| 59 | + | func (h *SecretsChangedHandler) Handle(_ context.Context, event domain.Event) error { | |
| 60 | + | payload, ok := event.Data.(domain.SecretsChangedPayload) | |
| 61 | + | if !ok { | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | if payload.Domain == "" { | |
| 65 | + | return nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |
| 69 | + | zerowrap.FieldLayer: "usecase", | |
| 70 | + | zerowrap.FieldHandler: "SecretsChangedHandler", | |
| 71 | + | "domain": payload.Domain, | |
| 72 | + | "operation": payload.Operation, | |
| 73 | + | }) | |
| 74 | + | log := zerowrap.FromCtx(ctx) | |
| 75 | + | ||
| 76 | + | log.Debug(). | |
| 77 | + | Strs("keys", payload.Keys). | |
| 78 | + | Dur("debounce", h.debounceDelay). | |
| 79 | + | Msg("secrets changed, resetting debounce timer") | |
| 80 | + | ||
| 81 | + | h.resetTimer(ctx, payload.Domain) | |
| 82 | + | return nil | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // CanHandle returns true for secrets.changed events. | |
| 86 | + | func (h *SecretsChangedHandler) CanHandle(eventType domain.EventType) bool { | |
| 87 | + | return eventType == domain.EventSecretsChanged | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Stop cancels the handler context and all pending timers. | |
| 91 | + | // Call before graceful shutdown to prevent deploys during drain. | |
| 92 | + | func (h *SecretsChangedHandler) Stop() { | |
| 93 | + | h.cancel() | |
| 94 | + | ||
| 95 | + | h.mu.Lock() | |
| 96 | + | defer h.mu.Unlock() | |
| 97 | + | ||
| 98 | + | for domainName, timer := range h.timers { | |
| 99 | + | timer.Stop() | |
| 100 | + | delete(h.timers, domainName) | |
| 101 | + | delete(h.generations, domainName) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // resetTimer replaces any existing timer for the domain with a new one. | |
| 106 | + | func (h *SecretsChangedHandler) resetTimer(ctx context.Context, domainName string) { | |
| 107 | + | h.mu.Lock() | |
| 108 | + | defer h.mu.Unlock() | |
| 109 | + | ||
| 110 | + | if existing, ok := h.timers[domainName]; ok { | |
| 111 | + | existing.Stop() | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | h.generations[domainName]++ | |
| 115 | + | gen := h.generations[domainName] | |
| 116 | + | ||
| 117 | + | h.timers[domainName] = time.AfterFunc(h.debounceDelay, func() { | |
| 118 | + | h.fireDeploy(ctx, domainName, gen) | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // fireDeploy looks up the route and triggers a deploy. | |
| 123 | + | func (h *SecretsChangedHandler) fireDeploy(ctx context.Context, domainName string, generation uint64) { | |
| 124 | + | // Bail if the handler has been stopped (shutdown in progress). | |
| 125 | + | if h.ctx.Err() != nil { | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | log := zerowrap.FromCtx(ctx) | |
| 130 | + | ||
| 131 | + | h.mu.Lock() | |
| 132 | + | if h.generations[domainName] != generation { | |
| 133 | + | h.mu.Unlock() | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | delete(h.timers, domainName) | |
| 137 | + | delete(h.generations, domainName) | |
| 138 | + | h.mu.Unlock() | |
| 139 | + | ||
| 140 | + | route, err := h.configSvc.GetRoute(ctx, domainName) | |
| 141 | + | if err != nil { | |
| 142 | + | log.WrapErr(err, "failed to get route for secrets-changed deploy") | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | if route == nil { | |
| 146 | + | log.Debug().Msg("no route configured for domain, skipping secrets-changed deploy") | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | log.Info().Str("image", route.Image).Msg("debounce fired, deploying after secrets change") | |
| 151 | + | ||
| 152 | + | if _, err := h.containerSvc.Deploy(ctx, *route); err != nil { | |
| 153 | + | log.WrapErr(err, "secrets-changed deploy failed") | |
| 154 | + | } | |
| 155 | + | } | |
| @@ -0,0 +1,155 @@ | |||||
| 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 | + | const DefaultSecretsDebounce = 60 * time.Second | |||
| 15 | + | ||||
| 16 | + | // SecretsChangedHandler handles secrets.changed events with per-domain | |||
| 17 | + | // debouncing. When secrets are modified rapidly (e.g. multiple | |||
| 18 | + | // "gordon secrets set" calls), the handler resets a timer on each | |||
| 19 | + | // event and only triggers a deploy once the domain is quiet for | |||
| 20 | + | // the configured delay. | |||
| 21 | + | type SecretsChangedHandler struct { | |||
| 22 | + | containerSvc in.ContainerService | |||
| 23 | + | configSvc in.ConfigService | |||
| 24 | + | ctx context.Context | |||
| 25 | + | cancel context.CancelFunc | |||
| 26 | + | debounceDelay time.Duration | |||
| 27 | + | ||||
| 28 | + | mu sync.Mutex | |||
| 29 | + | timers map[string]*time.Timer | |||
| 30 | + | generations map[string]uint64 | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewSecretsChangedHandler creates a new SecretsChangedHandler. | |||
| 34 | + | func NewSecretsChangedHandler( | |||
| 35 | + | ctx context.Context, | |||
| 36 | + | containerSvc in.ContainerService, | |||
| 37 | + | configSvc in.ConfigService, | |||
| 38 | + | debounceDelay time.Duration, | |||
| 39 | + | ) *SecretsChangedHandler { | |||
| 40 | + | ctx, cancel := context.WithCancel(ctx) | |||
| 41 | + | return &SecretsChangedHandler{ | |||
| 42 | + | containerSvc: containerSvc, | |||
| 43 | + | configSvc: configSvc, | |||
| 44 | + | ctx: ctx, | |||
| 45 | + | cancel: cancel, | |||
| 46 | + | debounceDelay: debounceDelay, | |||
| 47 | + | timers: make(map[string]*time.Timer), | |||
| 48 | + | generations: make(map[string]uint64), | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // Handle processes a secrets.changed event by resetting the debounce | |||
| 53 | + | // timer for the affected domain. Returns immediately - the deploy | |||
| 54 | + | // runs asynchronously when the timer fires. | |||
| 55 | + | // | |||
| 56 | + | // Uses the handler's long-lived context (h.ctx) rather than the event | |||
| 57 | + | // context because the debounced deploy fires after this method returns; | |||
| 58 | + | // using the event context would cause premature cancellation. | |||
| 59 | + | func (h *SecretsChangedHandler) Handle(_ context.Context, event domain.Event) error { | |||
| 60 | + | payload, ok := event.Data.(domain.SecretsChangedPayload) | |||
| 61 | + | if !ok { | |||
| 62 | + | return nil | |||
| 63 | + | } | |||
| 64 | + | if payload.Domain == "" { | |||
| 65 | + | return nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{ | |||
| 69 | + | zerowrap.FieldLayer: "usecase", | |||
| 70 | + | zerowrap.FieldHandler: "SecretsChangedHandler", | |||
| 71 | + | "domain": payload.Domain, | |||
| 72 | + | "operation": payload.Operation, | |||
| 73 | + | }) | |||
| 74 | + | log := zerowrap.FromCtx(ctx) | |||
| 75 | + | ||||
| 76 | + | log.Debug(). | |||
| 77 | + | Strs("keys", payload.Keys). | |||
| 78 | + | Dur("debounce", h.debounceDelay). | |||
| 79 | + | Msg("secrets changed, resetting debounce timer") | |||
| 80 | + | ||||
| 81 | + | h.resetTimer(ctx, payload.Domain) | |||
| 82 | + | return nil | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // CanHandle returns true for secrets.changed events. | |||
| 86 | + | func (h *SecretsChangedHandler) CanHandle(eventType domain.EventType) bool { | |||
| 87 | + | return eventType == domain.EventSecretsChanged | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Stop cancels the handler context and all pending timers. | |||
| 91 | + | // Call before graceful shutdown to prevent deploys during drain. | |||
| 92 | + | func (h *SecretsChangedHandler) Stop() { | |||
| 93 | + | h.cancel() | |||
| 94 | + | ||||
| 95 | + | h.mu.Lock() | |||
| 96 | + | defer h.mu.Unlock() | |||
| 97 | + | ||||
| 98 | + | for domainName, timer := range h.timers { | |||
| 99 | + | timer.Stop() | |||
| 100 | + | delete(h.timers, domainName) | |||
| 101 | + | delete(h.generations, domainName) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // resetTimer replaces any existing timer for the domain with a new one. | |||
| 106 | + | func (h *SecretsChangedHandler) resetTimer(ctx context.Context, domainName string) { | |||
| 107 | + | h.mu.Lock() | |||
| 108 | + | defer h.mu.Unlock() | |||
| 109 | + | ||||
| 110 | + | if existing, ok := h.timers[domainName]; ok { | |||
| 111 | + | existing.Stop() | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | h.generations[domainName]++ | |||
| 115 | + | gen := h.generations[domainName] | |||
| 116 | + | ||||
| 117 | + | h.timers[domainName] = time.AfterFunc(h.debounceDelay, func() { | |||
| 118 | + | h.fireDeploy(ctx, domainName, gen) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // fireDeploy looks up the route and triggers a deploy. | |||
| 123 | + | func (h *SecretsChangedHandler) fireDeploy(ctx context.Context, domainName string, generation uint64) { | |||
| 124 | + | // Bail if the handler has been stopped (shutdown in progress). | |||
| 125 | + | if h.ctx.Err() != nil { | |||
| 126 | + | return | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | log := zerowrap.FromCtx(ctx) | |||
| 130 | + | ||||
| 131 | + | h.mu.Lock() | |||
| 132 | + | if h.generations[domainName] != generation { | |||
| 133 | + | h.mu.Unlock() | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | delete(h.timers, domainName) | |||
| 137 | + | delete(h.generations, domainName) | |||
| 138 | + | h.mu.Unlock() | |||
| 139 | + | ||||
| 140 | + | route, err := h.configSvc.GetRoute(ctx, domainName) | |||
| 141 | + | if err != nil { | |||
| 142 | + | log.WrapErr(err, "failed to get route for secrets-changed deploy") | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | if route == nil { | |||
| 146 | + | log.Debug().Msg("no route configured for domain, skipping secrets-changed deploy") | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | log.Info().Str("image", route.Image).Msg("debounce fired, deploying after secrets change") | |||
| 151 | + | ||||
| 152 | + | if _, err := h.containerSvc.Deploy(ctx, *route); err != nil { | |||
| 153 | + | log.WrapErr(err, "secrets-changed deploy failed") | |||
| 154 | + | } | |||
| 155 | + | } | |||
A internal/usecase/container/secrets_handler_test.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 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 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestSecretsChangedHandler_CanHandle(t *testing.T) { | |
| 18 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 19 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 20 | + | ||
| 21 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond) | |
| 22 | + | defer h.Stop() | |
| 23 | + | ||
| 24 | + | assert.True(t, h.CanHandle(domain.EventSecretsChanged)) | |
| 25 | + | assert.False(t, h.CanHandle(domain.EventConfigReload)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) { | |
| 29 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 30 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 31 | + | ||
| 32 | + | debounceDelay := 200 * time.Millisecond | |
| 33 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 34 | + | defer h.Stop() | |
| 35 | + | ||
| 36 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |
| 37 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |
| 38 | + | ||
| 39 | + | var deployCalls atomic.Int32 | |
| 40 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) { | |
| 41 | + | deployCalls.Add(1) | |
| 42 | + | return &domain.Container{ID: "container-1"}, nil | |
| 43 | + | }).Once() | |
| 44 | + | ||
| 45 | + | event := domain.Event{ | |
| 46 | + | Type: domain.EventSecretsChanged, | |
| 47 | + | Data: domain.SecretsChangedPayload{ | |
| 48 | + | Domain: route.Domain, | |
| 49 | + | Operation: "set", | |
| 50 | + | Keys: []string{"A", "B"}, | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | for range 5 { | |
| 55 | + | assert.NoError(t, h.Handle(context.Background(), event)) | |
| 56 | + | time.Sleep(10 * time.Millisecond) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | require.Eventually(t, func() bool { | |
| 60 | + | return deployCalls.Load() == 1 | |
| 61 | + | }, debounceDelay+time.Second, 10*time.Millisecond, "expected exactly 1 deploy call after debounce") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestSecretsChangedHandler_IndependentDomains(t *testing.T) { | |
| 65 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 66 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 67 | + | ||
| 68 | + | debounceDelay := 100 * time.Millisecond | |
| 69 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 70 | + | defer h.Stop() | |
| 71 | + | ||
| 72 | + | routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"} | |
| 73 | + | routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"} | |
| 74 | + | ||
| 75 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once() | |
| 76 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once() | |
| 77 | + | ||
| 78 | + | var deployCalls atomic.Int32 | |
| 79 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) { | |
| 80 | + | deployCalls.Add(1) | |
| 81 | + | return &domain.Container{ID: "container-1"}, nil | |
| 82 | + | }).Once() | |
| 83 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) { | |
| 84 | + | deployCalls.Add(1) | |
| 85 | + | return &domain.Container{ID: "container-2"}, nil | |
| 86 | + | }).Once() | |
| 87 | + | ||
| 88 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 89 | + | Type: domain.EventSecretsChanged, | |
| 90 | + | Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}}, | |
| 91 | + | })) | |
| 92 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 93 | + | Type: domain.EventSecretsChanged, | |
| 94 | + | Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}}, | |
| 95 | + | })) | |
| 96 | + | ||
| 97 | + | require.Eventually(t, func() bool { | |
| 98 | + | return deployCalls.Load() == 2 | |
| 99 | + | }, debounceDelay+time.Second, 10*time.Millisecond, "expected 2 independent deploy calls") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) { | |
| 103 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 104 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 105 | + | ||
| 106 | + | debounceDelay := 100 * time.Millisecond | |
| 107 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 108 | + | defer h.Stop() | |
| 109 | + | ||
| 110 | + | configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once() | |
| 111 | + | ||
| 112 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 113 | + | Type: domain.EventSecretsChanged, | |
| 114 | + | Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}}, | |
| 115 | + | })) | |
| 116 | + | ||
| 117 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) { | |
| 121 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 122 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 123 | + | ||
| 124 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond) | |
| 125 | + | defer h.Stop() | |
| 126 | + | ||
| 127 | + | err := h.Handle(context.Background(), domain.Event{ | |
| 128 | + | Type: domain.EventSecretsChanged, | |
| 129 | + | Data: "wrong-payload", | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | assert.NoError(t, err) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) { | |
| 136 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 137 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 138 | + | ||
| 139 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |
| 140 | + | defer h.Stop() | |
| 141 | + | ||
| 142 | + | const domainName = "app.example.com" | |
| 143 | + | ||
| 144 | + | // White-box: directly set internal state to simulate a stale generation. | |
| 145 | + | // This exercises the generation-based debounce logic that prevents | |
| 146 | + | // already-fired timers from triggering duplicate deploys. | |
| 147 | + | h.mu.Lock() | |
| 148 | + | h.generations[domainName] = 2 | |
| 149 | + | h.timers[domainName] = time.NewTimer(time.Hour) | |
| 150 | + | h.mu.Unlock() | |
| 151 | + | ||
| 152 | + | staleDone := make(chan struct{}) | |
| 153 | + | go func() { | |
| 154 | + | defer close(staleDone) | |
| 155 | + | h.fireDeploy(context.Background(), domainName, 1) | |
| 156 | + | }() | |
| 157 | + | ||
| 158 | + | select { | |
| 159 | + | case <-staleDone: | |
| 160 | + | case <-time.After(2 * time.Second): | |
| 161 | + | t.Fatal("stale fireDeploy did not return") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | h.mu.Lock() | |
| 165 | + | _, timerExists := h.timers[domainName] | |
| 166 | + | gen := h.generations[domainName] | |
| 167 | + | h.mu.Unlock() | |
| 168 | + | ||
| 169 | + | assert.True(t, timerExists) | |
| 170 | + | assert.Equal(t, uint64(2), gen) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) { | |
| 174 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 175 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 176 | + | ||
| 177 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |
| 178 | + | defer h.Stop() | |
| 179 | + | ||
| 180 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |
| 181 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |
| 182 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once() | |
| 183 | + | ||
| 184 | + | h.mu.Lock() | |
| 185 | + | h.generations[route.Domain] = 1 | |
| 186 | + | h.timers[route.Domain] = time.NewTimer(time.Hour) | |
| 187 | + | h.mu.Unlock() | |
| 188 | + | ||
| 189 | + | h.fireDeploy(context.Background(), route.Domain, 1) | |
| 190 | + | ||
| 191 | + | h.mu.Lock() | |
| 192 | + | _, timerExists := h.timers[route.Domain] | |
| 193 | + | _, generationExists := h.generations[route.Domain] | |
| 194 | + | h.mu.Unlock() | |
| 195 | + | ||
| 196 | + | assert.False(t, timerExists) | |
| 197 | + | assert.False(t, generationExists) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) { | |
| 201 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 202 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 203 | + | ||
| 204 | + | debounceDelay := 50 * time.Millisecond | |
| 205 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |
| 206 | + | ||
| 207 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |
| 208 | + | Type: domain.EventSecretsChanged, | |
| 209 | + | Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}}, | |
| 210 | + | })) | |
| 211 | + | ||
| 212 | + | h.Stop() | |
| 213 | + | time.Sleep(debounceDelay + 50*time.Millisecond) | |
| 214 | + | } | |
| @@ -0,0 +1,214 @@ | |||||
| 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 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestSecretsChangedHandler_CanHandle(t *testing.T) { | |||
| 18 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 19 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 20 | + | ||||
| 21 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond) | |||
| 22 | + | defer h.Stop() | |||
| 23 | + | ||||
| 24 | + | assert.True(t, h.CanHandle(domain.EventSecretsChanged)) | |||
| 25 | + | assert.False(t, h.CanHandle(domain.EventConfigReload)) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) { | |||
| 29 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 30 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 31 | + | ||||
| 32 | + | debounceDelay := 200 * time.Millisecond | |||
| 33 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 34 | + | defer h.Stop() | |||
| 35 | + | ||||
| 36 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |||
| 37 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |||
| 38 | + | ||||
| 39 | + | var deployCalls atomic.Int32 | |||
| 40 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) { | |||
| 41 | + | deployCalls.Add(1) | |||
| 42 | + | return &domain.Container{ID: "container-1"}, nil | |||
| 43 | + | }).Once() | |||
| 44 | + | ||||
| 45 | + | event := domain.Event{ | |||
| 46 | + | Type: domain.EventSecretsChanged, | |||
| 47 | + | Data: domain.SecretsChangedPayload{ | |||
| 48 | + | Domain: route.Domain, | |||
| 49 | + | Operation: "set", | |||
| 50 | + | Keys: []string{"A", "B"}, | |||
| 51 | + | }, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | for range 5 { | |||
| 55 | + | assert.NoError(t, h.Handle(context.Background(), event)) | |||
| 56 | + | time.Sleep(10 * time.Millisecond) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | require.Eventually(t, func() bool { | |||
| 60 | + | return deployCalls.Load() == 1 | |||
| 61 | + | }, debounceDelay+time.Second, 10*time.Millisecond, "expected exactly 1 deploy call after debounce") | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestSecretsChangedHandler_IndependentDomains(t *testing.T) { | |||
| 65 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 66 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 67 | + | ||||
| 68 | + | debounceDelay := 100 * time.Millisecond | |||
| 69 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 70 | + | defer h.Stop() | |||
| 71 | + | ||||
| 72 | + | routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"} | |||
| 73 | + | routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"} | |||
| 74 | + | ||||
| 75 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once() | |||
| 76 | + | configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once() | |||
| 77 | + | ||||
| 78 | + | var deployCalls atomic.Int32 | |||
| 79 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) { | |||
| 80 | + | deployCalls.Add(1) | |||
| 81 | + | return &domain.Container{ID: "container-1"}, nil | |||
| 82 | + | }).Once() | |||
| 83 | + | containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) { | |||
| 84 | + | deployCalls.Add(1) | |||
| 85 | + | return &domain.Container{ID: "container-2"}, nil | |||
| 86 | + | }).Once() | |||
| 87 | + | ||||
| 88 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 89 | + | Type: domain.EventSecretsChanged, | |||
| 90 | + | Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}}, | |||
| 91 | + | })) | |||
| 92 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 93 | + | Type: domain.EventSecretsChanged, | |||
| 94 | + | Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}}, | |||
| 95 | + | })) | |||
| 96 | + | ||||
| 97 | + | require.Eventually(t, func() bool { | |||
| 98 | + | return deployCalls.Load() == 2 | |||
| 99 | + | }, debounceDelay+time.Second, 10*time.Millisecond, "expected 2 independent deploy calls") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) { | |||
| 103 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 104 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 105 | + | ||||
| 106 | + | debounceDelay := 100 * time.Millisecond | |||
| 107 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 108 | + | defer h.Stop() | |||
| 109 | + | ||||
| 110 | + | configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once() | |||
| 111 | + | ||||
| 112 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 113 | + | Type: domain.EventSecretsChanged, | |||
| 114 | + | Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}}, | |||
| 115 | + | })) | |||
| 116 | + | ||||
| 117 | + | time.Sleep(debounceDelay + 100*time.Millisecond) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) { | |||
| 121 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 122 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 123 | + | ||||
| 124 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond) | |||
| 125 | + | defer h.Stop() | |||
| 126 | + | ||||
| 127 | + | err := h.Handle(context.Background(), domain.Event{ | |||
| 128 | + | Type: domain.EventSecretsChanged, | |||
| 129 | + | Data: "wrong-payload", | |||
| 130 | + | }) | |||
| 131 | + | ||||
| 132 | + | assert.NoError(t, err) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) { | |||
| 136 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 137 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 138 | + | ||||
| 139 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |||
| 140 | + | defer h.Stop() | |||
| 141 | + | ||||
| 142 | + | const domainName = "app.example.com" | |||
| 143 | + | ||||
| 144 | + | // White-box: directly set internal state to simulate a stale generation. | |||
| 145 | + | // This exercises the generation-based debounce logic that prevents | |||
| 146 | + | // already-fired timers from triggering duplicate deploys. | |||
| 147 | + | h.mu.Lock() | |||
| 148 | + | h.generations[domainName] = 2 | |||
| 149 | + | h.timers[domainName] = time.NewTimer(time.Hour) | |||
| 150 | + | h.mu.Unlock() | |||
| 151 | + | ||||
| 152 | + | staleDone := make(chan struct{}) | |||
| 153 | + | go func() { | |||
| 154 | + | defer close(staleDone) | |||
| 155 | + | h.fireDeploy(context.Background(), domainName, 1) | |||
| 156 | + | }() | |||
| 157 | + | ||||
| 158 | + | select { | |||
| 159 | + | case <-staleDone: | |||
| 160 | + | case <-time.After(2 * time.Second): | |||
| 161 | + | t.Fatal("stale fireDeploy did not return") | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | h.mu.Lock() | |||
| 165 | + | _, timerExists := h.timers[domainName] | |||
| 166 | + | gen := h.generations[domainName] | |||
| 167 | + | h.mu.Unlock() | |||
| 168 | + | ||||
| 169 | + | assert.True(t, timerExists) | |||
| 170 | + | assert.Equal(t, uint64(2), gen) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) { | |||
| 174 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 175 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 176 | + | ||||
| 177 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second) | |||
| 178 | + | defer h.Stop() | |||
| 179 | + | ||||
| 180 | + | route := &domain.Route{Domain: "app.example.com", Image: "app:latest"} | |||
| 181 | + | configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once() | |||
| 182 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once() | |||
| 183 | + | ||||
| 184 | + | h.mu.Lock() | |||
| 185 | + | h.generations[route.Domain] = 1 | |||
| 186 | + | h.timers[route.Domain] = time.NewTimer(time.Hour) | |||
| 187 | + | h.mu.Unlock() | |||
| 188 | + | ||||
| 189 | + | h.fireDeploy(context.Background(), route.Domain, 1) | |||
| 190 | + | ||||
| 191 | + | h.mu.Lock() | |||
| 192 | + | _, timerExists := h.timers[route.Domain] | |||
| 193 | + | _, generationExists := h.generations[route.Domain] | |||
| 194 | + | h.mu.Unlock() | |||
| 195 | + | ||||
| 196 | + | assert.False(t, timerExists) | |||
| 197 | + | assert.False(t, generationExists) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) { | |||
| 201 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 202 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 203 | + | ||||
| 204 | + | debounceDelay := 50 * time.Millisecond | |||
| 205 | + | h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay) | |||
| 206 | + | ||||
| 207 | + | assert.NoError(t, h.Handle(context.Background(), domain.Event{ | |||
| 208 | + | Type: domain.EventSecretsChanged, | |||
| 209 | + | Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}}, | |||
| 210 | + | })) | |||
| 211 | + | ||||
| 212 | + | h.Stop() | |||
| 213 | + | time.Sleep(debounceDelay + 50*time.Millisecond) | |||
| 214 | + | } | |||
M internal/usecase/container/service.go +140 -30
| @@ -210,7 +210,7 @@ func (s *Service) deploymentContainerName(containerDomain string, existing *doma | |||
| 210 | 210 | } | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | - | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | |
| 213 | + | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, envHash string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | |
| 214 | 214 | containerName := s.deploymentContainerName(containerDomain, existing) | |
| 215 | 215 | ||
| 216 | 216 | return &domain.ContainerConfig{ | |
| @@ -223,6 +223,7 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||
| 223 | 223 | Hostname: containerDomain, | |
| 224 | 224 | Labels: map[string]string{ | |
| 225 | 225 | domain.LabelDomain: containerDomain, | |
| 226 | + | domain.LabelEnvHash: envHash, | |
| 226 | 227 | domain.LabelImage: image, | |
| 227 | 228 | domain.LabelManaged: "true", | |
| 228 | 229 | domain.LabelRoute: containerDomain, | |
| @@ -279,7 +280,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 279 | 280 | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | |
| 280 | 281 | } | |
| 281 | 282 | if existingForSkip.ImageID != "" { | |
| 282 | - | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip { | |
| 283 | + | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef, resources.envHash); skip { | |
| 283 | 284 | return container, nil | |
| 284 | 285 | } | |
| 285 | 286 | } | |
| @@ -351,7 +352,7 @@ func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, rout | |||
| 351 | 352 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | |
| 352 | 353 | // the second one arrives after the first has already completed; this avoids | |
| 353 | 354 | // a full redundant create-start-readiness cycle. | |
| 354 | - | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef string) (bool, *domain.Container) { | |
| 355 | + | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef, envHash string) (bool, *domain.Container) { | |
| 355 | 356 | log := zerowrap.FromCtx(ctx) | |
| 356 | 357 | ||
| 357 | 358 | newImageID, err := s.runtime.GetImageID(ctx, actualImageRef) | |
| @@ -370,6 +371,19 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont | |||
| 370 | 371 | return false, nil | |
| 371 | 372 | } | |
| 372 | 373 | ||
| 374 | + | if existing.Labels != nil { | |
| 375 | + | existingEnvHash, hasEnvHash := existing.Labels[domain.LabelEnvHash] | |
| 376 | + | if !hasEnvHash || existingEnvHash != envHash { | |
| 377 | + | log.Info(). | |
| 378 | + | Str("container_id", existing.ID). | |
| 379 | + | Bool("has_env_hash", hasEnvHash). | |
| 380 | + | Msg("env changed, proceeding with deploy despite same image") | |
| 381 | + | return false, nil | |
| 382 | + | } | |
| 383 | + | } else { | |
| 384 | + | return false, nil | |
| 385 | + | } | |
| 386 | + | ||
| 373 | 387 | log.Info(). | |
| 374 | 388 | Str("container_id", existing.ID). | |
| 375 | 389 | Str("image_id", newImageID). | |
| @@ -403,6 +417,7 @@ type deployResources struct { | |||
| 403 | 417 | actualImageRef string | |
| 404 | 418 | exposedPorts []int | |
| 405 | 419 | envVars []string | |
| 420 | + | envHash string | |
| 406 | 421 | volumes map[string]string | |
| 407 | 422 | } | |
| 408 | 423 | ||
| @@ -505,6 +520,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||
| 505 | 520 | if err != nil { | |
| 506 | 521 | return nil, err | |
| 507 | 522 | } | |
| 523 | + | envHash := hashEnvironment(envVars) | |
| 508 | 524 | ||
| 509 | 525 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | |
| 510 | 526 | if err != nil { | |
| @@ -516,6 +532,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||
| 516 | 532 | actualImageRef: actualImageRef, | |
| 517 | 533 | exposedPorts: exposedPorts, | |
| 518 | 534 | envVars: envVars, | |
| 535 | + | envHash: envHash, | |
| 519 | 536 | volumes: volumes, | |
| 520 | 537 | }, nil | |
| 521 | 538 | } | |
| @@ -532,6 +549,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 532 | 549 | resources.actualImageRef, | |
| 533 | 550 | resources.exposedPorts, | |
| 534 | 551 | resources.envVars, | |
| 552 | + | resources.envHash, | |
| 535 | 553 | resources.volumes, | |
| 536 | 554 | resources.networkName, | |
| 537 | 555 | existing, | |
| @@ -2031,37 +2049,28 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2031 | 2049 | ||
| 2032 | 2050 | // Check if already running (idempotent) | |
| 2033 | 2051 | // Try new name first, then fall back to legacy name for backwards compatibility | |
| 2034 | - | existingContainer := s.findContainerByName(ctx, containerName) | |
| 2035 | - | if existingContainer == nil { | |
| 2036 | - | // Try legacy naming scheme for backwards compatibility during upgrades | |
| 2037 | - | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |
| 2038 | - | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |
| 2039 | - | if existingContainer != nil { | |
| 2040 | - | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 2041 | - | // Remove the old container and recreate with new name | |
| 2042 | - | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 2043 | - | return log.WrapErr(err, "failed to stop legacy attachment container") | |
| 2044 | - | } | |
| 2045 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 2046 | - | return log.WrapErr(err, "failed to remove legacy attachment container") | |
| 2047 | - | } | |
| 2048 | - | existingContainer = nil // Clear so we create new one below | |
| 2049 | - | } | |
| 2050 | - | } | |
| 2051 | - | ||
| 2052 | - | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | |
| 2053 | - | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | |
| 2054 | - | return nil | |
| 2052 | + | existingContainer, err := s.resolveExistingAttachment(ctx, ownerDomain, serviceName) | |
| 2053 | + | if err != nil { | |
| 2054 | + | return err | |
| 2055 | 2055 | } | |
| 2056 | 2056 | ||
| 2057 | - | // Remove existing stopped container if present | |
| 2058 | 2057 | if existingContainer != nil { | |
| 2059 | - | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |
| 2060 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 2061 | - | return log.WrapErr(err, "failed to remove existing attachment container") | |
| 2058 | + | shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage) | |
| 2059 | + | if err != nil { | |
| 2060 | + | return err | |
| 2061 | + | } | |
| 2062 | + | if shouldSkip { | |
| 2063 | + | return nil | |
| 2064 | + | } | |
| 2065 | + | if existingContainer.Status == string(domain.ContainerStatusRunning) { | |
| 2066 | + | existingContainer = nil | |
| 2062 | 2067 | } | |
| 2063 | 2068 | } | |
| 2064 | 2069 | ||
| 2070 | + | if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil { | |
| 2071 | + | return err | |
| 2072 | + | } | |
| 2073 | + | ||
| 2065 | 2074 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | |
| 2066 | 2075 | ||
| 2067 | 2076 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | |
| @@ -2091,6 +2100,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2091 | 2100 | log.WrapErr(err, "failed to load environment for attachment") | |
| 2092 | 2101 | envVars = []string{} | |
| 2093 | 2102 | } | |
| 2103 | + | envHash := hashEnvironment(envVars) | |
| 2094 | 2104 | ||
| 2095 | 2105 | // Create container with attachment labels (use actual ref, track original in labels) | |
| 2096 | 2106 | config := &domain.ContainerConfig{ | |
| @@ -2105,6 +2115,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2105 | 2115 | domain.LabelManaged: "true", | |
| 2106 | 2116 | domain.LabelAttachment: "true", | |
| 2107 | 2117 | domain.LabelAttachedTo: ownerDomain, | |
| 2118 | + | domain.LabelEnvHash: envHash, | |
| 2108 | 2119 | domain.LabelImage: serviceImage, | |
| 2109 | 2120 | }, | |
| 2110 | 2121 | } | |
| @@ -2132,6 +2143,99 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2132 | 2143 | return nil | |
| 2133 | 2144 | } | |
| 2134 | 2145 | ||
| 2146 | + | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |
| 2147 | + | currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage)) | |
| 2148 | + | if err != nil { | |
| 2149 | + | return false, err | |
| 2150 | + | } | |
| 2151 | + | ||
| 2152 | + | currentHash := hashEnvironment(currentEnv) | |
| 2153 | + | existingHash, ok := existing.Labels[domain.LabelEnvHash] | |
| 2154 | + | if ok && existingHash == currentHash { | |
| 2155 | + | return false, nil | |
| 2156 | + | } | |
| 2157 | + | ||
| 2158 | + | return true, nil | |
| 2159 | + | } | |
| 2160 | + | ||
| 2161 | + | func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |
| 2162 | + | if existing.Status != string(domain.ContainerStatusRunning) { | |
| 2163 | + | return false, nil | |
| 2164 | + | } | |
| 2165 | + | ||
| 2166 | + | log := zerowrap.FromCtx(ctx) | |
| 2167 | + | ||
| 2168 | + | // Check image drift | |
| 2169 | + | existingImage := existing.Labels[domain.LabelImage] | |
| 2170 | + | imageDrifted := existingImage != "" && existingImage != serviceImage | |
| 2171 | + | ||
| 2172 | + | // Check env drift | |
| 2173 | + | envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage) | |
| 2174 | + | if err != nil { | |
| 2175 | + | return false, log.WrapErr(err, "failed to check attachment env drift") | |
| 2176 | + | } | |
| 2177 | + | ||
| 2178 | + | if !envDrifted && !imageDrifted { | |
| 2179 | + | log.Debug().Str("container_name", containerName).Msg("attachment already running with current env and image, skipping") | |
| 2180 | + | return true, nil | |
| 2181 | + | } | |
| 2182 | + | ||
| 2183 | + | if imageDrifted { | |
| 2184 | + | log.Info().Str("container_name", containerName).Str("existing_image", existingImage).Str("desired_image", serviceImage).Msg("attachment image changed, recreating") | |
| 2185 | + | } | |
| 2186 | + | if envDrifted { | |
| 2187 | + | log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating") | |
| 2188 | + | } | |
| 2189 | + | ||
| 2190 | + | if err := s.runtime.StopContainer(ctx, existing.ID); err != nil { | |
| 2191 | + | return false, log.WrapErr(err, "failed to stop attachment for drift update") | |
| 2192 | + | } | |
| 2193 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 2194 | + | return false, log.WrapErr(err, "failed to remove attachment for drift update") | |
| 2195 | + | } | |
| 2196 | + | ||
| 2197 | + | return false, nil | |
| 2198 | + | } | |
| 2199 | + | ||
| 2200 | + | func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error { | |
| 2201 | + | if existing == nil { | |
| 2202 | + | return nil | |
| 2203 | + | } | |
| 2204 | + | ||
| 2205 | + | log := zerowrap.FromCtx(ctx) | |
| 2206 | + | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |
| 2207 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 2208 | + | return log.WrapErr(err, "failed to remove existing attachment container") | |
| 2209 | + | } | |
| 2210 | + | ||
| 2211 | + | return nil | |
| 2212 | + | } | |
| 2213 | + | ||
| 2214 | + | func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, serviceName string) (*domain.Container, error) { | |
| 2215 | + | log := zerowrap.FromCtx(ctx) | |
| 2216 | + | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | |
| 2217 | + | existingContainer := s.findContainerByName(ctx, containerName) | |
| 2218 | + | if existingContainer != nil { | |
| 2219 | + | return existingContainer, nil | |
| 2220 | + | } | |
| 2221 | + | ||
| 2222 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |
| 2223 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |
| 2224 | + | if existingContainer == nil { | |
| 2225 | + | return nil, nil | |
| 2226 | + | } | |
| 2227 | + | ||
| 2228 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 2229 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 2230 | + | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | |
| 2231 | + | } | |
| 2232 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 2233 | + | return nil, log.WrapErr(err, "failed to remove legacy attachment container") | |
| 2234 | + | } | |
| 2235 | + | ||
| 2236 | + | return nil, nil | |
| 2237 | + | } | |
| 2238 | + | ||
| 2135 | 2239 | // Utility functions | |
| 2136 | 2240 | ||
| 2137 | 2241 | func normalizeImageRef(image string) string { | |
| @@ -2192,9 +2296,15 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |||
| 2192 | 2296 | } | |
| 2193 | 2297 | } | |
| 2194 | 2298 | ||
| 2299 | + | keys := make([]string, 0, len(envMap)) | |
| 2300 | + | for k := range envMap { | |
| 2301 | + | keys = append(keys, k) | |
| 2302 | + | } | |
| 2303 | + | slices.Sort(keys) | |
| 2304 | + | ||
| 2195 | 2305 | result := make([]string, 0, len(envMap)) | |
| 2196 | - | for k, v := range envMap { | |
| 2197 | - | result = append(result, k+"="+v) | |
| 2306 | + | for _, k := range keys { | |
| 2307 | + | result = append(result, k+"="+envMap[k]) | |
| 2198 | 2308 | } | |
| 2199 | 2309 | ||
| 2200 | 2310 | return result | |
| @@ -210,7 +210,7 @@ func (s *Service) deploymentContainerName(containerDomain string, existing *doma | |||||
| 210 | } | 210 | } | ||
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| 213 | - | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | 213 | + | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, envHash string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { |
| 214 | containerName := s.deploymentContainerName(containerDomain, existing) | 214 | containerName := s.deploymentContainerName(containerDomain, existing) | ||
| 215 | 215 | ||||
| 216 | return &domain.ContainerConfig{ | 216 | return &domain.ContainerConfig{ | ||
| @@ -223,6 +223,7 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||||
| 223 | Hostname: containerDomain, | 223 | Hostname: containerDomain, | ||
| 224 | Labels: map[string]string{ | 224 | Labels: map[string]string{ | ||
| 225 | domain.LabelDomain: containerDomain, | 225 | domain.LabelDomain: containerDomain, | ||
| 226 | + | domain.LabelEnvHash: envHash, | |||
| 226 | domain.LabelImage: image, | 227 | domain.LabelImage: image, | ||
| 227 | domain.LabelManaged: "true", | 228 | domain.LabelManaged: "true", | ||
| 228 | domain.LabelRoute: containerDomain, | 229 | domain.LabelRoute: containerDomain, | ||
| @@ -279,7 +280,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 279 | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | 280 | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | ||
| 280 | } | 281 | } | ||
| 281 | if existingForSkip.ImageID != "" { | 282 | if existingForSkip.ImageID != "" { | ||
| 282 | - | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip { | 283 | + | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef, resources.envHash); skip { |
| 283 | return container, nil | 284 | return container, nil | ||
| 284 | } | 285 | } | ||
| 285 | } | 286 | } | ||
| @@ -351,7 +352,7 @@ func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, rout | |||||
| 351 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | 352 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | ||
| 352 | // the second one arrives after the first has already completed; this avoids | 353 | // the second one arrives after the first has already completed; this avoids | ||
| 353 | // a full redundant create-start-readiness cycle. | 354 | // a full redundant create-start-readiness cycle. | ||
| 354 | - | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef string) (bool, *domain.Container) { | 355 | + | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef, envHash string) (bool, *domain.Container) { |
| 355 | log := zerowrap.FromCtx(ctx) | 356 | log := zerowrap.FromCtx(ctx) | ||
| 356 | 357 | ||||
| 357 | newImageID, err := s.runtime.GetImageID(ctx, actualImageRef) | 358 | newImageID, err := s.runtime.GetImageID(ctx, actualImageRef) | ||
| @@ -370,6 +371,19 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont | |||||
| 370 | return false, nil | 371 | return false, nil | ||
| 371 | } | 372 | } | ||
| 372 | 373 | ||||
| 374 | + | if existing.Labels != nil { | |||
| 375 | + | existingEnvHash, hasEnvHash := existing.Labels[domain.LabelEnvHash] | |||
| 376 | + | if !hasEnvHash || existingEnvHash != envHash { | |||
| 377 | + | log.Info(). | |||
| 378 | + | Str("container_id", existing.ID). | |||
| 379 | + | Bool("has_env_hash", hasEnvHash). | |||
| 380 | + | Msg("env changed, proceeding with deploy despite same image") | |||
| 381 | + | return false, nil | |||
| 382 | + | } | |||
| 383 | + | } else { | |||
| 384 | + | return false, nil | |||
| 385 | + | } | |||
| 386 | + | ||||
| 373 | log.Info(). | 387 | log.Info(). | ||
| 374 | Str("container_id", existing.ID). | 388 | Str("container_id", existing.ID). | ||
| 375 | Str("image_id", newImageID). | 389 | Str("image_id", newImageID). | ||
| @@ -403,6 +417,7 @@ type deployResources struct { | |||||
| 403 | actualImageRef string | 417 | actualImageRef string | ||
| 404 | exposedPorts []int | 418 | exposedPorts []int | ||
| 405 | envVars []string | 419 | envVars []string | ||
| 420 | + | envHash string | |||
| 406 | volumes map[string]string | 421 | volumes map[string]string | ||
| 407 | } | 422 | } | ||
| 408 | 423 | ||||
| @@ -505,6 +520,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||||
| 505 | if err != nil { | 520 | if err != nil { | ||
| 506 | return nil, err | 521 | return nil, err | ||
| 507 | } | 522 | } | ||
| 523 | + | envHash := hashEnvironment(envVars) | |||
| 508 | 524 | ||||
| 509 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | 525 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | ||
| 510 | if err != nil { | 526 | if err != nil { | ||
| @@ -516,6 +532,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||||
| 516 | actualImageRef: actualImageRef, | 532 | actualImageRef: actualImageRef, | ||
| 517 | exposedPorts: exposedPorts, | 533 | exposedPorts: exposedPorts, | ||
| 518 | envVars: envVars, | 534 | envVars: envVars, | ||
| 535 | + | envHash: envHash, | |||
| 519 | volumes: volumes, | 536 | volumes: volumes, | ||
| 520 | }, nil | 537 | }, nil | ||
| 521 | } | 538 | } | ||
| @@ -532,6 +549,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 532 | resources.actualImageRef, | 549 | resources.actualImageRef, | ||
| 533 | resources.exposedPorts, | 550 | resources.exposedPorts, | ||
| 534 | resources.envVars, | 551 | resources.envVars, | ||
| 552 | + | resources.envHash, | |||
| 535 | resources.volumes, | 553 | resources.volumes, | ||
| 536 | resources.networkName, | 554 | resources.networkName, | ||
| 537 | existing, | 555 | existing, | ||
| @@ -2031,37 +2049,28 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2031 | 2049 | ||||
| 2032 | // Check if already running (idempotent) | 2050 | // Check if already running (idempotent) | ||
| 2033 | // Try new name first, then fall back to legacy name for backwards compatibility | 2051 | // Try new name first, then fall back to legacy name for backwards compatibility | ||
| 2034 | - | existingContainer := s.findContainerByName(ctx, containerName) | 2052 | + | existingContainer, err := s.resolveExistingAttachment(ctx, ownerDomain, serviceName) |
| 2035 | - | if existingContainer == nil { | 2053 | + | if err != nil { |
| 2036 | - | // Try legacy naming scheme for backwards compatibility during upgrades | 2054 | + | return err |
| 2037 | - | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |||
| 2038 | - | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |||
| 2039 | - | if existingContainer != nil { | |||
| 2040 | - | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |||
| 2041 | - | // Remove the old container and recreate with new name | |||
| 2042 | - | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |||
| 2043 | - | return log.WrapErr(err, "failed to stop legacy attachment container") | |||
| 2044 | - | } | |||
| 2045 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |||
| 2046 | - | return log.WrapErr(err, "failed to remove legacy attachment container") | |||
| 2047 | - | } | |||
| 2048 | - | existingContainer = nil // Clear so we create new one below | |||
| 2049 | - | } | |||
| 2050 | - | } | |||
| 2051 | - | ||||
| 2052 | - | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | |||
| 2053 | - | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | |||
| 2054 | - | return nil | |||
| 2055 | } | 2055 | } | ||
| 2056 | 2056 | ||||
| 2057 | - | // Remove existing stopped container if present | |||
| 2058 | if existingContainer != nil { | 2057 | if existingContainer != nil { | ||
| 2059 | - | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | 2058 | + | shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage) |
| 2060 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | 2059 | + | if err != nil { |
| 2061 | - | return log.WrapErr(err, "failed to remove existing attachment container") | 2060 | + | return err |
| 2061 | + | } | |||
| 2062 | + | if shouldSkip { | |||
| 2063 | + | return nil | |||
| 2064 | + | } | |||
| 2065 | + | if existingContainer.Status == string(domain.ContainerStatusRunning) { | |||
| 2066 | + | existingContainer = nil | |||
| 2062 | } | 2067 | } | ||
| 2063 | } | 2068 | } | ||
| 2064 | 2069 | ||||
| 2070 | + | if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil { | |||
| 2071 | + | return err | |||
| 2072 | + | } | |||
| 2073 | + | ||||
| 2065 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | 2074 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | ||
| 2066 | 2075 | ||||
| 2067 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | 2076 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | ||
| @@ -2091,6 +2100,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2091 | log.WrapErr(err, "failed to load environment for attachment") | 2100 | log.WrapErr(err, "failed to load environment for attachment") | ||
| 2092 | envVars = []string{} | 2101 | envVars = []string{} | ||
| 2093 | } | 2102 | } | ||
| 2103 | + | envHash := hashEnvironment(envVars) | |||
| 2094 | 2104 | ||||
| 2095 | // Create container with attachment labels (use actual ref, track original in labels) | 2105 | // Create container with attachment labels (use actual ref, track original in labels) | ||
| 2096 | config := &domain.ContainerConfig{ | 2106 | config := &domain.ContainerConfig{ | ||
| @@ -2105,6 +2115,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2105 | domain.LabelManaged: "true", | 2115 | domain.LabelManaged: "true", | ||
| 2106 | domain.LabelAttachment: "true", | 2116 | domain.LabelAttachment: "true", | ||
| 2107 | domain.LabelAttachedTo: ownerDomain, | 2117 | domain.LabelAttachedTo: ownerDomain, | ||
| 2118 | + | domain.LabelEnvHash: envHash, | |||
| 2108 | domain.LabelImage: serviceImage, | 2119 | domain.LabelImage: serviceImage, | ||
| 2109 | }, | 2120 | }, | ||
| 2110 | } | 2121 | } | ||
| @@ -2132,6 +2143,99 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2132 | return nil | 2143 | return nil | ||
| 2133 | } | 2144 | } | ||
| 2134 | 2145 | ||||
| 2146 | + | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |||
| 2147 | + | currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage)) | |||
| 2148 | + | if err != nil { | |||
| 2149 | + | return false, err | |||
| 2150 | + | } | |||
| 2151 | + | ||||
| 2152 | + | currentHash := hashEnvironment(currentEnv) | |||
| 2153 | + | existingHash, ok := existing.Labels[domain.LabelEnvHash] | |||
| 2154 | + | if ok && existingHash == currentHash { | |||
| 2155 | + | return false, nil | |||
| 2156 | + | } | |||
| 2157 | + | ||||
| 2158 | + | return true, nil | |||
| 2159 | + | } | |||
| 2160 | + | ||||
| 2161 | + | func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |||
| 2162 | + | if existing.Status != string(domain.ContainerStatusRunning) { | |||
| 2163 | + | return false, nil | |||
| 2164 | + | } | |||
| 2165 | + | ||||
| 2166 | + | log := zerowrap.FromCtx(ctx) | |||
| 2167 | + | ||||
| 2168 | + | // Check image drift | |||
| 2169 | + | existingImage := existing.Labels[domain.LabelImage] | |||
| 2170 | + | imageDrifted := existingImage != "" && existingImage != serviceImage | |||
| 2171 | + | ||||
| 2172 | + | // Check env drift | |||
| 2173 | + | envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage) | |||
| 2174 | + | if err != nil { | |||
| 2175 | + | return false, log.WrapErr(err, "failed to check attachment env drift") | |||
| 2176 | + | } | |||
| 2177 | + | ||||
| 2178 | + | if !envDrifted && !imageDrifted { | |||
| 2179 | + | log.Debug().Str("container_name", containerName).Msg("attachment already running with current env and image, skipping") | |||
| 2180 | + | return true, nil | |||
| 2181 | + | } | |||
| 2182 | + | ||||
| 2183 | + | if imageDrifted { | |||
| 2184 | + | log.Info().Str("container_name", containerName).Str("existing_image", existingImage).Str("desired_image", serviceImage).Msg("attachment image changed, recreating") | |||
| 2185 | + | } | |||
| 2186 | + | if envDrifted { | |||
| 2187 | + | log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating") | |||
| 2188 | + | } | |||
| 2189 | + | ||||
| 2190 | + | if err := s.runtime.StopContainer(ctx, existing.ID); err != nil { | |||
| 2191 | + | return false, log.WrapErr(err, "failed to stop attachment for drift update") | |||
| 2192 | + | } | |||
| 2193 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |||
| 2194 | + | return false, log.WrapErr(err, "failed to remove attachment for drift update") | |||
| 2195 | + | } | |||
| 2196 | + | ||||
| 2197 | + | return false, nil | |||
| 2198 | + | } | |||
| 2199 | + | ||||
| 2200 | + | func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error { | |||
| 2201 | + | if existing == nil { | |||
| 2202 | + | return nil | |||
| 2203 | + | } | |||
| 2204 | + | ||||
| 2205 | + | log := zerowrap.FromCtx(ctx) | |||
| 2206 | + | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |||
| 2207 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |||
| 2208 | + | return log.WrapErr(err, "failed to remove existing attachment container") | |||
| 2209 | + | } | |||
| 2210 | + | ||||
| 2211 | + | return nil | |||
| 2212 | + | } | |||
| 2213 | + | ||||
| 2214 | + | func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, serviceName string) (*domain.Container, error) { | |||
| 2215 | + | log := zerowrap.FromCtx(ctx) | |||
| 2216 | + | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | |||
| 2217 | + | existingContainer := s.findContainerByName(ctx, containerName) | |||
| 2218 | + | if existingContainer != nil { | |||
| 2219 | + | return existingContainer, nil | |||
| 2220 | + | } | |||
| 2221 | + | ||||
| 2222 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |||
| 2223 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |||
| 2224 | + | if existingContainer == nil { | |||
| 2225 | + | return nil, nil | |||
| 2226 | + | } | |||
| 2227 | + | ||||
| 2228 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |||
| 2229 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |||
| 2230 | + | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | |||
| 2231 | + | } | |||
| 2232 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |||
| 2233 | + | return nil, log.WrapErr(err, "failed to remove legacy attachment container") | |||
| 2234 | + | } | |||
| 2235 | + | ||||
| 2236 | + | return nil, nil | |||
| 2237 | + | } | |||
| 2238 | + | ||||
| 2135 | // Utility functions | 2239 | // Utility functions | ||
| 2136 | 2240 | ||||
| 2137 | func normalizeImageRef(image string) string { | 2241 | func normalizeImageRef(image string) string { | ||
| @@ -2192,9 +2296,15 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |||||
| 2192 | } | 2296 | } | ||
| 2193 | } | 2297 | } | ||
| 2194 | 2298 | ||||
| 2299 | + | keys := make([]string, 0, len(envMap)) | |||
| 2300 | + | for k := range envMap { | |||
| 2301 | + | keys = append(keys, k) | |||
| 2302 | + | } | |||
| 2303 | + | slices.Sort(keys) | |||
| 2304 | + | ||||
| 2195 | result := make([]string, 0, len(envMap)) | 2305 | result := make([]string, 0, len(envMap)) | ||
| 2196 | - | for k, v := range envMap { | 2306 | + | for _, k := range keys { |
| 2197 | - | result = append(result, k+"="+v) | 2307 | + | result = append(result, k+"="+envMap[k]) |
| 2198 | } | 2308 | } | ||
| 2199 | 2309 | ||||
| 2200 | return result | 2310 | return result | ||
M internal/usecase/container/service_test.go +190 -0
| @@ -606,6 +606,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 606 | 606 | config := testMinDelayConfig() | |
| 607 | 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 608 | 608 | ctx := testContext() | |
| 609 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 609 | 610 | ||
| 610 | 611 | // Existing container has ImageID, but GetImageID will fail | |
| 611 | 612 | existingContainer := &domain.Container{ | |
| @@ -614,6 +615,9 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 614 | 615 | Image: "myapp:latest", | |
| 615 | 616 | ImageID: "sha256:abc123", | |
| 616 | 617 | Status: "running", | |
| 618 | + | Labels: map[string]string{ | |
| 619 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 620 | + | }, | |
| 617 | 621 | } | |
| 618 | 622 | svc.containers["test.example.com"] = existingContainer | |
| 619 | 623 | ||
| @@ -2378,6 +2382,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2378 | 2382 | } | |
| 2379 | 2383 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2380 | 2384 | ctx := testContext() | |
| 2385 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2381 | 2386 | ||
| 2382 | 2387 | // Pre-populate with existing container that has an ImageID (set by InspectContainer). | |
| 2383 | 2388 | // This simulates the first deploy (from image.pushed event) having already completed. | |
| @@ -2387,6 +2392,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2387 | 2392 | Image: "myapp:latest", | |
| 2388 | 2393 | ImageID: "sha256:abc123", | |
| 2389 | 2394 | Status: "running", | |
| 2395 | + | Labels: map[string]string{ | |
| 2396 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 2397 | + | }, | |
| 2390 | 2398 | } | |
| 2391 | 2399 | svc.containers["test.example.com"] = existingContainer | |
| 2392 | 2400 | ||
| @@ -2432,6 +2440,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||
| 2432 | 2440 | } | |
| 2433 | 2441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2434 | 2442 | ctx := testContext() | |
| 2443 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2435 | 2444 | ||
| 2436 | 2445 | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | |
| 2437 | 2446 | existingContainer := &domain.Container{ | |
| @@ -2439,6 +2448,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||
| 2439 | 2448 | Name: "gordon-test.example.com", | |
| 2440 | 2449 | Image: "myapp:latest", | |
| 2441 | 2450 | Status: "running", | |
| 2451 | + | Labels: map[string]string{ | |
| 2452 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 2453 | + | }, | |
| 2442 | 2454 | } | |
| 2443 | 2455 | svc.containers["test.example.com"] = existingContainer | |
| 2444 | 2456 | ||
| @@ -2583,6 +2595,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2583 | 2595 | config := testMinDelayConfig() | |
| 2584 | 2596 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2585 | 2597 | ctx := testContext() | |
| 2598 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2586 | 2599 | ||
| 2587 | 2600 | // Existing container has same image ID but is NOT running (crashed) | |
| 2588 | 2601 | existingContainer := &domain.Container{ | |
| @@ -2591,6 +2604,9 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2591 | 2604 | Image: "myapp:latest", | |
| 2592 | 2605 | ImageID: "sha256:abc123", | |
| 2593 | 2606 | Status: "exited", | |
| 2607 | + | Labels: map[string]string{ | |
| 2608 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 2609 | + | }, | |
| 2594 | 2610 | } | |
| 2595 | 2611 | svc.containers["test.example.com"] = existingContainer | |
| 2596 | 2612 | ||
| @@ -2632,6 +2648,180 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2632 | 2648 | assert.Equal(t, "new-container", result.ID) | |
| 2633 | 2649 | } | |
| 2634 | 2650 | ||
| 2651 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) { | |
| 2652 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2653 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2654 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2655 | + | ||
| 2656 | + | config := testMinDelayConfig() | |
| 2657 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2658 | + | ctx := testContext() | |
| 2659 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2660 | + | ||
| 2661 | + | existingContainer := &domain.Container{ | |
| 2662 | + | ID: "existing-container", | |
| 2663 | + | Name: "gordon-test.example.com", | |
| 2664 | + | Image: "myapp:latest", | |
| 2665 | + | ImageID: "sha256:abc123", | |
| 2666 | + | Status: "running", | |
| 2667 | + | Labels: map[string]string{ | |
| 2668 | + | domain.LabelEnvHash: "different-hash", | |
| 2669 | + | }, | |
| 2670 | + | } | |
| 2671 | + | svc.containers["test.example.com"] = existingContainer | |
| 2672 | + | ||
| 2673 | + | route := domain.Route{ | |
| 2674 | + | Domain: "test.example.com", | |
| 2675 | + | Image: "myapp:latest", | |
| 2676 | + | } | |
| 2677 | + | ||
| 2678 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2679 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 2680 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| 2681 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2682 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 2683 | + | ||
| 2684 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |
| 2685 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |
| 2686 | + | ||
| 2687 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2688 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2689 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |
| 2690 | + | })).Return(newContainer, nil) | |
| 2691 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2692 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2693 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2694 | + | ID: "new-container", Status: "running", | |
| 2695 | + | }, nil) | |
| 2696 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2697 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |
| 2698 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |
| 2699 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2700 | + | ||
| 2701 | + | result, err := svc.Deploy(ctx, route) | |
| 2702 | + | ||
| 2703 | + | assert.NoError(t, err) | |
| 2704 | + | assert.Equal(t, "new-container", result.ID) | |
| 2705 | + | } | |
| 2706 | + | ||
| 2707 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) { | |
| 2708 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2709 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2710 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2711 | + | ||
| 2712 | + | config := testMinDelayConfig() | |
| 2713 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2714 | + | ctx := testContext() | |
| 2715 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2716 | + | ||
| 2717 | + | existingContainer := &domain.Container{ | |
| 2718 | + | ID: "existing-container", | |
| 2719 | + | Name: "gordon-test.example.com", | |
| 2720 | + | Image: "myapp:latest", | |
| 2721 | + | ImageID: "sha256:abc123", | |
| 2722 | + | Status: "running", | |
| 2723 | + | Labels: map[string]string{}, | |
| 2724 | + | } | |
| 2725 | + | svc.containers["test.example.com"] = existingContainer | |
| 2726 | + | ||
| 2727 | + | route := domain.Route{ | |
| 2728 | + | Domain: "test.example.com", | |
| 2729 | + | Image: "myapp:latest", | |
| 2730 | + | } | |
| 2731 | + | ||
| 2732 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2733 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 2734 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| 2735 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2736 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 2737 | + | ||
| 2738 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |
| 2739 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |
| 2740 | + | ||
| 2741 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2742 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2743 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |
| 2744 | + | })).Return(newContainer, nil) | |
| 2745 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2746 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2747 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2748 | + | ID: "new-container", Status: "running", | |
| 2749 | + | }, nil) | |
| 2750 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2751 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |
| 2752 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |
| 2753 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2754 | + | ||
| 2755 | + | result, err := svc.Deploy(ctx, route) | |
| 2756 | + | ||
| 2757 | + | assert.NoError(t, err) | |
| 2758 | + | assert.Equal(t, "new-container", result.ID) | |
| 2759 | + | } | |
| 2760 | + | ||
| 2761 | + | func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) { | |
| 2762 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2763 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2764 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2765 | + | ||
| 2766 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |
| 2767 | + | ctx := testContext() | |
| 2768 | + | imageRef := svc.buildImageRef("postgres:16") | |
| 2769 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |
| 2770 | + | envHash := hashEnvironment(envVars) | |
| 2771 | + | ||
| 2772 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |
| 2773 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |
| 2774 | + | ||
| 2775 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |
| 2776 | + | Labels: map[string]string{domain.LabelEnvHash: envHash}, | |
| 2777 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |
| 2778 | + | ||
| 2779 | + | assert.NoError(t, err) | |
| 2780 | + | assert.False(t, drifted) | |
| 2781 | + | } | |
| 2782 | + | ||
| 2783 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) { | |
| 2784 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2785 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2786 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2787 | + | ||
| 2788 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |
| 2789 | + | ctx := testContext() | |
| 2790 | + | imageRef := svc.buildImageRef("postgres:16") | |
| 2791 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |
| 2792 | + | ||
| 2793 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |
| 2794 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |
| 2795 | + | ||
| 2796 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |
| 2797 | + | Labels: map[string]string{domain.LabelEnvHash: "stale-hash"}, | |
| 2798 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |
| 2799 | + | ||
| 2800 | + | assert.NoError(t, err) | |
| 2801 | + | assert.True(t, drifted) | |
| 2802 | + | } | |
| 2803 | + | ||
| 2804 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) { | |
| 2805 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2806 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2807 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2808 | + | ||
| 2809 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |
| 2810 | + | ctx := testContext() | |
| 2811 | + | imageRef := svc.buildImageRef("postgres:16") | |
| 2812 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |
| 2813 | + | ||
| 2814 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |
| 2815 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |
| 2816 | + | ||
| 2817 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |
| 2818 | + | Labels: map[string]string{}, | |
| 2819 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |
| 2820 | + | ||
| 2821 | + | assert.NoError(t, err) | |
| 2822 | + | assert.True(t, drifted) | |
| 2823 | + | } | |
| 2824 | + | ||
| 2635 | 2825 | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | |
| 2636 | 2826 | // cleanup never stops a running container with the canonical name. Only stopped | |
| 2637 | 2827 | // canonical containers and temp containers (-new/-next) should be cleaned up. | |
| @@ -606,6 +606,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||||
| 606 | config := testMinDelayConfig() | 606 | config := testMinDelayConfig() | ||
| 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 608 | ctx := testContext() | 608 | ctx := testContext() | ||
| 609 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 609 | 610 | ||||
| 610 | // Existing container has ImageID, but GetImageID will fail | 611 | // Existing container has ImageID, but GetImageID will fail | ||
| 611 | existingContainer := &domain.Container{ | 612 | existingContainer := &domain.Container{ | ||
| @@ -614,6 +615,9 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||||
| 614 | Image: "myapp:latest", | 615 | Image: "myapp:latest", | ||
| 615 | ImageID: "sha256:abc123", | 616 | ImageID: "sha256:abc123", | ||
| 616 | Status: "running", | 617 | Status: "running", | ||
| 618 | + | Labels: map[string]string{ | |||
| 619 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 620 | + | }, | |||
| 617 | } | 621 | } | ||
| 618 | svc.containers["test.example.com"] = existingContainer | 622 | svc.containers["test.example.com"] = existingContainer | ||
| 619 | 623 | ||||
| @@ -2378,6 +2382,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2378 | } | 2382 | } | ||
| 2379 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2383 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2380 | ctx := testContext() | 2384 | ctx := testContext() | ||
| 2385 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2381 | 2386 | ||||
| 2382 | // Pre-populate with existing container that has an ImageID (set by InspectContainer). | 2387 | // Pre-populate with existing container that has an ImageID (set by InspectContainer). | ||
| 2383 | // This simulates the first deploy (from image.pushed event) having already completed. | 2388 | // This simulates the first deploy (from image.pushed event) having already completed. | ||
| @@ -2387,6 +2392,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2387 | Image: "myapp:latest", | 2392 | Image: "myapp:latest", | ||
| 2388 | ImageID: "sha256:abc123", | 2393 | ImageID: "sha256:abc123", | ||
| 2389 | Status: "running", | 2394 | Status: "running", | ||
| 2395 | + | Labels: map[string]string{ | |||
| 2396 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 2397 | + | }, | |||
| 2390 | } | 2398 | } | ||
| 2391 | svc.containers["test.example.com"] = existingContainer | 2399 | svc.containers["test.example.com"] = existingContainer | ||
| 2392 | 2400 | ||||
| @@ -2432,6 +2440,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||||
| 2432 | } | 2440 | } | ||
| 2433 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2434 | ctx := testContext() | 2442 | ctx := testContext() | ||
| 2443 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2435 | 2444 | ||||
| 2436 | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | 2445 | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | ||
| 2437 | existingContainer := &domain.Container{ | 2446 | existingContainer := &domain.Container{ | ||
| @@ -2439,6 +2448,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||||
| 2439 | Name: "gordon-test.example.com", | 2448 | Name: "gordon-test.example.com", | ||
| 2440 | Image: "myapp:latest", | 2449 | Image: "myapp:latest", | ||
| 2441 | Status: "running", | 2450 | Status: "running", | ||
| 2451 | + | Labels: map[string]string{ | |||
| 2452 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 2453 | + | }, | |||
| 2442 | } | 2454 | } | ||
| 2443 | svc.containers["test.example.com"] = existingContainer | 2455 | svc.containers["test.example.com"] = existingContainer | ||
| 2444 | 2456 | ||||
| @@ -2583,6 +2595,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2583 | config := testMinDelayConfig() | 2595 | config := testMinDelayConfig() | ||
| 2584 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2596 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2585 | ctx := testContext() | 2597 | ctx := testContext() | ||
| 2598 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2586 | 2599 | ||||
| 2587 | // Existing container has same image ID but is NOT running (crashed) | 2600 | // Existing container has same image ID but is NOT running (crashed) | ||
| 2588 | existingContainer := &domain.Container{ | 2601 | existingContainer := &domain.Container{ | ||
| @@ -2591,6 +2604,9 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2591 | Image: "myapp:latest", | 2604 | Image: "myapp:latest", | ||
| 2592 | ImageID: "sha256:abc123", | 2605 | ImageID: "sha256:abc123", | ||
| 2593 | Status: "exited", | 2606 | Status: "exited", | ||
| 2607 | + | Labels: map[string]string{ | |||
| 2608 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 2609 | + | }, | |||
| 2594 | } | 2610 | } | ||
| 2595 | svc.containers["test.example.com"] = existingContainer | 2611 | svc.containers["test.example.com"] = existingContainer | ||
| 2596 | 2612 | ||||
| @@ -2632,6 +2648,180 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2632 | assert.Equal(t, "new-container", result.ID) | 2648 | assert.Equal(t, "new-container", result.ID) | ||
| 2633 | } | 2649 | } | ||
| 2634 | 2650 | ||||
| 2651 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) { | |||
| 2652 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2653 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2654 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2655 | + | ||||
| 2656 | + | config := testMinDelayConfig() | |||
| 2657 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2658 | + | ctx := testContext() | |||
| 2659 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2660 | + | ||||
| 2661 | + | existingContainer := &domain.Container{ | |||
| 2662 | + | ID: "existing-container", | |||
| 2663 | + | Name: "gordon-test.example.com", | |||
| 2664 | + | Image: "myapp:latest", | |||
| 2665 | + | ImageID: "sha256:abc123", | |||
| 2666 | + | Status: "running", | |||
| 2667 | + | Labels: map[string]string{ | |||
| 2668 | + | domain.LabelEnvHash: "different-hash", | |||
| 2669 | + | }, | |||
| 2670 | + | } | |||
| 2671 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2672 | + | ||||
| 2673 | + | route := domain.Route{ | |||
| 2674 | + | Domain: "test.example.com", | |||
| 2675 | + | Image: "myapp:latest", | |||
| 2676 | + | } | |||
| 2677 | + | ||||
| 2678 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2679 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |||
| 2680 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |||
| 2681 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2682 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |||
| 2683 | + | ||||
| 2684 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |||
| 2685 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |||
| 2686 | + | ||||
| 2687 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2688 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2689 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |||
| 2690 | + | })).Return(newContainer, nil) | |||
| 2691 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2692 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2693 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2694 | + | ID: "new-container", Status: "running", | |||
| 2695 | + | }, nil) | |||
| 2696 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2697 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |||
| 2698 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |||
| 2699 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2700 | + | ||||
| 2701 | + | result, err := svc.Deploy(ctx, route) | |||
| 2702 | + | ||||
| 2703 | + | assert.NoError(t, err) | |||
| 2704 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2705 | + | } | |||
| 2706 | + | ||||
| 2707 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) { | |||
| 2708 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2709 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2710 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2711 | + | ||||
| 2712 | + | config := testMinDelayConfig() | |||
| 2713 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2714 | + | ctx := testContext() | |||
| 2715 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2716 | + | ||||
| 2717 | + | existingContainer := &domain.Container{ | |||
| 2718 | + | ID: "existing-container", | |||
| 2719 | + | Name: "gordon-test.example.com", | |||
| 2720 | + | Image: "myapp:latest", | |||
| 2721 | + | ImageID: "sha256:abc123", | |||
| 2722 | + | Status: "running", | |||
| 2723 | + | Labels: map[string]string{}, | |||
| 2724 | + | } | |||
| 2725 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2726 | + | ||||
| 2727 | + | route := domain.Route{ | |||
| 2728 | + | Domain: "test.example.com", | |||
| 2729 | + | Image: "myapp:latest", | |||
| 2730 | + | } | |||
| 2731 | + | ||||
| 2732 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2733 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |||
| 2734 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |||
| 2735 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2736 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |||
| 2737 | + | ||||
| 2738 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |||
| 2739 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |||
| 2740 | + | ||||
| 2741 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2742 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2743 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |||
| 2744 | + | })).Return(newContainer, nil) | |||
| 2745 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2746 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2747 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2748 | + | ID: "new-container", Status: "running", | |||
| 2749 | + | }, nil) | |||
| 2750 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2751 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |||
| 2752 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |||
| 2753 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2754 | + | ||||
| 2755 | + | result, err := svc.Deploy(ctx, route) | |||
| 2756 | + | ||||
| 2757 | + | assert.NoError(t, err) | |||
| 2758 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2759 | + | } | |||
| 2760 | + | ||||
| 2761 | + | func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) { | |||
| 2762 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2763 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2764 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2765 | + | ||||
| 2766 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |||
| 2767 | + | ctx := testContext() | |||
| 2768 | + | imageRef := svc.buildImageRef("postgres:16") | |||
| 2769 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |||
| 2770 | + | envHash := hashEnvironment(envVars) | |||
| 2771 | + | ||||
| 2772 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |||
| 2773 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |||
| 2774 | + | ||||
| 2775 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |||
| 2776 | + | Labels: map[string]string{domain.LabelEnvHash: envHash}, | |||
| 2777 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |||
| 2778 | + | ||||
| 2779 | + | assert.NoError(t, err) | |||
| 2780 | + | assert.False(t, drifted) | |||
| 2781 | + | } | |||
| 2782 | + | ||||
| 2783 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) { | |||
| 2784 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2785 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2786 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2787 | + | ||||
| 2788 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |||
| 2789 | + | ctx := testContext() | |||
| 2790 | + | imageRef := svc.buildImageRef("postgres:16") | |||
| 2791 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |||
| 2792 | + | ||||
| 2793 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |||
| 2794 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |||
| 2795 | + | ||||
| 2796 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |||
| 2797 | + | Labels: map[string]string{domain.LabelEnvHash: "stale-hash"}, | |||
| 2798 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |||
| 2799 | + | ||||
| 2800 | + | assert.NoError(t, err) | |||
| 2801 | + | assert.True(t, drifted) | |||
| 2802 | + | } | |||
| 2803 | + | ||||
| 2804 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) { | |||
| 2805 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2806 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2807 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2808 | + | ||||
| 2809 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |||
| 2810 | + | ctx := testContext() | |||
| 2811 | + | imageRef := svc.buildImageRef("postgres:16") | |||
| 2812 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |||
| 2813 | + | ||||
| 2814 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |||
| 2815 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |||
| 2816 | + | ||||
| 2817 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |||
| 2818 | + | Labels: map[string]string{}, | |||
| 2819 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |||
| 2820 | + | ||||
| 2821 | + | assert.NoError(t, err) | |||
| 2822 | + | assert.True(t, drifted) | |||
| 2823 | + | } | |||
| 2824 | + | ||||
| 2635 | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | 2825 | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | ||
| 2636 | // cleanup never stops a running container with the canonical name. Only stopped | 2826 | // cleanup never stops a running container with the canonical name. Only stopped | ||
| 2637 | // canonical containers and temp containers (-new/-next) should be cleaned up. | 2827 | // canonical containers and temp containers (-new/-next) should be cleaned up. | ||
M internal/usecase/secrets/service.go +44 -5
| @@ -4,6 +4,7 @@ package secrets | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "sort" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| @@ -24,18 +25,48 @@ var ( | |||
| 24 | 25 | ||
| 25 | 26 | // Service implements the SecretService interface. | |
| 26 | 27 | type Service struct { | |
| 27 | - | store out.DomainSecretStore | |
| 28 | - | log zerowrap.Logger | |
| 28 | + | store out.DomainSecretStore | |
| 29 | + | log zerowrap.Logger | |
| 30 | + | eventBus out.EventPublisher | |
| 29 | 31 | } | |
| 30 | 32 | ||
| 31 | 33 | // NewService creates a new secrets service. | |
| 32 | - | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | |
| 34 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service { | |
| 33 | 35 | return &Service{ | |
| 34 | - | store: store, | |
| 35 | - | log: log, | |
| 36 | + | store: store, | |
| 37 | + | log: log, | |
| 38 | + | eventBus: eventBus, | |
| 36 | 39 | } | |
| 37 | 40 | } | |
| 38 | 41 | ||
| 42 | + | // publishSecretsChanged publishes a secrets.changed event. | |
| 43 | + | // Failures are logged but do not fail the mutation - secret storage | |
| 44 | + | // is the source of truth; the event is a best-effort notification. | |
| 45 | + | func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) { | |
| 46 | + | if s.eventBus == nil { | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | log := zerowrap.FromCtx(ctx) | |
| 50 | + | payload := domain.SecretsChangedPayload{ | |
| 51 | + | Domain: domainName, | |
| 52 | + | Operation: operation, | |
| 53 | + | Keys: keys, | |
| 54 | + | } | |
| 55 | + | if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil { | |
| 56 | + | log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event") | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // sortedKeys returns the keys of a map in sorted order. | |
| 61 | + | func sortedKeys(m map[string]string) []string { | |
| 62 | + | keys := make([]string, 0, len(m)) | |
| 63 | + | for k := range m { | |
| 64 | + | keys = append(keys, k) | |
| 65 | + | } | |
| 66 | + | sort.Strings(keys) | |
| 67 | + | return keys | |
| 68 | + | } | |
| 69 | + | ||
| 39 | 70 | // ListKeys returns the list of secret keys for a domain (not values). | |
| 40 | 71 | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | |
| 41 | 72 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -140,6 +171,8 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str | |||
| 140 | 171 | return err | |
| 141 | 172 | } | |
| 142 | 173 | ||
| 174 | + | s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets)) | |
| 175 | + | ||
| 143 | 176 | log.Info().Int("count", len(secrets)).Msg("secrets set") | |
| 144 | 177 | return nil | |
| 145 | 178 | } | |
| @@ -164,6 +197,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||
| 164 | 197 | return err | |
| 165 | 198 | } | |
| 166 | 199 | ||
| 200 | + | s.publishSecretsChanged(ctx, domain, "delete", []string{key}) | |
| 201 | + | ||
| 167 | 202 | log.Info().Msg("secret deleted") | |
| 168 | 203 | return nil | |
| 169 | 204 | } | |
| @@ -195,6 +230,8 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string, | |||
| 195 | 230 | return err | |
| 196 | 231 | } | |
| 197 | 232 | ||
| 233 | + | s.publishSecretsChanged(ctx, domainName, "set", sortedKeys(secrets)) | |
| 234 | + | ||
| 198 | 235 | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | |
| 199 | 236 | return nil | |
| 200 | 237 | } | |
| @@ -227,6 +264,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key | |||
| 227 | 264 | return err | |
| 228 | 265 | } | |
| 229 | 266 | ||
| 267 | + | s.publishSecretsChanged(ctx, domainName, "delete", []string{key}) | |
| 268 | + | ||
| 230 | 269 | log.Info().Str("container", containerName).Msg("attachment secret deleted") | |
| 231 | 270 | return nil | |
| 232 | 271 | } | |
| @@ -4,6 +4,7 @@ package secrets | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "sort" | |||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| @@ -24,18 +25,48 @@ var ( | |||||
| 24 | 25 | ||||
| 25 | // Service implements the SecretService interface. | 26 | // Service implements the SecretService interface. | ||
| 26 | type Service struct { | 27 | type Service struct { | ||
| 27 | - | store out.DomainSecretStore | 28 | + | store out.DomainSecretStore |
| 28 | - | log zerowrap.Logger | 29 | + | log zerowrap.Logger |
| 30 | + | eventBus out.EventPublisher | |||
| 29 | } | 31 | } | ||
| 30 | 32 | ||||
| 31 | // NewService creates a new secrets service. | 33 | // NewService creates a new secrets service. | ||
| 32 | - | func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service { | 34 | + | func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service { |
| 33 | return &Service{ | 35 | return &Service{ | ||
| 34 | - | store: store, | 36 | + | store: store, |
| 35 | - | log: log, | 37 | + | log: log, |
| 38 | + | eventBus: eventBus, | |||
| 36 | } | 39 | } | ||
| 37 | } | 40 | } | ||
| 38 | 41 | ||||
| 42 | + | // publishSecretsChanged publishes a secrets.changed event. | |||
| 43 | + | // Failures are logged but do not fail the mutation - secret storage | |||
| 44 | + | // is the source of truth; the event is a best-effort notification. | |||
| 45 | + | func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) { | |||
| 46 | + | if s.eventBus == nil { | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | log := zerowrap.FromCtx(ctx) | |||
| 50 | + | payload := domain.SecretsChangedPayload{ | |||
| 51 | + | Domain: domainName, | |||
| 52 | + | Operation: operation, | |||
| 53 | + | Keys: keys, | |||
| 54 | + | } | |||
| 55 | + | if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil { | |||
| 56 | + | log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event") | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // sortedKeys returns the keys of a map in sorted order. | |||
| 61 | + | func sortedKeys(m map[string]string) []string { | |||
| 62 | + | keys := make([]string, 0, len(m)) | |||
| 63 | + | for k := range m { | |||
| 64 | + | keys = append(keys, k) | |||
| 65 | + | } | |||
| 66 | + | sort.Strings(keys) | |||
| 67 | + | return keys | |||
| 68 | + | } | |||
| 69 | + | ||||
| 39 | // ListKeys returns the list of secret keys for a domain (not values). | 70 | // ListKeys returns the list of secret keys for a domain (not values). | ||
| 40 | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | 71 | func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) { | ||
| 41 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 72 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -140,6 +171,8 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str | |||||
| 140 | return err | 171 | return err | ||
| 141 | } | 172 | } | ||
| 142 | 173 | ||||
| 174 | + | s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets)) | |||
| 175 | + | ||||
| 143 | log.Info().Int("count", len(secrets)).Msg("secrets set") | 176 | log.Info().Int("count", len(secrets)).Msg("secrets set") | ||
| 144 | return nil | 177 | return nil | ||
| 145 | } | 178 | } | ||
| @@ -164,6 +197,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error { | |||||
| 164 | return err | 197 | return err | ||
| 165 | } | 198 | } | ||
| 166 | 199 | ||||
| 200 | + | s.publishSecretsChanged(ctx, domain, "delete", []string{key}) | |||
| 201 | + | ||||
| 167 | log.Info().Msg("secret deleted") | 202 | log.Info().Msg("secret deleted") | ||
| 168 | return nil | 203 | return nil | ||
| 169 | } | 204 | } | ||
| @@ -195,6 +230,8 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string, | |||||
| 195 | return err | 230 | return err | ||
| 196 | } | 231 | } | ||
| 197 | 232 | ||||
| 233 | + | s.publishSecretsChanged(ctx, domainName, "set", sortedKeys(secrets)) | |||
| 234 | + | ||||
| 198 | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | 235 | log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set") | ||
| 199 | return nil | 236 | return nil | ||
| 200 | } | 237 | } | ||
| @@ -227,6 +264,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key | |||||
| 227 | return err | 264 | return err | ||
| 228 | } | 265 | } | ||
| 229 | 266 | ||||
| 267 | + | s.publishSecretsChanged(ctx, domainName, "delete", []string{key}) | |||
| 268 | + | ||||
| 230 | log.Info().Str("container", containerName).Msg("attachment secret deleted") | 269 | log.Info().Str("container", containerName).Msg("attachment secret deleted") | ||
| 231 | return nil | 270 | return nil | ||
| 232 | } | 271 | } | ||
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"}) | ||