detached 989f93f

Merge pull request #110 from bnema/fix/redeploy-on-env-change

feat: redeploy containers when route secrets or env change
commit
989f93ffaeab715237e8ba835b92f1e4774f3e80
author
bnema · · 2026-03-14 20:46 UTC
committer
GitHub · · 2026-03-14 20:46 UTC
merge of
9a3a764 + 093ac5a · diff against the first parent
signature
unsigned
13 files · +964 -79
M internal/adapters/in/cli/local.go +1 -1
@@ -90,7 +90,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
9090 if err != nil {
9191 return nil, fmt.Errorf("failed to create domain secret store: %w", err)
9292 }
93- secretSvc := secretsSvc.NewService(domainSecretStore, log)
93+ secretSvc := secretsSvc.NewService(domainSecretStore, log, nil)
9494
9595 return &LocalServices{
9696 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) {
7474 return nil, fmt.Errorf("failed to create local secret store: %w", err)
7575 }
7676
77- secretSvc := secretsusecase.NewService(domainSecretStore, log)
77+ secretSvc := secretsusecase.NewService(domainSecretStore, log, nil)
7878
7979 return &Kernel{
8080 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 {
253253 }
254254
255255 // Register event handlers
256- if err := registerEventHandlers(ctx, svc, cfg); err != nil {
256+ cleanupHandlers, err := registerEventHandlers(ctx, svc, cfg)
257+ if err != nil {
257258 return err
258259 }
260+ // cleanupHandlers is passed into runServers so it can stop debounce
261+ // timers before graceful shutdown, preventing deploys during drain.
259262
260263 // Set up config hot reload
261264 setupConfigHotReload(ctx, v, svc, log)
@@ -270,7 +273,7 @@ func Run(ctx context.Context, configPath string) error {
270273 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
271274
272275 // 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)
274277 }
275278
276279 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -490,7 +493,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
490493 return nil, err
491494 }
492495
493- svc.secretSvc = secretsSvc.NewService(domainSecretStore, log)
496+ svc.secretSvc = secretsSvc.NewService(domainSecretStore, log, svc.eventBus)
494497
495498 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
496499 return nil, err
@@ -1298,41 +1301,50 @@ func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
12981301 }
12991302
13001303 // 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) {
13021305 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
13031306 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)
13051308 }
13061309
13071310 // Auto-route handler for creating routes from image labels
13081311 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
13091312 WithEnvExtractor(svc.runtime, svc.envDir)
13101313 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)
13121315 }
13131316
13141317 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
13151318 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)
13171320 }
13181321
13191322 manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
13201323 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)
13221325 }
13231326
13241327 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
13251328 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)
13271335 }
13281336
13291337 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
13301338 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
13311339 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)
13331341 }
13341342
1335- return nil
1343+ cleanup := func() {
1344+ secretsChangedHandler.Stop()
1345+ }
1346+
1347+ return cleanup, nil
13361348 }
13371349
13381350 // setupConfigHotReload sets up Viper config hot reload.
@@ -1689,7 +1701,7 @@ func isLoopbackHost(host string) bool {
16891701 // - SIGUSR2: Triggers manual deploy for a specific route
16901702 // The deferred signal.Stop calls ensure signal handlers are properly
16911703 // 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 {
16931705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
16941706 defer cancel()
16951707
@@ -1759,8 +1771,9 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17591771 // Auto-start after servers are listening (registry port is now bound).
17601772 syncAndAutoStart(ctx, svc, log)
17611773
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)
17641777 return nil
17651778 }
17661779
@@ -253,9 +253,12 @@ func Run(ctx context.Context, configPath string) error {
253 }253 }
254 254
255 // Register event handlers255 // 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 err258 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 reload263 // 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, err493 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, err499 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 labels1310 // 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 nil1343+ 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 route1701 // - SIGUSR2: Triggers manual deploy for a specific route
1690 // The deferred signal.Stop calls ensure signal handlers are properly1702 // 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 nil1777 return nil
1765 }1778 }
1766 1779
M internal/domain/event.go +8 -0
@@ -18,6 +18,7 @@ const (
1818 EventContainerStart EventType = "container.start"
1919 EventContainerHealthCheck EventType = "container.health_check"
2020 EventContainerDeployed EventType = "container.deployed"
21+ EventSecretsChanged EventType = "secrets.changed"
2122 )
2223
2324 // Event represents a domain event that occurred in the system.
@@ -61,6 +62,13 @@ type ManualDeployPayload struct {
6162 Domain string `json:"domain"`
6263 }
6364
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+
6472 // Context keys for domain-level concerns.
6573 type contextKey string
6674
@@ -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 string73 type contextKey string
66 74
M internal/domain/labels.go +4 -0
@@ -10,6 +10,10 @@ const (
1010 LabelAttachment = "gordon.attachment"
1111 LabelAttachedTo = "gordon.attached-to"
1212 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"
1317
1418 // Image labels (set in Dockerfile)
1519 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
210210 }
211211 }
212212
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 {
214214 containerName := s.deploymentContainerName(containerDomain, existing)
215215
216216 return &domain.ContainerConfig{
@@ -223,6 +223,7 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st
223223 Hostname: containerDomain,
224224 Labels: map[string]string{
225225 domain.LabelDomain: containerDomain,
226+ domain.LabelEnvHash: envHash,
226227 domain.LabelImage: image,
227228 domain.LabelManaged: "true",
228229 domain.LabelRoute: containerDomain,
@@ -279,7 +280,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
279280 existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip)
280281 }
281282 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 {
283284 return container, nil
284285 }
285286 }
@@ -351,7 +352,7 @@ func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, rout
351352 // When a push triggers both an event-based deploy and an explicit CLI deploy,
352353 // the second one arrives after the first has already completed; this avoids
353354 // 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) {
355356 log := zerowrap.FromCtx(ctx)
356357
357358 newImageID, err := s.runtime.GetImageID(ctx, actualImageRef)
@@ -370,6 +371,19 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont
370371 return false, nil
371372 }
372373
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+
373387 log.Info().
374388 Str("container_id", existing.ID).
375389 Str("image_id", newImageID).
@@ -403,6 +417,7 @@ type deployResources struct {
403417 actualImageRef string
404418 exposedPorts []int
405419 envVars []string
420+ envHash string
406421 volumes map[string]string
407422 }
408423
@@ -505,6 +520,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
505520 if err != nil {
506521 return nil, err
507522 }
523+ envHash := hashEnvironment(envVars)
508524
509525 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
510526 if err != nil {
@@ -516,6 +532,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
516532 actualImageRef: actualImageRef,
517533 exposedPorts: exposedPorts,
518534 envVars: envVars,
535+ envHash: envHash,
519536 volumes: volumes,
520537 }, nil
521538 }
@@ -532,6 +549,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
532549 resources.actualImageRef,
533550 resources.exposedPorts,
534551 resources.envVars,
552+ resources.envHash,
535553 resources.volumes,
536554 resources.networkName,
537555 existing,
@@ -2031,37 +2049,28 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
20312049
20322050 // Check if already running (idempotent)
20332051 // 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
20552055 }
20562056
2057- // Remove existing stopped container if present
20582057 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
20622067 }
20632068 }
20642069
2070+ if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil {
2071+ return err
2072+ }
2073+
20652074 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
20662075
20672076 // 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
20912100 log.WrapErr(err, "failed to load environment for attachment")
20922101 envVars = []string{}
20932102 }
2103+ envHash := hashEnvironment(envVars)
20942104
20952105 // Create container with attachment labels (use actual ref, track original in labels)
20962106 config := &domain.ContainerConfig{
@@ -2105,6 +2115,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
21052115 domain.LabelManaged: "true",
21062116 domain.LabelAttachment: "true",
21072117 domain.LabelAttachedTo: ownerDomain,
2118+ domain.LabelEnvHash: envHash,
21082119 domain.LabelImage: serviceImage,
21092120 },
21102121 }
@@ -2132,6 +2143,99 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
21322143 return nil
21332144 }
21342145
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+
21352239 // Utility functions
21362240
21372241 func normalizeImageRef(image string) string {
@@ -2192,9 +2296,15 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
21922296 }
21932297 }
21942298
2299+ keys := make([]string, 0, len(envMap))
2300+ for k := range envMap {
2301+ keys = append(keys, k)
2302+ }
2303+ slices.Sort(keys)
2304+
21952305 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])
21982308 }
21992309
22002310 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, nil284 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 avoids353 // 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, nil371 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 string417 actualImageRef string
404 exposedPorts []int418 exposedPorts []int
405 envVars []string419 envVars []string
420+ envHash string
406 volumes map[string]string421 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, err521 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 }, nil537 }, 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 compatibility2051 // 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 upgrades2054+ 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 nil2143 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 functions2239 // 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 result2310 return result
M internal/usecase/container/service_test.go +190 -0
@@ -606,6 +606,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
606606 config := testMinDelayConfig()
607607 svc := NewService(runtime, envLoader, eventBus, nil, config)
608608 ctx := testContext()
609+ expectedEnvHash := hashEnvironment([]string{})
609610
610611 // Existing container has ImageID, but GetImageID will fail
611612 existingContainer := &domain.Container{
@@ -614,6 +615,9 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
614615 Image: "myapp:latest",
615616 ImageID: "sha256:abc123",
616617 Status: "running",
618+ Labels: map[string]string{
619+ domain.LabelEnvHash: expectedEnvHash,
620+ },
617621 }
618622 svc.containers["test.example.com"] = existingContainer
619623
@@ -2378,6 +2382,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
23782382 }
23792383 svc := NewService(runtime, envLoader, eventBus, nil, config)
23802384 ctx := testContext()
2385+ expectedEnvHash := hashEnvironment([]string{})
23812386
23822387 // Pre-populate with existing container that has an ImageID (set by InspectContainer).
23832388 // This simulates the first deploy (from image.pushed event) having already completed.
@@ -2387,6 +2392,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
23872392 Image: "myapp:latest",
23882393 ImageID: "sha256:abc123",
23892394 Status: "running",
2395+ Labels: map[string]string{
2396+ domain.LabelEnvHash: expectedEnvHash,
2397+ },
23902398 }
23912399 svc.containers["test.example.com"] = existingContainer
23922400
@@ -2432,6 +2440,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24322440 }
24332441 svc := NewService(runtime, envLoader, eventBus, nil, config)
24342442 ctx := testContext()
2443+ expectedEnvHash := hashEnvironment([]string{})
24352444
24362445 // Pre-populate with existing container missing ImageID (common after stale in-memory state).
24372446 existingContainer := &domain.Container{
@@ -2439,6 +2448,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24392448 Name: "gordon-test.example.com",
24402449 Image: "myapp:latest",
24412450 Status: "running",
2451+ Labels: map[string]string{
2452+ domain.LabelEnvHash: expectedEnvHash,
2453+ },
24422454 }
24432455 svc.containers["test.example.com"] = existingContainer
24442456
@@ -2583,6 +2595,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
25832595 config := testMinDelayConfig()
25842596 svc := NewService(runtime, envLoader, eventBus, nil, config)
25852597 ctx := testContext()
2598+ expectedEnvHash := hashEnvironment([]string{})
25862599
25872600 // Existing container has same image ID but is NOT running (crashed)
25882601 existingContainer := &domain.Container{
@@ -2591,6 +2604,9 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
25912604 Image: "myapp:latest",
25922605 ImageID: "sha256:abc123",
25932606 Status: "exited",
2607+ Labels: map[string]string{
2608+ domain.LabelEnvHash: expectedEnvHash,
2609+ },
25942610 }
25952611 svc.containers["test.example.com"] = existingContainer
25962612
@@ -2632,6 +2648,180 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
26322648 assert.Equal(t, "new-container", result.ID)
26332649 }
26342650
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+
26352825 // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
26362826 // cleanup never stops a running container with the canonical name. Only stopped
26372827 // 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 fail611 // 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"] = existingContainer622 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"] = existingContainer2399 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"] = existingContainer2455 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"] = existingContainer2611 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 orphan2825 // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
2636 // cleanup never stops a running container with the canonical name. Only stopped2826 // 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
44 import (
55 "context"
66 "errors"
7+ "sort"
78 "strings"
89
910 "github.com/bnema/zerowrap"
@@ -24,18 +25,48 @@ var (
2425
2526 // Service implements the SecretService interface.
2627 type Service struct {
27- store out.DomainSecretStore
28- log zerowrap.Logger
28+ store out.DomainSecretStore
29+ log zerowrap.Logger
30+ eventBus out.EventPublisher
2931 }
3032
3133 // 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 {
3335 return &Service{
34- store: store,
35- log: log,
36+ store: store,
37+ log: log,
38+ eventBus: eventBus,
3639 }
3740 }
3841
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+
3970 // ListKeys returns the list of secret keys for a domain (not values).
4071 func (s *Service) ListKeys(ctx context.Context, domain string) ([]string, error) {
4172 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -140,6 +171,8 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str
140171 return err
141172 }
142173
174+ s.publishSecretsChanged(ctx, domain, "set", sortedKeys(secrets))
175+
143176 log.Info().Int("count", len(secrets)).Msg("secrets set")
144177 return nil
145178 }
@@ -164,6 +197,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error {
164197 return err
165198 }
166199
200+ s.publishSecretsChanged(ctx, domain, "delete", []string{key})
201+
167202 log.Info().Msg("secret deleted")
168203 return nil
169204 }
@@ -195,6 +230,8 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string,
195230 return err
196231 }
197232
233+ s.publishSecretsChanged(ctx, domainName, "set", sortedKeys(secrets))
234+
198235 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
199236 return nil
200237 }
@@ -227,6 +264,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key
227264 return err
228265 }
229266
267+ s.publishSecretsChanged(ctx, domainName, "delete", []string{key})
268+
230269 log.Info().Str("container", containerName).Msg("attachment secret deleted")
231270 return nil
232271 }
@@ -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.DomainSecretStore28+ store out.DomainSecretStore
28- log zerowrap.Logger29+ 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 err171 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 nil177 return nil
145 }178 }
@@ -164,6 +197,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error {
164 return err197 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 nil203 return nil
169 }204 }
@@ -195,6 +230,8 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string,
195 return err230 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 nil236 return nil
200 }237 }
@@ -227,6 +264,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key
227 return err264 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 nil270 return nil
232 }271 }
M internal/usecase/secrets/service_test.go +98 -28
@@ -11,6 +11,7 @@ import (
1111
1212 "github.com/bnema/gordon/internal/boundaries/out"
1313 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
1415 )
1516
1617 func testLogger() zerowrap.Logger {
@@ -135,7 +136,7 @@ func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) {
135136
136137 func TestService_ListKeys_ValidationErrors(t *testing.T) {
137138 store := outmocks.NewMockDomainSecretStore(t)
138- svc := NewService(store, testLogger())
139+ svc := NewService(store, testLogger(), nil)
139140
140141 tests := []struct {
141142 name string
@@ -170,7 +171,7 @@ func TestService_ListKeys_ValidationErrors(t *testing.T) {
170171
171172 func TestService_ListKeys_Success(t *testing.T) {
172173 store := outmocks.NewMockDomainSecretStore(t)
173- svc := NewService(store, testLogger())
174+ svc := NewService(store, testLogger(), nil)
174175
175176 expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
176177 store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
@@ -182,7 +183,7 @@ func TestService_ListKeys_Success(t *testing.T) {
182183
183184 func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) {
184185 store := outmocks.NewMockDomainSecretStore(t)
185- svc := NewService(store, testLogger())
186+ svc := NewService(store, testLogger(), nil)
186187
187188 tests := []struct {
188189 name string
@@ -219,7 +220,7 @@ func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) {
219220 func TestService_ListKeysWithAttachments_Success(t *testing.T) {
220221 t.Run("returns both domain and attachment secrets", func(t *testing.T) {
221222 store := outmocks.NewMockDomainSecretStore(t)
222- svc := NewService(store, testLogger())
223+ svc := NewService(store, testLogger(), nil)
223224
224225 expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
225226 expectedAttachments := []out.AttachmentSecrets{
@@ -238,7 +239,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) {
238239
239240 t.Run("returns empty attachments when none exist", func(t *testing.T) {
240241 store := outmocks.NewMockDomainSecretStore(t)
241- svc := NewService(store, testLogger())
242+ svc := NewService(store, testLogger(), nil)
242243
243244 expectedKeys := []string{"API_KEY"}
244245
@@ -253,7 +254,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) {
253254
254255 t.Run("continues without attachments on attachment error", func(t *testing.T) {
255256 store := outmocks.NewMockDomainSecretStore(t)
256- svc := NewService(store, testLogger())
257+ svc := NewService(store, testLogger(), nil)
257258
258259 expectedKeys := []string{"API_KEY"}
259260 attachmentErr := assert.AnError
@@ -271,7 +272,7 @@ func TestService_ListKeysWithAttachments_Success(t *testing.T) {
271272
272273 func TestService_ListKeysWithAttachments_StoreError(t *testing.T) {
273274 store := outmocks.NewMockDomainSecretStore(t)
274- svc := NewService(store, testLogger())
275+ svc := NewService(store, testLogger(), nil)
275276
276277 storeErr := assert.AnError
277278 store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
@@ -284,7 +285,7 @@ func TestService_ListKeysWithAttachments_StoreError(t *testing.T) {
284285
285286 func TestService_GetAll_ValidationErrors(t *testing.T) {
286287 store := outmocks.NewMockDomainSecretStore(t)
287- svc := NewService(store, testLogger())
288+ svc := NewService(store, testLogger(), nil)
288289
289290 tests := []struct {
290291 name string
@@ -314,7 +315,7 @@ func TestService_GetAll_ValidationErrors(t *testing.T) {
314315
315316 func TestService_GetAll_Success(t *testing.T) {
316317 store := outmocks.NewMockDomainSecretStore(t)
317- svc := NewService(store, testLogger())
318+ svc := NewService(store, testLogger(), nil)
318319
319320 expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
320321 store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
@@ -326,7 +327,7 @@ func TestService_GetAll_Success(t *testing.T) {
326327
327328 func TestService_Set_ValidationErrors(t *testing.T) {
328329 store := outmocks.NewMockDomainSecretStore(t)
329- svc := NewService(store, testLogger())
330+ svc := NewService(store, testLogger(), nil)
330331
331332 secrets := map[string]string{"API_KEY": "secret123"}
332333
@@ -357,10 +358,30 @@ func TestService_Set_ValidationErrors(t *testing.T) {
357358
358359 func TestService_Set_Success(t *testing.T) {
359360 store := outmocks.NewMockDomainSecretStore(t)
360- svc := NewService(store, testLogger())
361+ eventBus := outmocks.NewMockEventPublisher(t)
362+ svc := NewService(store, testLogger(), eventBus)
361363
362364 secrets := map[string]string{"API_KEY": "secret123"}
363365 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)
364385
365386 err := svc.Set(context.Background(), "app.example.com", secrets)
366387 assert.NoError(t, err)
@@ -368,7 +389,7 @@ func TestService_Set_Success(t *testing.T) {
368389
369390 func TestService_Delete_ValidationErrors(t *testing.T) {
370391 store := outmocks.NewMockDomainSecretStore(t)
371- svc := NewService(store, testLogger())
392+ svc := NewService(store, testLogger(), nil)
372393
373394 tests := []struct {
374395 name string
@@ -397,17 +418,62 @@ func TestService_Delete_ValidationErrors(t *testing.T) {
397418
398419 func TestService_Delete_Success(t *testing.T) {
399420 store := outmocks.NewMockDomainSecretStore(t)
400- svc := NewService(store, testLogger())
421+ eventBus := outmocks.NewMockEventPublisher(t)
422+ svc := NewService(store, testLogger(), eventBus)
401423
402424 store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
425+ eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
403426
404427 err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
405428 assert.NoError(t, err)
406429 }
407430
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+
408474 func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
409475 store := outmocks.NewMockDomainSecretStore(t)
410- svc := NewService(store, testLogger())
476+ svc := NewService(store, testLogger(), nil)
411477
412478 storeErr := assert.AnError
413479 store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
@@ -450,7 +516,7 @@ func TestPathTraversalVariants(t *testing.T) {
450516 // TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
451517 func TestService_ValidationHappensBeforeStore(t *testing.T) {
452518 store := outmocks.NewMockDomainSecretStore(t)
453- svc := NewService(store, testLogger())
519+ svc := NewService(store, testLogger(), nil)
454520
455521 // With path traversal domain, store methods should never be called
456522 // 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) {
473539
474540 func TestService_SetAttachment(t *testing.T) {
475541 store := outmocks.NewMockDomainSecretStore(t)
476- svc := NewService(store, testLogger())
542+ eventBus := outmocks.NewMockEventPublisher(t)
543+ svc := NewService(store, testLogger(), eventBus)
477544
478545 secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
479546 store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil)
547+ eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
480548
481549 err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
482550 assert.NoError(t, err)
@@ -484,9 +552,11 @@ func TestService_SetAttachment(t *testing.T) {
484552
485553 func TestService_DeleteAttachment(t *testing.T) {
486554 store := outmocks.NewMockDomainSecretStore(t)
487- svc := NewService(store, testLogger())
555+ eventBus := outmocks.NewMockEventPublisher(t)
556+ svc := NewService(store, testLogger(), eventBus)
488557
489558 store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil)
559+ eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
490560
491561 err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
492562 assert.NoError(t, err)
@@ -494,7 +564,7 @@ func TestService_DeleteAttachment(t *testing.T) {
494564
495565 func TestService_SetAttachment_InvalidDomain(t *testing.T) {
496566 store := outmocks.NewMockDomainSecretStore(t)
497- svc := NewService(store, testLogger())
567+ svc := NewService(store, testLogger(), nil)
498568
499569 err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"})
500570 assert.ErrorIs(t, err, ErrDomainEmpty)
@@ -502,7 +572,7 @@ func TestService_SetAttachment_InvalidDomain(t *testing.T) {
502572
503573 func TestService_SetAttachment_EmptyService(t *testing.T) {
504574 store := outmocks.NewMockDomainSecretStore(t)
505- svc := NewService(store, testLogger())
575+ svc := NewService(store, testLogger(), nil)
506576
507577 err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"})
508578 assert.ErrorIs(t, err, ErrServiceEmpty)
@@ -510,7 +580,7 @@ func TestService_SetAttachment_EmptyService(t *testing.T) {
510580
511581 func TestService_DeleteAttachment_InvalidDomain(t *testing.T) {
512582 store := outmocks.NewMockDomainSecretStore(t)
513- svc := NewService(store, testLogger())
583+ svc := NewService(store, testLogger(), nil)
514584
515585 err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY")
516586 assert.ErrorIs(t, err, ErrDomainEmpty)
@@ -518,7 +588,7 @@ func TestService_DeleteAttachment_InvalidDomain(t *testing.T) {
518588
519589 func TestService_DeleteAttachment_EmptyService(t *testing.T) {
520590 store := outmocks.NewMockDomainSecretStore(t)
521- svc := NewService(store, testLogger())
591+ svc := NewService(store, testLogger(), nil)
522592
523593 err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY")
524594 assert.ErrorIs(t, err, ErrServiceEmpty)
@@ -526,7 +596,7 @@ func TestService_DeleteAttachment_EmptyService(t *testing.T) {
526596
527597 func TestService_SetAttachment_InvalidServiceName(t *testing.T) {
528598 store := outmocks.NewMockDomainSecretStore(t)
529- svc := NewService(store, testLogger())
599+ svc := NewService(store, testLogger(), nil)
530600
531601 err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"})
532602 assert.ErrorIs(t, err, ErrInvalidServiceName)
@@ -534,7 +604,7 @@ func TestService_SetAttachment_InvalidServiceName(t *testing.T) {
534604
535605 func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) {
536606 store := outmocks.NewMockDomainSecretStore(t)
537- svc := NewService(store, testLogger())
607+ svc := NewService(store, testLogger(), nil)
538608
539609 err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY")
540610 assert.ErrorIs(t, err, ErrInvalidServiceName)
@@ -542,7 +612,7 @@ func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) {
542612
543613 func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) {
544614 store := outmocks.NewMockDomainSecretStore(t)
545- svc := NewService(store, testLogger())
615+ svc := NewService(store, testLogger(), nil)
546616
547617 // Service name exceeds DNS label limit of 63 characters
548618 longService := strings.Repeat("a", 64)
@@ -552,7 +622,7 @@ func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) {
552622
553623 func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) {
554624 store := outmocks.NewMockDomainSecretStore(t)
555- svc := NewService(store, testLogger())
625+ svc := NewService(store, testLogger(), nil)
556626
557627 err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"})
558628 assert.ErrorIs(t, err, ErrInvalidServiceName)
@@ -560,7 +630,7 @@ func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) {
560630
561631 func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) {
562632 store := outmocks.NewMockDomainSecretStore(t)
563- svc := NewService(store, testLogger())
633+ svc := NewService(store, testLogger(), nil)
564634
565635 // Service name exceeds DNS label limit of 63 characters
566636 longService := strings.Repeat("a", 64)
@@ -570,7 +640,7 @@ func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) {
570640
571641 func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) {
572642 store := outmocks.NewMockDomainSecretStore(t)
573- svc := NewService(store, testLogger())
643+ svc := NewService(store, testLogger(), nil)
574644
575645 err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY")
576646 assert.ErrorIs(t, err, ErrInvalidServiceName)
@@ -578,7 +648,7 @@ func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) {
578648
579649 func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) {
580650 store := outmocks.NewMockDomainSecretStore(t)
581- svc := NewService(store, testLogger())
651+ svc := NewService(store, testLogger(), nil)
582652
583653 // With invalid service name, store methods should never be called
584654 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 string142 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 string189 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.AnError260 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.AnError277 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 string291 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 string395 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.AnError478 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 called521 // 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 fail522 // 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 characters617 // 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 characters635 // 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 called653 // 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"})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close