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