v2.2.2 dbdcf7e

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

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