v2.3.0 fc65046

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

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