config.go RawHistoryFolder

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

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