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