v3.3.0 b164fe9

reload.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"crypto/tls"
6	"fmt"
7	"sync"
8	"time"
9
10	"github.com/bnema/zerowrap"
11	"github.com/spf13/viper"
12
13	"github.com/bnema/gordon/internal/boundaries/out"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/internal/usecase/container"
16	"github.com/bnema/gordon/internal/usecase/proxy"
17)
18
19type configWatcher interface {
20	Watch(ctx context.Context, onChange func()) error
21}
22
23// publicTLSReconciler is the interface for reconciling public TLS certificates.
24type publicTLSReconciler interface {
25	Reconcile(context.Context) error
26}
27
28type configReloader interface {
29	Reload(ctx context.Context) error
30}
31
32type proxyConfigUpdater interface {
33	UpdateConfig(config proxy.Config)
34}
35
36type reloadTrigger interface {
37	Trigger(ctx context.Context) error
38}
39
40type loadedConfigApplier interface {
41	ApplyLoadedConfig(ctx context.Context) error
42}
43
44type reloadCoordinator struct {
45	mu                 sync.Mutex
46	lastRun            time.Time
47	debounce           time.Duration
48	trailingTimer      *time.Timer
49	trailingGeneration uint64
50	stopped            bool
51
52	// lifecycleCtx owns the context used by debounced trailing reloads. Its
53	// base is detached from any caller's cancellation so a short-lived request
54	// context cannot abort a coalesced apply, while lifecycleCancel lets Stop
55	// tear the coordinator's own lifecycle down explicitly.
56	lifecycleCtx    context.Context
57	lifecycleCancel context.CancelFunc
58
59	configSvc configReloader
60	v         *viper.Viper
61	proxySvc  proxyConfigUpdater
62	// applyRuntime applies one validated config to the running daemon
63	// (policies, management hosts, traffic, entrypoints, container
64	// config). Nil when no runtime is wired (tests).
65	applyRuntime   func(context.Context, Config) error
66	registryLimits interface {
67		UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
68	}
69	eventBus  out.EventPublisher
70	publicTLS publicTLSReconciler
71	log       zerowrap.Logger
72}
73
74func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface {
75	UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
76}, eventBus out.EventPublisher, publicTLS publicTLSReconciler, applyRuntime func(context.Context, Config) error, log zerowrap.Logger) *reloadCoordinator {
77	return &reloadCoordinator{
78		debounce:       500 * time.Millisecond,
79		configSvc:      configSvc,
80		v:              v,
81		proxySvc:       proxySvc,
82		applyRuntime:   applyRuntime,
83		registryLimits: registryLimits,
84		eventBus:       eventBus,
85		publicTLS:      publicTLS,
86		log:            log,
87	}
88}
89
90func (c *reloadCoordinator) SetRegistryLimits(limits interface {
91	UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
92}) {
93	c.mu.Lock()
94	defer c.mu.Unlock()
95	c.registryLimits = limits
96}
97
98// Trigger requests a config reload that first re-reads config from disk.
99func (c *reloadCoordinator) Trigger(ctx context.Context) error {
100	c.mu.Lock()
101	defer c.mu.Unlock()
102
103	return c.reloadDebouncedLocked(ctx, true)
104}
105
106// ApplyLoadedConfig requests a reload of the config the watcher already
107// loaded from disk. It shares the debounce/coalescing policy with Trigger so
108// a burst of fsnotify callbacks applies the final state exactly once.
109func (c *reloadCoordinator) ApplyLoadedConfig(ctx context.Context) error {
110	c.mu.Lock()
111	defer c.mu.Unlock()
112
113	return c.reloadDebouncedLocked(ctx, false)
114}
115
116// reloadDebouncedLocked is the single owner of the debounce/coalescing policy
117// shared by every reload entrypoint. Requests inside the debounce window are
118// merged into one trailing reload carrying the latest loadConfig intent.
119func (c *reloadCoordinator) reloadDebouncedLocked(ctx context.Context, loadConfig bool) error {
120	now := time.Now()
121	if !c.lastRun.IsZero() && now.Sub(c.lastRun) < c.debounce {
122		c.scheduleTrailingReloadLocked(ctx, loadConfig)
123		return nil
124	}
125
126	return c.reloadLocked(ctx, loadConfig)
127}
128
129func (c *reloadCoordinator) scheduleTrailingReloadLocked(ctx context.Context, loadConfig bool) {
130	if c.stopped {
131		return
132	}
133	if c.trailingTimer != nil {
134		c.trailingTimer.Stop()
135	}
136	if c.lifecycleCancel == nil {
137		c.lifecycleCtx, c.lifecycleCancel = context.WithCancel(context.WithoutCancel(ctx))
138	}
139	c.trailingGeneration++
140	generation := c.trailingGeneration
141	trailingCtx := c.lifecycleCtx
142	c.trailingTimer = time.AfterFunc(c.debounce, func() {
143		c.runTrailingReload(trailingCtx, generation, loadConfig)
144	})
145	c.log.Debug().Msg("coalescing config reload trigger")
146}
147
148func (c *reloadCoordinator) runTrailingReload(ctx context.Context, generation uint64, loadConfig bool) {
149	c.mu.Lock()
150	defer c.mu.Unlock()
151
152	if c.stopped || generation != c.trailingGeneration {
153		return
154	}
155	c.trailingTimer = nil
156	if err := c.reloadLocked(ctx, loadConfig); err != nil {
157		c.log.Error().Err(err).Msg("failed trailing config reload")
158	}
159}
160
161func (c *reloadCoordinator) Stop() {
162	c.mu.Lock()
163	defer c.mu.Unlock()
164	c.stopped = true
165	c.trailingGeneration++
166	if c.trailingTimer != nil {
167		c.trailingTimer.Stop()
168		c.trailingTimer = nil
169	}
170	if c.lifecycleCancel != nil {
171		c.lifecycleCancel()
172	}
173}
174
175func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) error {
176	now := time.Now()
177	if loadConfig {
178		if err := c.configSvc.Reload(ctx); err != nil {
179			c.log.Error().Err(err).Msg("failed to reload config")
180			return fmt.Errorf("failed to reload config: %w", err)
181		}
182	}
183
184	if err := c.applyLoadedConfig(ctx, now); err != nil {
185		return err
186	}
187
188	return nil
189}
190
191func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time) error {
192	var reloadCfg Config
193	if err := c.v.Unmarshal(&reloadCfg); err != nil {
194		c.log.Error().Err(err).Msg("failed to unmarshal config on reload")
195		return fmt.Errorf("failed to unmarshal config on reload: %w", err)
196	}
197	if err := validateEntrypointMigration(c.v, reloadCfg); err != nil {
198		return err
199	}
200	if err := validateRetiredAppConfig(c.v); err != nil {
201		return err
202	}
203	if err := validateAppPolicies(c.v, reloadCfg); err != nil {
204		return err
205	}
206
207	reloadedProxy, err := buildProxyConfig(reloadCfg, c.log)
208	if err != nil {
209		c.log.Error().Err(err).Msg("failed to parse proxy config on reload")
210		return fmt.Errorf("failed to parse proxy config on reload: %w", err)
211	}
212
213	if c.applyRuntime != nil {
214		if err := c.applyRuntime(ctx, reloadCfg); err != nil {
215			c.log.Error().Err(err).Msg("failed to apply runtime config on reload")
216			return fmt.Errorf("failed to apply runtime config on reload: %w", err)
217		}
218	}
219	c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
220	if c.registryLimits != nil {
221		c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
222	}
223
224	// Reconcile public TLS before publishing reload events so certificate
225	// authorization reflects the loaded config even if event delivery fails.
226	// A transient ACME issue must not abort the rest of the reload.
227	if c.publicTLS != nil {
228		if err := c.publicTLS.Reconcile(ctx); err != nil {
229			c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing")
230		}
231	}
232
233	if c.eventBus != nil {
234		if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
235			c.log.Error().Err(err).Msg("failed to publish config reload event")
236			return fmt.Errorf("failed to publish config reload event: %w", err)
237		}
238	}
239
240	c.lastRun = now
241
242	c.log.Debug().Msg("config hot reload complete")
243	return nil
244}
245
246// reloadRuntime applies a validated config to the running daemon. It
247// prepares every derived value before the first side effect, so a config
248// that cannot be converted leaves the whole runtime at the previous
249// version. Side effects then run in one fixed order: traffic graph (the
250// only fallible step), management hosts, HTTP entrypoints, container
251// config, app policies.
252type reloadRuntime struct {
253	v   *viper.Viper
254	svc *services
255	log zerowrap.Logger
256}
257
258// reloadPlan holds the values derived from one config before any apply.
259type reloadPlan struct {
260	containerCfg   container.Config
261	tlsConfig      *tls.Config
262	bindPolicies   map[string]domain.AppBindPolicy
263	devicePolicies map[string]domain.AppDevicePolicy
264}
265
266// Apply is the reloadCoordinator's runtime step.
267func (r *reloadRuntime) Apply(ctx context.Context, cfg Config) error {
268	plan, err := r.prepare(ctx, cfg)
269	if err != nil {
270		return fmt.Errorf("prepare reload: %w", err)
271	}
272	if r.svc.containerSvc != nil {
273		if err := r.applyServing(ctx, cfg, plan); err != nil {
274			return fmt.Errorf("apply serving config: %w", err)
275		}
276	}
277	r.applyAppPolicies(plan)
278	return nil
279}
280
281func (r *reloadRuntime) prepare(ctx context.Context, cfg Config) (reloadPlan, error) {
282	var plan reloadPlan
283	var err error
284	if r.svc.containerSvc != nil {
285		if plan.containerCfg, err = buildContainerServiceConfig(ctx, r.v, cfg, r.svc, r.log); err != nil {
286			return plan, err
287		}
288		// The TLS config selects certificates at handshake time, so it is
289		// safe to build before management hosts change; building it here
290		// surfaces keypair load errors before any side effect.
291		if hasTLSCapableEntrypoint(cfg) && r.svc.httpsProxyHandler != nil {
292			if plan.tlsConfig, err = proxyTLSConfig(cfg, r.svc.pkiSvc, r.svc.publicTLSSvc, r.log); err != nil {
293				return plan, err
294			}
295		}
296	}
297	if r.svc.appSvcImpl != nil {
298		if plan.bindPolicies, err = buildAppMountPolicies(cfg); err != nil {
299			return plan, err
300		}
301		if plan.devicePolicies, err = buildAppDevicePolicies(cfg); err != nil {
302			return plan, err
303		}
304	}
305	return plan, nil
306}
307
308// applyServing updates everything that serves traffic. The serialized
309// traffic graph rebuild is the only fallible step and runs first, so a
310// rejected graph leaves management hosts, entrypoints, and container
311// config at the previous version.
312func (r *reloadRuntime) applyServing(ctx context.Context, cfg Config, plan reloadPlan) error {
313	svc := r.svc
314	if err := svc.appTrafficPublisher.RebuildWithConfig(ctx, cfg); err != nil {
315		return err
316	}
317	managementHosts := []string{cfg.Server.GordonDomain}
318	if svc.pkiSvc != nil {
319		svc.pkiSvc.SetAdditionalDomains(managementHosts)
320	}
321	if svc.publicTLSSvc != nil {
322		svc.publicTLSSvc.SetAdditionalHosts(ctx, managementHosts)
323	}
324	svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(svc.trafficManager, cfg, svc.httpsProxyHandler, plan.tlsConfig, svc.tlsHTTPEntryPoints)
325	svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(svc.trafficManager, cfg, svc.httpProxyHandler, svc.httpsProxyHandler, plan.tlsConfig, svc.smartHTTPEntryPoints)
326	svc.containerSvc.UpdateConfig(plan.containerCfg)
327	return nil
328}
329
330// applyAppPolicies publishes the reloaded bind and device policies to both
331// app services that enforce them.
332func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {
333	if r.svc.appSvcImpl == nil {
334		return
335	}
336	r.svc.appSvcImpl.SetBindPolicies(plan.bindPolicies)
337	r.svc.appSvcImpl.SetDevicePolicies(plan.devicePolicies)
338	if r.svc.appDeploySvc != nil {
339		r.svc.appDeploySvc.SetBindPolicies(plan.bindPolicies)
340		r.svc.appDeploySvc.SetDevicePolicies(plan.devicePolicies)
341	}
342}
343
344// setupConfigHotReload sets up config hot reload.
345func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordinator loadedConfigApplier) error {
346	if err := configSvc.Watch(ctx, func() {
347		_ = coordinator.ApplyLoadedConfig(ctx)
348	}); err != nil {
349		return fmt.Errorf("failed to watch config: %w", err)
350	}
351
352	return nil
353}

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