v3.5.0 8345e76

config.go RawHistoryFolder

1package app
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"strings"
8	"time"
9
10	"github.com/bnema/zerowrap"
11	"github.com/spf13/viper"
12
13	"github.com/bnema/gordon/internal/adapters/out/telemetry"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/internal/usecase/publictls"
16	servicecfg "github.com/bnema/gordon/internal/usecase/services"
17	"github.com/bnema/gordon/internal/usecase/traffic"
18)
19
20// Config holds the application configuration.
21type Config struct {
22	Server struct {
23		Port                  int      `mapstructure:"port"`
24		RegistryPort          int      `mapstructure:"registry_port"`
25		GordonDomain          string   `mapstructure:"gordon_domain"`
26		RegistryDomain        string   `mapstructure:"registry_domain"`
27		LegacyRegistryDomains []string `mapstructure:"legacy_registry_domains"`
28		TLSPort               int      `mapstructure:"tls_port"`
29		TLSCertFile           string   `mapstructure:"tls_cert_file"`
30		TLSKeyFile            string   `mapstructure:"tls_key_file"`
31		ForceHTTPSRedirect    bool     `mapstructure:"force_https_redirect"`
32		DataDir               string   `mapstructure:"data_dir"`
33		MaxProxyBodySize      string   `mapstructure:"max_proxy_body_size"`     // e.g., "512MB", "1GB"
34		MaxBlobChunkSize      string   `mapstructure:"max_blob_chunk_size"`     // e.g., "512MB", "1GB"
35		MaxBlobSize           string   `mapstructure:"max_blob_size"`           // e.g., "1GB", "2GB"
36		MaxProxyResponseSize  string   `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
37		MaxConcurrentConns    int      `mapstructure:"max_concurrent_connections"`
38		RegistryAllowedIPs    []string `mapstructure:"registry_allowed_ips"`
39		ProxyAllowedIPs       []string `mapstructure:"proxy_allowed_ips"`
40		RegistryListenAddr    string   `mapstructure:"registry_listen_address"`
41
42		// HTTP holds the registry HTTP server timeouts (Go duration strings).
43		HTTP struct {
44			ReadHeaderTimeout string `mapstructure:"read_header_timeout"`
45			ReadTimeout       string `mapstructure:"read_timeout"`
46			WriteTimeout      string `mapstructure:"write_timeout"`
47			IdleTimeout       string `mapstructure:"idle_timeout"`
48		} `mapstructure:"http"`
49	} `mapstructure:"server"`
50
51	Apps struct {
52		// RevisionRetention bounds unreferenced-revision GC per app.
53		// Zero or negative restores the compiled default (8).
54		RevisionRetention int `mapstructure:"revision_retention"`
55	} `mapstructure:"apps"`
56
57	// AppMounts declares administrative bind policies keyed by stable mount
58	// name, referenced by name from an app manifest's [[services.<name>.bind]].
59	AppMounts map[string]AppMountPolicy `mapstructure:"app_mounts"`
60
61	// AppDevices declares administrative device grants keyed by stable
62	// logical device name, referenced by name from an app manifest's
63	// `devices` list.
64	AppDevices map[string]AppDevicePolicy `mapstructure:"app_devices"`
65
66	// AppCapabilities declares administrative Linux capability grants keyed
67	// by stable logical name, referenced by name from an app manifest's
68	// `capabilities` list.
69	AppCapabilities map[string]AppCapabilityPolicy `mapstructure:"app_capabilities"`
70
71	Logging struct {
72		Level  string `mapstructure:"level"`
73		Format string `mapstructure:"format"`
74		File   struct {
75			Enabled    bool   `mapstructure:"enabled"`
76			Path       string `mapstructure:"path"`
77			MaxSize    int    `mapstructure:"max_size"`
78			MaxBackups int    `mapstructure:"max_backups"`
79			MaxAge     int    `mapstructure:"max_age"`
80		} `mapstructure:"file"`
81		ContainerLogs struct {
82			Enabled    bool   `mapstructure:"enabled"`
83			Dir        string `mapstructure:"dir"`
84			MaxSize    int    `mapstructure:"max_size"`
85			MaxBackups int    `mapstructure:"max_backups"`
86			MaxAge     int    `mapstructure:"max_age"`
87		} `mapstructure:"container_logs"`
88		AccessLog struct {
89			Enabled             bool   `mapstructure:"enabled"`
90			Format              string `mapstructure:"format"`
91			Output              string `mapstructure:"output"`
92			FilePath            string `mapstructure:"file_path"`
93			MaxSize             int    `mapstructure:"max_size"`
94			MaxBackups          int    `mapstructure:"max_backups"`
95			MaxAge              int    `mapstructure:"max_age"`
96			ExcludeHealthChecks bool   `mapstructure:"exclude_health_checks"`
97			SyslogIdentifier    string `mapstructure:"syslog_identifier"`
98		} `mapstructure:"access_log"`
99	} `mapstructure:"logging"`
100
101	Volumes struct {
102		AutoCreate bool   `mapstructure:"auto_create"`
103		Prefix     string `mapstructure:"prefix"`
104		Preserve   bool   `mapstructure:"preserve"`
105	} `mapstructure:"volumes"`
106
107	Auth struct {
108		Enabled        bool   `mapstructure:"enabled"`
109		Type           string `mapstructure:"type"`            // only "token" is supported
110		SecretsBackend string `mapstructure:"secrets_backend"` // "pass", "sops", or "unsafe"
111		Username       string `mapstructure:"username"`
112		TokenSecret    string `mapstructure:"token_secret"`     // path in secrets backend
113		TokenExpiry    string `mapstructure:"token_expiry"`     // e.g., "720h", "30d"
114		AccessTokenTTL string `mapstructure:"access_token_ttl"` // e.g., "15m", "30m" (default: 15m)
115	} `mapstructure:"auth"`
116
117	API struct {
118		RateLimit struct {
119			Enabled        bool     `mapstructure:"enabled"`
120			GlobalRPS      float64  `mapstructure:"global_rps"`
121			PerIPRPS       float64  `mapstructure:"per_ip_rps"`
122			Burst          int      `mapstructure:"burst"`
123			TrustedProxies []string `mapstructure:"trusted_proxies"`
124		} `mapstructure:"rate_limit"`
125	} `mapstructure:"api"`
126
127	EntryPoints     map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
128	Traffic         traffic.Config                      `mapstructure:"traffic"`
129	NetworkServices []traffic.NetworkServiceConfig      `mapstructure:"network_services"`
130	Services        []servicecfg.Config                 `mapstructure:"services"`
131
132	Backups struct {
133		// Legacy database backup keys. Prefer backups.databases.* for new configs.
134		Enabled    bool   `mapstructure:"enabled"`
135		Schedule   string `mapstructure:"schedule"`
136		StorageDir string `mapstructure:"storage_dir"`
137		Retention  struct {
138			Hourly  int `mapstructure:"hourly"`
139			Daily   int `mapstructure:"daily"`
140			Weekly  int `mapstructure:"weekly"`
141			Monthly int `mapstructure:"monthly"`
142		} `mapstructure:"retention"`
143		Databases struct {
144			Enabled    bool   `mapstructure:"enabled"`
145			Schedule   string `mapstructure:"schedule"`
146			Time       string `mapstructure:"time"`
147			StorageDir string `mapstructure:"storage_dir"`
148			Retention  struct {
149				Hourly  int `mapstructure:"hourly"`
150				Daily   int `mapstructure:"daily"`
151				Weekly  int `mapstructure:"weekly"`
152				Monthly int `mapstructure:"monthly"`
153			} `mapstructure:"retention"`
154		} `mapstructure:"databases"`
155		Volumes struct {
156			Enabled        bool   `mapstructure:"enabled"`
157			Schedule       string `mapstructure:"schedule"`
158			Time           string `mapstructure:"time"`
159			Compression    string `mapstructure:"compression"`
160			Timeout        string `mapstructure:"timeout"`
161			MaxConcurrency int    `mapstructure:"max_concurrency"`
162			HelperImage    string `mapstructure:"helper_image"`
163			S3             struct {
164				Bucket       string `mapstructure:"bucket"`
165				Region       string `mapstructure:"region"`
166				Prefix       string `mapstructure:"prefix"`
167				Endpoint     string `mapstructure:"endpoint"`
168				PathStyle    bool   `mapstructure:"path_style"`
169				SSEAlgorithm string `mapstructure:"sse_algorithm"`
170				SSEKMSKeyID  string `mapstructure:"sse_kms_key_id"`
171				StorageClass string `mapstructure:"storage_class"`
172			} `mapstructure:"s3"`
173			Retention struct {
174				Keep int `mapstructure:"keep"`
175			} `mapstructure:"retention"`
176		} `mapstructure:"volumes"`
177	} `mapstructure:"backups"`
178
179	Images struct {
180		AllowedRegistries []string `mapstructure:"allowed_registries"`
181		RequireDigest     bool     `mapstructure:"require_digest"`
182		Prune             struct {
183			Enabled  bool   `mapstructure:"enabled"`
184			Schedule string `mapstructure:"schedule"`
185			KeepLast int    `mapstructure:"keep_last"`
186		} `mapstructure:"prune"`
187	} `mapstructure:"images"`
188
189	Containers struct {
190		MemoryLimit     string  `mapstructure:"memory_limit"`     // e.g., "512MB", "1GB"
191		CPULimit        float64 `mapstructure:"cpu_limit"`        // CPU cores, e.g., 1.0 = 1 core
192		PidsLimit       int64   `mapstructure:"pids_limit"`       // e.g., 512
193		SecurityProfile string  `mapstructure:"security_profile"` // compat or strict
194	} `mapstructure:"containers"`
195
196	NetworkIsolation struct {
197		Enabled  bool   `mapstructure:"enabled"`
198		Prefix   string `mapstructure:"network_prefix"`
199		Internal bool   `mapstructure:"internal"`
200	} `mapstructure:"network_isolation"`
201
202	Telemetry telemetry.Config `mapstructure:"telemetry"`
203
204	TLS struct {
205		ACME struct {
206			Enabled         bool   `mapstructure:"enabled"`
207			Email           string `mapstructure:"email"`
208			Challenge       string `mapstructure:"challenge"`
209			ObtainBatchSize int    `mapstructure:"obtain_batch_size"`
210		} `mapstructure:"acme"`
211	} `mapstructure:"tls"`
212
213	DNS struct {
214		Resolvers          []string `mapstructure:"resolvers"`
215		PropagationTimeout string   `mapstructure:"propagation_timeout"`
216		PollingInterval    string   `mapstructure:"polling_interval"`
217	} `mapstructure:"dns"`
218}
219
220func warnDeprecatedConfigKeys(v *viper.Viper, log zerowrap.Logger) {
221	for _, key := range []string{"server.tls_enabled", "server.force_hsts"} {
222		if v.IsSet(key) {
223			log.Warn().Str("key", key).Msg("deprecated config key — Gordon now uses an internal CA with automatic TLS; remove this from your config")
224		}
225	}
226}
227
228// initConfig loads configuration from file.
229func initConfig(configPath string) (*viper.Viper, Config, error) {
230	v := viper.New()
231	if err := loadConfig(v, configPath); err != nil {
232		return nil, Config{}, fmt.Errorf("failed to load config: %w", err)
233	}
234
235	var cfg Config
236	if err := v.Unmarshal(&cfg); err != nil {
237		return nil, Config{}, fmt.Errorf("failed to unmarshal config: %w", err)
238	}
239	if err := validateEntrypointMigration(v, cfg); err != nil {
240		return nil, Config{}, err
241	}
242	if err := validateRetiredAppConfig(v); err != nil {
243		return nil, Config{}, err
244	}
245	if err := validateHTTPTimeouts(cfg); err != nil {
246		return nil, Config{}, err
247	}
248	if _, err := buildAppMountPolicies(cfg); err != nil {
249		return nil, Config{}, err
250	}
251	if err := validateDeviceConfig(v, cfg); err != nil {
252		return nil, Config{}, err
253	}
254	if err := validateCapabilityConfig(v, cfg); err != nil {
255		return nil, Config{}, err
256	}
257
258	return v, cfg, nil
259}
260
261// retiredAppConfigKeys are pre-v3 application keys removed with the
262// declarative-apps cutover. Presence fails boot/reload closed with a
263// config-retired diagnostic naming the fix — never a silent migration.
264// Keys that remain live installation-level settings are deliberately
265// absent: top-level "services" still declares standalone L4 workloads
266// (see config.services), so it must never be rejected here.
267var retiredAppConfigKeys = []struct {
268	key  string
269	hint string
270}{
271	{"routes", "declare [[services.<name>.http]] in an app file, then 'gordon apps apply'"},
272	{"attachments", "declare [services.<name>] + volumes; attachments are removed"},
273	{"network_groups", "declare [[network.shared]] with ownership verification"},
274	{"service_routes", "declare [[services.<name>.http]] instead"},
275	{"auto", "feature removed; declare explicit interfaces"},
276	{"auto_route", "feature removed; declare explicit interfaces"},
277	{"auto_route_allowed_domains", "feature removed; declare explicit interfaces"},
278	{"previews", "staging is an ordinary app file"},
279	{"env", "remove the [env] section; declare [env] and [services.<name>.secrets] in app files"},
280	{"backups.volumes.interval", "use backups.volumes.schedule and backups.volumes.time (server local time)"},
281}
282
283// validateRetiredAppConfig rejects obsolete application configuration
284// BEFORE any app/runtime mutation, at startup and reload. InConfig
285// (not IsSet) targets explicit file keys only — installation defaults
286// registered via SetDefault must not trip the rejection.
287func validateRetiredAppConfig(v *viper.Viper) error {
288	diagnostics := make([]string, 0, len(retiredAppConfigKeys))
289	for _, retired := range retiredAppConfigKeys {
290		if v.InConfig(retired.key) {
291			diagnostics = append(diagnostics, fmt.Sprintf("key %q was removed in v3; %s", retired.key, retired.hint))
292		}
293	}
294	if len(diagnostics) > 0 {
295		return fmt.Errorf("config-retired: %s", strings.Join(diagnostics, "; "))
296	}
297	return nil
298}
299
300func validateEntrypointMigration(v *viper.Viper, cfg Config) error {
301	if len(cfg.EntryPoints) > 0 {
302		return nil
303	}
304
305	legacyKeys := make([]string, 0, 2)
306	for _, key := range []string{"server.port", "server.tls_port"} {
307		if v.IsSet(key) {
308			legacyKeys = append(legacyKeys, key)
309		}
310	}
311	if len(legacyKeys) == 0 {
312		return nil
313	}
314
315	return fmt.Errorf("legacy %s configuration requires at least one [entrypoints] entry; see docs/upgrading.md", strings.Join(legacyKeys, " or "))
316}
317
318// resolveLogFilePath returns the configured log file path or a default.
319func resolveLogFilePath(cfg Config) string {
320	if cfg.Logging.File.Path != "" {
321		return cfg.Logging.File.Path
322	}
323	dataDir := cfg.Server.DataDir
324	if dataDir == "" {
325		dataDir = DefaultDataDir()
326	}
327	return filepath.Join(dataDir, "logs", "gordon.log")
328}
329
330// resolveRuntimeConfig converts a server.runtime config value to a socket path.
331// "auto" or "" means auto-detect.
332// Named runtimes ("podman", "docker") are resolved to well-known socket paths.
333// URI schemes (unix://) are stripped so callers receive a bare path.
334func resolveRuntimeConfig(value string) string {
335	if value == "" || value == "auto" {
336		return ""
337	}
338	// Named runtimes: resolve to well-known socket paths.
339	switch value {
340	case "podman":
341		// Check XDG_RUNTIME_DIR first (rootless Podman).
342		if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
343			candidate := filepath.Join(xdg, "podman", "podman.sock")
344			if _, err := os.Stat(candidate); err == nil {
345				return candidate
346			}
347		}
348		// Fallback to system-wide Podman socket.
349		return "/run/podman/podman.sock"
350	case "docker":
351		return "/var/run/docker.sock"
352	}
353	// Explicit socket path — strip URI scheme if present.
354	if socketPath, ok := strings.CutPrefix(value, "unix://"); ok {
355		return socketPath
356	}
357	return value
358}
359
360func resolveDataDir(dataDir string) string {
361	if dataDir == "" {
362		return DefaultDataDir()
363	}
364	return dataDir
365}
366
367func resolveRegistryDomains(cfg Config) (string, []string) {
368	registryDomain := cfg.Server.RegistryDomain
369	if registryDomain == "" {
370		registryDomain = cfg.Server.GordonDomain
371	}
372	return registryDomain, append([]string{}, cfg.Server.LegacyRegistryDomains...)
373}
374
375// loadConfig loads configuration from file and sets defaults.
376func loadConfig(v *viper.Viper, configPath string) error {
377	v.SetDefault("server.registry_port", 5000)
378	v.SetDefault("server.legacy_registry_domains", []string{})
379	v.SetDefault("server.tls_cert_file", "")
380	v.SetDefault("server.tls_key_file", "")
381	v.SetDefault("tls.acme.enabled", false)
382	v.SetDefault("tls.acme.email", "")
383	v.SetDefault("tls.acme.challenge", "auto")
384	v.SetDefault("tls.acme.obtain_batch_size", 1)
385	v.SetDefault("dns.resolvers", publictls.DefaultDNSResolvers)
386	v.SetDefault("dns.propagation_timeout", "5m")
387	v.SetDefault("dns.polling_interval", "5s")
388	v.SetDefault("server.force_https_redirect", false)
389	v.SetDefault("server.data_dir", DefaultDataDir())
390	v.SetDefault("server.runtime", "auto")
391	v.SetDefault("logging.level", "info")
392	v.SetDefault("logging.format", "console")
393	v.SetDefault("logging.file.enabled", false)
394	v.SetDefault("logging.file.max_size", 100)
395	v.SetDefault("logging.file.max_backups", 3)
396	v.SetDefault("logging.file.max_age", 28)
397	v.SetDefault("logging.container_logs.enabled", true)
398	v.SetDefault("logging.container_logs.dir", "")
399	v.SetDefault("logging.container_logs.max_size", 100)
400	v.SetDefault("logging.container_logs.max_backups", 3)
401	v.SetDefault("logging.container_logs.max_age", 28)
402	v.SetDefault("logging.access_log.enabled", false)
403	v.SetDefault("logging.access_log.format", "json")
404	v.SetDefault("logging.access_log.output", "stdout")
405	v.SetDefault("logging.access_log.file_path", "")
406	v.SetDefault("logging.access_log.max_size", 100)
407	v.SetDefault("logging.access_log.max_backups", 3)
408	v.SetDefault("logging.access_log.max_age", 28)
409	v.SetDefault("logging.access_log.exclude_health_checks", true)
410	v.SetDefault("logging.access_log.syslog_identifier", "gordon-access")
411	v.SetDefault("auth.enabled", true)
412	// Note: auth.type defaults to "token" (the only supported mode)
413	v.SetDefault("auth.secrets_backend", "")
414	v.SetDefault("auth.token_expiry", "720h")
415	v.SetDefault("api.rate_limit.enabled", true)
416	v.SetDefault("api.rate_limit.global_rps", 500)
417	v.SetDefault("api.rate_limit.per_ip_rps", 50)
418	v.SetDefault("api.rate_limit.burst", 100)
419	v.SetDefault("auto_route.enabled", false)
420	v.SetDefault("network_isolation.enabled", true)
421	v.SetDefault("network_isolation.network_prefix", "gordon")
422	v.SetDefault("network_isolation.internal", false)
423	v.SetDefault("volumes.auto_create", true)
424	v.SetDefault("volumes.prefix", "gordon")
425	v.SetDefault("volumes.preserve", true)
426	v.SetDefault("backups.databases.enabled", false)
427	v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
428	v.SetDefault("backups.databases.time", "")
429	v.SetDefault("backups.databases.storage_dir", "")
430	v.SetDefault("backups.databases.retention.hourly", 0)
431	v.SetDefault("backups.databases.retention.daily", 0)
432	v.SetDefault("backups.databases.retention.weekly", 0)
433	v.SetDefault("backups.databases.retention.monthly", 0)
434	v.SetDefault("backups.volumes.enabled", false)
435	v.SetDefault("backups.volumes.schedule", string(domain.ScheduleDaily))
436	v.SetDefault("backups.volumes.time", "")
437	v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
438	v.SetDefault("backups.volumes.timeout", "2h")
439	v.SetDefault("backups.volumes.max_concurrency", 2)
440	v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
441	v.SetDefault("backups.volumes.s3.bucket", "")
442	v.SetDefault("backups.volumes.s3.region", "")
443	v.SetDefault("backups.volumes.s3.prefix", "")
444	v.SetDefault("backups.volumes.s3.endpoint", "")
445	v.SetDefault("backups.volumes.s3.path_style", false)
446	v.SetDefault("backups.volumes.s3.sse_algorithm", "")
447	v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
448	v.SetDefault("backups.volumes.s3.storage_class", "")
449	v.SetDefault("backups.volumes.retention.keep", 14)
450	v.SetDefault("images.allowed_registries", []string{})
451	v.SetDefault("images.require_digest", false)
452	v.SetDefault("images.prune.enabled", false)
453	v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
454	v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
455	v.SetDefault("containers.security_profile", "compat")
456	v.SetDefault("telemetry.enabled", false)
457	v.SetDefault("telemetry.endpoint", "")
458	v.SetDefault("telemetry.auth_token", "")
459	v.SetDefault("telemetry.traces", true)
460	v.SetDefault("telemetry.metrics", true)
461	v.SetDefault("telemetry.logs", true)
462	v.SetDefault("telemetry.trace_sample_rate", 1.0)
463
464	v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
465	v.SetDefault("server.registry_allowed_ips", []string{})
466	v.SetDefault("server.proxy_allowed_ips", []string{})
467	v.SetDefault("server.registry_listen_address", "")
468
469	// HTTP timeouts. Every key needs a default so GORDON_* env overrides apply.
470	v.SetDefault("server.http.read_header_timeout", domain.DefaultRegistryReadHeaderTimeout.String())
471	v.SetDefault("server.http.read_timeout", domain.DefaultRegistryReadTimeout.String())
472	v.SetDefault("server.http.write_timeout", domain.DefaultRegistryWriteTimeout.String())
473	v.SetDefault("server.http.idle_timeout", domain.DefaultRegistryIdleTimeout.String())
474	v.SetDefault("traffic.http.read_header_timeout", domain.DefaultEntryPointReadHeaderTimeout.String())
475	v.SetDefault("traffic.http.idle_timeout", domain.DefaultEntryPointIdleTimeout.String())
476	v.SetDefault("traffic.http.transfer_idle_timeout", domain.DefaultEntryPointTransferIdleTimeout.String())
477	v.SetDefault("traffic.tcp.dial_timeout", "10s")
478	v.SetDefault("traffic.tcp.idle_timeout", "5m")
479	v.SetDefault("traffic.tcp.drain_timeout", "30s")
480	v.SetDefault("traffic.udp.idle_timeout", "30s")
481	v.SetDefault("traffic.udp.drain_timeout", "30s")
482	v.SetDefault("traffic.tcp.max_connections", 0) // 0 = runtime default
483	v.SetDefault("traffic.udp.max_sessions", 0)    // 0 = runtime default
484
485	ConfigureViper(v, configPath)
486
487	if err := v.ReadInConfig(); err != nil {
488		if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
489			return fmt.Errorf("failed to read config file: %w", err)
490		}
491	}
492
493	v.SetEnvPrefix("GORDON")
494	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
495	v.AutomaticEnv()
496
497	return nil
498}
499
500// validateHTTPTimeouts rejects malformed, zero, or negative HTTP timeouts at
501// config load, before any listener is started.
502func validateHTTPTimeouts(cfg Config) error {
503	if _, err := buildRegistryHTTPTimeouts(cfg); err != nil {
504		return err
505	}
506	if _, err := traffic.ParseHTTPOptions(cfg.Traffic.HTTP); err != nil {
507		return err
508	}
509	return nil
510}
511
512// buildRegistryHTTPTimeouts parses server.http into validated registry server
513// timeouts. Empty values select the defaults; malformed, zero, or negative
514// values are rejected.
515func buildRegistryHTTPTimeouts(cfg Config) (domain.RegistryHTTPTimeouts, error) {
516	defaults := domain.DefaultRegistryHTTPTimeouts()
517	var (
518		t   domain.RegistryHTTPTimeouts
519		err error
520	)
521	if t.ReadHeaderTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadHeaderTimeout, defaults.ReadHeaderTimeout, "server.http.read_header_timeout"); err != nil {
522		return t, err
523	}
524	if t.ReadTimeout, err = parsePositiveDuration(cfg.Server.HTTP.ReadTimeout, defaults.ReadTimeout, "server.http.read_timeout"); err != nil {
525		return t, err
526	}
527	if t.WriteTimeout, err = parsePositiveDuration(cfg.Server.HTTP.WriteTimeout, defaults.WriteTimeout, "server.http.write_timeout"); err != nil {
528		return t, err
529	}
530	if t.IdleTimeout, err = parsePositiveDuration(cfg.Server.HTTP.IdleTimeout, defaults.IdleTimeout, "server.http.idle_timeout"); err != nil {
531		return t, err
532	}
533	return t, t.Validate()
534}
535
536func parsePositiveDuration(value string, fallback time.Duration, key string) (time.Duration, error) {
537	value = strings.TrimSpace(value)
538	if value == "" {
539		return fallback, nil
540	}
541	d, err := time.ParseDuration(value)
542	if err != nil {
543		return 0, fmt.Errorf("invalid %s: %w", key, err)
544	}
545	if d <= 0 {
546		return 0, fmt.Errorf("invalid %s: must be positive", key)
547	}
548	return d, nil
549}

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