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