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}