v2.31.0-dev8 ec8c5b9

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

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