1// Package app provides the application initialization and wiring.
2package app
3
4import (
5 "context"
6 "crypto/rand"
7 "encoding/hex"
8 "encoding/json"
9 "fmt"
10 "net/http"
11 "os"
12 "os/signal"
13 "path/filepath"
14 "strings"
15 "syscall"
16 "time"
17
18 "github.com/bnema/zerowrap"
19 "github.com/fsnotify/fsnotify"
20 "github.com/spf13/viper"
21
22 // Adapters - Output
23 "gordon/internal/adapters/out/docker"
24 "gordon/internal/adapters/out/domainsecrets"
25 "gordon/internal/adapters/out/envloader"
26 "gordon/internal/adapters/out/eventbus"
27 "gordon/internal/adapters/out/filesystem"
28 "gordon/internal/adapters/out/httpprober"
29 "gordon/internal/adapters/out/logwriter"
30 "gordon/internal/adapters/out/secrets"
31 "gordon/internal/adapters/out/tokenstore"
32
33 "golang.org/x/time/rate"
34
35 // Adapters - Input
36 "gordon/internal/adapters/in/http/admin"
37 "gordon/internal/adapters/in/http/middleware"
38 "gordon/internal/adapters/in/http/registry"
39
40 // Boundaries
41 "gordon/internal/boundaries/out"
42
43 // Domain
44 "gordon/internal/domain"
45
46 // Use cases
47 "gordon/internal/usecase/auth"
48 "gordon/internal/usecase/config"
49 "gordon/internal/usecase/container"
50 "gordon/internal/usecase/health"
51 "gordon/internal/usecase/proxy"
52 registrySvc "gordon/internal/usecase/registry"
53 secretsSvc "gordon/internal/usecase/secrets"
54)
55
56// Config holds the application configuration.
57type Config struct {
58 Server struct {
59 Port int `mapstructure:"port"`
60 RegistryPort int `mapstructure:"registry_port"`
61 GordonDomain string `mapstructure:"gordon_domain"`
62 RegistryDomain string `mapstructure:"registry_domain"` // Deprecated: use gordon_domain
63 DataDir string `mapstructure:"data_dir"`
64 } `mapstructure:"server"`
65
66 Logging struct {
67 Level string `mapstructure:"level"`
68 Format string `mapstructure:"format"`
69 File struct {
70 Enabled bool `mapstructure:"enabled"`
71 Path string `mapstructure:"path"`
72 MaxSize int `mapstructure:"max_size"`
73 MaxBackups int `mapstructure:"max_backups"`
74 MaxAge int `mapstructure:"max_age"`
75 } `mapstructure:"file"`
76 ContainerLogs struct {
77 Enabled bool `mapstructure:"enabled"`
78 Dir string `mapstructure:"dir"`
79 MaxSize int `mapstructure:"max_size"`
80 MaxBackups int `mapstructure:"max_backups"`
81 MaxAge int `mapstructure:"max_age"`
82 } `mapstructure:"container_logs"`
83 } `mapstructure:"logging"`
84
85 Env struct {
86 Dir string `mapstructure:"dir"`
87 } `mapstructure:"env"`
88
89 Secrets struct {
90 Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe"
91 } `mapstructure:"secrets"`
92
93 RegistryAuth struct {
94 Enabled bool `mapstructure:"enabled"`
95 Type string `mapstructure:"type"` // "password" or "token"
96 Username string `mapstructure:"username"`
97 Password string `mapstructure:"password"` // deprecated: use password_hash
98 PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
99 TokenSecret string `mapstructure:"token_secret"` // path in secrets backend
100 TokenExpiry string `mapstructure:"token_expiry"` // e.g., "720h"
101 } `mapstructure:"registry_auth"`
102}
103
104// services holds all the services used by the application.
105type services struct {
106 runtime *docker.Runtime
107 eventBus *eventbus.InMemory
108 blobStorage *filesystem.BlobStorage
109 manifestStorage *filesystem.ManifestStorage
110 envLoader *envloader.FileLoader
111 logWriter *logwriter.LogWriter
112 tokenStore out.TokenStore
113 configSvc *config.Service
114 containerSvc *container.Service
115 registrySvc *registrySvc.Service
116 proxySvc *proxy.Service
117 authSvc *auth.Service
118 tokenHandler *registry.TokenHandler
119 adminHandler *admin.Handler
120 internalRegUser string
121 internalRegPass string
122 envDir string
123}
124
125// Run initializes and starts the Gordon application.
126func Run(ctx context.Context, configPath string) error {
127 // Load configuration
128 v, cfg, err := initConfig(configPath)
129 if err != nil {
130 return err
131 }
132
133 // Initialize logger
134 log, cleanup, err := initLogger(cfg)
135 if err != nil {
136 return err
137 }
138 if cleanup != nil {
139 defer cleanup()
140 }
141
142 ctx = zerowrap.WithCtx(ctx, log)
143 log.Info().Msg("Gordon starting")
144
145 // Create PID file
146 pidFile := createPidFile(log)
147 if pidFile != "" {
148 defer removePidFile(pidFile, log)
149 }
150
151 // Create all services
152 svc, err := createServices(ctx, v, cfg, log)
153 if err != nil {
154 return err
155 }
156
157 // Register event handlers
158 if err := registerEventHandlers(ctx, svc, cfg); err != nil {
159 return err
160 }
161
162 // Set up config hot reload
163 setupConfigHotReload(ctx, v, svc, log)
164
165 // Start event bus
166 if err := svc.eventBus.Start(); err != nil {
167 return log.WrapErr(err, "failed to start event bus")
168 }
169 defer svc.eventBus.Stop()
170
171 // Sync and auto-start containers
172 syncAndAutoStart(ctx, svc, log)
173
174 // Create HTTP handlers
175 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
176
177 // Start servers and wait for shutdown
178 return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, log)
179}
180
181// initConfig loads configuration from file.
182func initConfig(configPath string) (*viper.Viper, Config, error) {
183 v := viper.New()
184 if err := loadConfig(v, configPath); err != nil {
185 return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
186 }
187
188 var cfg Config
189 if err := v.Unmarshal(&cfg); err != nil {
190 return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
191 }
192
193 // Normalize domain config: prefer gordon_domain over registry_domain
194 if cfg.Server.GordonDomain != "" {
195 cfg.Server.RegistryDomain = cfg.Server.GordonDomain
196 }
197
198 return v, cfg, nil
199}
200
201// initLogger initializes the zerowrap logger.
202func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
203 logConfig := zerowrap.Config{
204 Level: cfg.Logging.Level,
205 Format: cfg.Logging.Format,
206 }
207
208 if cfg.Logging.File.Enabled {
209 log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
210 Enabled: true,
211 Path: cfg.Logging.File.Path,
212 MaxSize: cfg.Logging.File.MaxSize,
213 MaxBackups: cfg.Logging.File.MaxBackups,
214 MaxAge: cfg.Logging.File.MaxAge,
215 Compress: true,
216 })
217 if err != nil {
218 return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
219 }
220 return log, cleanup, nil
221 }
222
223 return zerowrap.New(logConfig), nil, nil
224}
225
226// createServices creates all the application services.
227func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
228 svc := &services{}
229 var err error
230
231 // Create output adapters
232 if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
233 return nil, err
234 }
235
236 // Create storage
237 if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
238 return nil, err
239 }
240
241 // Create env loader
242 if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
243 return nil, err
244 }
245
246 // Create log writer
247 if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
248 return nil, err
249 }
250
251 // Create auth service (if enabled)
252 if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
253 return nil, err
254 }
255
256 // Generate internal registry credentials for loopback-only pulls
257 if cfg.RegistryAuth.Enabled {
258 svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
259 if err != nil {
260 return nil, log.WrapErr(err, "failed to generate internal registry credentials")
261 }
262
263 // Persist credentials to file for CLI access (gordon auth internal)
264 if err := persistInternalCredentials(svc.internalRegUser, svc.internalRegPass); err != nil {
265 log.Warn().Err(err).Msg("failed to persist internal credentials for CLI access")
266 }
267
268 log.Debug().Msg("internal registry auth generated for loopback pulls")
269 }
270
271 // Create use case services
272 svc.configSvc = config.NewService(v, svc.eventBus)
273 if err := svc.configSvc.Load(ctx); err != nil {
274 return nil, log.WrapErr(err, "failed to load configuration")
275 }
276
277 svc.containerSvc = createContainerService(v, cfg, svc)
278 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
279 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
280 RegistryDomain: cfg.Server.RegistryDomain,
281 RegistryPort: cfg.Server.RegistryPort,
282 })
283
284 // Create token handler for registry token endpoint
285 if svc.authSvc != nil {
286 internalAuth := registry.InternalAuth{
287 Username: svc.internalRegUser,
288 Password: svc.internalRegPass,
289 }
290 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
291 }
292
293 // Determine env directory for admin API
294 dataDir := cfg.Server.DataDir
295 if dataDir == "" {
296 dataDir = DefaultDataDir()
297 }
298 svc.envDir = cfg.Env.Dir
299 if svc.envDir == "" {
300 svc.envDir = filepath.Join(dataDir, "env")
301 }
302
303 // Create domain secret store and service
304 domainSecretStore, err := domainsecrets.NewFileStore(svc.envDir, log)
305 if err != nil {
306 return nil, log.WrapErr(err, "failed to create domain secret store")
307 }
308 secretSvc := secretsSvc.NewService(domainSecretStore, log)
309
310 // Create health service for route health checking
311 prober := httpprober.New()
312 healthSvc := health.NewService(svc.configSvc, svc.containerSvc, prober, log)
313
314 // Create admin handler for admin API
315 svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, svc.eventBus, log)
316
317 return svc, nil
318}
319
320// createOutputAdapters creates the Docker runtime and event bus.
321func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
322 runtime, err := docker.NewRuntime()
323 if err != nil {
324 return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
325 }
326
327 if err := runtime.Ping(ctx); err != nil {
328 return nil, nil, log.WrapErr(err, "Docker is not available")
329 }
330
331 dockerVersion, _ := runtime.Version(ctx)
332 log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
333
334 eventBus := eventbus.NewInMemory(100, log)
335
336 return runtime, eventBus, nil
337}
338
339// createStorage creates blob and manifest storage.
340func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
341 dataDir := cfg.Server.DataDir
342 if dataDir == "" {
343 dataDir = DefaultDataDir()
344 }
345
346 registryDir := filepath.Join(dataDir, "registry")
347
348 blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
349 if err != nil {
350 return nil, nil, log.WrapErr(err, "failed to create blob storage")
351 }
352
353 manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
354 if err != nil {
355 return nil, nil, log.WrapErr(err, "failed to create manifest storage")
356 }
357
358 return blobStorage, manifestStorage, nil
359}
360
361// createEnvLoader creates the environment loader with secret providers.
362func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {
363 dataDir := cfg.Server.DataDir
364 if dataDir == "" {
365 dataDir = DefaultDataDir()
366 }
367
368 envDir := cfg.Env.Dir
369 if envDir == "" {
370 envDir = filepath.Join(dataDir, "env")
371 }
372
373 envLoader, err := envloader.NewFileLoader(envDir, log)
374 if err != nil {
375 return nil, log.WrapErr(err, "failed to create env loader")
376 }
377
378 // Register secret providers
379 passProvider := secrets.NewPassProvider(log)
380 if passProvider.IsAvailable() {
381 envLoader.RegisterSecretProvider(passProvider)
382 log.Debug().Msg("pass secret provider registered")
383 }
384
385 sopsProvider := secrets.NewSopsProvider(log)
386 if sopsProvider.IsAvailable() {
387 envLoader.RegisterSecretProvider(sopsProvider)
388 log.Debug().Msg("sops secret provider registered")
389 }
390
391 return envLoader, nil
392}
393
394// createLogWriter creates the container log writer.
395func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
396 if !cfg.Logging.ContainerLogs.Enabled {
397 log.Debug().Msg("container log collection disabled")
398 return nil, nil
399 }
400
401 // Determine log directory
402 logDir := cfg.Logging.ContainerLogs.Dir
403 if logDir == "" {
404 dataDir := cfg.Server.DataDir
405 if dataDir == "" {
406 dataDir = DefaultDataDir()
407 }
408 logDir = filepath.Join(dataDir, "logs", "containers")
409 }
410
411 writer, err := logwriter.New(logwriter.Config{
412 Dir: logDir,
413 MaxSize: cfg.Logging.ContainerLogs.MaxSize,
414 MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
415 MaxAge: cfg.Logging.ContainerLogs.MaxAge,
416 })
417 if err != nil {
418 return nil, log.WrapErr(err, "failed to create container log writer")
419 }
420
421 log.Info().Str("dir", logDir).Msg("container log collection enabled")
422 return writer, nil
423}
424
425const internalRegistryUsername = "gordon-internal"
426
427func generateInternalRegistryAuth() (string, string, error) {
428 password, err := randomTokenHex(32)
429 if err != nil {
430 return "", "", err
431 }
432 return internalRegistryUsername, password, nil
433}
434
435func randomTokenHex(size int) (string, error) {
436 buf := make([]byte, size)
437 if _, err := rand.Read(buf); err != nil {
438 return "", err
439 }
440 return hex.EncodeToString(buf), nil
441}
442
443// InternalCredentials holds the internal registry credentials for CLI access.
444type InternalCredentials struct {
445 Username string `json:"username"`
446 Password string `json:"password"`
447}
448
449// getInternalCredentialsFile returns the path to the internal credentials file.
450func getInternalCredentialsFile() string {
451 return filepath.Join(os.TempDir(), "gordon-internal-creds.json")
452}
453
454// persistInternalCredentials saves the internal registry credentials to a temp file.
455// Security note: Credentials are stored in the system temp directory with 0600 permissions.
456// The file is cleaned up on graceful shutdown but may persist if Gordon crashes.
457// These credentials are for internal loopback communication only and are regenerated on each start.
458func persistInternalCredentials(username, password string) error {
459 creds := InternalCredentials{
460 Username: username,
461 Password: password,
462 }
463 data, err := json.Marshal(creds)
464 if err != nil {
465 return fmt.Errorf("failed to marshal credentials: %w", err)
466 }
467 credFile := getInternalCredentialsFile()
468 if err := os.WriteFile(credFile, data, 0600); err != nil {
469 return fmt.Errorf("failed to write credentials file: %w", err)
470 }
471 return nil
472}
473
474// cleanupInternalCredentials removes the internal credentials file.
475func cleanupInternalCredentials() {
476 _ = os.Remove(getInternalCredentialsFile())
477}
478
479// GetInternalCredentials reads the internal registry credentials from file.
480// This is used by the CLI to display credentials for manual recovery.
481func GetInternalCredentials() (*InternalCredentials, error) {
482 credFile := getInternalCredentialsFile()
483 data, err := os.ReadFile(credFile)
484 if err != nil {
485 return nil, fmt.Errorf("failed to read credentials file (is Gordon running?): %w", err)
486 }
487 var creds InternalCredentials
488 if err := json.Unmarshal(data, &creds); err != nil {
489 return nil, fmt.Errorf("failed to parse credentials: %w", err)
490 }
491 return &creds, nil
492}
493
494// createAuthService creates the authentication service and token store.
495func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
496 if !cfg.RegistryAuth.Enabled {
497 return nil, nil, nil
498 }
499
500 authType := resolveAuthType(cfg.RegistryAuth.Type)
501 backend := resolveSecretsBackend(cfg.Secrets.Backend)
502 dataDir := resolveDataDir(cfg.Server.DataDir)
503
504 store, err := createTokenStore(authType, backend, dataDir, log)
505 if err != nil {
506 return nil, nil, err
507 }
508
509 authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
510 if err != nil {
511 return nil, nil, err
512 }
513
514 authSvc := auth.NewService(authConfig, store, log)
515
516 log.Info().
517 Str("type", string(authType)).
518 Str("backend", string(backend)).
519 Msg("registry authentication enabled")
520
521 return store, authSvc, nil
522}
523
524func resolveAuthType(authType string) domain.AuthType {
525 if authType == "token" {
526 return domain.AuthTypeToken
527 }
528 return domain.AuthTypePassword
529}
530
531func resolveSecretsBackend(backend string) domain.SecretsBackend {
532 switch backend {
533 case "pass":
534 return domain.SecretsBackendPass
535 case "sops":
536 return domain.SecretsBackendSops
537 case "unsafe", "":
538 return domain.SecretsBackendUnsafe
539 default:
540 return domain.SecretsBackendUnsafe
541 }
542}
543
544func resolveDataDir(dataDir string) string {
545 if dataDir == "" {
546 return DefaultDataDir()
547 }
548 return dataDir
549}
550
551func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
552 if authType != domain.AuthTypeToken {
553 return nil, nil
554 }
555
556 store, err := tokenstore.NewStore(backend, dataDir, log)
557 if err != nil {
558 return nil, log.WrapErr(err, "failed to create token store")
559 }
560 return store, nil
561}
562
563func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
564 authConfig := auth.Config{
565 Enabled: cfg.RegistryAuth.Enabled,
566 AuthType: authType,
567 Username: cfg.RegistryAuth.Username,
568 }
569
570 switch authType {
571 case domain.AuthTypePassword:
572 hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
573 if err != nil {
574 return auth.Config{}, err
575 }
576 authConfig.PasswordHash = hash
577 case domain.AuthTypeToken:
578 secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
579 if err != nil {
580 return auth.Config{}, err
581 }
582 authConfig.TokenSecret = secret
583 authConfig.TokenExpiry = expiry
584 }
585
586 return authConfig, nil
587}
588
589func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
590 if cfg.RegistryAuth.PasswordHash != "" {
591 hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log)
592 if err != nil {
593 return "", log.WrapErr(err, "failed to load password hash")
594 }
595 return hash, nil
596 }
597
598 if cfg.RegistryAuth.Password != "" {
599 log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
600 return cfg.RegistryAuth.Password, nil
601 }
602
603 return "", nil
604}
605
606func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
607 secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
608 if err != nil {
609 return nil, 0, err
610 }
611
612 expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry)
613 if err != nil {
614 return nil, 0, err
615 }
616
617 return secret, expiry, nil
618}
619
620func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
621 if cfg.RegistryAuth.TokenSecret == "" {
622 return nil, fmt.Errorf("token_secret is required for token authentication")
623 }
624
625 secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log)
626 if err != nil {
627 return nil, log.WrapErr(err, "failed to load token secret")
628 }
629
630 return []byte(secret), nil
631}
632
633func parseTokenExpiry(expiry string) (time.Duration, error) {
634 if expiry == "" {
635 return 0, nil
636 }
637
638 parsed, err := time.ParseDuration(expiry)
639 if err != nil {
640 return 0, fmt.Errorf("invalid token_expiry: %w", err)
641 }
642
643 return parsed, nil
644}
645
646// loadSecret loads a secret from the configured backend.
647func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
648 switch backend {
649 case domain.SecretsBackendPass:
650 provider := secrets.NewPassProvider(log)
651 return provider.GetSecret(ctx, path)
652 case domain.SecretsBackendSops:
653 provider := secrets.NewSopsProvider(log)
654 return provider.GetSecret(ctx, path)
655 case domain.SecretsBackendUnsafe:
656 // For unsafe backend, path is relative to dataDir/secrets/
657 secretFile := filepath.Join(dataDir, "secrets", path)
658 data, err := os.ReadFile(secretFile)
659 if err != nil {
660 return "", fmt.Errorf("failed to read secret file: %w", err)
661 }
662 return string(data), nil
663 default:
664 return "", fmt.Errorf("unknown secrets backend: %s", backend)
665 }
666}
667
668// createContainerService creates the container service with configuration.
669func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
670 containerConfig := container.Config{
671 RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
672 RegistryDomain: cfg.Server.RegistryDomain,
673 RegistryPort: cfg.Server.RegistryPort,
674 RegistryUsername: cfg.RegistryAuth.Username,
675 RegistryPassword: cfg.RegistryAuth.Password,
676 InternalRegistryUsername: svc.internalRegUser,
677 InternalRegistryPassword: svc.internalRegPass,
678 PullPolicy: v.GetString("deploy.pull_policy"),
679 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
680 VolumePrefix: v.GetString("volumes.prefix"),
681 VolumePreserve: v.GetBool("volumes.preserve"),
682 NetworkIsolation: v.GetBool("network_isolation.enabled"),
683 NetworkPrefix: v.GetString("network_isolation.network_prefix"),
684 DNSSuffix: v.GetString("network_isolation.dns_suffix"),
685 NetworkGroups: svc.configSvc.GetNetworkGroups(),
686 Attachments: svc.configSvc.GetAttachments(),
687 }
688 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
689}
690
691// registerEventHandlers registers all event handlers.
692func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
693 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
694 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
695 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
696 }
697
698 // Auto-route handler for creating routes from image labels
699 autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
700 WithEnvExtractor(svc.runtime, svc.envDir)
701 if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
702 return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
703 }
704
705 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
706 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
707 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
708 }
709
710 manualReloadHandler := container.NewManualReloadHandler(ctx, svc.containerSvc, svc.configSvc)
711 if err := svc.eventBus.Subscribe(manualReloadHandler); err != nil {
712 return fmt.Errorf("failed to subscribe manual reload handler: %w", err)
713 }
714
715 manualDeployHandler := container.NewManualDeployHandler(ctx, svc.containerSvc, svc.configSvc)
716 if err := svc.eventBus.Subscribe(manualDeployHandler); err != nil {
717 return fmt.Errorf("failed to subscribe manual deploy handler: %w", err)
718 }
719
720 // Proxy cache invalidation on container deployment (for zero-downtime)
721 containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
722 if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
723 return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
724 }
725
726 return nil
727}
728
729// setupConfigHotReload sets up Viper config hot reload.
730// NOTE: This does NOT reload routes into memory. Routes are managed via API
731// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
732// The file watcher only updates proxy config and refreshes targets.
733// Manual config file edits to routes require a server restart.
734func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
735 v.OnConfigChange(func(e fsnotify.Event) {
736 log.Info().Str("file", e.Name).Msg("config file changed")
737
738 if err := v.ReadInConfig(); err != nil {
739 log.Error().Err(err).Msg("failed to reload config")
740 return
741 }
742
743 // Update proxy config from viper (reads directly from viper, not memory)
744 svc.proxySvc.UpdateConfig(proxy.Config{
745 RegistryDomain: v.GetString("server.registry_domain"),
746 RegistryPort: v.GetInt("server.registry_port"),
747 })
748
749 // Clear proxy target cache to pick up external route changes
750 if err := svc.proxySvc.RefreshTargets(ctx); err != nil {
751 log.Warn().Err(err).Msg("failed to refresh proxy targets")
752 }
753
754 log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
755 })
756 v.WatchConfig()
757}
758
759// syncAndAutoStart syncs existing containers and auto-starts if configured.
760func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
761 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
762 log.Warn().Err(err).Msg("failed to sync existing containers")
763 }
764
765 if svc.configSvc.IsAutoRouteEnabled() {
766 routes := svc.configSvc.GetRoutes(ctx)
767 if err := svc.containerSvc.AutoStart(ctx, routes); err != nil {
768 log.Warn().Err(err).Msg("failed to auto-start containers")
769 }
770 }
771}
772
773// createHTTPHandlers creates HTTP handlers with middleware.
774func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
775 registryHandler := registry.NewHandler(svc.registrySvc, log)
776
777 registryMiddlewares := []func(http.Handler) http.Handler{
778 middleware.PanicRecovery(log),
779 middleware.RequestLogger(log),
780 }
781
782 if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
783 internalAuth := middleware.InternalRegistryAuth{
784 Username: svc.internalRegUser,
785 Password: svc.internalRegPass,
786 }
787 registryMiddlewares = append(registryMiddlewares,
788 middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
789 }
790
791 registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
792
793 // Create a mux that routes /v2/token to the token handler (no auth required)
794 // and all other /v2/* routes to the registry handler (with auth)
795 registryMux := http.NewServeMux()
796 if svc.tokenHandler != nil {
797 // Token endpoint is NOT protected by auth - it's where clients get tokens
798 tokenWithLogging := middleware.Chain(
799 middleware.PanicRecovery(log),
800 middleware.RequestLogger(log),
801 )(svc.tokenHandler)
802 registryMux.Handle("/v2/token", tokenWithLogging)
803 }
804 registryMux.Handle("/v2/", registryWithMiddleware)
805
806 // Add admin API handler with auth middleware
807 if svc.adminHandler != nil {
808 adminMiddlewares := []func(http.Handler) http.Handler{
809 middleware.PanicRecovery(log),
810 middleware.RequestLogger(log),
811 }
812 // Add admin auth middleware if auth is enabled
813 if svc.authSvc != nil {
814 // Rate limiter for admin API: 10 req/s with burst of 20
815 adminLimiter := rate.NewLimiter(rate.Limit(10), 20)
816 adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, adminLimiter, log))
817 }
818 adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
819 registryMux.Handle("/admin/", adminWithMiddleware)
820 }
821
822 proxyMiddlewares := []func(http.Handler) http.Handler{
823 middleware.PanicRecovery(log),
824 middleware.RequestLogger(log),
825 middleware.CORS,
826 }
827
828 proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
829
830 return registryMux, proxyWithMiddleware
831}
832
833// runServers starts the HTTP servers and waits for shutdown.
834// Signal handling notes:
835// - SIGINT/SIGTERM: Triggers graceful shutdown via signal.NotifyContext
836// - SIGUSR1: Triggers config reload without restart
837// - SIGUSR2: Triggers manual deploy for a specific route
838// The deferred signal.Stop calls ensure signal handlers are properly
839// cleaned up before program exit, preventing signal handler leaks.
840func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, log zerowrap.Logger) error {
841 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
842 defer cancel()
843
844 // Set up SIGUSR1 for reload.
845 // Note: signal.Stop must be called (via defer) to release the channel
846 // and prevent signal handler leaks when the function returns.
847 reloadChan := make(chan os.Signal, 1)
848 signal.Notify(reloadChan, syscall.SIGUSR1)
849 defer signal.Stop(reloadChan)
850
851 // Set up SIGUSR2 for manual deploy.
852 deployChan := make(chan os.Signal, 1)
853 signal.Notify(deployChan, syscall.SIGUSR2)
854 defer signal.Stop(deployChan)
855
856 errChan := make(chan error, 2)
857
858 go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
859 go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
860
861 log.Info().
862 Int("proxy_port", cfg.Server.Port).
863 Int("registry_port", cfg.Server.RegistryPort).
864 Msg("Gordon is running")
865
866 for {
867 select {
868 case err := <-errChan:
869 return err
870 case <-reloadChan:
871 log.Info().Msg("reload signal received (SIGUSR1)")
872 if err := eventBus.Publish(domain.EventManualReload, nil); err != nil {
873 log.Error().Err(err).Msg("failed to publish manual reload event")
874 }
875 case <-deployChan:
876 log.Info().Msg("deploy signal received (SIGUSR2)")
877 domainName, err := readDeployRequest()
878 if err != nil {
879 log.Error().Err(err).Msg("failed to read deploy request")
880 continue
881 }
882 payload := &domain.ManualDeployPayload{Domain: domainName}
883 if err := eventBus.Publish(domain.EventManualDeploy, payload); err != nil {
884 log.Error().Err(err).Str("domain", domainName).Msg("failed to publish manual deploy event")
885 }
886 case <-ctx.Done():
887 log.Info().Msg("shutdown signal received")
888 goto shutdown
889 }
890 }
891
892shutdown:
893 log.Info().Msg("shutting down Gordon...")
894
895 if err := containerSvc.Shutdown(ctx); err != nil {
896 log.Warn().Err(err).Msg("error during container shutdown")
897 }
898
899 // Clean up internal credentials file
900 cleanupInternalCredentials()
901
902 log.Info().Msg("Gordon stopped")
903 return nil
904}
905
906// startServer starts an HTTP server.
907func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {
908 log.Info().Str("address", addr).Msgf("%s server starting", name)
909
910 server := &http.Server{
911 Addr: addr,
912 Handler: handler,
913 ReadHeaderTimeout: 10 * time.Second,
914 ReadTimeout: 5 * time.Minute, // Timeout for reading entire request
915 WriteTimeout: 5 * time.Minute, // Timeout for writing response
916 IdleTimeout: 120 * time.Second, // Timeout for idle keep-alive connections
917 MaxHeaderBytes: 1 << 20, // 1MB max header size
918 }
919
920 if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
921 errChan <- fmt.Errorf("%s server error: %w", name, err)
922 }
923}
924
925// SendReloadSignal sends SIGUSR1 to the running Gordon process.
926func SendReloadSignal() error {
927 pidFile := findPidFile()
928 if pidFile == "" {
929 return fmt.Errorf("gordon PID file not found, is Gordon running?")
930 }
931
932 pidBytes, err := os.ReadFile(pidFile)
933 if err != nil {
934 return fmt.Errorf("failed to read PID file: %w", err)
935 }
936
937 var pid int
938 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
939 return fmt.Errorf("failed to parse PID: %w", err)
940 }
941
942 process, err := os.FindProcess(pid)
943 if err != nil {
944 return fmt.Errorf("failed to find process: %w", err)
945 }
946
947 if err := process.Signal(syscall.SIGUSR1); err != nil {
948 return fmt.Errorf("failed to send reload signal: %w", err)
949 }
950
951 return nil
952}
953
954// getDeployRequestFile returns the path to the deploy request file.
955func getDeployRequestFile() string {
956 return filepath.Join(os.TempDir(), "gordon-deploy-request")
957}
958
959// writeDeployRequestFile creates the deploy request file exclusively with retry.
960// This prevents race conditions when multiple deploy commands run simultaneously.
961func writeDeployRequestFile(path string, data []byte, timeout time.Duration) error {
962 deadline := time.Now().Add(timeout)
963
964 for {
965 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
966 if err == nil {
967 _, writeErr := f.Write(data)
968 f.Close()
969 if writeErr != nil {
970 _ = os.Remove(path)
971 return writeErr
972 }
973 return nil
974 }
975
976 if os.IsExist(err) {
977 if time.Now().After(deadline) {
978 return fmt.Errorf("deploy request file still present after timeout; another deploy may be in progress")
979 }
980 time.Sleep(100 * time.Millisecond)
981 continue
982 }
983
984 return err
985 }
986}
987
988// SendDeploySignal triggers a manual deploy for a specific route via SIGUSR2.
989// Returns the domain name on success for the caller to display.
990func SendDeploySignal(domain string) (string, error) {
991 deployFile := getDeployRequestFile()
992 if err := writeDeployRequestFile(deployFile, []byte(domain), 5*time.Second); err != nil {
993 return "", fmt.Errorf("failed to write deploy request: %w", err)
994 }
995
996 // Find PID and send SIGUSR2
997 pidFile := findPidFile()
998 if pidFile == "" {
999 _ = os.Remove(deployFile)
1000 return "", fmt.Errorf("gordon PID file not found, is Gordon running?")
1001 }
1002
1003 pidBytes, err := os.ReadFile(pidFile)
1004 if err != nil {
1005 _ = os.Remove(deployFile)
1006 return "", fmt.Errorf("failed to read PID file: %w", err)
1007 }
1008
1009 var pid int
1010 if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
1011 _ = os.Remove(deployFile)
1012 return "", fmt.Errorf("failed to parse PID: %w", err)
1013 }
1014
1015 process, err := os.FindProcess(pid)
1016 if err != nil {
1017 _ = os.Remove(deployFile)
1018 return "", fmt.Errorf("failed to find process: %w", err)
1019 }
1020
1021 if err := process.Signal(syscall.SIGUSR2); err != nil {
1022 _ = os.Remove(deployFile)
1023 return "", fmt.Errorf("failed to send deploy signal: %w", err)
1024 }
1025
1026 return domain, nil
1027}
1028
1029// readDeployRequest reads and removes the deploy request file atomically.
1030// Returns empty string if file doesn't exist (may have been consumed by another handler).
1031func readDeployRequest() (string, error) {
1032 deployFile := getDeployRequestFile()
1033
1034 // Rename to a temp file first to make the read-and-delete atomic
1035 tmpFile := deployFile + ".processing"
1036 if err := os.Rename(deployFile, tmpFile); err != nil {
1037 if os.IsNotExist(err) {
1038 return "", fmt.Errorf("deploy request file not found (may have been processed already)")
1039 }
1040 return "", fmt.Errorf("failed to acquire deploy request: %w", err)
1041 }
1042
1043 data, err := os.ReadFile(tmpFile)
1044 _ = os.Remove(tmpFile) // Always clean up
1045 if err != nil {
1046 return "", fmt.Errorf("failed to read deploy request: %w", err)
1047 }
1048
1049 return string(data), nil
1050}
1051
1052// createPidFile creates a PID file for the Gordon process.
1053func createPidFile(log zerowrap.Logger) string {
1054 pid := os.Getpid()
1055
1056 locations := []string{
1057 "/tmp/gordon.pid",
1058 filepath.Join(os.TempDir(), "gordon.pid"),
1059 }
1060
1061 if homeDir, err := os.UserHomeDir(); err == nil {
1062 locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1063 }
1064
1065 for _, location := range locations {
1066 if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
1067 log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
1068 return location
1069 }
1070 }
1071
1072 log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
1073 return ""
1074}
1075
1076// removePidFile removes the PID file.
1077func removePidFile(pidFile string, log zerowrap.Logger) {
1078 if err := os.Remove(pidFile); err != nil {
1079 log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
1080 } else {
1081 log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
1082 }
1083}
1084
1085// findPidFile finds the Gordon PID file.
1086func findPidFile() string {
1087 locations := []string{
1088 "/tmp/gordon.pid",
1089 filepath.Join(os.TempDir(), "gordon.pid"),
1090 }
1091
1092 if homeDir, err := os.UserHomeDir(); err == nil {
1093 locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
1094 }
1095
1096 for _, location := range locations {
1097 if _, err := os.Stat(location); err == nil {
1098 return location
1099 }
1100 }
1101
1102 return ""
1103}
1104
1105// loadConfig loads configuration from file and sets defaults.
1106func loadConfig(v *viper.Viper, configPath string) error {
1107 v.SetDefault("server.port", 80)
1108 v.SetDefault("server.registry_port", 5000)
1109 v.SetDefault("server.data_dir", DefaultDataDir())
1110 v.SetDefault("logging.level", "info")
1111 v.SetDefault("logging.format", "console")
1112 v.SetDefault("logging.file.enabled", false)
1113 v.SetDefault("logging.file.max_size", 100)
1114 v.SetDefault("logging.file.max_backups", 3)
1115 v.SetDefault("logging.file.max_age", 28)
1116 v.SetDefault("logging.container_logs.enabled", true)
1117 v.SetDefault("logging.container_logs.dir", "")
1118 v.SetDefault("logging.container_logs.max_size", 100)
1119 v.SetDefault("logging.container_logs.max_backups", 3)
1120 v.SetDefault("logging.container_logs.max_age", 28)
1121 v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
1122 v.SetDefault("secrets.backend", "unsafe")
1123 v.SetDefault("registry_auth.enabled", false)
1124 v.SetDefault("registry_auth.type", "password")
1125 v.SetDefault("registry_auth.token_expiry", "720h")
1126 v.SetDefault("auto_route.enabled", false)
1127 v.SetDefault("network_isolation.enabled", false)
1128 v.SetDefault("network_isolation.network_prefix", "gordon")
1129 v.SetDefault("network_isolation.dns_suffix", ".internal")
1130 v.SetDefault("volumes.auto_create", true)
1131 v.SetDefault("volumes.prefix", "gordon")
1132 v.SetDefault("volumes.preserve", true)
1133 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1134
1135 ConfigureViper(v, configPath)
1136
1137 if err := v.ReadInConfig(); err != nil {
1138 if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
1139 return fmt.Errorf("failed to read config file: %w", err)
1140 }
1141 }
1142
1143 v.SetEnvPrefix("GORDON")
1144 v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
1145 v.AutomaticEnv()
1146
1147 return nil
1148}