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}