v2.31.0-dev10 f49cffc

service.go RawHistoryFolder

1// Package config implements the configuration management use case.
2package config
3
4import (
5	"cmp"
6	"context"
7	"fmt"
8	"os"
9	"path/filepath"
10	"reflect"
11	"regexp"
12	"slices"
13	"strconv"
14	"strings"
15	"sync"
16	"sync/atomic"
17	"time"
18
19	"github.com/bnema/zerowrap"
20	"github.com/fsnotify/fsnotify"
21	"github.com/pelletier/go-toml/v2"
22	"github.com/spf13/viper"
23
24	"github.com/bnema/gordon/internal/boundaries/out"
25	"github.com/bnema/gordon/internal/domain"
26)
27
28// Config holds the loaded configuration.
29type Config struct {
30	ServerPort              int
31	RegistryPort            int
32	RegistryDomain          string
33	DataDir                 string
34	AutoRouteEnabled        bool
35	AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
36	PreviewEnabled          bool
37	PreviewTTL              time.Duration
38	PreviewSeparator        string
39	PreviewTagPatterns      []string
40	PreviewDataCopy         bool
41	PreviewEnvCopy          bool
42	NetworkIsolation        bool
43	NetworkPrefix           string
44	Routes                  map[string]routeConfig
45	ExternalRoutes          map[string]string // domain -> "host:port"
46	RegistryAuthEnabled     bool
47	RegistryAuthUsername    string
48	RegistryAuthPassword    string
49	VolumeAutoCreate        bool
50	VolumePrefix            string
51	VolumePreserve          bool
52	NetworkGroups           map[string][]string
53	Attachments             map[string][]string
54}
55
56type routeConfig struct {
57	Image string `toml:"image"`
58	HTTPS bool   `toml:"https"`
59}
60
61// Service implements the ConfigService interface.
62type Service struct {
63	viper         *viper.Viper
64	eventBus      out.EventPublisher
65	config        Config
66	mu            sync.RWMutex
67	lastSaveTime  int64 // Unix nano timestamp of last save (to debounce file watcher)
68	debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
69}
70
71// NewService creates a new config service.
72func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
73	return &Service{
74		viper:         v,
75		eventBus:      eventBus,
76		debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
77	}
78}
79
80// Load loads the configuration from the configured source.
81func (s *Service) Load(ctx context.Context) error {
82	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
83		zerowrap.FieldLayer:   "usecase",
84		zerowrap.FieldUseCase: "Load",
85	})
86	log := zerowrap.FromCtx(ctx)
87
88	s.mu.Lock()
89	defer s.mu.Unlock()
90
91	newConfig := s.loadConfigValues()
92
93	routes, err := loadCanonicalRoutes(s.viper.Get("routes"))
94	if err != nil {
95		return log.WrapErr(err, "failed to load routes")
96	}
97	newConfig.Routes = routes
98	externalRoutes, err := loadExternalRoutes(s.viper.Get("external_routes"))
99	if err != nil {
100		return log.WrapErr(err, "failed to load external routes")
101	}
102	for domainName := range externalRoutes {
103		if _, exists := routes[domainName]; exists {
104			return fmt.Errorf("external route %q conflicts with configured route", domainName)
105		}
106	}
107	newConfig.ExternalRoutes = externalRoutes
108	newConfig.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
109	newConfig.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
110
111	s.config = newConfig
112
113	log.Info().
114		Int("server_port", s.config.ServerPort).
115		Int("registry_port", s.config.RegistryPort).
116		Int(zerowrap.FieldCount, len(s.config.Routes)).
117		Msg("configuration loaded")
118
119	return nil
120}
121
122// Reload re-reads the configuration file from disk and loads it into memory.
123// This should be used when you want to pick up external changes to the config file.
124// It differs from Load() which only loads from cached viper values.
125func (s *Service) Reload(ctx context.Context) error {
126	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
127		zerowrap.FieldLayer:   "usecase",
128		zerowrap.FieldUseCase: "Reload",
129	})
130	log := zerowrap.FromCtx(ctx)
131
132	// Re-read the config file from disk
133	if err := s.viper.ReadInConfig(); err != nil {
134		return log.WrapErr(err, "failed to read config file")
135	}
136
137	// Load the values into memory
138	return s.Load(ctx)
139}
140
141// loadConfigValues loads simple config values from viper.
142func (s *Service) loadConfigValues() Config {
143	// Prefer gordon_domain over registry_domain
144	registryDomain := s.viper.GetString("server.gordon_domain")
145	if registryDomain == "" {
146		registryDomain = s.viper.GetString("server.registry_domain")
147	}
148
149	// Backward compat: try new keys first, fall back to old
150	autoEnabled := s.viper.GetBool("auto.enabled")
151	if !autoEnabled {
152		autoEnabled = s.viper.GetBool("auto_route.enabled")
153	}
154	allowedDomains := s.viper.GetStringSlice("auto.allowed_domains")
155	if len(allowedDomains) == 0 {
156		allowedDomains = s.viper.GetStringSlice("auto_route_allowed_domains")
157	}
158
159	// Preview config
160	previewTTLStr := s.viper.GetString("auto.preview.ttl")
161	previewTTL, err := time.ParseDuration(previewTTLStr)
162	if err != nil && previewTTLStr != "" {
163		log := zerowrap.FromCtx(context.Background())
164		log.Warn().Err(err).Str("ttl", previewTTLStr).Msg("invalid preview TTL format, using default 48h (use Go duration strings, e.g. \"168h\" for 7 days)")
165	}
166	if previewTTL == 0 {
167		previewTTL = 48 * time.Hour
168	}
169	previewSep := s.viper.GetString("auto.preview.separator")
170	if previewSep == "" {
171		previewSep = "--"
172	}
173
174	return Config{
175		ServerPort:              s.viper.GetInt("server.port"),
176		RegistryPort:            s.viper.GetInt("server.registry_port"),
177		RegistryDomain:          registryDomain,
178		DataDir:                 s.viper.GetString("server.data_dir"),
179		AutoRouteEnabled:        autoEnabled,
180		AutoRouteAllowedDomains: append([]string{}, allowedDomains...),
181		PreviewEnabled:          s.viper.GetBool("auto.preview.enabled"),
182		PreviewTTL:              previewTTL,
183		PreviewSeparator:        previewSep,
184		PreviewTagPatterns:      append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
185		PreviewDataCopy:         s.viper.GetBool("auto.preview.data_copy"),
186		PreviewEnvCopy:          !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"),
187		NetworkIsolation:        s.viper.GetBool("network_isolation.enabled"),
188		NetworkPrefix:           s.viper.GetString("network_isolation.network_prefix"),
189		RegistryAuthEnabled:     s.viper.GetBool("auth.enabled"),
190		RegistryAuthUsername:    s.viper.GetString("auth.username"),
191		RegistryAuthPassword:    s.viper.GetString("auth.password"),
192		VolumeAutoCreate:        s.viper.GetBool("volumes.auto_create"),
193		VolumePrefix:            s.viper.GetString("volumes.prefix"),
194		VolumePreserve:          s.viper.GetBool("volumes.preserve"),
195		Routes:                  make(map[string]routeConfig),
196		ExternalRoutes:          make(map[string]string),
197		NetworkGroups:           make(map[string][]string),
198		Attachments:             make(map[string][]string),
199	}
200}
201
202// loadStringMap loads a map[string]string from a viper value.
203func loadStringMap(raw any) map[string]string {
204	result := make(map[string]string)
205	if raw == nil {
206		return result
207	}
208	m, ok := raw.(map[string]any)
209	if !ok {
210		return result
211	}
212	for k, v := range m {
213		if vs, ok := v.(string); ok {
214			result[k] = vs
215		}
216	}
217	return result
218}
219
220// loadExternalRoutes loads and canonicalizes external route mappings.
221func loadExternalRoutes(raw any) (map[string]string, error) {
222	result := make(map[string]string)
223	if raw == nil {
224		return result, nil
225	}
226	m, ok := raw.(map[string]any)
227	if !ok {
228		return result, nil
229	}
230	for k, v := range m {
231		vs, ok := v.(string)
232		if !ok {
233			continue
234		}
235		canonicalKey, ok := domain.CanonicalRouteDomain(k)
236		if !ok {
237			return nil, fmt.Errorf("invalid external route key %q: %w", k, domain.ErrRouteDomainInvalid)
238		}
239		if _, exists := result[canonicalKey]; exists {
240			return nil, fmt.Errorf("duplicate external route key %q canonicalizes to %q", k, canonicalKey)
241		}
242		result[canonicalKey] = vs
243	}
244	return result, nil
245}
246
247// loadStringArrayMap loads a map[string][]string from a viper value.
248func loadStringArrayMap(raw any) map[string][]string {
249	result := make(map[string][]string)
250	if raw == nil {
251		return result
252	}
253	m, ok := raw.(map[string]any)
254	if !ok {
255		return result
256	}
257	for k, v := range m {
258		if arr, ok := v.([]any); ok {
259			var strs []string
260			for _, item := range arr {
261				if s, ok := item.(string); ok {
262					strs = append(strs, s)
263				}
264			}
265			result[k] = strs
266		}
267	}
268	return result
269}
270
271func loadCanonicalRoutes(raw any) (map[string]routeConfig, error) {
272	result := make(map[string]routeConfig)
273	if raw == nil {
274		return result, nil
275	}
276
277	routes, ok := raw.(map[string]any)
278	if !ok {
279		return nil, fmt.Errorf("routes section must be a table, got %T", raw)
280	}
281
282	for key, value := range routes {
283		if strings.HasPrefix(key, "http://") {
284			continue
285		}
286
287		route, err := parseCanonicalRouteEntry(key, value)
288		if err != nil {
289			return nil, err
290		}
291		canonicalKey, _ := domain.CanonicalRouteDomain(key)
292		if _, exists := result[canonicalKey]; exists {
293			return nil, fmt.Errorf("duplicate route key %q canonicalizes to %q", key, canonicalKey)
294		}
295		result[canonicalKey] = route
296	}
297
298	for key, value := range routes {
299		if !strings.HasPrefix(key, "http://") {
300			continue
301		}
302
303		domainName := strings.TrimPrefix(key, "http://")
304		canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
305		if !ok {
306			return nil, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
307		}
308		if _, exists := result[canonicalDomain]; exists {
309			continue
310		}
311
312		route, err := parseLegacyRouteEntry(domainName, value)
313		if err != nil {
314			return nil, err
315		}
316		result[canonicalDomain] = route
317	}
318
319	return result, nil
320}
321
322func parseCanonicalRouteEntry(domainName string, raw any) (routeConfig, error) {
323	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
324	if !ok {
325		return routeConfig{}, fmt.Errorf("invalid route key %q: %w", domainName, domain.ErrRouteDomainInvalid)
326	}
327	domainName = canonicalDomain
328
329	switch value := raw.(type) {
330	case string:
331		if value == "" {
332			return routeConfig{}, fmt.Errorf("route %q has empty image", domainName)
333		}
334		return routeConfig{Image: value, HTTPS: true}, nil
335	case map[string]any:
336		return parseRouteTable(domainName, value)
337	default:
338		return routeConfig{}, fmt.Errorf("route %q has unsupported value type %T", domainName, raw)
339	}
340}
341
342func parseLegacyRouteEntry(domainName string, raw any) (routeConfig, error) {
343	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
344	if !ok {
345		return routeConfig{}, fmt.Errorf("invalid route key %q: %w", legacyRouteStorageKey(domainName), domain.ErrRouteDomainInvalid)
346	}
347	domainName = canonicalDomain
348
349	value, ok := raw.(string)
350	if !ok {
351		return routeConfig{}, fmt.Errorf("legacy route %q must be a string", legacyRouteStorageKey(domainName))
352	}
353	if value == "" {
354		return routeConfig{}, fmt.Errorf("legacy route %q has empty image", legacyRouteStorageKey(domainName))
355	}
356
357	return routeConfig{Image: value, HTTPS: false}, nil
358}
359
360func parseRouteTable(domainName string, raw map[string]any) (routeConfig, error) {
361	imageValue, ok := raw["image"].(string)
362	if !ok || imageValue == "" {
363		return routeConfig{}, fmt.Errorf("route %q has invalid image field", domainName)
364	}
365
366	httpsValue, ok := raw["https"]
367	if !ok {
368		return routeConfig{Image: imageValue, HTTPS: true}, nil
369	}
370	https, ok := httpsValue.(bool)
371	if !ok {
372		return routeConfig{}, fmt.Errorf("route %q has invalid https field", domainName)
373	}
374
375	return routeConfig{Image: imageValue, HTTPS: https}, nil
376}
377
378// GetRoutes returns all configured routes.
379func (s *Service) GetRoutes(_ context.Context) []domain.Route {
380	s.mu.RLock()
381	defer s.mu.RUnlock()
382
383	var routes []domain.Route
384	for domainName, route := range s.config.Routes {
385		if strings.HasPrefix(domainName, "http://") {
386			continue
387		}
388		routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
389	}
390
391	return routes
392}
393
394// GetRoute returns a single route by domain.
395func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
396	s.mu.RLock()
397	defer s.mu.RUnlock()
398
399	domainName, ok := domain.CanonicalRouteDomain(domainName)
400	if !ok {
401		return nil, domain.ErrRouteNotFound
402	}
403	routeCfg, ok := s.config.Routes[domainName]
404	if !ok {
405		return nil, domain.ErrRouteNotFound
406	}
407
408	routeValue := domain.Route{Domain: domainName, Image: routeCfg.Image, HTTPS: routeCfg.HTTPS}
409	route := &routeValue
410
411	return route, nil
412}
413
414// FindRoutesByImage returns all routes whose image matches the given image name.
415// Image names are normalized by stripping the registry domain prefix before comparison.
416// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
417func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
418	s.mu.RLock()
419	defer s.mu.RUnlock()
420
421	var routes []domain.Route
422	for domainName, route := range s.config.Routes {
423		if strings.HasPrefix(domainName, "http://") {
424			continue
425		}
426		if matchesImageName(imageName, route.Image, s.config.RegistryDomain) {
427			routes = append(routes, domain.Route{Domain: domainName, Image: route.Image, HTTPS: route.HTTPS})
428		}
429	}
430
431	return routes
432}
433
434// FindAttachmentTargetsByImage returns all attachment targets whose image matches the given image name.
435func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName string) []string {
436	s.mu.RLock()
437	defer s.mu.RUnlock()
438
439	var targets []string
440	for target, images := range s.config.Attachments {
441		for _, image := range images {
442			if matchesImageName(imageName, image, s.config.RegistryDomain) {
443				targets = append(targets, target)
444				break
445			}
446		}
447	}
448
449	return targets
450}
451
452func matchesImageName(inputImage, candidateImage, registryDomain string) bool {
453	normalizedInput := NormalizeRegistryImage(inputImage, registryDomain)
454	inputName, inputHasTag := splitImageNameTag(normalizedInput)
455	normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain)
456
457	if inputHasTag {
458		return strings.EqualFold(normalizedCandidate, normalizedInput)
459	}
460
461	candidateName, _ := splitImageNameTag(normalizedCandidate)
462	return strings.EqualFold(candidateName, inputName)
463}
464
465// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
466func splitImageNameTag(image string) (name string, hasTag bool) {
467	if idx := strings.LastIndex(image, ":"); idx != -1 {
468		return image[:idx], true
469	}
470	return image, false
471}
472
473// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
474func NormalizeRegistryImage(imageName, registryDomain string) string {
475	registryDomain = strings.TrimSuffix(registryDomain, "/")
476	if registryDomain == "" {
477		return imageName
478	}
479
480	prefix := registryDomain + "/"
481	if strings.HasPrefix(imageName, prefix) {
482		return strings.TrimPrefix(imageName, prefix)
483	}
484
485	return imageName
486}
487
488// NormalizeBootstrapImage converts a user-supplied image argument into
489// the canonical "registry/name" format expected by push.
490// Bare names get the registry domain prepended; tags are stripped.
491func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
492	if image == "" {
493		return "", fmt.Errorf("image is required")
494	}
495	if registryDomain == "" {
496		return "", fmt.Errorf("registry domain is not configured")
497	}
498
499	registryDomain = strings.TrimSuffix(registryDomain, "/")
500
501	if slashIdx := strings.LastIndex(image, "/"); slashIdx != -1 {
502		nameTag := image[slashIdx+1:]
503		if colonIdx := strings.LastIndex(nameTag, ":"); colonIdx != -1 {
504			image = image[:slashIdx+1] + nameTag[:colonIdx]
505		}
506	} else {
507		if colonIdx := strings.LastIndex(image, ":"); colonIdx != -1 {
508			image = image[:colonIdx]
509		}
510	}
511
512	if !strings.Contains(image, "/") {
513		image = registryDomain + "/" + image
514	}
515
516	return image, nil
517}
518
519func legacyRouteStorageKey(domainName string) string {
520	return "http://" + domainName
521}
522
523func cloneRouteConfigs(routes map[string]routeConfig) map[string]routeConfig {
524	if routes == nil {
525		return make(map[string]routeConfig)
526	}
527
528	cloned := make(map[string]routeConfig, len(routes))
529	for key, route := range routes {
530		cloned[key] = route
531	}
532
533	return cloned
534}
535
536// AddRoute adds a new route to the configuration and persists it.
537func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
538	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
539		zerowrap.FieldLayer:   "usecase",
540		zerowrap.FieldUseCase: "AddRoute",
541		"domain":              route.Domain,
542	})
543	log := zerowrap.FromCtx(ctx)
544
545	// Validate route
546	if route.Domain == "" {
547		return domain.ErrRouteDomainEmpty
548	}
549	canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
550	if !ok {
551		return domain.ErrRouteDomainInvalid
552	}
553	route.Domain = canonicalDomain
554	if route.Image == "" {
555		return domain.ErrRouteImageEmpty
556	}
557
558	// Store previous value for rollback
559	s.mu.Lock()
560	currentConfig := s.config
561	if currentConfig.Routes == nil {
562		currentConfig.Routes = make(map[string]routeConfig)
563	}
564	if _, exists := currentConfig.ExternalRoutes[route.Domain]; exists {
565		s.mu.Unlock()
566		return fmt.Errorf("%w: route %q conflicts with external route", domain.ErrRouteConflict, route.Domain)
567	}
568	previousCanonicalRoute, canonicalExisted := currentConfig.Routes[route.Domain]
569	legacyKey := legacyRouteStorageKey(route.Domain)
570	_, legacyExisted := currentConfig.Routes[legacyKey]
571	newRoute := routeConfig{Image: route.Image, HTTPS: route.HTTPS}
572	if canonicalExisted && previousCanonicalRoute == newRoute && !legacyExisted {
573		s.mu.Unlock()
574		return nil
575	}
576	nextRoutes := cloneRouteConfigs(currentConfig.Routes)
577	nextRoutes[route.Domain] = newRoute
578	if legacyExisted {
579		delete(nextRoutes, legacyKey)
580	}
581
582	nextConfig := currentConfig
583	nextConfig.Routes = nextRoutes
584
585	if err := s.persistConfig(ctx, nextConfig); err != nil {
586		log.Warn().Err(err).Msg("failed to persist route to disk")
587		s.mu.Unlock()
588		return err
589	}
590
591	s.config.Routes = nextRoutes
592	s.mu.Unlock()
593
594	log.Info().Str("image", route.Image).Msg("route added to configuration")
595	return nil
596}
597
598// UpdateRoute updates an existing route and persists it.
599func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
600	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
601		zerowrap.FieldLayer:   "usecase",
602		zerowrap.FieldUseCase: "UpdateRoute",
603		"domain":              route.Domain,
604	})
605	log := zerowrap.FromCtx(ctx)
606
607	// Validate route
608	if route.Domain == "" {
609		return domain.ErrRouteDomainEmpty
610	}
611	canonicalDomain, ok := domain.CanonicalRouteDomain(route.Domain)
612	if !ok {
613		return domain.ErrRouteDomainInvalid
614	}
615	route.Domain = canonicalDomain
616	if route.Image == "" {
617		return domain.ErrRouteImageEmpty
618	}
619
620	// Store previous value for rollback
621	s.mu.Lock()
622	currentConfig := s.config
623	previousRoute, ok := currentConfig.Routes[route.Domain]
624	if !ok {
625		s.mu.Unlock()
626		return domain.ErrRouteNotFound
627	}
628	updatedRoute := previousRoute
629	legacyKey := legacyRouteStorageKey(route.Domain)
630	nextRoutes := cloneRouteConfigs(currentConfig.Routes)
631	delete(nextRoutes, legacyKey)
632	updatedRoute.Image = route.Image
633	updatedRoute.HTTPS = route.HTTPS
634	nextRoutes[route.Domain] = updatedRoute
635
636	nextConfig := currentConfig
637	nextConfig.Routes = nextRoutes
638
639	if err := s.persistConfig(ctx, nextConfig); err != nil {
640		log.Warn().Err(err).Msg("failed to persist route update to disk")
641		s.mu.Unlock()
642		return err
643	}
644
645	s.config.Routes = nextRoutes
646	s.mu.Unlock()
647
648	log.Info().Str("image", route.Image).Msg("route updated")
649	return nil
650}
651
652// RemoveRoute removes a route from the configuration and persists it.
653func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
654	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
655		zerowrap.FieldLayer:   "usecase",
656		zerowrap.FieldUseCase: "RemoveRoute",
657		"domain":              domainName,
658	})
659	log := zerowrap.FromCtx(ctx)
660
661	if domainName == "" {
662		return domain.ErrRouteDomainEmpty
663	}
664	canonicalDomain, ok := domain.CanonicalRouteDomain(domainName)
665	if !ok {
666		return domain.ErrRouteDomainInvalid
667	}
668	domainName = canonicalDomain
669
670	// Store previous value for rollback
671	s.mu.Lock()
672	currentConfig := s.config
673	_, ok = currentConfig.Routes[domainName]
674	if !ok {
675		s.mu.Unlock()
676		return domain.ErrRouteNotFound
677	}
678	legacyKey := legacyRouteStorageKey(domainName)
679	nextRoutes := cloneRouteConfigs(currentConfig.Routes)
680	delete(nextRoutes, legacyKey)
681	delete(nextRoutes, domainName)
682
683	nextConfig := currentConfig
684	nextConfig.Routes = nextRoutes
685
686	if err := s.persistConfig(ctx, nextConfig); err != nil {
687		log.Warn().Err(err).Msg("failed to persist route removal to disk")
688		s.mu.Unlock()
689		return err
690	}
691
692	s.config.Routes = nextRoutes
693	s.mu.Unlock()
694
695	log.Info().Msg("route removed")
696	return nil
697}
698
699// Save persists the current configuration to disk.
700func (s *Service) Save(ctx context.Context) error {
701	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
702		zerowrap.FieldLayer:   "usecase",
703		zerowrap.FieldUseCase: "SaveConfig",
704	})
705
706	s.mu.Lock()
707	defer s.mu.Unlock()
708
709	return s.persistConfig(ctx, s.config)
710}
711
712func (s *Service) persistConfig(ctx context.Context, config Config) error {
713	log := zerowrap.FromCtx(ctx)
714
715	configFile := s.viper.ConfigFileUsed()
716	if configFile == "" {
717		return log.WrapErr(fmt.Errorf("no config file path"), "cannot save config")
718	}
719
720	if err := backupConfigFile(configFile); err != nil {
721		log.Warn().Err(err).Msg("failed to create config backup")
722	}
723
724	snapshot := s.snapshotCriticalFields()
725
726	if err := s.writeConfigSurgical(configFile, config); err != nil {
727		return log.WrapErr(err, "failed to write config")
728	}
729
730	atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
731
732	if err := s.viper.ReadInConfig(); err != nil {
733		return log.WrapErr(err, "failed to re-read config after save")
734	}
735
736	if err := s.verifyCriticalFields(snapshot); err != nil {
737		log.Error().Err(err).Msg("config corruption detected after save, restoring backup")
738		if restoreErr := restoreConfigBackup(configFile); restoreErr != nil {
739			log.Error().Err(restoreErr).Msg("CRITICAL: failed to restore config backup")
740		} else if reloadErr := s.viper.ReadInConfig(); reloadErr != nil {
741			log.Error().Err(reloadErr).Msg("failed to reload config after restore")
742		}
743		return log.WrapErr(err, "config verification failed after save")
744	}
745
746	log.Info().Msg("configuration saved to disk")
747	return nil
748}
749
750type configSnapshot struct {
751	values map[string]any
752}
753
754// mutableSections lists config sections that are intentionally modified by service operations.
755var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains", "auto."}
756
757func isMutableKey(key string) bool {
758	for _, prefix := range mutableSections {
759		if strings.HasPrefix(key, prefix) {
760			return true
761		}
762	}
763
764	switch key {
765	case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains", "auto":
766		return true
767	}
768
769	return false
770}
771
772func (s *Service) snapshotCriticalFields() configSnapshot {
773	snap := configSnapshot{values: make(map[string]any)}
774	for _, key := range s.viper.AllKeys() {
775		if isMutableKey(key) {
776			continue
777		}
778		snap.values[key] = s.viper.Get(key)
779	}
780	return snap
781}
782
783func (s *Service) verifyCriticalFields(snap configSnapshot) error {
784	for key, oldVal := range snap.values {
785		if oldVal == nil {
786			continue
787		}
788		newVal := s.viper.Get(key)
789		if !reflect.DeepEqual(oldVal, newVal) {
790			return fmt.Errorf("config key %q changed from %v to %v after save", key, oldVal, newVal)
791		}
792	}
793
794	return nil
795}
796
797func (s *Service) writeConfigSurgical(configFile string, snapshotConfig Config) error {
798	data, err := os.ReadFile(configFile)
799	var configMap map[string]any
800	if err != nil {
801		if os.IsNotExist(err) {
802			configMap = s.viper.AllSettings()
803		} else {
804			return fmt.Errorf("failed to read config file: %w", err)
805		}
806	}
807
808	if configMap == nil {
809		configMap = make(map[string]any)
810	}
811	if len(data) > 0 {
812		if err := toml.Unmarshal(data, &configMap); err != nil {
813			return fmt.Errorf("failed to parse config file: %w", err)
814		}
815	}
816
817	delete(configMap, "routes")
818	configMap["external_routes"] = snapshotConfig.ExternalRoutes
819	configMap["attachments"] = snapshotConfig.Attachments
820	configMap["network_groups"] = snapshotConfig.NetworkGroups
821	applyAutoAllowedDomains(configMap, snapshotConfig.AutoRouteAllowedDomains)
822
823	out, err := toml.Marshal(configMap)
824	if err != nil {
825		return fmt.Errorf("failed to marshal config: %w", err)
826	}
827
828	routes, err := canonicalRoutesForSave(snapshotConfig.Routes)
829	if err != nil {
830		return err
831	}
832	routesOut := renderCanonicalRoutesSection(routes)
833	if routesOut != "" {
834		if len(out) > 0 {
835			out = append(out, '\n')
836		}
837		out = append(out, []byte(routesOut)...)
838	}
839
840	configDir := filepath.Dir(configFile)
841	tmpFile, err := os.CreateTemp(configDir, ".gordon-config-*.tmp")
842	if err != nil {
843		return fmt.Errorf("failed to create temp config file: %w", err)
844	}
845	tmpPath := tmpFile.Name()
846
847	if _, writeErr := tmpFile.Write(out); writeErr != nil {
848		_ = tmpFile.Close()
849		_ = os.Remove(tmpPath)
850		return fmt.Errorf("failed to write temp config: %w", writeErr)
851	}
852	if chmodErr := tmpFile.Chmod(0600); chmodErr != nil {
853		_ = tmpFile.Close()
854		_ = os.Remove(tmpPath)
855		return fmt.Errorf("failed to set temp config permissions: %w", chmodErr)
856	}
857	if closeErr := tmpFile.Close(); closeErr != nil {
858		_ = os.Remove(tmpPath)
859		return fmt.Errorf("failed to close temp config: %w", closeErr)
860	}
861	if renameErr := os.Rename(tmpPath, configFile); renameErr != nil {
862		_ = os.Remove(tmpPath)
863		return fmt.Errorf("failed to rename temp config: %w", renameErr)
864	}
865
866	return nil
867}
868
869func applyAutoAllowedDomains(config map[string]any, allowedDomains []string) {
870	// Write to both old and new keys for backward compat
871	config["auto_route_allowed_domains"] = allowedDomains
872	if autoSection, ok := config["auto"].(map[string]any); ok {
873		autoSection["allowed_domains"] = allowedDomains
874		return
875	}
876
877	config["auto"] = map[string]any{
878		"allowed_domains": allowedDomains,
879	}
880}
881
882func canonicalRoutesForSave(routes map[string]routeConfig) (map[string]routeConfig, error) {
883	result := make(map[string]routeConfig)
884	if routes == nil {
885		return result, nil
886	}
887
888	for key, route := range routes {
889		if strings.HasPrefix(key, "http://") {
890			continue
891		}
892		if !domain.IsValidRouteDomain(key) {
893			return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
894		}
895		result[key] = route
896	}
897
898	for key, route := range routes {
899		if !strings.HasPrefix(key, "http://") {
900			continue
901		}
902
903		domainName := strings.TrimPrefix(key, "http://")
904		if !domain.IsValidRouteDomain(domainName) {
905			return nil, fmt.Errorf("invalid route key %q: %w", key, domain.ErrRouteDomainInvalid)
906		}
907		if _, exists := result[domainName]; exists {
908			continue
909		}
910		route.HTTPS = false
911		result[domainName] = route
912	}
913
914	return result, nil
915}
916
917func renderCanonicalRoutesSection(routes map[string]routeConfig) string {
918	if len(routes) == 0 {
919		return ""
920	}
921
922	keys := make([]string, 0, len(routes))
923	for key := range routes {
924		keys = append(keys, key)
925	}
926	slices.Sort(keys)
927
928	var b strings.Builder
929	b.WriteString("[routes]\n")
930	for _, key := range keys {
931		route := routes[key]
932		b.WriteString(strconv.Quote(key))
933		b.WriteString(" = { image = ")
934		b.WriteString(strconv.Quote(route.Image))
935		b.WriteString(", https = ")
936		if route.HTTPS {
937			b.WriteString("true")
938		} else {
939			b.WriteString("false")
940		}
941		b.WriteString(" }\n")
942	}
943
944	return b.String()
945}
946
947func backupConfigFile(configFile string) error {
948	src, err := os.ReadFile(configFile)
949	if err != nil {
950		if os.IsNotExist(err) {
951			return nil
952		}
953		return fmt.Errorf("failed to read config for backup: %w", err)
954	}
955
956	backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().UnixNano())
957	if err := os.WriteFile(backupPath, src, 0600); err != nil {
958		return fmt.Errorf("failed to write backup config: %w", err)
959	}
960	if err := cleanupOldBackups(configFile, 5); err != nil {
961		return fmt.Errorf("failed to clean up old backups: %w", err)
962	}
963
964	return nil
965}
966
967// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
968// It only removes files matching the pattern `<configFile>.bak.<numeric-timestamp>`.
969func cleanupOldBackups(configFile string, keep int) error {
970	dir := filepath.Dir(configFile)
971	base := filepath.Base(configFile)
972	entries, err := os.ReadDir(dir)
973	if err != nil {
974		return fmt.Errorf("failed to read backup directory: %w", err)
975	}
976
977	prefix := base + ".bak."
978	type tsBackup struct {
979		ts   int64
980		name string
981	}
982	var backups []tsBackup
983	for _, entry := range entries {
984		if entry.IsDir() {
985			continue
986		}
987		name := entry.Name()
988		if !strings.HasPrefix(name, prefix) {
989			continue
990		}
991		suffix := strings.TrimPrefix(name, prefix)
992		ts, err := strconv.ParseInt(suffix, 10, 64)
993		if err != nil {
994			continue
995		}
996		backups = append(backups, tsBackup{ts: ts, name: name})
997	}
998
999	slices.SortFunc(backups, func(a, b tsBackup) int {
1000		return cmp.Compare(b.ts, a.ts)
1001	})
1002	if len(backups) <= keep {
1003		return nil
1004	}
1005
1006	var firstErr error
1007	for _, backup := range backups[keep:] {
1008		backupPath := filepath.Join(dir, backup.name)
1009		if err := os.Remove(backupPath); err != nil && firstErr == nil {
1010			firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
1011		}
1012	}
1013
1014	return firstErr
1015}
1016
1017func restoreConfigBackup(configFile string) error {
1018	dir := filepath.Dir(configFile)
1019	base := filepath.Base(configFile)
1020	entries, err := os.ReadDir(dir)
1021	if err != nil {
1022		return fmt.Errorf("failed to read backup directory: %w", err)
1023	}
1024
1025	prefix := base + ".bak."
1026	type tsBackup struct {
1027		ts   int64
1028		name string
1029	}
1030	var backups []tsBackup
1031	for _, entry := range entries {
1032		if entry.IsDir() {
1033			continue
1034		}
1035		name := entry.Name()
1036		if !strings.HasPrefix(name, prefix) {
1037			continue
1038		}
1039		suffix := strings.TrimPrefix(name, prefix)
1040		ts, err := strconv.ParseInt(suffix, 10, 64)
1041		if err != nil {
1042			continue
1043		}
1044		backups = append(backups, tsBackup{ts: ts, name: name})
1045	}
1046
1047	slices.SortFunc(backups, func(a, b tsBackup) int {
1048		return cmp.Compare(b.ts, a.ts)
1049	})
1050
1051	backupPath := configFile + ".bak"
1052	if len(backups) > 0 {
1053		backupPath = filepath.Join(dir, backups[0].name)
1054	}
1055
1056	src, err := os.ReadFile(backupPath)
1057	if err != nil {
1058		return fmt.Errorf("failed to read backup: %w", err)
1059	}
1060
1061	if err := os.WriteFile(configFile, src, 0600); err != nil {
1062		return fmt.Errorf("failed to restore backup: %w", err)
1063	}
1064
1065	return nil
1066}
1067
1068// Watch starts watching for configuration changes.
1069func (s *Service) Watch(ctx context.Context, onChange func()) error {
1070	log := zerowrap.FromCtx(ctx)
1071
1072	s.viper.OnConfigChange(func(e fsnotify.Event) {
1073		// Check if this event is within the debounce window of our own Save
1074		lastSave := atomic.LoadInt64(&s.lastSaveTime)
1075		if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
1076			log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
1077			return
1078		}
1079
1080		log.Info().Str("file", e.Name).Msg("config file changed")
1081
1082		if err := s.viper.ReadInConfig(); err != nil {
1083			log.WrapErr(err, "failed to reload config")
1084			return
1085		}
1086
1087		if err := s.Load(ctx); err != nil {
1088			log.WrapErr(err, "failed to load updated config")
1089			return
1090		}
1091
1092		if onChange != nil {
1093			onChange()
1094		}
1095	})
1096
1097	s.viper.WatchConfig()
1098	log.Info().Msg("watching for configuration changes")
1099
1100	return nil
1101}
1102
1103// GetServerPort returns the configured server port.
1104func (s *Service) GetServerPort() int {
1105	s.mu.RLock()
1106	defer s.mu.RUnlock()
1107	return s.config.ServerPort
1108}
1109
1110// GetRegistryPort returns the configured registry port.
1111func (s *Service) GetRegistryPort() int {
1112	s.mu.RLock()
1113	defer s.mu.RUnlock()
1114	return s.config.RegistryPort
1115}
1116
1117// GetRegistryDomain returns the configured registry domain.
1118func (s *Service) GetRegistryDomain() string {
1119	s.mu.RLock()
1120	defer s.mu.RUnlock()
1121	return s.config.RegistryDomain
1122}
1123
1124// GetDataDir returns the configured data directory.
1125func (s *Service) GetDataDir() string {
1126	s.mu.RLock()
1127	defer s.mu.RUnlock()
1128	return s.config.DataDir
1129}
1130
1131// IsAutoRouteEnabled returns whether auto-route is enabled.
1132func (s *Service) IsAutoRouteEnabled() bool {
1133	s.mu.RLock()
1134	defer s.mu.RUnlock()
1135	return s.config.AutoRouteEnabled
1136}
1137
1138// IsNetworkIsolationEnabled returns whether network isolation is enabled.
1139func (s *Service) IsNetworkIsolationEnabled() bool {
1140	s.mu.RLock()
1141	defer s.mu.RUnlock()
1142	return s.config.NetworkIsolation
1143}
1144
1145// GetNetworkPrefix returns the prefix for created networks.
1146func (s *Service) GetNetworkPrefix() string {
1147	s.mu.RLock()
1148	defer s.mu.RUnlock()
1149	return s.config.NetworkPrefix
1150}
1151
1152// GetConfig returns a copy of the current configuration.
1153func (s *Service) GetConfig() Config {
1154	s.mu.RLock()
1155	defer s.mu.RUnlock()
1156	return s.config
1157}
1158
1159// GetVolumeConfig returns volume configuration.
1160func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
1161	s.mu.RLock()
1162	defer s.mu.RUnlock()
1163	return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
1164}
1165
1166// GetNetworkGroups returns network group configuration.
1167func (s *Service) GetNetworkGroups() map[string][]string {
1168	s.mu.RLock()
1169	defer s.mu.RUnlock()
1170
1171	result := make(map[string][]string)
1172	for k, v := range s.config.NetworkGroups {
1173		result[k] = append([]string{}, v...)
1174	}
1175	return result
1176}
1177
1178// GetAttachmentConfig returns a consistent snapshot of attachments and network
1179// groups under a single lock, preventing cross-field races during config reloads.
1180func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot {
1181	s.mu.RLock()
1182	defer s.mu.RUnlock()
1183
1184	attachments := make(map[string][]string, len(s.config.Attachments))
1185	for k, v := range s.config.Attachments {
1186		attachments[k] = append([]string{}, v...)
1187	}
1188
1189	networkGroups := make(map[string][]string, len(s.config.NetworkGroups))
1190	for k, v := range s.config.NetworkGroups {
1191		networkGroups[k] = append([]string{}, v...)
1192	}
1193
1194	return out.AttachmentConfigSnapshot{
1195		Attachments:   attachments,
1196		NetworkGroups: networkGroups,
1197	}
1198}
1199
1200// GetAllAttachments returns all configured attachments.
1201func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
1202	s.mu.RLock()
1203	defer s.mu.RUnlock()
1204
1205	result := make(map[string][]string)
1206	for k, v := range s.config.Attachments {
1207		result[k] = append([]string{}, v...)
1208	}
1209	return result
1210}
1211
1212// GetAttachmentsFor returns attachments for a specific domain or network group.
1213func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
1214	s.mu.RLock()
1215	defer s.mu.RUnlock()
1216
1217	images, exists := s.config.Attachments[domainOrGroup]
1218	if !exists {
1219		return nil, domain.ErrAttachmentNotFound
1220	}
1221
1222	return append([]string{}, images...), nil
1223}
1224
1225// AddAttachment adds an image to a domain/group's attachments.
1226func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
1227	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1228		zerowrap.FieldLayer:   "usecase",
1229		zerowrap.FieldUseCase: "AddAttachment",
1230		"target":              domainOrGroup,
1231		"image":               image,
1232	})
1233	log := zerowrap.FromCtx(ctx)
1234
1235	// Validate input
1236	if domainOrGroup == "" {
1237		return domain.ErrAttachmentTargetEmpty
1238	}
1239	if image == "" {
1240		return domain.ErrAttachmentImageEmpty
1241	}
1242
1243	s.mu.Lock()
1244
1245	// Initialize map if needed
1246	if s.config.Attachments == nil {
1247		s.config.Attachments = make(map[string][]string)
1248	}
1249
1250	// Check if already exists
1251	existing := s.config.Attachments[domainOrGroup]
1252	for _, img := range existing {
1253		if img == image {
1254			s.mu.Unlock()
1255			return domain.ErrAttachmentExists
1256		}
1257	}
1258
1259	// Store previous value for rollback
1260	previousImages := append([]string{}, existing...)
1261	hadKey := len(existing) > 0
1262
1263	// Add the image
1264	s.config.Attachments[domainOrGroup] = append(existing, image)
1265	s.mu.Unlock()
1266
1267	// Persist to disk - rollback on failure
1268	if err := s.Save(ctx); err != nil {
1269		log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
1270		s.mu.Lock()
1271		if hadKey {
1272			s.config.Attachments[domainOrGroup] = previousImages
1273		} else {
1274			delete(s.config.Attachments, domainOrGroup)
1275		}
1276		s.mu.Unlock()
1277		return err
1278	}
1279
1280	log.Info().Msg("attachment added to configuration")
1281	return nil
1282}
1283
1284// RemoveAttachment removes an image from a domain/group's attachments.
1285func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1286	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1287		zerowrap.FieldLayer:   "usecase",
1288		zerowrap.FieldUseCase: "RemoveAttachment",
1289		"target":              domainOrGroup,
1290		"image":               image,
1291	})
1292	log := zerowrap.FromCtx(ctx)
1293
1294	// Validate input
1295	if domainOrGroup == "" {
1296		return domain.ErrAttachmentTargetEmpty
1297	}
1298	if image == "" {
1299		return domain.ErrAttachmentImageEmpty
1300	}
1301
1302	s.mu.Lock()
1303
1304	existing, exists := s.config.Attachments[domainOrGroup]
1305	if !exists {
1306		s.mu.Unlock()
1307		return domain.ErrAttachmentNotFound
1308	}
1309
1310	// Find and remove the image
1311	found := false
1312	newImages := make([]string, 0, len(existing))
1313	for _, img := range existing {
1314		if img == image {
1315			found = true
1316		} else {
1317			newImages = append(newImages, img)
1318		}
1319	}
1320
1321	if !found {
1322		s.mu.Unlock()
1323		return domain.ErrAttachmentNotFound
1324	}
1325
1326	// Store previous value for rollback
1327	previousImages := existing
1328
1329	// Update or remove the key
1330	if len(newImages) == 0 {
1331		delete(s.config.Attachments, domainOrGroup)
1332	} else {
1333		s.config.Attachments[domainOrGroup] = newImages
1334	}
1335	s.mu.Unlock()
1336
1337	// Persist to disk - rollback on failure
1338	if err := s.Save(ctx); err != nil {
1339		log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
1340		s.mu.Lock()
1341		s.config.Attachments[domainOrGroup] = previousImages
1342		s.mu.Unlock()
1343		return err
1344	}
1345
1346	log.Info().Msg("attachment removed from configuration")
1347	return nil
1348}
1349
1350// GetPreviewConfig returns the preview environment configuration.
1351func (s *Service) GetPreviewConfig() domain.PreviewConfig {
1352	s.mu.RLock()
1353	defer s.mu.RUnlock()
1354	return domain.PreviewConfig{
1355		Enabled:     s.config.PreviewEnabled,
1356		TTL:         s.config.PreviewTTL,
1357		Separator:   s.config.PreviewSeparator,
1358		TagPatterns: s.config.PreviewTagPatterns,
1359		DataCopy:    s.config.PreviewDataCopy,
1360		EnvCopy:     s.config.PreviewEnvCopy,
1361	}
1362}
1363
1364// IsPreviewEnabled returns whether preview environments are enabled.
1365func (s *Service) IsPreviewEnabled() bool {
1366	s.mu.RLock()
1367	defer s.mu.RUnlock()
1368	return s.config.PreviewEnabled
1369}
1370
1371// IsAutoEnabled returns whether the auto feature is enabled (alias for IsAutoRouteEnabled).
1372func (s *Service) IsAutoEnabled() bool {
1373	return s.IsAutoRouteEnabled()
1374}
1375
1376// GetPreviewTagPatterns returns the configured tag patterns for preview environments.
1377func (s *Service) GetPreviewTagPatterns() []string {
1378	s.mu.RLock()
1379	defer s.mu.RUnlock()
1380	return append([]string{}, s.config.PreviewTagPatterns...)
1381}
1382
1383// GetAllowedDomains returns the auto-route allowed domain patterns.
1384func (s *Service) GetAllowedDomains() []string {
1385	s.mu.RLock()
1386	defer s.mu.RUnlock()
1387	return append([]string{}, s.config.AutoRouteAllowedDomains...)
1388}
1389
1390// GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1391func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1392	s.mu.RLock()
1393	defer s.mu.RUnlock()
1394
1395	return append([]string{}, s.config.AutoRouteAllowedDomains...), nil
1396}
1397
1398// AddAutoRouteAllowedDomain adds a domain pattern to the auto-route allowlist.
1399func (s *Service) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1400	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1401		zerowrap.FieldLayer:   "usecase",
1402		zerowrap.FieldUseCase: "AddAutoRouteAllowedDomain",
1403		"pattern":             pattern,
1404	})
1405	log := zerowrap.FromCtx(ctx)
1406
1407	if err := validateDomainPattern(pattern); err != nil {
1408		return err
1409	}
1410
1411	s.mu.Lock()
1412	for _, existing := range s.config.AutoRouteAllowedDomains {
1413		if existing == pattern {
1414			s.mu.Unlock()
1415			return nil
1416		}
1417	}
1418	snapshot := make([]string, len(s.config.AutoRouteAllowedDomains))
1419	copy(snapshot, s.config.AutoRouteAllowedDomains)
1420	s.config.AutoRouteAllowedDomains = append(s.config.AutoRouteAllowedDomains, pattern)
1421	s.mu.Unlock()
1422
1423	if err := s.Save(ctx); err != nil {
1424		log.Warn().Err(err).Msg("failed to persist allowed domain, rolling back")
1425		s.mu.Lock()
1426		s.config.AutoRouteAllowedDomains = snapshot
1427		s.mu.Unlock()
1428		return err
1429	}
1430
1431	log.Info().Msg("auto-route allowed domain added")
1432	return nil
1433}
1434
1435// RemoveAutoRouteAllowedDomain removes a domain pattern from the auto-route allowlist.
1436func (s *Service) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1437	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1438		zerowrap.FieldLayer:   "usecase",
1439		zerowrap.FieldUseCase: "RemoveAutoRouteAllowedDomain",
1440		"pattern":             pattern,
1441	})
1442	log := zerowrap.FromCtx(ctx)
1443
1444	s.mu.Lock()
1445	previous := append([]string{}, s.config.AutoRouteAllowedDomains...)
1446	filtered := make([]string, 0, len(s.config.AutoRouteAllowedDomains))
1447	removed := false
1448	for _, existing := range s.config.AutoRouteAllowedDomains {
1449		if existing == pattern {
1450			removed = true
1451			continue
1452		}
1453		filtered = append(filtered, existing)
1454	}
1455	s.config.AutoRouteAllowedDomains = filtered
1456	s.mu.Unlock()
1457
1458	if !removed {
1459		log.Debug().Msg("pattern not found in allowlist")
1460		return nil
1461	}
1462
1463	if err := s.Save(ctx); err != nil {
1464		log.Warn().Err(err).Msg("failed to persist allowed domain removal, rolling back")
1465		s.mu.Lock()
1466		s.config.AutoRouteAllowedDomains = previous
1467		s.mu.Unlock()
1468		return err
1469	}
1470
1471	log.Info().Msg("auto-route allowed domain removed")
1472	return nil
1473}
1474
1475func validateDomainPattern(pattern string) error {
1476	if pattern == "" {
1477		return fmt.Errorf("%w: pattern is required", domain.ErrInvalidDomainPattern)
1478	}
1479	if pattern == "*" {
1480		return nil
1481	}
1482	if pattern != strings.ToLower(pattern) {
1483		return fmt.Errorf("%w: must be lowercase", domain.ErrInvalidDomainPattern)
1484	}
1485	if strings.HasSuffix(pattern, ".") {
1486		return fmt.Errorf("%w: must not have trailing dots", domain.ErrInvalidDomainPattern)
1487	}
1488	if strings.Contains(pattern, "**") {
1489		return fmt.Errorf("%w: must not contain double wildcards", domain.ErrInvalidDomainPattern)
1490	}
1491	if strings.Count(pattern, "*") > 1 {
1492		return fmt.Errorf("%w: must not contain multiple wildcards", domain.ErrInvalidDomainPattern)
1493	}
1494	if strings.Contains(pattern, "*") {
1495		if !strings.HasPrefix(pattern, "*.") {
1496			return fmt.Errorf("%w: wildcard must be in *.domain form", domain.ErrInvalidDomainPattern)
1497		}
1498		if !isValidExactDomain(strings.TrimPrefix(pattern, "*.")) {
1499			return fmt.Errorf("%w: invalid domain after wildcard", domain.ErrInvalidDomainPattern)
1500		}
1501		return nil
1502	}
1503	if !isValidExactDomain(pattern) {
1504		return fmt.Errorf("%w: invalid domain format", domain.ErrInvalidDomainPattern)
1505	}
1506	return nil
1507}
1508
1509var exactDomainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
1510
1511func isValidExactDomain(pattern string) bool {
1512	return exactDomainPattern.MatchString(pattern)
1513}
1514
1515// GetExternalRoutes returns all configured external routes.
1516func (s *Service) GetExternalRoutes() map[string]string {
1517	s.mu.RLock()
1518	defer s.mu.RUnlock()
1519
1520	result := make(map[string]string, len(s.config.ExternalRoutes))
1521	for k, v := range s.config.ExternalRoutes {
1522		result[k] = v
1523	}
1524	return result
1525}
1526
1527// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
1528func ExtractDomainFromImageName(imageName string) (string, bool) {
1529	parts := strings.Split(imageName, ":")
1530	imageNamePart := parts[0]
1531
1532	// Simple domain check - contains at least one dot and valid characters
1533	if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
1534		// Additional check: should not look like a registry path
1535		if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
1536			return imageNamePart, true
1537		}
1538	}
1539
1540	return "", false
1541}

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 8

Tags 106

↑↓ moveenter openesc close