v3.1.0 d676137

run.go RawHistoryFolder

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}

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