v2.1.0 0fb867d

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	"fmt"
9	"net/http"
10	"os"
11	"os/signal"
12	"path/filepath"
13	"syscall"
14	"time"
15
16	"github.com/bnema/zerowrap"
17	"github.com/fsnotify/fsnotify"
18	"github.com/spf13/viper"
19
20	// Adapters - Output
21	"gordon/internal/adapters/out/docker"
22	"gordon/internal/adapters/out/envloader"
23	"gordon/internal/adapters/out/eventbus"
24	"gordon/internal/adapters/out/filesystem"
25	"gordon/internal/adapters/out/logwriter"
26	"gordon/internal/adapters/out/secrets"
27	"gordon/internal/adapters/out/tokenstore"
28
29	// Adapters - Input
30	"gordon/internal/adapters/in/http/middleware"
31	"gordon/internal/adapters/in/http/registry"
32
33	// Boundaries
34	"gordon/internal/boundaries/out"
35
36	// Domain
37	"gordon/internal/domain"
38
39	// Use cases
40	"gordon/internal/usecase/auth"
41	"gordon/internal/usecase/config"
42	"gordon/internal/usecase/container"
43	"gordon/internal/usecase/proxy"
44	registrySvc "gordon/internal/usecase/registry"
45)
46
47// Config holds the application configuration.
48type Config struct {
49	Server struct {
50		Port           int    `mapstructure:"port"`
51		RegistryPort   int    `mapstructure:"registry_port"`
52		RegistryDomain string `mapstructure:"registry_domain"`
53		DataDir        string `mapstructure:"data_dir"`
54	} `mapstructure:"server"`
55
56	Logging struct {
57		Level  string `mapstructure:"level"`
58		Format string `mapstructure:"format"`
59		File   struct {
60			Enabled    bool   `mapstructure:"enabled"`
61			Path       string `mapstructure:"path"`
62			MaxSize    int    `mapstructure:"max_size"`
63			MaxBackups int    `mapstructure:"max_backups"`
64			MaxAge     int    `mapstructure:"max_age"`
65		} `mapstructure:"file"`
66		ContainerLogs struct {
67			Enabled    bool   `mapstructure:"enabled"`
68			Dir        string `mapstructure:"dir"`
69			MaxSize    int    `mapstructure:"max_size"`
70			MaxBackups int    `mapstructure:"max_backups"`
71			MaxAge     int    `mapstructure:"max_age"`
72		} `mapstructure:"container_logs"`
73	} `mapstructure:"logging"`
74
75	Env struct {
76		Dir string `mapstructure:"dir"`
77	} `mapstructure:"env"`
78
79	Secrets struct {
80		Backend string `mapstructure:"backend"` // "pass", "sops", or "unsafe"
81	} `mapstructure:"secrets"`
82
83	RegistryAuth struct {
84		Enabled      bool   `mapstructure:"enabled"`
85		Type         string `mapstructure:"type"` // "password" or "token"
86		Username     string `mapstructure:"username"`
87		Password     string `mapstructure:"password"`      // deprecated: use password_hash
88		PasswordHash string `mapstructure:"password_hash"` // path in secrets backend
89		TokenSecret  string `mapstructure:"token_secret"`  // path in secrets backend
90		TokenExpiry  string `mapstructure:"token_expiry"`  // e.g., "720h"
91	} `mapstructure:"registry_auth"`
92}
93
94// services holds all the services used by the application.
95type services struct {
96	runtime         *docker.Runtime
97	eventBus        *eventbus.InMemory
98	blobStorage     *filesystem.BlobStorage
99	manifestStorage *filesystem.ManifestStorage
100	envLoader       *envloader.FileLoader
101	logWriter       *logwriter.LogWriter
102	tokenStore      out.TokenStore
103	configSvc       *config.Service
104	containerSvc    *container.Service
105	registrySvc     *registrySvc.Service
106	proxySvc        *proxy.Service
107	authSvc         *auth.Service
108	tokenHandler    *registry.TokenHandler
109	internalRegUser string
110	internalRegPass string
111}
112
113// Run initializes and starts the Gordon application.
114func Run(ctx context.Context, configPath string) error {
115	// Load configuration
116	v, cfg, err := initConfig(configPath)
117	if err != nil {
118		return err
119	}
120
121	// Initialize logger
122	log, cleanup, err := initLogger(cfg)
123	if err != nil {
124		return err
125	}
126	if cleanup != nil {
127		defer cleanup()
128	}
129
130	ctx = zerowrap.WithCtx(ctx, log)
131	log.Info().Msg("Gordon starting")
132
133	// Create PID file
134	pidFile := createPidFile(log)
135	if pidFile != "" {
136		defer removePidFile(pidFile, log)
137	}
138
139	// Create all services
140	svc, err := createServices(ctx, v, cfg, log)
141	if err != nil {
142		return err
143	}
144
145	// Register event handlers
146	if err := registerEventHandlers(ctx, svc); err != nil {
147		return err
148	}
149
150	// Set up config hot reload
151	setupConfigHotReload(ctx, v, svc, log)
152
153	// Start event bus
154	if err := svc.eventBus.Start(); err != nil {
155		return log.WrapErr(err, "failed to start event bus")
156	}
157	defer svc.eventBus.Stop()
158
159	// Sync and auto-start containers
160	syncAndAutoStart(ctx, svc, log)
161
162	// Create HTTP handlers
163	registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
164
165	// Start servers and wait for shutdown
166	return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, log)
167}
168
169// initConfig loads configuration from file.
170func initConfig(configPath string) (*viper.Viper, Config, error) {
171	v := viper.New()
172	if err := loadConfig(v, configPath); err != nil {
173		return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
174	}
175
176	var cfg Config
177	if err := v.Unmarshal(&cfg); err != nil {
178		return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
179	}
180
181	return v, cfg, nil
182}
183
184// initLogger initializes the zerowrap logger.
185func initLogger(cfg Config) (zerowrap.Logger, func(), error) {
186	logConfig := zerowrap.Config{
187		Level:  cfg.Logging.Level,
188		Format: cfg.Logging.Format,
189	}
190
191	if cfg.Logging.File.Enabled {
192		log, cleanup, err := zerowrap.NewWithFile(logConfig, zerowrap.FileConfig{
193			Enabled:    true,
194			Path:       cfg.Logging.File.Path,
195			MaxSize:    cfg.Logging.File.MaxSize,
196			MaxBackups: cfg.Logging.File.MaxBackups,
197			MaxAge:     cfg.Logging.File.MaxAge,
198			Compress:   true,
199		})
200		if err != nil {
201			return zerowrap.Default(), nil, fmt.Errorf("failed to create logger with file: %w", err)
202		}
203		return log, cleanup, nil
204	}
205
206	return zerowrap.New(logConfig), nil, nil
207}
208
209// createServices creates all the application services.
210func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (*services, error) {
211	svc := &services{}
212	var err error
213
214	// Create output adapters
215	if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
216		return nil, err
217	}
218
219	// Create storage
220	if svc.blobStorage, svc.manifestStorage, err = createStorage(cfg, log); err != nil {
221		return nil, err
222	}
223
224	// Create env loader
225	if svc.envLoader, err = createEnvLoader(cfg, log); err != nil {
226		return nil, err
227	}
228
229	// Create log writer
230	if svc.logWriter, err = createLogWriter(cfg, log); err != nil {
231		return nil, err
232	}
233
234	// Create auth service (if enabled)
235	if svc.tokenStore, svc.authSvc, err = createAuthService(ctx, cfg, log); err != nil {
236		return nil, err
237	}
238
239	// Generate internal registry credentials for loopback-only pulls
240	if cfg.RegistryAuth.Enabled {
241		svc.internalRegUser, svc.internalRegPass, err = generateInternalRegistryAuth()
242		if err != nil {
243			return nil, log.WrapErr(err, "failed to generate internal registry credentials")
244		}
245		log.Debug().Msg("internal registry auth generated for loopback pulls")
246	}
247
248	// Create use case services
249	svc.configSvc = config.NewService(v, svc.eventBus)
250	if err := svc.configSvc.Load(ctx); err != nil {
251		return nil, log.WrapErr(err, "failed to load configuration")
252	}
253
254	svc.containerSvc = createContainerService(v, cfg, svc)
255	svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
256	svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
257		RegistryDomain: cfg.Server.RegistryDomain,
258		RegistryPort:   cfg.Server.RegistryPort,
259	})
260
261	// Create token handler for registry token endpoint
262	if svc.authSvc != nil {
263		internalAuth := registry.InternalAuth{
264			Username: svc.internalRegUser,
265			Password: svc.internalRegPass,
266		}
267		svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
268	}
269
270	return svc, nil
271}
272
273// createOutputAdapters creates the Docker runtime and event bus.
274func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
275	runtime, err := docker.NewRuntime()
276	if err != nil {
277		return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
278	}
279
280	if err := runtime.Ping(ctx); err != nil {
281		return nil, nil, log.WrapErr(err, "Docker is not available")
282	}
283
284	dockerVersion, _ := runtime.Version(ctx)
285	log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
286
287	eventBus := eventbus.NewInMemory(100, log)
288
289	return runtime, eventBus, nil
290}
291
292// createStorage creates blob and manifest storage.
293func createStorage(cfg Config, log zerowrap.Logger) (*filesystem.BlobStorage, *filesystem.ManifestStorage, error) {
294	dataDir := cfg.Server.DataDir
295	if dataDir == "" {
296		dataDir = DefaultDataDir()
297	}
298
299	registryDir := filepath.Join(dataDir, "registry")
300
301	blobStorage, err := filesystem.NewBlobStorage(registryDir, log)
302	if err != nil {
303		return nil, nil, log.WrapErr(err, "failed to create blob storage")
304	}
305
306	manifestStorage, err := filesystem.NewManifestStorage(registryDir, log)
307	if err != nil {
308		return nil, nil, log.WrapErr(err, "failed to create manifest storage")
309	}
310
311	return blobStorage, manifestStorage, nil
312}
313
314// createEnvLoader creates the environment loader with secret providers.
315func createEnvLoader(cfg Config, log zerowrap.Logger) (*envloader.FileLoader, error) {
316	dataDir := cfg.Server.DataDir
317	if dataDir == "" {
318		dataDir = DefaultDataDir()
319	}
320
321	envDir := cfg.Env.Dir
322	if envDir == "" {
323		envDir = filepath.Join(dataDir, "env")
324	}
325
326	envLoader, err := envloader.NewFileLoader(envDir, log)
327	if err != nil {
328		return nil, log.WrapErr(err, "failed to create env loader")
329	}
330
331	// Register secret providers
332	passProvider := secrets.NewPassProvider(log)
333	if passProvider.IsAvailable() {
334		envLoader.RegisterSecretProvider(passProvider)
335		log.Debug().Msg("pass secret provider registered")
336	}
337
338	sopsProvider := secrets.NewSopsProvider(log)
339	if sopsProvider.IsAvailable() {
340		envLoader.RegisterSecretProvider(sopsProvider)
341		log.Debug().Msg("sops secret provider registered")
342	}
343
344	return envLoader, nil
345}
346
347// createLogWriter creates the container log writer.
348func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, error) {
349	if !cfg.Logging.ContainerLogs.Enabled {
350		log.Debug().Msg("container log collection disabled")
351		return nil, nil
352	}
353
354	// Determine log directory
355	logDir := cfg.Logging.ContainerLogs.Dir
356	if logDir == "" {
357		dataDir := cfg.Server.DataDir
358		if dataDir == "" {
359			dataDir = DefaultDataDir()
360		}
361		logDir = filepath.Join(dataDir, "logs", "containers")
362	}
363
364	writer, err := logwriter.New(logwriter.Config{
365		Dir:        logDir,
366		MaxSize:    cfg.Logging.ContainerLogs.MaxSize,
367		MaxBackups: cfg.Logging.ContainerLogs.MaxBackups,
368		MaxAge:     cfg.Logging.ContainerLogs.MaxAge,
369	})
370	if err != nil {
371		return nil, log.WrapErr(err, "failed to create container log writer")
372	}
373
374	log.Info().Str("dir", logDir).Msg("container log collection enabled")
375	return writer, nil
376}
377
378const internalRegistryUsername = "gordon-internal"
379
380func generateInternalRegistryAuth() (string, string, error) {
381	password, err := randomTokenHex(32)
382	if err != nil {
383		return "", "", err
384	}
385	return internalRegistryUsername, password, nil
386}
387
388func randomTokenHex(size int) (string, error) {
389	buf := make([]byte, size)
390	if _, err := rand.Read(buf); err != nil {
391		return "", err
392	}
393	return hex.EncodeToString(buf), nil
394}
395
396// createAuthService creates the authentication service and token store.
397func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (out.TokenStore, *auth.Service, error) {
398	if !cfg.RegistryAuth.Enabled {
399		return nil, nil, nil
400	}
401
402	authType := resolveAuthType(cfg.RegistryAuth.Type)
403	backend := resolveSecretsBackend(cfg.Secrets.Backend)
404	dataDir := resolveDataDir(cfg.Server.DataDir)
405
406	store, err := createTokenStore(authType, backend, dataDir, log)
407	if err != nil {
408		return nil, nil, err
409	}
410
411	authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log)
412	if err != nil {
413		return nil, nil, err
414	}
415
416	authSvc := auth.NewService(authConfig, store, log)
417
418	log.Info().
419		Str("type", string(authType)).
420		Str("backend", string(backend)).
421		Msg("registry authentication enabled")
422
423	return store, authSvc, nil
424}
425
426func resolveAuthType(authType string) domain.AuthType {
427	if authType == "token" {
428		return domain.AuthTypeToken
429	}
430	return domain.AuthTypePassword
431}
432
433func resolveSecretsBackend(backend string) domain.SecretsBackend {
434	switch backend {
435	case "pass":
436		return domain.SecretsBackendPass
437	case "sops":
438		return domain.SecretsBackendSops
439	case "unsafe", "":
440		return domain.SecretsBackendUnsafe
441	default:
442		return domain.SecretsBackendUnsafe
443	}
444}
445
446func resolveDataDir(dataDir string) string {
447	if dataDir == "" {
448		return DefaultDataDir()
449	}
450	return dataDir
451}
452
453func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) {
454	if authType != domain.AuthTypeToken {
455		return nil, nil
456	}
457
458	store, err := tokenstore.NewStore(backend, dataDir, log)
459	if err != nil {
460		return nil, log.WrapErr(err, "failed to create token store")
461	}
462	return store, nil
463}
464
465func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) {
466	authConfig := auth.Config{
467		Enabled:  cfg.RegistryAuth.Enabled,
468		AuthType: authType,
469		Username: cfg.RegistryAuth.Username,
470	}
471
472	switch authType {
473	case domain.AuthTypePassword:
474		hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log)
475		if err != nil {
476			return auth.Config{}, err
477		}
478		authConfig.PasswordHash = hash
479	case domain.AuthTypeToken:
480		secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log)
481		if err != nil {
482			return auth.Config{}, err
483		}
484		authConfig.TokenSecret = secret
485		authConfig.TokenExpiry = expiry
486	}
487
488	return authConfig, nil
489}
490
491func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) {
492	if cfg.RegistryAuth.PasswordHash != "" {
493		hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log)
494		if err != nil {
495			return "", log.WrapErr(err, "failed to load password hash")
496		}
497		return hash, nil
498	}
499
500	if cfg.RegistryAuth.Password != "" {
501		log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend")
502		return cfg.RegistryAuth.Password, nil
503	}
504
505	return "", nil
506}
507
508func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) {
509	secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log)
510	if err != nil {
511		return nil, 0, err
512	}
513
514	expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry)
515	if err != nil {
516		return nil, 0, err
517	}
518
519	return secret, expiry, nil
520}
521
522func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) {
523	if cfg.RegistryAuth.TokenSecret == "" {
524		return nil, fmt.Errorf("token_secret is required for token authentication")
525	}
526
527	secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log)
528	if err != nil {
529		return nil, log.WrapErr(err, "failed to load token secret")
530	}
531
532	return []byte(secret), nil
533}
534
535func parseTokenExpiry(expiry string) (time.Duration, error) {
536	if expiry == "" {
537		return 0, nil
538	}
539
540	parsed, err := time.ParseDuration(expiry)
541	if err != nil {
542		return 0, fmt.Errorf("invalid token_expiry: %w", err)
543	}
544
545	return parsed, nil
546}
547
548// loadSecret loads a secret from the configured backend.
549func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDir string, log zerowrap.Logger) (string, error) {
550	switch backend {
551	case domain.SecretsBackendPass:
552		provider := secrets.NewPassProvider(log)
553		return provider.GetSecret(ctx, path)
554	case domain.SecretsBackendSops:
555		provider := secrets.NewSopsProvider(log)
556		return provider.GetSecret(ctx, path)
557	case domain.SecretsBackendUnsafe:
558		// For unsafe backend, path is relative to dataDir/secrets/
559		secretFile := filepath.Join(dataDir, "secrets", path)
560		data, err := os.ReadFile(secretFile)
561		if err != nil {
562			return "", fmt.Errorf("failed to read secret file: %w", err)
563		}
564		return string(data), nil
565	default:
566		return "", fmt.Errorf("unknown secrets backend: %s", backend)
567	}
568}
569
570// createContainerService creates the container service with configuration.
571func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
572	containerConfig := container.Config{
573		RegistryAuthEnabled:      cfg.RegistryAuth.Enabled,
574		RegistryDomain:           cfg.Server.RegistryDomain,
575		RegistryPort:             cfg.Server.RegistryPort,
576		RegistryUsername:         cfg.RegistryAuth.Username,
577		RegistryPassword:         cfg.RegistryAuth.Password,
578		InternalRegistryUsername: svc.internalRegUser,
579		InternalRegistryPassword: svc.internalRegPass,
580		VolumeAutoCreate:         v.GetBool("volumes.auto_create"),
581		VolumePrefix:             v.GetString("volumes.prefix"),
582		VolumePreserve:           v.GetBool("volumes.preserve"),
583		NetworkIsolation:         v.GetBool("network_isolation.enabled"),
584		NetworkPrefix:            v.GetString("network_isolation.network_prefix"),
585		DNSSuffix:                v.GetString("network_isolation.dns_suffix"),
586		NetworkGroups:            svc.configSvc.GetNetworkGroups(),
587		Attachments:              svc.configSvc.GetAttachments(),
588	}
589	return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
590}
591
592// registerEventHandlers registers all event handlers.
593func registerEventHandlers(ctx context.Context, svc *services) error {
594	imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
595	if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
596		return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
597	}
598
599	configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
600	if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
601		return fmt.Errorf("failed to subscribe config reload handler: %w", err)
602	}
603
604	// Proxy cache invalidation on container deployment (for zero-downtime)
605	containerDeployedHandler := proxy.NewContainerDeployedHandler(ctx, svc.proxySvc)
606	if err := svc.eventBus.Subscribe(containerDeployedHandler); err != nil {
607		return fmt.Errorf("failed to subscribe container deployed handler: %w", err)
608	}
609
610	return nil
611}
612
613// setupConfigHotReload sets up Viper config hot reload.
614func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
615	v.OnConfigChange(func(e fsnotify.Event) {
616		log.Info().Str("file", e.Name).Msg("config file changed")
617
618		if err := v.ReadInConfig(); err != nil {
619			log.Error().Err(err).Msg("failed to reload config")
620			return
621		}
622
623		if err := svc.configSvc.Load(ctx); err != nil {
624			log.Error().Err(err).Msg("failed to reload configuration")
625			return
626		}
627
628		svc.proxySvc.UpdateConfig(proxy.Config{
629			RegistryDomain: v.GetString("server.registry_domain"),
630			RegistryPort:   v.GetInt("server.registry_port"),
631		})
632	})
633	v.WatchConfig()
634}
635
636// syncAndAutoStart syncs existing containers and auto-starts if configured.
637func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
638	if err := svc.containerSvc.SyncContainers(ctx); err != nil {
639		log.Warn().Err(err).Msg("failed to sync existing containers")
640	}
641
642	if svc.configSvc.IsAutoRouteEnabled() {
643		if err := svc.containerSvc.AutoStart(ctx); err != nil {
644			log.Warn().Err(err).Msg("failed to auto-start containers")
645		}
646	}
647}
648
649// createHTTPHandlers creates HTTP handlers with middleware.
650func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Handler, http.Handler) {
651	registryHandler := registry.NewHandler(svc.registrySvc, svc.blobStorage, svc.eventBus, log)
652
653	registryMiddlewares := []func(http.Handler) http.Handler{
654		middleware.PanicRecovery(log),
655		middleware.RequestLogger(log),
656	}
657
658	if cfg.RegistryAuth.Enabled && svc.authSvc != nil {
659		internalAuth := middleware.InternalRegistryAuth{
660			Username: svc.internalRegUser,
661			Password: svc.internalRegPass,
662		}
663		registryMiddlewares = append(registryMiddlewares,
664			middleware.RegistryAuthV2(svc.authSvc, internalAuth, log))
665	}
666
667	registryWithMiddleware := middleware.Chain(registryMiddlewares...)(registryHandler)
668
669	// Create a mux that routes /v2/token to the token handler (no auth required)
670	// and all other /v2/* routes to the registry handler (with auth)
671	registryMux := http.NewServeMux()
672	if svc.tokenHandler != nil {
673		// Token endpoint is NOT protected by auth - it's where clients get tokens
674		tokenWithLogging := middleware.Chain(
675			middleware.PanicRecovery(log),
676			middleware.RequestLogger(log),
677		)(svc.tokenHandler)
678		registryMux.Handle("/v2/token", tokenWithLogging)
679	}
680	registryMux.Handle("/v2/", registryWithMiddleware)
681
682	proxyMiddlewares := []func(http.Handler) http.Handler{
683		middleware.PanicRecovery(log),
684		middleware.RequestLogger(log),
685		middleware.CORS,
686	}
687
688	proxyWithMiddleware := middleware.Chain(proxyMiddlewares...)(svc.proxySvc)
689
690	return registryMux, proxyWithMiddleware
691}
692
693// runServers starts the HTTP servers and waits for shutdown.
694func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, log zerowrap.Logger) error {
695	ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
696	defer cancel()
697
698	errChan := make(chan error, 2)
699
700	go startServer(fmt.Sprintf(":%d", cfg.Server.RegistryPort), registryHandler, "registry", errChan, log)
701	go startServer(fmt.Sprintf(":%d", cfg.Server.Port), proxyHandler, "proxy", errChan, log)
702
703	log.Info().
704		Int("proxy_port", cfg.Server.Port).
705		Int("registry_port", cfg.Server.RegistryPort).
706		Msg("Gordon is running")
707
708	select {
709	case err := <-errChan:
710		return err
711	case <-ctx.Done():
712		log.Info().Msg("shutdown signal received")
713	}
714
715	log.Info().Msg("shutting down Gordon...")
716
717	if err := containerSvc.Shutdown(ctx); err != nil {
718		log.Warn().Err(err).Msg("error during container shutdown")
719	}
720
721	log.Info().Msg("Gordon stopped")
722	return nil
723}
724
725// startServer starts an HTTP server.
726func startServer(addr string, handler http.Handler, name string, errChan chan<- error, log zerowrap.Logger) {
727	log.Info().Str("address", addr).Msgf("%s server starting", name)
728
729	server := &http.Server{
730		Addr:              addr,
731		Handler:           handler,
732		ReadHeaderTimeout: 10 * time.Second,
733	}
734
735	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
736		errChan <- fmt.Errorf("%s server error: %w", name, err)
737	}
738}
739
740// SendReloadSignal sends SIGUSR1 to the running Gordon process.
741func SendReloadSignal() error {
742	pidFile := findPidFile()
743	if pidFile == "" {
744		return fmt.Errorf("gordon PID file not found, is Gordon running?")
745	}
746
747	pidBytes, err := os.ReadFile(pidFile)
748	if err != nil {
749		return fmt.Errorf("failed to read PID file: %w", err)
750	}
751
752	var pid int
753	if _, err := fmt.Sscanf(string(pidBytes), "%d", &pid); err != nil {
754		return fmt.Errorf("failed to parse PID: %w", err)
755	}
756
757	process, err := os.FindProcess(pid)
758	if err != nil {
759		return fmt.Errorf("failed to find process: %w", err)
760	}
761
762	if err := process.Signal(syscall.SIGUSR1); err != nil {
763		return fmt.Errorf("failed to send reload signal: %w", err)
764	}
765
766	return nil
767}
768
769// createPidFile creates a PID file for the Gordon process.
770func createPidFile(log zerowrap.Logger) string {
771	pid := os.Getpid()
772
773	locations := []string{
774		"/tmp/gordon.pid",
775		filepath.Join(os.TempDir(), "gordon.pid"),
776	}
777
778	if homeDir, err := os.UserHomeDir(); err == nil {
779		locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
780	}
781
782	for _, location := range locations {
783		if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil {
784			log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file")
785			return location
786		}
787	}
788
789	log.Warn().Int("pid", pid).Msg("failed to create PID file in any location")
790	return ""
791}
792
793// removePidFile removes the PID file.
794func removePidFile(pidFile string, log zerowrap.Logger) {
795	if err := os.Remove(pidFile); err != nil {
796		log.Warn().Err(err).Str("pid_file", pidFile).Msg("failed to remove PID file")
797	} else {
798		log.Debug().Str("pid_file", pidFile).Msg("removed PID file")
799	}
800}
801
802// findPidFile finds the Gordon PID file.
803func findPidFile() string {
804	locations := []string{
805		"/tmp/gordon.pid",
806		filepath.Join(os.TempDir(), "gordon.pid"),
807	}
808
809	if homeDir, err := os.UserHomeDir(); err == nil {
810		locations = append(locations, filepath.Join(homeDir, ".gordon.pid"))
811	}
812
813	for _, location := range locations {
814		if _, err := os.Stat(location); err == nil {
815			return location
816		}
817	}
818
819	return ""
820}
821
822// loadConfig loads configuration from file and sets defaults.
823func loadConfig(v *viper.Viper, configPath string) error {
824	v.SetDefault("server.port", 80)
825	v.SetDefault("server.registry_port", 5000)
826	v.SetDefault("server.data_dir", DefaultDataDir())
827	v.SetDefault("logging.level", "info")
828	v.SetDefault("logging.format", "console")
829	v.SetDefault("logging.file.enabled", false)
830	v.SetDefault("logging.file.max_size", 100)
831	v.SetDefault("logging.file.max_backups", 3)
832	v.SetDefault("logging.file.max_age", 28)
833	v.SetDefault("logging.container_logs.enabled", true)
834	v.SetDefault("logging.container_logs.dir", "")
835	v.SetDefault("logging.container_logs.max_size", 100)
836	v.SetDefault("logging.container_logs.max_backups", 3)
837	v.SetDefault("logging.container_logs.max_age", 28)
838	v.SetDefault("env.dir", "") // defaults to {data_dir}/env when empty
839	v.SetDefault("secrets.backend", "unsafe")
840	v.SetDefault("registry_auth.enabled", false)
841	v.SetDefault("registry_auth.type", "password")
842	v.SetDefault("registry_auth.token_expiry", "720h")
843
844	ConfigureViper(v, configPath)
845
846	if err := v.ReadInConfig(); err != nil {
847		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
848			return fmt.Errorf("failed to read config file: %w", err)
849		}
850	}
851
852	v.SetEnvPrefix("GORDON")
853	v.AutomaticEnv()
854
855	return nil
856}

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