v2.31.0-dev8 ec8c5b9

secrets_handler.go RawHistoryFolder

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}

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