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