1// Package config implements the configuration management use case.
2package config
3
4import (
5 "context"
6 "strings"
7 "sync"
8 "sync/atomic"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/fsnotify/fsnotify"
13 "github.com/spf13/viper"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// Config holds the loaded configuration.
20type Config struct {
21 ServerPort int
22 RegistryPort int
23 RegistryDomain string
24 DataDir string
25 AutoRouteEnabled bool
26 NetworkIsolation bool
27 NetworkPrefix string
28 Routes map[string]string
29 ExternalRoutes map[string]string // domain -> "host:port"
30 RegistryAuthEnabled bool
31 RegistryAuthUsername string
32 RegistryAuthPassword string
33 VolumeAutoCreate bool
34 VolumePrefix string
35 VolumePreserve bool
36 NetworkGroups map[string][]string
37 Attachments map[string][]string
38}
39
40// Service implements the ConfigService interface.
41type Service struct {
42 viper *viper.Viper
43 eventBus out.EventPublisher
44 config Config
45 mu sync.RWMutex
46 lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher)
47 debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
48}
49
50// NewService creates a new config service.
51func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
52 return &Service{
53 viper: v,
54 eventBus: eventBus,
55 debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
56 }
57}
58
59// Load loads the configuration from the configured source.
60func (s *Service) Load(ctx context.Context) error {
61 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
62 zerowrap.FieldLayer: "usecase",
63 zerowrap.FieldUseCase: "Load",
64 })
65 log := zerowrap.FromCtx(ctx)
66
67 s.mu.Lock()
68 defer s.mu.Unlock()
69
70 // Load configuration from viper
71 s.config = s.loadConfigValues()
72
73 // Load complex nested structures
74 s.config.Routes = loadStringMap(s.viper.Get("routes"))
75 s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
76 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
77 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
78
79 log.Info().
80 Int("server_port", s.config.ServerPort).
81 Int("registry_port", s.config.RegistryPort).
82 Int(zerowrap.FieldCount, len(s.config.Routes)).
83 Msg("configuration loaded")
84
85 return nil
86}
87
88// Reload re-reads the configuration file from disk and loads it into memory.
89// This should be used when you want to pick up external changes to the config file.
90// It differs from Load() which only loads from cached viper values.
91func (s *Service) Reload(ctx context.Context) error {
92 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
93 zerowrap.FieldLayer: "usecase",
94 zerowrap.FieldUseCase: "Reload",
95 })
96 log := zerowrap.FromCtx(ctx)
97
98 // Re-read the config file from disk
99 if err := s.viper.ReadInConfig(); err != nil {
100 return log.WrapErr(err, "failed to read config file")
101 }
102
103 // Load the values into memory
104 return s.Load(ctx)
105}
106
107// loadConfigValues loads simple config values from viper.
108func (s *Service) loadConfigValues() Config {
109 // Prefer gordon_domain over registry_domain
110 registryDomain := s.viper.GetString("server.gordon_domain")
111 if registryDomain == "" {
112 registryDomain = s.viper.GetString("server.registry_domain")
113 }
114
115 return Config{
116 ServerPort: s.viper.GetInt("server.port"),
117 RegistryPort: s.viper.GetInt("server.registry_port"),
118 RegistryDomain: registryDomain,
119 DataDir: s.viper.GetString("server.data_dir"),
120 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
121 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
122 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
123 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
124 RegistryAuthUsername: s.viper.GetString("auth.username"),
125 RegistryAuthPassword: s.viper.GetString("auth.password"),
126 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
127 VolumePrefix: s.viper.GetString("volumes.prefix"),
128 VolumePreserve: s.viper.GetBool("volumes.preserve"),
129 Routes: make(map[string]string),
130 ExternalRoutes: make(map[string]string),
131 NetworkGroups: make(map[string][]string),
132 Attachments: make(map[string][]string),
133 }
134}
135
136// loadStringMap loads a map[string]string from a viper value.
137func loadStringMap(raw any) map[string]string {
138 result := make(map[string]string)
139 if raw == nil {
140 return result
141 }
142 m, ok := raw.(map[string]any)
143 if !ok {
144 return result
145 }
146 for k, v := range m {
147 if vs, ok := v.(string); ok {
148 result[k] = vs
149 }
150 }
151 return result
152}
153
154// loadStringArrayMap loads a map[string][]string from a viper value.
155func loadStringArrayMap(raw any) map[string][]string {
156 result := make(map[string][]string)
157 if raw == nil {
158 return result
159 }
160 m, ok := raw.(map[string]any)
161 if !ok {
162 return result
163 }
164 for k, v := range m {
165 if arr, ok := v.([]any); ok {
166 var strs []string
167 for _, item := range arr {
168 if s, ok := item.(string); ok {
169 strs = append(strs, s)
170 }
171 }
172 result[k] = strs
173 }
174 }
175 return result
176}
177
178// GetRoutes returns all configured routes.
179func (s *Service) GetRoutes(_ context.Context) []domain.Route {
180 s.mu.RLock()
181 defer s.mu.RUnlock()
182
183 var routes []domain.Route
184 for domainName, image := range s.config.Routes {
185 route := domain.Route{
186 Image: image,
187 HTTPS: true,
188 }
189
190 // Check if domain has http:// prefix
191 if strings.HasPrefix(domainName, "http://") {
192 route.Domain = strings.TrimPrefix(domainName, "http://")
193 route.HTTPS = false
194 } else {
195 route.Domain = domainName
196 }
197
198 routes = append(routes, route)
199 }
200
201 return routes
202}
203
204// GetRoute returns a single route by domain.
205func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
206 s.mu.RLock()
207 defer s.mu.RUnlock()
208
209 image, exists := s.config.Routes[domainName]
210 if !exists {
211 return nil, domain.ErrRouteNotFound
212 }
213
214 route := &domain.Route{
215 Domain: domainName,
216 Image: image,
217 HTTPS: true,
218 }
219
220 // Check if domain has http:// prefix
221 if strings.HasPrefix(domainName, "http://") {
222 route.Domain = strings.TrimPrefix(domainName, "http://")
223 route.HTTPS = false
224 }
225
226 return route, nil
227}
228
229// FindRoutesByImage returns all routes whose image matches the given image name.
230// Image names are normalized by stripping the registry domain prefix before comparison.
231// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
232func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
233 s.mu.RLock()
234 defer s.mu.RUnlock()
235
236 registryDomain := s.config.RegistryDomain
237 normalizedImage := NormalizeRegistryImage(imageName, registryDomain)
238 inputName, inputHasTag := splitImageNameTag(normalizedImage)
239
240 var routes []domain.Route
241 for domainName, image := range s.config.Routes {
242 normalizedRouteImage := NormalizeRegistryImage(image, registryDomain)
243
244 match := false
245 if inputHasTag {
246 // Exact match when the caller provided a tag (e.g. event handler).
247 match = strings.EqualFold(normalizedRouteImage, normalizedImage)
248 } else {
249 // Name-only match when no tag was provided (e.g. CLI push).
250 routeName, _ := splitImageNameTag(normalizedRouteImage)
251 match = strings.EqualFold(routeName, inputName)
252 }
253
254 if match {
255 route := domain.Route{
256 Image: image,
257 HTTPS: true,
258 }
259 if strings.HasPrefix(domainName, "http://") {
260 route.Domain = strings.TrimPrefix(domainName, "http://")
261 route.HTTPS = false
262 } else {
263 route.Domain = domainName
264 }
265 routes = append(routes, route)
266 }
267 }
268
269 return routes
270}
271
272// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
273func splitImageNameTag(image string) (name string, hasTag bool) {
274 if idx := strings.LastIndex(image, ":"); idx != -1 {
275 return image[:idx], true
276 }
277 return image, false
278}
279
280// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
281func NormalizeRegistryImage(imageName, registryDomain string) string {
282 registryDomain = strings.TrimSuffix(registryDomain, "/")
283 if registryDomain == "" {
284 return imageName
285 }
286
287 prefix := registryDomain + "/"
288 if strings.HasPrefix(imageName, prefix) {
289 return strings.TrimPrefix(imageName, prefix)
290 }
291
292 return imageName
293}
294
295// AddRoute adds a new route to the configuration and persists it.
296func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
297 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
298 zerowrap.FieldLayer: "usecase",
299 zerowrap.FieldUseCase: "AddRoute",
300 "domain": route.Domain,
301 })
302 log := zerowrap.FromCtx(ctx)
303
304 // Validate route
305 if route.Domain == "" {
306 return domain.ErrRouteDomainEmpty
307 }
308 if route.Image == "" {
309 return domain.ErrRouteImageEmpty
310 }
311
312 // Store previous value for rollback
313 s.mu.Lock()
314 previousImage, existed := s.config.Routes[route.Domain]
315 if s.config.Routes == nil {
316 s.config.Routes = make(map[string]string)
317 }
318 s.config.Routes[route.Domain] = route.Image
319 s.mu.Unlock()
320
321 // Persist to disk - rollback on failure
322 if err := s.Save(ctx); err != nil {
323 log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
324 s.mu.Lock()
325 if existed {
326 s.config.Routes[route.Domain] = previousImage
327 } else {
328 delete(s.config.Routes, route.Domain)
329 }
330 s.mu.Unlock()
331 return err
332 }
333
334 log.Info().Str("image", route.Image).Msg("route added to configuration")
335 return nil
336}
337
338// UpdateRoute updates an existing route and persists it.
339func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
340 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
341 zerowrap.FieldLayer: "usecase",
342 zerowrap.FieldUseCase: "UpdateRoute",
343 "domain": route.Domain,
344 })
345 log := zerowrap.FromCtx(ctx)
346
347 // Validate route
348 if route.Domain == "" {
349 return domain.ErrRouteDomainEmpty
350 }
351 if route.Image == "" {
352 return domain.ErrRouteImageEmpty
353 }
354
355 // Store previous value for rollback
356 s.mu.Lock()
357 previousImage, exists := s.config.Routes[route.Domain]
358 if !exists {
359 s.mu.Unlock()
360 return domain.ErrRouteNotFound
361 }
362 s.config.Routes[route.Domain] = route.Image
363 s.mu.Unlock()
364
365 // Persist to disk - rollback on failure
366 if err := s.Save(ctx); err != nil {
367 log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
368 s.mu.Lock()
369 s.config.Routes[route.Domain] = previousImage
370 s.mu.Unlock()
371 return err
372 }
373
374 log.Info().Str("image", route.Image).Msg("route updated")
375 return nil
376}
377
378// RemoveRoute removes a route from the configuration and persists it.
379func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
380 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
381 zerowrap.FieldLayer: "usecase",
382 zerowrap.FieldUseCase: "RemoveRoute",
383 "domain": domainName,
384 })
385 log := zerowrap.FromCtx(ctx)
386
387 // Store previous value for rollback
388 s.mu.Lock()
389 previousImage, exists := s.config.Routes[domainName]
390 if !exists {
391 s.mu.Unlock()
392 return domain.ErrRouteNotFound
393 }
394 delete(s.config.Routes, domainName)
395 s.mu.Unlock()
396
397 // Persist to disk - rollback on failure
398 if err := s.Save(ctx); err != nil {
399 log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
400 s.mu.Lock()
401 s.config.Routes[domainName] = previousImage
402 s.mu.Unlock()
403 return err
404 }
405
406 log.Info().Msg("route removed")
407 return nil
408}
409
410// Save persists the current configuration to disk.
411func (s *Service) Save(ctx context.Context) error {
412 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
413 zerowrap.FieldLayer: "usecase",
414 zerowrap.FieldUseCase: "SaveConfig",
415 })
416 log := zerowrap.FromCtx(ctx)
417
418 s.mu.Lock()
419 defer s.mu.Unlock()
420
421 // Update viper with current config
422 s.viper.Set("routes", s.config.Routes)
423 s.viper.Set("attachments", s.config.Attachments)
424
425 // Record save time to debounce file watcher events
426 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
427
428 // Write config to disk
429 if err := s.viper.WriteConfig(); err != nil {
430 return log.WrapErr(err, "failed to write config")
431 }
432
433 log.Info().Msg("configuration saved to disk")
434 return nil
435}
436
437// Watch starts watching for configuration changes.
438func (s *Service) Watch(ctx context.Context, onChange func()) error {
439 log := zerowrap.FromCtx(ctx)
440
441 s.viper.OnConfigChange(func(e fsnotify.Event) {
442 // Check if this event is within the debounce window of our own Save
443 lastSave := atomic.LoadInt64(&s.lastSaveTime)
444 if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
445 log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
446 return
447 }
448
449 log.Info().Str("file", e.Name).Msg("config file changed")
450
451 if err := s.viper.ReadInConfig(); err != nil {
452 log.WrapErr(err, "failed to reload config")
453 return
454 }
455
456 if err := s.Load(ctx); err != nil {
457 log.WrapErr(err, "failed to load updated config")
458 return
459 }
460
461 // Publish config reload event
462 if s.eventBus != nil {
463 if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
464 Source: "file",
465 }); err != nil {
466 log.WrapErr(err, "failed to publish config reload event")
467 }
468 }
469
470 if onChange != nil {
471 onChange()
472 }
473 })
474
475 s.viper.WatchConfig()
476 log.Info().Msg("watching for configuration changes")
477
478 return nil
479}
480
481// GetServerPort returns the configured server port.
482func (s *Service) GetServerPort() int {
483 s.mu.RLock()
484 defer s.mu.RUnlock()
485 return s.config.ServerPort
486}
487
488// GetRegistryPort returns the configured registry port.
489func (s *Service) GetRegistryPort() int {
490 s.mu.RLock()
491 defer s.mu.RUnlock()
492 return s.config.RegistryPort
493}
494
495// GetRegistryDomain returns the configured registry domain.
496func (s *Service) GetRegistryDomain() string {
497 s.mu.RLock()
498 defer s.mu.RUnlock()
499 return s.config.RegistryDomain
500}
501
502// GetDataDir returns the configured data directory.
503func (s *Service) GetDataDir() string {
504 s.mu.RLock()
505 defer s.mu.RUnlock()
506 return s.config.DataDir
507}
508
509// IsAutoRouteEnabled returns whether auto-route is enabled.
510func (s *Service) IsAutoRouteEnabled() bool {
511 s.mu.RLock()
512 defer s.mu.RUnlock()
513 return s.config.AutoRouteEnabled
514}
515
516// IsNetworkIsolationEnabled returns whether network isolation is enabled.
517func (s *Service) IsNetworkIsolationEnabled() bool {
518 s.mu.RLock()
519 defer s.mu.RUnlock()
520 return s.config.NetworkIsolation
521}
522
523// GetNetworkPrefix returns the prefix for created networks.
524func (s *Service) GetNetworkPrefix() string {
525 s.mu.RLock()
526 defer s.mu.RUnlock()
527 return s.config.NetworkPrefix
528}
529
530// GetConfig returns a copy of the current configuration.
531func (s *Service) GetConfig() Config {
532 s.mu.RLock()
533 defer s.mu.RUnlock()
534 return s.config
535}
536
537// GetRegistryAuthConfig returns registry authentication configuration.
538func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
539 s.mu.RLock()
540 defer s.mu.RUnlock()
541 return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
542}
543
544// GetVolumeConfig returns volume configuration.
545func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
546 s.mu.RLock()
547 defer s.mu.RUnlock()
548 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
549}
550
551// GetNetworkGroups returns network group configuration.
552func (s *Service) GetNetworkGroups() map[string][]string {
553 s.mu.RLock()
554 defer s.mu.RUnlock()
555
556 result := make(map[string][]string)
557 for k, v := range s.config.NetworkGroups {
558 result[k] = append([]string{}, v...)
559 }
560 return result
561}
562
563// GetAttachments returns attachment configuration.
564// Deprecated: Use GetAllAttachments instead.
565func (s *Service) GetAttachments() map[string][]string {
566 s.mu.RLock()
567 defer s.mu.RUnlock()
568
569 result := make(map[string][]string)
570 for k, v := range s.config.Attachments {
571 result[k] = append([]string{}, v...)
572 }
573 return result
574}
575
576// GetAllAttachments returns all configured attachments.
577func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
578 s.mu.RLock()
579 defer s.mu.RUnlock()
580
581 result := make(map[string][]string)
582 for k, v := range s.config.Attachments {
583 result[k] = append([]string{}, v...)
584 }
585 return result
586}
587
588// GetAttachmentsFor returns attachments for a specific domain or network group.
589func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
590 s.mu.RLock()
591 defer s.mu.RUnlock()
592
593 images, exists := s.config.Attachments[domainOrGroup]
594 if !exists {
595 return nil, domain.ErrAttachmentNotFound
596 }
597
598 return append([]string{}, images...), nil
599}
600
601// AddAttachment adds an image to a domain/group's attachments.
602func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
603 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
604 zerowrap.FieldLayer: "usecase",
605 zerowrap.FieldUseCase: "AddAttachment",
606 "target": domainOrGroup,
607 "image": image,
608 })
609 log := zerowrap.FromCtx(ctx)
610
611 // Validate input
612 if domainOrGroup == "" {
613 return domain.ErrAttachmentTargetEmpty
614 }
615 if image == "" {
616 return domain.ErrAttachmentImageEmpty
617 }
618
619 s.mu.Lock()
620
621 // Initialize map if needed
622 if s.config.Attachments == nil {
623 s.config.Attachments = make(map[string][]string)
624 }
625
626 // Check if already exists
627 existing := s.config.Attachments[domainOrGroup]
628 for _, img := range existing {
629 if img == image {
630 s.mu.Unlock()
631 return domain.ErrAttachmentExists
632 }
633 }
634
635 // Store previous value for rollback
636 previousImages := append([]string{}, existing...)
637 hadKey := len(existing) > 0
638
639 // Add the image
640 s.config.Attachments[domainOrGroup] = append(existing, image)
641 s.mu.Unlock()
642
643 // Persist to disk - rollback on failure
644 if err := s.Save(ctx); err != nil {
645 log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
646 s.mu.Lock()
647 if hadKey {
648 s.config.Attachments[domainOrGroup] = previousImages
649 } else {
650 delete(s.config.Attachments, domainOrGroup)
651 }
652 s.mu.Unlock()
653 return err
654 }
655
656 log.Info().Msg("attachment added to configuration")
657 return nil
658}
659
660// RemoveAttachment removes an image from a domain/group's attachments.
661func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
662 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
663 zerowrap.FieldLayer: "usecase",
664 zerowrap.FieldUseCase: "RemoveAttachment",
665 "target": domainOrGroup,
666 "image": image,
667 })
668 log := zerowrap.FromCtx(ctx)
669
670 // Validate input
671 if domainOrGroup == "" {
672 return domain.ErrAttachmentTargetEmpty
673 }
674 if image == "" {
675 return domain.ErrAttachmentImageEmpty
676 }
677
678 s.mu.Lock()
679
680 existing, exists := s.config.Attachments[domainOrGroup]
681 if !exists {
682 s.mu.Unlock()
683 return domain.ErrAttachmentNotFound
684 }
685
686 // Find and remove the image
687 found := false
688 newImages := make([]string, 0, len(existing))
689 for _, img := range existing {
690 if img == image {
691 found = true
692 } else {
693 newImages = append(newImages, img)
694 }
695 }
696
697 if !found {
698 s.mu.Unlock()
699 return domain.ErrAttachmentNotFound
700 }
701
702 // Store previous value for rollback
703 previousImages := existing
704
705 // Update or remove the key
706 if len(newImages) == 0 {
707 delete(s.config.Attachments, domainOrGroup)
708 } else {
709 s.config.Attachments[domainOrGroup] = newImages
710 }
711 s.mu.Unlock()
712
713 // Persist to disk - rollback on failure
714 if err := s.Save(ctx); err != nil {
715 log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
716 s.mu.Lock()
717 s.config.Attachments[domainOrGroup] = previousImages
718 s.mu.Unlock()
719 return err
720 }
721
722 log.Info().Msg("attachment removed from configuration")
723 return nil
724}
725
726// GetExternalRoutes returns all configured external routes.
727func (s *Service) GetExternalRoutes() map[string]string {
728 s.mu.RLock()
729 defer s.mu.RUnlock()
730
731 result := make(map[string]string)
732 for k, v := range s.config.ExternalRoutes {
733 result[k] = v
734 }
735 return result
736}
737
738// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
739func ExtractDomainFromImageName(imageName string) (string, bool) {
740 parts := strings.Split(imageName, ":")
741 imageNamePart := parts[0]
742
743 // Simple domain check - contains at least one dot and valid characters
744 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
745 // Additional check: should not look like a registry path
746 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
747 return imageNamePart, true
748 }
749 }
750
751 return "", false
752}