v2.31.0-dev23 58a92f3

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

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