detached 868d000

feat: publish secrets.changed event and debounced redeploy handler

- Add EventPublisher to secrets.Service; publish secrets.changed after
  Set/Delete/SetAttachment/DeleteAttachment (best-effort, nil-safe)
- Implement SecretsChangedHandler with per-domain 60s debounce: each
  new secret mutation resets the timer; deploy fires once after quiet
- Use generation counter to prevent stale timer callbacks from deploying
- Wire handler in registerEventHandlers with graceful shutdown cleanup
- Pass nil publisher in Kernel and local CLI (no running event bus)

Closes #109: gordon secrets set/remove now triggers automatic
container recreation after a 60-second debounce window.
commit
868d000900a5b2409b09710a3b76d663a7d1c601
author
brice · · 2026-03-14 18:18 UTC
committer
brice · · 2026-03-14 18:55 UTC
parent
e7424b0
signature
unsigned
7 files · +522 -47
M internal/adapters/in/cli/local.go +1 -1
@@ -90,7 +90,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
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 +25 -12
@@ -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.containerSvc, svc.eventBus, 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, 60*time.Second)
1333+ if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {
1334+ return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)
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, containerSvc *container.Service, eventBus out.EventBus, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {
16931705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
16941706 defer cancel()
16951707
@@ -1760,6 +1772,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17601772 syncAndAutoStart(ctx, svc, log)
17611773
17621774 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1775+ cleanupHandlers() // Stop debounce timers before draining containers
17631776 gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)
17641777 return nil
17651778 }
@@ -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.containerSvc, svc.eventBus, svc, cleanupHandlers, log)
274 }277 }
275 278
276 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {279 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -490,7 +493,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
490 return nil, 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, 60*time.Second)
1333+ if err := svc.eventBus.Subscribe(secretsChangedHandler); err != nil {
1334+ return nil, fmt.Errorf("failed to subscribe secrets changed handler: %w", err)
1327 }1335 }
1328 1336
1329 // Proxy cache invalidation on config reload (clears stale targets for removed routes)1337 // Proxy cache invalidation on config reload (clears stale targets for removed routes)
1330 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)1338 configReloadProxyHandler := proxy.NewConfigReloadProxyHandler(ctx, svc.proxySvc)
1331 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {1339 if err := svc.eventBus.Subscribe(configReloadProxyHandler); err != nil {
1332- return fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)1340+ return nil, fmt.Errorf("failed to subscribe config reload proxy handler: %w", err)
1333 }1341 }
1334 1342
1335- return 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, containerSvc *container.Service, eventBus out.EventBus, svc *services, cleanupHandlers func(), log zerowrap.Logger) error {
1693 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)1705 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1694 defer cancel()1706 defer cancel()
1695 1707
@@ -1760,6 +1772,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1760 syncAndAutoStart(ctx, svc, log)1772 syncAndAutoStart(ctx, svc, log)
1761 1773
1762 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)1774 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1775+ cleanupHandlers() // Stop debounce timers before draining containers
1763 gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)1776 gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)
1764 return nil1777 return nil
1765 }1778 }
A internal/usecase/container/secrets_handler.go +153 -0
@@ -0,0 +1,153 @@
1+package container
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/in"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// SecretsChangedHandler handles secrets.changed events with per-domain
15+// debouncing. When secrets are modified rapidly (e.g. multiple
16+// "gordon secrets set" calls), the handler resets a timer on each
17+// event and only triggers a deploy once the domain is quiet for
18+// the configured delay.
19+type SecretsChangedHandler struct {
20+ containerSvc in.ContainerService
21+ configSvc in.ConfigService
22+ ctx context.Context
23+ cancel context.CancelFunc
24+ debounceDelay time.Duration
25+
26+ mu sync.Mutex
27+ timers map[string]*time.Timer
28+ generations map[string]uint64
29+}
30+
31+// NewSecretsChangedHandler creates a new SecretsChangedHandler.
32+func NewSecretsChangedHandler(
33+ ctx context.Context,
34+ containerSvc in.ContainerService,
35+ configSvc in.ConfigService,
36+ debounceDelay time.Duration,
37+) *SecretsChangedHandler {
38+ ctx, cancel := context.WithCancel(ctx)
39+ return &SecretsChangedHandler{
40+ containerSvc: containerSvc,
41+ configSvc: configSvc,
42+ ctx: ctx,
43+ cancel: cancel,
44+ debounceDelay: debounceDelay,
45+ timers: make(map[string]*time.Timer),
46+ generations: make(map[string]uint64),
47+ }
48+}
49+
50+// Handle processes a secrets.changed event by resetting the debounce
51+// timer for the affected domain. Returns immediately - the deploy
52+// runs asynchronously when the timer fires.
53+//
54+// Uses the handler's long-lived context (h.ctx) rather than the event
55+// context because the debounced deploy fires after this method returns;
56+// using the event context would cause premature cancellation.
57+func (h *SecretsChangedHandler) Handle(_ context.Context, event domain.Event) error {
58+ payload, ok := event.Data.(domain.SecretsChangedPayload)
59+ if !ok {
60+ return nil
61+ }
62+ if payload.Domain == "" {
63+ return nil
64+ }
65+
66+ ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
67+ zerowrap.FieldLayer: "usecase",
68+ zerowrap.FieldHandler: "SecretsChangedHandler",
69+ "domain": payload.Domain,
70+ "operation": payload.Operation,
71+ })
72+ log := zerowrap.FromCtx(ctx)
73+
74+ log.Debug().
75+ Strs("keys", payload.Keys).
76+ Dur("debounce", h.debounceDelay).
77+ Msg("secrets changed, resetting debounce timer")
78+
79+ h.resetTimer(ctx, payload.Domain)
80+ return nil
81+}
82+
83+// CanHandle returns true for secrets.changed events.
84+func (h *SecretsChangedHandler) CanHandle(eventType domain.EventType) bool {
85+ return eventType == domain.EventSecretsChanged
86+}
87+
88+// Stop cancels the handler context and all pending timers.
89+// Call before graceful shutdown to prevent deploys during drain.
90+func (h *SecretsChangedHandler) Stop() {
91+ h.cancel()
92+
93+ h.mu.Lock()
94+ defer h.mu.Unlock()
95+
96+ for domainName, timer := range h.timers {
97+ timer.Stop()
98+ delete(h.timers, domainName)
99+ delete(h.generations, domainName)
100+ }
101+}
102+
103+// resetTimer replaces any existing timer for the domain with a new one.
104+func (h *SecretsChangedHandler) resetTimer(ctx context.Context, domainName string) {
105+ h.mu.Lock()
106+ defer h.mu.Unlock()
107+
108+ if existing, ok := h.timers[domainName]; ok {
109+ existing.Stop()
110+ }
111+
112+ h.generations[domainName]++
113+ gen := h.generations[domainName]
114+
115+ h.timers[domainName] = time.AfterFunc(h.debounceDelay, func() {
116+ h.fireDeploy(ctx, domainName, gen)
117+ })
118+}
119+
120+// fireDeploy looks up the route and triggers a deploy.
121+func (h *SecretsChangedHandler) fireDeploy(ctx context.Context, domainName string, generation uint64) {
122+ // Bail if the handler has been stopped (shutdown in progress).
123+ if h.ctx.Err() != nil {
124+ return
125+ }
126+
127+ log := zerowrap.FromCtx(ctx)
128+
129+ h.mu.Lock()
130+ if h.generations[domainName] != generation {
131+ h.mu.Unlock()
132+ return
133+ }
134+ delete(h.timers, domainName)
135+ delete(h.generations, domainName)
136+ h.mu.Unlock()
137+
138+ route, err := h.configSvc.GetRoute(ctx, domainName)
139+ if err != nil {
140+ log.WrapErr(err, "failed to get route for secrets-changed deploy")
141+ return
142+ }
143+ if route == nil {
144+ log.Debug().Msg("no route configured for domain, skipping secrets-changed deploy")
145+ return
146+ }
147+
148+ log.Info().Str("image", route.Image).Msg("debounce fired, deploying after secrets change")
149+
150+ if _, err := h.containerSvc.Deploy(ctx, *route); err != nil {
151+ log.WrapErr(err, "secrets-changed deploy failed")
152+ }
153+}
@@ -0,0 +1,153 @@
1+package container
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/in"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// SecretsChangedHandler handles secrets.changed events with per-domain
15+// debouncing. When secrets are modified rapidly (e.g. multiple
16+// "gordon secrets set" calls), the handler resets a timer on each
17+// event and only triggers a deploy once the domain is quiet for
18+// the configured delay.
19+type SecretsChangedHandler struct {
20+ containerSvc in.ContainerService
21+ configSvc in.ConfigService
22+ ctx context.Context
23+ cancel context.CancelFunc
24+ debounceDelay time.Duration
25+
26+ mu sync.Mutex
27+ timers map[string]*time.Timer
28+ generations map[string]uint64
29+}
30+
31+// NewSecretsChangedHandler creates a new SecretsChangedHandler.
32+func NewSecretsChangedHandler(
33+ ctx context.Context,
34+ containerSvc in.ContainerService,
35+ configSvc in.ConfigService,
36+ debounceDelay time.Duration,
37+) *SecretsChangedHandler {
38+ ctx, cancel := context.WithCancel(ctx)
39+ return &SecretsChangedHandler{
40+ containerSvc: containerSvc,
41+ configSvc: configSvc,
42+ ctx: ctx,
43+ cancel: cancel,
44+ debounceDelay: debounceDelay,
45+ timers: make(map[string]*time.Timer),
46+ generations: make(map[string]uint64),
47+ }
48+}
49+
50+// Handle processes a secrets.changed event by resetting the debounce
51+// timer for the affected domain. Returns immediately - the deploy
52+// runs asynchronously when the timer fires.
53+//
54+// Uses the handler's long-lived context (h.ctx) rather than the event
55+// context because the debounced deploy fires after this method returns;
56+// using the event context would cause premature cancellation.
57+func (h *SecretsChangedHandler) Handle(_ context.Context, event domain.Event) error {
58+ payload, ok := event.Data.(domain.SecretsChangedPayload)
59+ if !ok {
60+ return nil
61+ }
62+ if payload.Domain == "" {
63+ return nil
64+ }
65+
66+ ctx := zerowrap.CtxWithFields(h.ctx, map[string]any{
67+ zerowrap.FieldLayer: "usecase",
68+ zerowrap.FieldHandler: "SecretsChangedHandler",
69+ "domain": payload.Domain,
70+ "operation": payload.Operation,
71+ })
72+ log := zerowrap.FromCtx(ctx)
73+
74+ log.Debug().
75+ Strs("keys", payload.Keys).
76+ Dur("debounce", h.debounceDelay).
77+ Msg("secrets changed, resetting debounce timer")
78+
79+ h.resetTimer(ctx, payload.Domain)
80+ return nil
81+}
82+
83+// CanHandle returns true for secrets.changed events.
84+func (h *SecretsChangedHandler) CanHandle(eventType domain.EventType) bool {
85+ return eventType == domain.EventSecretsChanged
86+}
87+
88+// Stop cancels the handler context and all pending timers.
89+// Call before graceful shutdown to prevent deploys during drain.
90+func (h *SecretsChangedHandler) Stop() {
91+ h.cancel()
92+
93+ h.mu.Lock()
94+ defer h.mu.Unlock()
95+
96+ for domainName, timer := range h.timers {
97+ timer.Stop()
98+ delete(h.timers, domainName)
99+ delete(h.generations, domainName)
100+ }
101+}
102+
103+// resetTimer replaces any existing timer for the domain with a new one.
104+func (h *SecretsChangedHandler) resetTimer(ctx context.Context, domainName string) {
105+ h.mu.Lock()
106+ defer h.mu.Unlock()
107+
108+ if existing, ok := h.timers[domainName]; ok {
109+ existing.Stop()
110+ }
111+
112+ h.generations[domainName]++
113+ gen := h.generations[domainName]
114+
115+ h.timers[domainName] = time.AfterFunc(h.debounceDelay, func() {
116+ h.fireDeploy(ctx, domainName, gen)
117+ })
118+}
119+
120+// fireDeploy looks up the route and triggers a deploy.
121+func (h *SecretsChangedHandler) fireDeploy(ctx context.Context, domainName string, generation uint64) {
122+ // Bail if the handler has been stopped (shutdown in progress).
123+ if h.ctx.Err() != nil {
124+ return
125+ }
126+
127+ log := zerowrap.FromCtx(ctx)
128+
129+ h.mu.Lock()
130+ if h.generations[domainName] != generation {
131+ h.mu.Unlock()
132+ return
133+ }
134+ delete(h.timers, domainName)
135+ delete(h.generations, domainName)
136+ h.mu.Unlock()
137+
138+ route, err := h.configSvc.GetRoute(ctx, domainName)
139+ if err != nil {
140+ log.WrapErr(err, "failed to get route for secrets-changed deploy")
141+ return
142+ }
143+ if route == nil {
144+ log.Debug().Msg("no route configured for domain, skipping secrets-changed deploy")
145+ return
146+ }
147+
148+ log.Info().Str("image", route.Image).Msg("debounce fired, deploying after secrets change")
149+
150+ if _, err := h.containerSvc.Deploy(ctx, *route); err != nil {
151+ log.WrapErr(err, "secrets-changed deploy failed")
152+ }
153+}
A internal/usecase/container/secrets_handler_test.go +203 -0
@@ -0,0 +1,203 @@
1+package container
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func TestSecretsChangedHandler_CanHandle(t *testing.T) {
17+ containerSvc := inmocks.NewMockContainerService(t)
18+ configSvc := inmocks.NewMockConfigService(t)
19+
20+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond)
21+ defer h.Stop()
22+
23+ assert.True(t, h.CanHandle(domain.EventSecretsChanged))
24+ assert.False(t, h.CanHandle(domain.EventConfigReload))
25+}
26+
27+func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) {
28+ containerSvc := inmocks.NewMockContainerService(t)
29+ configSvc := inmocks.NewMockConfigService(t)
30+
31+ debounceDelay := 200 * time.Millisecond
32+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
33+ defer h.Stop()
34+
35+ route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
36+ configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
37+
38+ var deployCalls atomic.Int32
39+ containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) {
40+ deployCalls.Add(1)
41+ return &domain.Container{ID: "container-1"}, nil
42+ }).Once()
43+
44+ event := domain.Event{
45+ Type: domain.EventSecretsChanged,
46+ Data: domain.SecretsChangedPayload{
47+ Domain: route.Domain,
48+ Operation: "set",
49+ Keys: []string{"A", "B"},
50+ },
51+ }
52+
53+ for range 5 {
54+ assert.NoError(t, h.Handle(context.Background(), event))
55+ time.Sleep(10 * time.Millisecond)
56+ }
57+
58+ time.Sleep(debounceDelay + 100*time.Millisecond)
59+ assert.Equal(t, int32(1), deployCalls.Load())
60+}
61+
62+func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
63+ containerSvc := inmocks.NewMockContainerService(t)
64+ configSvc := inmocks.NewMockConfigService(t)
65+
66+ debounceDelay := 100 * time.Millisecond
67+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
68+ defer h.Stop()
69+
70+ routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"}
71+ routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"}
72+
73+ configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()
74+ configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()
75+
76+ containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).Return(&domain.Container{ID: "container-1"}, nil).Once()
77+ containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).Return(&domain.Container{ID: "container-2"}, nil).Once()
78+
79+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
80+ Type: domain.EventSecretsChanged,
81+ Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}},
82+ }))
83+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
84+ Type: domain.EventSecretsChanged,
85+ Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},
86+ }))
87+
88+ time.Sleep(debounceDelay + 100*time.Millisecond)
89+}
90+
91+func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {
92+ containerSvc := inmocks.NewMockContainerService(t)
93+ configSvc := inmocks.NewMockConfigService(t)
94+
95+ debounceDelay := 100 * time.Millisecond
96+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
97+ defer h.Stop()
98+
99+ configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once()
100+
101+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
102+ Type: domain.EventSecretsChanged,
103+ Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}},
104+ }))
105+
106+ time.Sleep(debounceDelay + 100*time.Millisecond)
107+}
108+
109+func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) {
110+ containerSvc := inmocks.NewMockContainerService(t)
111+ configSvc := inmocks.NewMockConfigService(t)
112+
113+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond)
114+ defer h.Stop()
115+
116+ err := h.Handle(context.Background(), domain.Event{
117+ Type: domain.EventSecretsChanged,
118+ Data: "wrong-payload",
119+ })
120+
121+ assert.NoError(t, err)
122+}
123+
124+func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) {
125+ containerSvc := inmocks.NewMockContainerService(t)
126+ configSvc := inmocks.NewMockConfigService(t)
127+
128+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
129+ defer h.Stop()
130+
131+ const domainName = "app.example.com"
132+
133+ // White-box: directly set internal state to simulate a stale generation.
134+ // This exercises the generation-based debounce logic that prevents
135+ // already-fired timers from triggering duplicate deploys.
136+ h.mu.Lock()
137+ h.generations[domainName] = 2
138+ h.timers[domainName] = time.NewTimer(time.Hour)
139+ h.mu.Unlock()
140+
141+ staleDone := make(chan struct{})
142+ go func() {
143+ defer close(staleDone)
144+ h.fireDeploy(context.Background(), domainName, 1)
145+ }()
146+
147+ select {
148+ case <-staleDone:
149+ case <-time.After(2 * time.Second):
150+ t.Fatal("stale fireDeploy did not return")
151+ }
152+
153+ h.mu.Lock()
154+ _, timerExists := h.timers[domainName]
155+ gen := h.generations[domainName]
156+ h.mu.Unlock()
157+
158+ assert.True(t, timerExists)
159+ assert.Equal(t, uint64(2), gen)
160+}
161+
162+func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) {
163+ containerSvc := inmocks.NewMockContainerService(t)
164+ configSvc := inmocks.NewMockConfigService(t)
165+
166+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
167+ defer h.Stop()
168+
169+ route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
170+ configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
171+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once()
172+
173+ h.mu.Lock()
174+ h.generations[route.Domain] = 1
175+ h.timers[route.Domain] = time.NewTimer(time.Hour)
176+ h.mu.Unlock()
177+
178+ h.fireDeploy(context.Background(), route.Domain, 1)
179+
180+ h.mu.Lock()
181+ _, timerExists := h.timers[route.Domain]
182+ _, generationExists := h.generations[route.Domain]
183+ h.mu.Unlock()
184+
185+ assert.False(t, timerExists)
186+ assert.False(t, generationExists)
187+}
188+
189+func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) {
190+ containerSvc := inmocks.NewMockContainerService(t)
191+ configSvc := inmocks.NewMockConfigService(t)
192+
193+ debounceDelay := 50 * time.Millisecond
194+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
195+
196+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
197+ Type: domain.EventSecretsChanged,
198+ Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}},
199+ }))
200+
201+ h.Stop()
202+ time.Sleep(debounceDelay + 50*time.Millisecond)
203+}
@@ -0,0 +1,203 @@
1+package container
2+
3+import (
4+ "context"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func TestSecretsChangedHandler_CanHandle(t *testing.T) {
17+ containerSvc := inmocks.NewMockContainerService(t)
18+ configSvc := inmocks.NewMockConfigService(t)
19+
20+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond)
21+ defer h.Stop()
22+
23+ assert.True(t, h.CanHandle(domain.EventSecretsChanged))
24+ assert.False(t, h.CanHandle(domain.EventConfigReload))
25+}
26+
27+func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) {
28+ containerSvc := inmocks.NewMockContainerService(t)
29+ configSvc := inmocks.NewMockConfigService(t)
30+
31+ debounceDelay := 200 * time.Millisecond
32+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
33+ defer h.Stop()
34+
35+ route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
36+ configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
37+
38+ var deployCalls atomic.Int32
39+ containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) {
40+ deployCalls.Add(1)
41+ return &domain.Container{ID: "container-1"}, nil
42+ }).Once()
43+
44+ event := domain.Event{
45+ Type: domain.EventSecretsChanged,
46+ Data: domain.SecretsChangedPayload{
47+ Domain: route.Domain,
48+ Operation: "set",
49+ Keys: []string{"A", "B"},
50+ },
51+ }
52+
53+ for range 5 {
54+ assert.NoError(t, h.Handle(context.Background(), event))
55+ time.Sleep(10 * time.Millisecond)
56+ }
57+
58+ time.Sleep(debounceDelay + 100*time.Millisecond)
59+ assert.Equal(t, int32(1), deployCalls.Load())
60+}
61+
62+func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
63+ containerSvc := inmocks.NewMockContainerService(t)
64+ configSvc := inmocks.NewMockConfigService(t)
65+
66+ debounceDelay := 100 * time.Millisecond
67+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
68+ defer h.Stop()
69+
70+ routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"}
71+ routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"}
72+
73+ configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()
74+ configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()
75+
76+ containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).Return(&domain.Container{ID: "container-1"}, nil).Once()
77+ containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).Return(&domain.Container{ID: "container-2"}, nil).Once()
78+
79+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
80+ Type: domain.EventSecretsChanged,
81+ Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}},
82+ }))
83+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
84+ Type: domain.EventSecretsChanged,
85+ Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},
86+ }))
87+
88+ time.Sleep(debounceDelay + 100*time.Millisecond)
89+}
90+
91+func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {
92+ containerSvc := inmocks.NewMockContainerService(t)
93+ configSvc := inmocks.NewMockConfigService(t)
94+
95+ debounceDelay := 100 * time.Millisecond
96+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
97+ defer h.Stop()
98+
99+ configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once()
100+
101+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
102+ Type: domain.EventSecretsChanged,
103+ Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}},
104+ }))
105+
106+ time.Sleep(debounceDelay + 100*time.Millisecond)
107+}
108+
109+func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) {
110+ containerSvc := inmocks.NewMockContainerService(t)
111+ configSvc := inmocks.NewMockConfigService(t)
112+
113+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond)
114+ defer h.Stop()
115+
116+ err := h.Handle(context.Background(), domain.Event{
117+ Type: domain.EventSecretsChanged,
118+ Data: "wrong-payload",
119+ })
120+
121+ assert.NoError(t, err)
122+}
123+
124+func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) {
125+ containerSvc := inmocks.NewMockContainerService(t)
126+ configSvc := inmocks.NewMockConfigService(t)
127+
128+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
129+ defer h.Stop()
130+
131+ const domainName = "app.example.com"
132+
133+ // White-box: directly set internal state to simulate a stale generation.
134+ // This exercises the generation-based debounce logic that prevents
135+ // already-fired timers from triggering duplicate deploys.
136+ h.mu.Lock()
137+ h.generations[domainName] = 2
138+ h.timers[domainName] = time.NewTimer(time.Hour)
139+ h.mu.Unlock()
140+
141+ staleDone := make(chan struct{})
142+ go func() {
143+ defer close(staleDone)
144+ h.fireDeploy(context.Background(), domainName, 1)
145+ }()
146+
147+ select {
148+ case <-staleDone:
149+ case <-time.After(2 * time.Second):
150+ t.Fatal("stale fireDeploy did not return")
151+ }
152+
153+ h.mu.Lock()
154+ _, timerExists := h.timers[domainName]
155+ gen := h.generations[domainName]
156+ h.mu.Unlock()
157+
158+ assert.True(t, timerExists)
159+ assert.Equal(t, uint64(2), gen)
160+}
161+
162+func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) {
163+ containerSvc := inmocks.NewMockContainerService(t)
164+ configSvc := inmocks.NewMockConfigService(t)
165+
166+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
167+ defer h.Stop()
168+
169+ route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
170+ configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
171+ containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once()
172+
173+ h.mu.Lock()
174+ h.generations[route.Domain] = 1
175+ h.timers[route.Domain] = time.NewTimer(time.Hour)
176+ h.mu.Unlock()
177+
178+ h.fireDeploy(context.Background(), route.Domain, 1)
179+
180+ h.mu.Lock()
181+ _, timerExists := h.timers[route.Domain]
182+ _, generationExists := h.generations[route.Domain]
183+ h.mu.Unlock()
184+
185+ assert.False(t, timerExists)
186+ assert.False(t, generationExists)
187+}
188+
189+func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) {
190+ containerSvc := inmocks.NewMockContainerService(t)
191+ configSvc := inmocks.NewMockConfigService(t)
192+
193+ debounceDelay := 50 * time.Millisecond
194+ h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
195+
196+ assert.NoError(t, h.Handle(context.Background(), domain.Event{
197+ Type: domain.EventSecretsChanged,
198+ Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}},
199+ }))
200+
201+ h.Stop()
202+ time.Sleep(debounceDelay + 50*time.Millisecond)
203+}
M internal/usecase/secrets/service.go +41 -5
@@ -24,15 +24,35 @@ var (
2424
2525 // Service implements the SecretService interface.
2626 type Service struct {
27- store out.DomainSecretStore
28- log zerowrap.Logger
27+ store out.DomainSecretStore
28+ log zerowrap.Logger
29+ eventBus out.EventPublisher
2930 }
3031
3132 // NewService creates a new secrets service.
32-func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service {
33+func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service {
3334 return &Service{
34- store: store,
35- log: log,
35+ store: store,
36+ log: log,
37+ eventBus: eventBus,
38+ }
39+}
40+
41+// publishSecretsChanged publishes a secrets.changed event.
42+// Failures are logged but do not fail the mutation - secret storage
43+// is the source of truth; the event is a best-effort notification.
44+func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) {
45+ if s.eventBus == nil {
46+ return
47+ }
48+ log := zerowrap.FromCtx(ctx)
49+ payload := domain.SecretsChangedPayload{
50+ Domain: domainName,
51+ Operation: operation,
52+ Keys: keys,
53+ }
54+ if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil {
55+ log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event")
3656 }
3757 }
3858
@@ -140,6 +160,12 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str
140160 return err
141161 }
142162
163+ keys := make([]string, 0, len(secrets))
164+ for k := range secrets {
165+ keys = append(keys, k)
166+ }
167+ s.publishSecretsChanged(ctx, domain, "set", keys)
168+
143169 log.Info().Int("count", len(secrets)).Msg("secrets set")
144170 return nil
145171 }
@@ -164,6 +190,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error {
164190 return err
165191 }
166192
193+ s.publishSecretsChanged(ctx, domain, "delete", []string{key})
194+
167195 log.Info().Msg("secret deleted")
168196 return nil
169197 }
@@ -195,6 +223,12 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string,
195223 return err
196224 }
197225
226+ keys := make([]string, 0, len(secrets))
227+ for k := range secrets {
228+ keys = append(keys, k)
229+ }
230+ s.publishSecretsChanged(ctx, domainName, "set", keys)
231+
198232 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
199233 return nil
200234 }
@@ -227,6 +261,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key
227261 return err
228262 }
229263
264+ s.publishSecretsChanged(ctx, domainName, "delete", []string{key})
265+
230266 log.Info().Str("container", containerName).Msg("attachment secret deleted")
231267 return nil
232268 }
@@ -24,15 +24,35 @@ var (
24 24
25 // Service implements the SecretService interface.25 // Service implements the SecretService interface.
26 type Service struct {26 type Service struct {
27- store out.DomainSecretStore27+ store out.DomainSecretStore
28- log zerowrap.Logger28+ log zerowrap.Logger
29+ eventBus out.EventPublisher
29 }30 }
30 31
31 // NewService creates a new secrets service.32 // NewService creates a new secrets service.
32-func NewService(store out.DomainSecretStore, log zerowrap.Logger) *Service {33+func NewService(store out.DomainSecretStore, log zerowrap.Logger, eventBus out.EventPublisher) *Service {
33 return &Service{34 return &Service{
34- store: store,35+ store: store,
35- log: log,36+ log: log,
37+ eventBus: eventBus,
38+ }
39+}
40+
41+// publishSecretsChanged publishes a secrets.changed event.
42+// Failures are logged but do not fail the mutation - secret storage
43+// is the source of truth; the event is a best-effort notification.
44+func (s *Service) publishSecretsChanged(ctx context.Context, domainName, operation string, keys []string) {
45+ if s.eventBus == nil {
46+ return
47+ }
48+ log := zerowrap.FromCtx(ctx)
49+ payload := domain.SecretsChangedPayload{
50+ Domain: domainName,
51+ Operation: operation,
52+ Keys: keys,
53+ }
54+ if err := s.eventBus.Publish(domain.EventSecretsChanged, payload); err != nil {
55+ log.Warn().Err(err).Str("domain", domainName).Msg("failed to publish secrets.changed event")
36 }56 }
37 }57 }
38 58
@@ -140,6 +160,12 @@ func (s *Service) Set(ctx context.Context, domain string, secrets map[string]str
140 return err160 return err
141 }161 }
142 162
163+ keys := make([]string, 0, len(secrets))
164+ for k := range secrets {
165+ keys = append(keys, k)
166+ }
167+ s.publishSecretsChanged(ctx, domain, "set", keys)
168+
143 log.Info().Int("count", len(secrets)).Msg("secrets set")169 log.Info().Int("count", len(secrets)).Msg("secrets set")
144 return nil170 return nil
145 }171 }
@@ -164,6 +190,8 @@ func (s *Service) Delete(ctx context.Context, domain, key string) error {
164 return err190 return err
165 }191 }
166 192
193+ s.publishSecretsChanged(ctx, domain, "delete", []string{key})
194+
167 log.Info().Msg("secret deleted")195 log.Info().Msg("secret deleted")
168 return nil196 return nil
169 }197 }
@@ -195,6 +223,12 @@ func (s *Service) SetAttachment(ctx context.Context, domainName, service string,
195 return err223 return err
196 }224 }
197 225
226+ keys := make([]string, 0, len(secrets))
227+ for k := range secrets {
228+ keys = append(keys, k)
229+ }
230+ s.publishSecretsChanged(ctx, domainName, "set", keys)
231+
198 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")232 log.Info().Int("count", len(secrets)).Str("container", containerName).Msg("attachment secrets set")
199 return nil233 return nil
200 }234 }
@@ -227,6 +261,8 @@ func (s *Service) DeleteAttachment(ctx context.Context, domainName, service, key
227 return err261 return err
228 }262 }
229 263
264+ s.publishSecretsChanged(ctx, domainName, "delete", []string{key})
265+
230 log.Info().Str("container", containerName).Msg("attachment secret deleted")266 log.Info().Str("container", containerName).Msg("attachment secret deleted")
231 return nil267 return nil
232 }268 }
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