v2.5.0 b9b6391

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

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 8

Tags 106

↑↓ moveenter openesc close