v2.4.1 7c3b292

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
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}

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