1// Package app provides the application initialization and wiring.
2//
3// run.go owns the daemon lifecycle (Run, server start, readiness). Each
4// concern it orders lives in its own file: config.go (load/validate),
5// services.go (service construction), app_wiring.go (apps engine),
6// auth_secrets.go and credentials.go (auth and internal registry
7// credentials), http_edge.go (handlers and middleware), listeners.go
8// (proxy/TLS/registry listeners), reload.go (hot reload), schedulers.go,
9// shutdown.go, and pidfile.go.
10package app
11
12import (
13 "context"
14 "fmt"
15 "net/http"
16 "os"
17 "os/signal"
18 "path/filepath"
19 "syscall"
20 "time"
21
22 "github.com/bnema/zerowrap"
23 zerowrapotel "github.com/bnema/zerowrap/otel"
24 "github.com/spf13/viper"
25
26 "github.com/bnema/gordon/internal/adapters/out/accesslog"
27 "github.com/bnema/gordon/internal/adapters/out/telemetry"
28 "github.com/bnema/gordon/internal/boundaries/out"
29 "github.com/bnema/gordon/internal/usecase/logexport"
30 "github.com/bnema/gordon/pkg/version"
31)
32
33// Run initializes and starts the Gordon application.
34func Run(ctx context.Context, configPath string) error {
35 // Load configuration
36 v, cfg, err := initConfig(configPath)
37 if err != nil {
38 return err
39 }
40
41 // Initialize logger
42 log, cleanup, err := initLogger(cfg)
43 if err != nil {
44 return err
45 }
46 if cleanup != nil {
47 defer cleanup()
48 }
49
50 ctx = zerowrap.WithCtx(ctx, log)
51
52 // Initialize OpenTelemetry
53 telProvider, telShutdown, err := telemetry.NewProvider(ctx, cfg.Telemetry, "gordon", version.Version())
54 if err != nil {
55 log.Warn().Err(err).Msg("failed to initialize telemetry, continuing without it")
56 } else {
57 // Use a fresh context for shutdown so a canceled app ctx doesn't prevent flushing.
58 defer func() {
59 shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
60 defer cancel()
61 telShutdown(shutdownCtx)
62 }()
63 if cfg.Telemetry.Enabled && cfg.Telemetry.Endpoint != "" {
64 // Bridge zerowrap logs to OTel if log export is enabled
65 if cfg.Telemetry.Logs && telProvider.LogProvider != nil {
66 otelHook := zerowrapotel.NewHookWithProvider(telProvider.LogProvider, "gordon")
67 log = zerowrap.WithHook(log, otelHook)
68 ctx = zerowrap.WithCtx(ctx, log)
69 }
70 log.Info().Str("endpoint", cfg.Telemetry.Endpoint).Msg("telemetry initialized")
71 }
72 }
73
74 log.Info().Msg("Gordon starting")
75
76 warnDeprecatedConfigKeys(v, log)
77
78 // Create PID file
79 pidFile := createPidFile(log)
80 if pidFile != "" {
81 defer removePidFile(pidFile, log)
82 }
83
84 // Create all services
85 svc, err := createServices(ctx, v, cfg, log)
86 if err != nil {
87 return err
88 }
89 svc.workloadLogs = workloadLogExporter(telProvider)
90
91 // Register event handlers
92 cleanupHandlers, err := registerEventHandlers(ctx, svc)
93 if err != nil {
94 return err
95 }
96 // cleanupHandlers is passed into runServers so it can stop debounce
97 // timers before graceful shutdown, preventing deploys during drain.
98
99 // Set up config hot reload
100 if err := setupConfigHotReload(ctx, svc.configSvc, svc.reloadCoordinator); err != nil {
101 return err
102 }
103
104 // Start event bus
105 if err := svc.eventBus.Start(); err != nil {
106 return log.WrapErr(err, "failed to start event bus")
107 }
108 defer svc.eventBus.Stop()
109
110 // Start servers, wait for listeners to bind, then sync/auto-start containers.
111 return runServers(ctx, v, cfg, svc, svc.reloadCoordinator, cleanupHandlers, log)
112}
113
114// initLogger initializes the zerowrap logger.
115func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
116 logConfig := zerowrap.Config{
117 Level: cfg.Logging.Level,
118 Format: cfg.Logging.Format,
119 }
120
121 // Always enable file logging so admin API can read process logs
122 // (Config flag is respected for backward-compatibility)
123 logPath := cfg.Logging.File.Path
124 if logPath == "" {
125 dataDir := cfg.Server.DataDir
126 if dataDir == "" {
127 dataDir = DefaultDataDir()
128 }
129 logPath = filepath.Join(dataDir, "logs", "gordon.log")
130 }
131
132 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
133 Enabled: cfg.Logging.File.Enabled,
134 Path: logPath,
135 MaxSize: cfg.Logging.File.MaxSize,
136 MaxBackups: cfg.Logging.File.MaxBackups,
137 MaxAge: cfg.Logging.File.MaxAge,
138 Compress: true,
139 })
140 if err != nil {
141 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
142 }
143 return log, cleanup, nil
144}
145
146// initAccessLog creates an access log writer when access logging is enabled.
147// Returns nil, nil when disabled — callers must treat nil writer as "disabled".
148func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
149 if !cfg.Logging.AccessLog.Enabled {
150 return nil, nil
151 }
152
153 filePath := cfg.Logging.AccessLog.FilePath
154 if filePath == "" && cfg.Logging.AccessLog.Output == "file" {
155 dataDir := cfg.Server.DataDir
156 if dataDir == "" {
157 dataDir = DefaultDataDir()
158 }
159 filePath = filepath.Join(dataDir, "logs", "access.log")
160 }
161
162 writer, err := accesslog.New(accesslog.Config{
163 Format: cfg.Logging.AccessLog.Format,
164 Output: cfg.Logging.AccessLog.Output,
165 FilePath: filePath,
166 MaxSize: cfg.Logging.AccessLog.MaxSize,
167 MaxBackups: cfg.Logging.AccessLog.MaxBackups,
168 MaxAge: cfg.Logging.AccessLog.MaxAge,
169 SyslogIdentifier: cfg.Logging.AccessLog.SyslogIdentifier,
170 })
171 if err != nil {
172 return nil, fmt.Errorf("failed to initialize access log: %w", err)
173 }
174
175 log.Info().
176 Str("format", cfg.Logging.AccessLog.Format).
177 Str("output", cfg.Logging.AccessLog.Output).
178 Msg("access log enabled")
179
180 return writer, nil
181}
182
183// runServers starts the HTTP servers and waits for shutdown.
184// Signal handling notes:
185// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
186// - SIGUSR1: Triggers config reload without restart
187// (SIGUSR2 manual route deploy was removed with the declarative-apps
188// cutover; app deploys are explicit via `gordon apps deploy`.)
189// The deferred signal.Stop calls ensure signal handlers are properly
190// cleaned up before program exit, preventing signal handler leaks.
191func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, reload reloadTrigger, cleanupHandlers func(), log zerowrap.Logger) error {
192 // Initialize access log writer. Kept here (not in Run) to keep Run's cyclomatic
193 // complexity within the project limit of 15.
194 accessWriterConcrete, err := initAccessLog(cfg, log)
195 if err != nil {
196 return err
197 }
198 if accessWriterConcrete != nil {
199 defer accessWriterConcrete.Close()
200 }
201 // Convert to interface only when non-nil to avoid the Go nil-interface pitfall
202 // where a typed nil pointer becomes a non-nil interface value.
203 var accessWriter out.AccessLogWriter
204 if accessWriterConcrete != nil {
205 accessWriter = accessWriterConcrete
206 }
207 accessWriter = withAccessLogExport(svc, accessWriter)
208 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
209 defer cancel()
210
211 // Set up SIGUSR1 for reload.
212 // Note: signal.Stop must be called (via defer) to release the channel
213 // and prevent signal handler leaks when the function returns.
214 reloadChan := make(chan os.Signal, 1)
215 signal.Notify(reloadChan, syscall.SIGUSR1)
216 defer signal.Stop(reloadChan)
217
218 errChan := make(chan error, 4)
219
220 registryHandler, httpProxyHandler, httpsProxyHandler := createHTTPHandlers(svc, cfg, log, accessWriter)
221 svc.httpProxyHandler = httpProxyHandler
222 svc.httpsProxyHandler = httpsProxyHandler
223
224 registrySrv, registryReady, internalRegistrySrv, internalRegistryReady := startRegistryServers(cfg, registryHandler, errChan, log)
225
226 // closeStarted shuts down any servers that were started before an error occurred,
227 // preventing leaked listeners during partial startup failures.
228 closeStarted := func(servers ...*http.Server) {
229 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
230 defer shutdownCancel()
231 for _, srv := range servers {
232 if srv != nil {
233 if err := srv.Shutdown(shutdownCtx); err != nil {
234 log.Error().Err(err).Msg("failed to shut down server during startup cleanup")
235 }
236 }
237 }
238 }
239
240 proxySrv, proxyReady, tlsSrv, _, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log)
241 if err != nil {
242 closeStarted(registrySrv, internalRegistrySrv)
243 return err
244 }
245
246 // Owner-only local administration socket. Started before readiness is
247 // announced so the CLI can reach the daemon as soon as it is usable,
248 // and closed on every exit path. TCP /admin/* registration is unchanged.
249 localAdmin, err := startLocalAdminServer(svc, errChan, log)
250 if err != nil {
251 closeStarted(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
252 shutdownTrafficManagerForStartupCleanup(svc.trafficManager, log)
253 return err
254 }
255
256 // cleanupAfterLocalAdmin releases the local admin socket and traffic
257 // manager in addition to the servers that already bound, so no startup
258 // failure after this point leaks a listening socket or a live manager.
259 cleanupAfterLocalAdmin := func(servers ...*http.Server) {
260 cleanupStartupResources(localAdmin, svc.trafficManager, log, servers...)
261 }
262
263 svc.tlsHTTPEntryPoints = tlsMuxHTTPServerNames(cfg)
264 svc.smartHTTPEntryPoints = smartTCPHTTPServerNames(cfg)
265
266 // Wait for both registry listeners and the HTTP proxy before applying traffic.
267 if err := waitForServerReady(ctx, internalRegistryReady, errChan); err != nil {
268 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
269 return err
270 }
271 if err := waitForCoreProxyReady(ctx, registryReady, proxyReady, errChan); err != nil {
272 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
273 return err
274 }
275 // Apply only the installation graph fail-fast before slower app
276 // readiness checks. Persisted ACTIVE binds are intentionally excluded
277 // until boot reconciliation re-inspects them.
278 if err := applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc, nil); err != nil {
279 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
280 return err
281 }
282
283 // Reconcile ACTIVE and rebuild its host index before announcing the
284 // daemon ready. Applying traffic first publishes an empty app graph and
285 // leaves healthy converged apps returning 404 until a later lifecycle
286 // operation happens to rebuild it.
287 reconcileAppsAtBoot(ctx, svc, log)
288 stopLogExport := startContainerLogExport(ctx, svc, log)
289 defer stopLogExport()
290
291 logEvent := log.Info().
292 Int("proxy_port", cfg.Server.Port).
293 Int("registry_port", cfg.Server.RegistryPort)
294 if cfg.Server.TLSPort != 0 {
295 logEvent = logEvent.Int("tls_port", cfg.Server.TLSPort)
296 }
297 logEvent.Msg("Gordon is running")
298
299 startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
300
301 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
302 if err != nil {
303 cleanupAfterLocalAdmin(registrySrv, internalRegistrySrv, proxySrv, tlsSrv)
304 return err
305 }
306 if schedulerCleanup != nil {
307 defer schedulerCleanup()
308 }
309
310 waitForShutdown(ctx, errChan, reloadChan, reload, svc.eventBus, log)
311 cleanupHandlers() // Stop debounce timers before draining containers
312 // Stop app administration before tearing down traffic/runtime dependencies,
313 // so no new mutation can begin during shutdown.
314 localAdmin.Close()
315 registrySrvs := []*http.Server{registrySrv, internalRegistrySrv}
316 return gracefulShutdown(registrySrvs, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, svc.appMonitor, svc.appSvcImpl, svc.appState, log)
317}
318
319// workloadLogExporter returns the OTLP workload log exporter, or nil
320// when telemetry log export is disabled. A typed nil must not leak into
321// the out.LogExporter interface.
322func workloadLogExporter(provider *telemetry.Provider) out.LogExporter {
323 if provider == nil || provider.WorkloadLogs == nil {
324 return nil
325 }
326 return provider.WorkloadLogs
327}
328
329// withAccessLogExport also exports access entries over OTLP when
330// workload log export is enabled. next may be nil (no local sink).
331func withAccessLogExport(svc *services, next out.AccessLogWriter) out.AccessLogWriter {
332 if svc.workloadLogs == nil {
333 return next
334 }
335 return logexport.NewAccessLogExporter(svc.appHostIndex, svc.workloadLogs, next)
336}
337
338// startContainerLogExport follows ACTIVE app containers and exports
339// their output when telemetry log export is enabled. The returned stop
340// function cancels every follower and waits for them, so buffered
341// records reach the exporter before telemetry shuts down.
342func startContainerLogExport(ctx context.Context, svc *services, log zerowrap.Logger) func() {
343 if svc.workloadLogs == nil || svc.appState == nil || svc.runtime == nil {
344 return func() {}
345 }
346 ctx, cancel := context.WithCancel(ctx)
347 done := make(chan struct{})
348 collector := logexport.NewCollector(svc.appState, svc.runtime, svc.workloadLogs)
349 go func() {
350 defer close(done)
351 collector.Run(ctx)
352 }()
353 log.Info().Msg("app container log export enabled")
354 return func() {
355 cancel()
356 <-done
357 }
358}
359
360func waitForCoreProxyReady(ctx context.Context, registryReady <-chan struct{}, proxyReady <-chan struct{}, errChan <-chan error) error {
361 if err := waitForServerReady(ctx, registryReady, errChan); err != nil {
362 return err
363 }
364 return waitForServerReady(ctx, proxyReady, errChan)
365}
366
367func waitForServerReady(ctx context.Context, ready <-chan struct{}, errChan <-chan error) error {
368 if ready == nil {
369 return nil
370 }
371 select {
372 case <-ready:
373 return nil
374 case err := <-errChan:
375 return err
376 case <-ctx.Done():
377 return ctx.Err()
378 }
379}