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 "gordon/internal/boundaries/out"
16 "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// loadConfigValues loads simple config values from viper.
89func (s *Service) loadConfigValues() Config {
90 // Prefer gordon_domain over registry_domain
91 registryDomain := s.viper.GetString("server.gordon_domain")
92 if registryDomain == "" {
93 registryDomain = s.viper.GetString("server.registry_domain")
94 }
95
96 return Config{
97 ServerPort: s.viper.GetInt("server.port"),
98 RegistryPort: s.viper.GetInt("server.registry_port"),
99 RegistryDomain: registryDomain,
100 DataDir: s.viper.GetString("server.data_dir"),
101 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
102 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
103 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
104 RegistryAuthEnabled: s.viper.GetBool("auth.enabled"),
105 RegistryAuthUsername: s.viper.GetString("auth.username"),
106 RegistryAuthPassword: s.viper.GetString("auth.password"),
107 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
108 VolumePrefix: s.viper.GetString("volumes.prefix"),
109 VolumePreserve: s.viper.GetBool("volumes.preserve"),
110 Routes: make(map[string]string),
111 ExternalRoutes: make(map[string]string),
112 NetworkGroups: make(map[string][]string),
113 Attachments: make(map[string][]string),
114 }
115}
116
117// loadStringMap loads a map[string]string from a viper value.
118func loadStringMap(raw any) map[string]string {
119 result := make(map[string]string)
120 if raw == nil {
121 return result
122 }
123 m, ok := raw.(map[string]any)
124 if !ok {
125 return result
126 }
127 for k, v := range m {
128 if vs, ok := v.(string); ok {
129 result[k] = vs
130 }
131 }
132 return result
133}
134
135// loadStringArrayMap loads a map[string][]string from a viper value.
136func loadStringArrayMap(raw any) map[string][]string {
137 result := make(map[string][]string)
138 if raw == nil {
139 return result
140 }
141 m, ok := raw.(map[string]any)
142 if !ok {
143 return result
144 }
145 for k, v := range m {
146 if arr, ok := v.([]any); ok {
147 var strs []string
148 for _, item := range arr {
149 if s, ok := item.(string); ok {
150 strs = append(strs, s)
151 }
152 }
153 result[k] = strs
154 }
155 }
156 return result
157}
158
159// GetRoutes returns all configured routes.
160func (s *Service) GetRoutes(_ context.Context) []domain.Route {
161 s.mu.RLock()
162 defer s.mu.RUnlock()
163
164 var routes []domain.Route
165 for domainName, image := range s.config.Routes {
166 route := domain.Route{
167 Image: image,
168 HTTPS: true,
169 }
170
171 // Check if domain has http:// prefix
172 if strings.HasPrefix(domainName, "http://") {
173 route.Domain = strings.TrimPrefix(domainName, "http://")
174 route.HTTPS = false
175 } else {
176 route.Domain = domainName
177 }
178
179 routes = append(routes, route)
180 }
181
182 return routes
183}
184
185// GetRoute returns a single route by domain.
186func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
187 s.mu.RLock()
188 defer s.mu.RUnlock()
189
190 image, exists := s.config.Routes[domainName]
191 if !exists {
192 return nil, domain.ErrRouteNotFound
193 }
194
195 route := &domain.Route{
196 Domain: domainName,
197 Image: image,
198 HTTPS: true,
199 }
200
201 // Check if domain has http:// prefix
202 if strings.HasPrefix(domainName, "http://") {
203 route.Domain = strings.TrimPrefix(domainName, "http://")
204 route.HTTPS = false
205 }
206
207 return route, nil
208}
209
210// AddRoute adds a new route to the configuration and persists it.
211func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
212 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
213 zerowrap.FieldLayer: "usecase",
214 zerowrap.FieldUseCase: "AddRoute",
215 "domain": route.Domain,
216 })
217 log := zerowrap.FromCtx(ctx)
218
219 // Validate route
220 if route.Domain == "" {
221 return domain.ErrRouteDomainEmpty
222 }
223 if route.Image == "" {
224 return domain.ErrRouteImageEmpty
225 }
226
227 // Store previous value for rollback
228 s.mu.Lock()
229 previousImage, existed := s.config.Routes[route.Domain]
230 if s.config.Routes == nil {
231 s.config.Routes = make(map[string]string)
232 }
233 s.config.Routes[route.Domain] = route.Image
234 s.mu.Unlock()
235
236 // Persist to disk - rollback on failure
237 if err := s.Save(ctx); err != nil {
238 log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
239 s.mu.Lock()
240 if existed {
241 s.config.Routes[route.Domain] = previousImage
242 } else {
243 delete(s.config.Routes, route.Domain)
244 }
245 s.mu.Unlock()
246 return err
247 }
248
249 log.Info().Str("image", route.Image).Msg("route added to configuration")
250 return nil
251}
252
253// UpdateRoute updates an existing route and persists it.
254func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
255 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
256 zerowrap.FieldLayer: "usecase",
257 zerowrap.FieldUseCase: "UpdateRoute",
258 "domain": route.Domain,
259 })
260 log := zerowrap.FromCtx(ctx)
261
262 // Validate route
263 if route.Domain == "" {
264 return domain.ErrRouteDomainEmpty
265 }
266 if route.Image == "" {
267 return domain.ErrRouteImageEmpty
268 }
269
270 // Store previous value for rollback
271 s.mu.Lock()
272 previousImage, exists := s.config.Routes[route.Domain]
273 if !exists {
274 s.mu.Unlock()
275 return domain.ErrRouteNotFound
276 }
277 s.config.Routes[route.Domain] = route.Image
278 s.mu.Unlock()
279
280 // Persist to disk - rollback on failure
281 if err := s.Save(ctx); err != nil {
282 log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
283 s.mu.Lock()
284 s.config.Routes[route.Domain] = previousImage
285 s.mu.Unlock()
286 return err
287 }
288
289 log.Info().Str("image", route.Image).Msg("route updated")
290 return nil
291}
292
293// RemoveRoute removes a route from the configuration and persists it.
294func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
295 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
296 zerowrap.FieldLayer: "usecase",
297 zerowrap.FieldUseCase: "RemoveRoute",
298 "domain": domainName,
299 })
300 log := zerowrap.FromCtx(ctx)
301
302 // Store previous value for rollback
303 s.mu.Lock()
304 previousImage, exists := s.config.Routes[domainName]
305 if !exists {
306 s.mu.Unlock()
307 return domain.ErrRouteNotFound
308 }
309 delete(s.config.Routes, domainName)
310 s.mu.Unlock()
311
312 // Persist to disk - rollback on failure
313 if err := s.Save(ctx); err != nil {
314 log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
315 s.mu.Lock()
316 s.config.Routes[domainName] = previousImage
317 s.mu.Unlock()
318 return err
319 }
320
321 log.Info().Msg("route removed")
322 return nil
323}
324
325// Save persists the current configuration to disk.
326func (s *Service) Save(ctx context.Context) error {
327 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
328 zerowrap.FieldLayer: "usecase",
329 zerowrap.FieldUseCase: "SaveConfig",
330 })
331 log := zerowrap.FromCtx(ctx)
332
333 s.mu.Lock()
334 defer s.mu.Unlock()
335
336 // Update viper with current config
337 s.viper.Set("routes", s.config.Routes)
338 s.viper.Set("attachments", s.config.Attachments)
339
340 // Record save time to debounce file watcher events
341 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
342
343 // Write config to disk
344 if err := s.viper.WriteConfig(); err != nil {
345 return log.WrapErr(err, "failed to write config")
346 }
347
348 log.Info().Msg("configuration saved to disk")
349 return nil
350}
351
352// Watch starts watching for configuration changes.
353func (s *Service) Watch(ctx context.Context, onChange func()) error {
354 log := zerowrap.FromCtx(ctx)
355
356 s.viper.OnConfigChange(func(e fsnotify.Event) {
357 // Check if this event is within the debounce window of our own Save
358 lastSave := atomic.LoadInt64(&s.lastSaveTime)
359 if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
360 log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
361 return
362 }
363
364 log.Info().Str("file", e.Name).Msg("config file changed")
365
366 if err := s.viper.ReadInConfig(); err != nil {
367 log.WrapErr(err, "failed to reload config")
368 return
369 }
370
371 if err := s.Load(ctx); err != nil {
372 log.WrapErr(err, "failed to load updated config")
373 return
374 }
375
376 // Publish config reload event
377 if s.eventBus != nil {
378 if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
379 Source: "file",
380 }); err != nil {
381 log.WrapErr(err, "failed to publish config reload event")
382 }
383 }
384
385 if onChange != nil {
386 onChange()
387 }
388 })
389
390 s.viper.WatchConfig()
391 log.Info().Msg("watching for configuration changes")
392
393 return nil
394}
395
396// GetServerPort returns the configured server port.
397func (s *Service) GetServerPort() int {
398 s.mu.RLock()
399 defer s.mu.RUnlock()
400 return s.config.ServerPort
401}
402
403// GetRegistryPort returns the configured registry port.
404func (s *Service) GetRegistryPort() int {
405 s.mu.RLock()
406 defer s.mu.RUnlock()
407 return s.config.RegistryPort
408}
409
410// GetRegistryDomain returns the configured registry domain.
411func (s *Service) GetRegistryDomain() string {
412 s.mu.RLock()
413 defer s.mu.RUnlock()
414 return s.config.RegistryDomain
415}
416
417// GetDataDir returns the configured data directory.
418func (s *Service) GetDataDir() string {
419 s.mu.RLock()
420 defer s.mu.RUnlock()
421 return s.config.DataDir
422}
423
424// IsAutoRouteEnabled returns whether auto-route is enabled.
425func (s *Service) IsAutoRouteEnabled() bool {
426 s.mu.RLock()
427 defer s.mu.RUnlock()
428 return s.config.AutoRouteEnabled
429}
430
431// IsNetworkIsolationEnabled returns whether network isolation is enabled.
432func (s *Service) IsNetworkIsolationEnabled() bool {
433 s.mu.RLock()
434 defer s.mu.RUnlock()
435 return s.config.NetworkIsolation
436}
437
438// GetNetworkPrefix returns the prefix for created networks.
439func (s *Service) GetNetworkPrefix() string {
440 s.mu.RLock()
441 defer s.mu.RUnlock()
442 return s.config.NetworkPrefix
443}
444
445// GetConfig returns a copy of the current configuration.
446func (s *Service) GetConfig() Config {
447 s.mu.RLock()
448 defer s.mu.RUnlock()
449 return s.config
450}
451
452// GetRegistryAuthConfig returns registry authentication configuration.
453func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
454 s.mu.RLock()
455 defer s.mu.RUnlock()
456 return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
457}
458
459// GetVolumeConfig returns volume configuration.
460func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
461 s.mu.RLock()
462 defer s.mu.RUnlock()
463 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
464}
465
466// GetNetworkGroups returns network group configuration.
467func (s *Service) GetNetworkGroups() map[string][]string {
468 s.mu.RLock()
469 defer s.mu.RUnlock()
470
471 result := make(map[string][]string)
472 for k, v := range s.config.NetworkGroups {
473 result[k] = append([]string{}, v...)
474 }
475 return result
476}
477
478// GetAttachments returns attachment configuration.
479// Deprecated: Use GetAllAttachments instead.
480func (s *Service) GetAttachments() map[string][]string {
481 s.mu.RLock()
482 defer s.mu.RUnlock()
483
484 result := make(map[string][]string)
485 for k, v := range s.config.Attachments {
486 result[k] = append([]string{}, v...)
487 }
488 return result
489}
490
491// GetAllAttachments returns all configured attachments.
492func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
493 s.mu.RLock()
494 defer s.mu.RUnlock()
495
496 result := make(map[string][]string)
497 for k, v := range s.config.Attachments {
498 result[k] = append([]string{}, v...)
499 }
500 return result
501}
502
503// GetAttachmentsFor returns attachments for a specific domain or network group.
504func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
505 s.mu.RLock()
506 defer s.mu.RUnlock()
507
508 images, exists := s.config.Attachments[domainOrGroup]
509 if !exists {
510 return nil, domain.ErrAttachmentNotFound
511 }
512
513 return append([]string{}, images...), nil
514}
515
516// AddAttachment adds an image to a domain/group's attachments.
517func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
518 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
519 zerowrap.FieldLayer: "usecase",
520 zerowrap.FieldUseCase: "AddAttachment",
521 "target": domainOrGroup,
522 "image": image,
523 })
524 log := zerowrap.FromCtx(ctx)
525
526 // Validate input
527 if domainOrGroup == "" {
528 return domain.ErrAttachmentTargetEmpty
529 }
530 if image == "" {
531 return domain.ErrAttachmentImageEmpty
532 }
533
534 s.mu.Lock()
535
536 // Initialize map if needed
537 if s.config.Attachments == nil {
538 s.config.Attachments = make(map[string][]string)
539 }
540
541 // Check if already exists
542 existing := s.config.Attachments[domainOrGroup]
543 for _, img := range existing {
544 if img == image {
545 s.mu.Unlock()
546 return domain.ErrAttachmentExists
547 }
548 }
549
550 // Store previous value for rollback
551 previousImages := append([]string{}, existing...)
552 hadKey := len(existing) > 0
553
554 // Add the image
555 s.config.Attachments[domainOrGroup] = append(existing, image)
556 s.mu.Unlock()
557
558 // Persist to disk - rollback on failure
559 if err := s.Save(ctx); err != nil {
560 log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
561 s.mu.Lock()
562 if hadKey {
563 s.config.Attachments[domainOrGroup] = previousImages
564 } else {
565 delete(s.config.Attachments, domainOrGroup)
566 }
567 s.mu.Unlock()
568 return err
569 }
570
571 log.Info().Msg("attachment added to configuration")
572 return nil
573}
574
575// RemoveAttachment removes an image from a domain/group's attachments.
576func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
577 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
578 zerowrap.FieldLayer: "usecase",
579 zerowrap.FieldUseCase: "RemoveAttachment",
580 "target": domainOrGroup,
581 "image": image,
582 })
583 log := zerowrap.FromCtx(ctx)
584
585 // Validate input
586 if domainOrGroup == "" {
587 return domain.ErrAttachmentTargetEmpty
588 }
589 if image == "" {
590 return domain.ErrAttachmentImageEmpty
591 }
592
593 s.mu.Lock()
594
595 existing, exists := s.config.Attachments[domainOrGroup]
596 if !exists {
597 s.mu.Unlock()
598 return domain.ErrAttachmentNotFound
599 }
600
601 // Find and remove the image
602 found := false
603 newImages := make([]string, 0, len(existing))
604 for _, img := range existing {
605 if img == image {
606 found = true
607 } else {
608 newImages = append(newImages, img)
609 }
610 }
611
612 if !found {
613 s.mu.Unlock()
614 return domain.ErrAttachmentNotFound
615 }
616
617 // Store previous value for rollback
618 previousImages := existing
619
620 // Update or remove the key
621 if len(newImages) == 0 {
622 delete(s.config.Attachments, domainOrGroup)
623 } else {
624 s.config.Attachments[domainOrGroup] = newImages
625 }
626 s.mu.Unlock()
627
628 // Persist to disk - rollback on failure
629 if err := s.Save(ctx); err != nil {
630 log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
631 s.mu.Lock()
632 s.config.Attachments[domainOrGroup] = previousImages
633 s.mu.Unlock()
634 return err
635 }
636
637 log.Info().Msg("attachment removed from configuration")
638 return nil
639}
640
641// GetExternalRoutes returns all configured external routes.
642func (s *Service) GetExternalRoutes() map[string]string {
643 s.mu.RLock()
644 defer s.mu.RUnlock()
645
646 result := make(map[string]string)
647 for k, v := range s.config.ExternalRoutes {
648 result[k] = v
649 }
650 return result
651}
652
653// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
654func ExtractDomainFromImageName(imageName string) (string, bool) {
655 parts := strings.Split(imageName, ":")
656 imageNamePart := parts[0]
657
658 // Simple domain check - contains at least one dot and valid characters
659 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
660 // Additional check: should not look like a registry path
661 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
662 return imageNamePart, true
663 }
664 }
665
666 return "", false
667}