1package container
2
3import (
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
14const 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.
21type 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.
34func 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.
59func (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.
86func (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.
92func (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.
106func (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.
123func (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(domain.WithInternalDeploy(ctx), *route); err != nil {
153 log.WrapErr(err, "secrets-changed deploy failed")
154 }
155}