v2.6.0 fe25b4f

run.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close