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 capPolicies map[string]domain.AppCapabilityPolicy
265}
266
267// Apply is the reloadCoordinator's runtime step.
268func (r *reloadRuntime) Apply(ctx context.Context, cfg Config) error {
269 plan, err := r.prepare(ctx, cfg)
270 if err != nil {
271 return fmt.Errorf("prepare reload: %w", err)
272 }
273 if r.svc.containerSvc != nil {
274 if err := r.applyServing(ctx, cfg, plan); err != nil {
275 return fmt.Errorf("apply serving config: %w", err)
276 }
277 }
278 r.applyAppPolicies(plan)
279 return nil
280}
281
282func (r *reloadRuntime) prepare(ctx context.Context, cfg Config) (reloadPlan, error) {
283 var plan reloadPlan
284 var err error
285 if r.svc.containerSvc != nil {
286 if plan.containerCfg, err = buildContainerServiceConfig(ctx, r.v, cfg, r.svc, r.log); err != nil {
287 return plan, err
288 }
289 // The TLS config selects certificates at handshake time, so it is
290 // safe to build before management hosts change; building it here
291 // surfaces keypair load errors before any side effect.
292 if hasTLSCapableEntrypoint(cfg) && r.svc.httpsProxyHandler != nil {
293 if plan.tlsConfig, err = proxyTLSConfig(cfg, r.svc.pkiSvc, r.svc.publicTLSSvc, r.log); err != nil {
294 return plan, err
295 }
296 }
297 }
298 if r.svc.appSvcImpl != nil {
299 if plan.bindPolicies, err = buildAppMountPolicies(cfg); err != nil {
300 return plan, err
301 }
302 if plan.devicePolicies, err = buildAppDevicePolicies(cfg); err != nil {
303 return plan, err
304 }
305 if plan.capPolicies, err = buildAppCapabilityPolicies(cfg); err != nil {
306 return plan, err
307 }
308 }
309 return plan, nil
310}
311
312// applyServing updates everything that serves traffic. The serialized
313// traffic graph rebuild is the only fallible step and runs first, so a
314// rejected graph leaves management hosts, entrypoints, and container
315// config at the previous version.
316func (r *reloadRuntime) applyServing(ctx context.Context, cfg Config, plan reloadPlan) error {
317 svc := r.svc
318 if err := svc.appTrafficPublisher.RebuildWithConfig(ctx, cfg); err != nil {
319 return err
320 }
321 managementHosts := []string{cfg.Server.GordonDomain}
322 if svc.pkiSvc != nil {
323 svc.pkiSvc.SetAdditionalDomains(managementHosts)
324 }
325 if svc.publicTLSSvc != nil {
326 svc.publicTLSSvc.SetAdditionalHosts(ctx, managementHosts)
327 }
328 svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(svc.trafficManager, cfg, svc.httpsProxyHandler, plan.tlsConfig, svc.tlsHTTPEntryPoints)
329 svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(svc.trafficManager, cfg, svc.httpProxyHandler, svc.httpsProxyHandler, plan.tlsConfig, svc.smartHTTPEntryPoints)
330 svc.containerSvc.UpdateConfig(plan.containerCfg)
331 return nil
332}
333
334// applyAppPolicies publishes the reloaded bind, device, and capability policies to both
335// app services that enforce them.
336func (r *reloadRuntime) applyAppPolicies(plan reloadPlan) {
337 if r.svc.appSvcImpl == nil {
338 return
339 }
340 r.svc.appSvcImpl.SetBindPolicies(plan.bindPolicies)
341 r.svc.appSvcImpl.SetDevicePolicies(plan.devicePolicies)
342 r.svc.appSvcImpl.SetCapabilityPolicies(plan.capPolicies)
343 if r.svc.appDeploySvc != nil {
344 r.svc.appDeploySvc.SetBindPolicies(plan.bindPolicies)
345 r.svc.appDeploySvc.SetDevicePolicies(plan.devicePolicies)
346 r.svc.appDeploySvc.SetCapabilityPolicies(plan.capPolicies)
347 }
348}
349
350// setupConfigHotReload sets up config hot reload.
351func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordinator loadedConfigApplier) error {
352 if err := configSvc.Watch(ctx, func() {
353 _ = coordinator.ApplyLoadedConfig(ctx)
354 }); err != nil {
355 return fmt.Errorf("failed to watch config: %w", err)
356 }
357
358 return nil
359}