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