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// AddRoute adds a new route to the configuration and persists it.
230func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
231 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
232 zerowrap.FieldLayer: "usecase",
233 zerowrap.FieldUseCase: "AddRoute",
234 "domain": route.Domain,
235 })
236 log := zerowrap.FromCtx(ctx)
237
238 // Validate route
239 if route.Domain == "" {
240 return domain.ErrRouteDomainEmpty
241 }
242 if route.Image == "" {
243 return domain.ErrRouteImageEmpty
244 }
245
246 // Store previous value for rollback
247 s.mu.Lock()
248 previousImage, existed := s.config.Routes[route.Domain]
249 if s.config.Routes == nil {
250 s.config.Routes = make(map[string]string)
251 }
252 s.config.Routes[route.Domain] = route.Image
253 s.mu.Unlock()
254
255 // Persist to disk - rollback on failure
256 if err := s.Save(ctx); err != nil {
257 log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
258 s.mu.Lock()
259 if existed {
260 s.config.Routes[route.Domain] = previousImage
261 } else {
262 delete(s.config.Routes, route.Domain)
263 }
264 s.mu.Unlock()
265 return err
266 }
267
268 log.Info().Str("image", route.Image).Msg("route added to configuration")
269 return nil
270}
271
272// UpdateRoute updates an existing route and persists it.
273func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
274 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
275 zerowrap.FieldLayer: "usecase",
276 zerowrap.FieldUseCase: "UpdateRoute",
277 "domain": route.Domain,
278 })
279 log := zerowrap.FromCtx(ctx)
280
281 // Validate route
282 if route.Domain == "" {
283 return domain.ErrRouteDomainEmpty
284 }
285 if route.Image == "" {
286 return domain.ErrRouteImageEmpty
287 }
288
289 // Store previous value for rollback
290 s.mu.Lock()
291 previousImage, exists := s.config.Routes[route.Domain]
292 if !exists {
293 s.mu.Unlock()
294 return domain.ErrRouteNotFound
295 }
296 s.config.Routes[route.Domain] = route.Image
297 s.mu.Unlock()
298
299 // Persist to disk - rollback on failure
300 if err := s.Save(ctx); err != nil {
301 log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
302 s.mu.Lock()
303 s.config.Routes[route.Domain] = previousImage
304 s.mu.Unlock()
305 return err
306 }
307
308 log.Info().Str("image", route.Image).Msg("route updated")
309 return nil
310}
311
312// RemoveRoute removes a route from the configuration and persists it.
313func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
314 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
315 zerowrap.FieldLayer: "usecase",
316 zerowrap.FieldUseCase: "RemoveRoute",
317 "domain": domainName,
318 })
319 log := zerowrap.FromCtx(ctx)
320
321 // Store previous value for rollback
322 s.mu.Lock()
323 previousImage, exists := s.config.Routes[domainName]
324 if !exists {
325 s.mu.Unlock()
326 return domain.ErrRouteNotFound
327 }
328 delete(s.config.Routes, domainName)
329 s.mu.Unlock()
330
331 // Persist to disk - rollback on failure
332 if err := s.Save(ctx); err != nil {
333 log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
334 s.mu.Lock()
335 s.config.Routes[domainName] = previousImage
336 s.mu.Unlock()
337 return err
338 }
339
340 log.Info().Msg("route removed")
341 return nil
342}
343
344// Save persists the current configuration to disk.
345func (s *Service) Save(ctx context.Context) error {
346 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
347 zerowrap.FieldLayer: "usecase",
348 zerowrap.FieldUseCase: "SaveConfig",
349 })
350 log := zerowrap.FromCtx(ctx)
351
352 s.mu.Lock()
353 defer s.mu.Unlock()
354
355 // Update viper with current config
356 s.viper.Set("routes", s.config.Routes)
357 s.viper.Set("attachments", s.config.Attachments)
358
359 // Record save time to debounce file watcher events
360 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
361
362 // Write config to disk
363 if err := s.viper.WriteConfig(); err != nil {
364 return log.WrapErr(err, "failed to write config")
365 }
366
367 log.Info().Msg("configuration saved to disk")
368 return nil
369}
370
371// Watch starts watching for configuration changes.
372func (s *Service) Watch(ctx context.Context, onChange func()) error {
373 log := zerowrap.FromCtx(ctx)
374
375 s.viper.OnConfigChange(func(e fsnotify.Event) {
376 // Check if this event is within the debounce window of our own Save
377 lastSave := atomic.LoadInt64(&s.lastSaveTime)
378 if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
379 log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
380 return
381 }
382
383 log.Info().Str("file", e.Name).Msg("config file changed")
384
385 if err := s.viper.ReadInConfig(); err != nil {
386 log.WrapErr(err, "failed to reload config")
387 return
388 }
389
390 if err := s.Load(ctx); err != nil {
391 log.WrapErr(err, "failed to load updated config")
392 return
393 }
394
395 // Publish config reload event
396 if s.eventBus != nil {
397 if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
398 Source: "file",
399 }); err != nil {
400 log.WrapErr(err, "failed to publish config reload event")
401 }
402 }
403
404 if onChange != nil {
405 onChange()
406 }
407 })
408
409 s.viper.WatchConfig()
410 log.Info().Msg("watching for configuration changes")
411
412 return nil
413}
414
415// GetServerPort returns the configured server port.
416func (s *Service) GetServerPort() int {
417 s.mu.RLock()
418 defer s.mu.RUnlock()
419 return s.config.ServerPort
420}
421
422// GetRegistryPort returns the configured registry port.
423func (s *Service) GetRegistryPort() int {
424 s.mu.RLock()
425 defer s.mu.RUnlock()
426 return s.config.RegistryPort
427}
428
429// GetRegistryDomain returns the configured registry domain.
430func (s *Service) GetRegistryDomain() string {
431 s.mu.RLock()
432 defer s.mu.RUnlock()
433 return s.config.RegistryDomain
434}
435
436// GetDataDir returns the configured data directory.
437func (s *Service) GetDataDir() string {
438 s.mu.RLock()
439 defer s.mu.RUnlock()
440 return s.config.DataDir
441}
442
443// IsAutoRouteEnabled returns whether auto-route is enabled.
444func (s *Service) IsAutoRouteEnabled() bool {
445 s.mu.RLock()
446 defer s.mu.RUnlock()
447 return s.config.AutoRouteEnabled
448}
449
450// IsNetworkIsolationEnabled returns whether network isolation is enabled.
451func (s *Service) IsNetworkIsolationEnabled() bool {
452 s.mu.RLock()
453 defer s.mu.RUnlock()
454 return s.config.NetworkIsolation
455}
456
457// GetNetworkPrefix returns the prefix for created networks.
458func (s *Service) GetNetworkPrefix() string {
459 s.mu.RLock()
460 defer s.mu.RUnlock()
461 return s.config.NetworkPrefix
462}
463
464// GetConfig returns a copy of the current configuration.
465func (s *Service) GetConfig() Config {
466 s.mu.RLock()
467 defer s.mu.RUnlock()
468 return s.config
469}
470
471// GetRegistryAuthConfig returns registry authentication configuration.
472func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
473 s.mu.RLock()
474 defer s.mu.RUnlock()
475 return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
476}
477
478// GetVolumeConfig returns volume configuration.
479func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
480 s.mu.RLock()
481 defer s.mu.RUnlock()
482 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
483}
484
485// GetNetworkGroups returns network group configuration.
486func (s *Service) GetNetworkGroups() map[string][]string {
487 s.mu.RLock()
488 defer s.mu.RUnlock()
489
490 result := make(map[string][]string)
491 for k, v := range s.config.NetworkGroups {
492 result[k] = append([]string{}, v...)
493 }
494 return result
495}
496
497// GetAttachments returns attachment configuration.
498// Deprecated: Use GetAllAttachments instead.
499func (s *Service) GetAttachments() map[string][]string {
500 s.mu.RLock()
501 defer s.mu.RUnlock()
502
503 result := make(map[string][]string)
504 for k, v := range s.config.Attachments {
505 result[k] = append([]string{}, v...)
506 }
507 return result
508}
509
510// GetAllAttachments returns all configured attachments.
511func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
512 s.mu.RLock()
513 defer s.mu.RUnlock()
514
515 result := make(map[string][]string)
516 for k, v := range s.config.Attachments {
517 result[k] = append([]string{}, v...)
518 }
519 return result
520}
521
522// GetAttachmentsFor returns attachments for a specific domain or network group.
523func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
524 s.mu.RLock()
525 defer s.mu.RUnlock()
526
527 images, exists := s.config.Attachments[domainOrGroup]
528 if !exists {
529 return nil, domain.ErrAttachmentNotFound
530 }
531
532 return append([]string{}, images...), nil
533}
534
535// AddAttachment adds an image to a domain/group's attachments.
536func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
537 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
538 zerowrap.FieldLayer: "usecase",
539 zerowrap.FieldUseCase: "AddAttachment",
540 "target": domainOrGroup,
541 "image": image,
542 })
543 log := zerowrap.FromCtx(ctx)
544
545 // Validate input
546 if domainOrGroup == "" {
547 return domain.ErrAttachmentTargetEmpty
548 }
549 if image == "" {
550 return domain.ErrAttachmentImageEmpty
551 }
552
553 s.mu.Lock()
554
555 // Initialize map if needed
556 if s.config.Attachments == nil {
557 s.config.Attachments = make(map[string][]string)
558 }
559
560 // Check if already exists
561 existing := s.config.Attachments[domainOrGroup]
562 for _, img := range existing {
563 if img == image {
564 s.mu.Unlock()
565 return domain.ErrAttachmentExists
566 }
567 }
568
569 // Store previous value for rollback
570 previousImages := append([]string{}, existing...)
571 hadKey := len(existing) > 0
572
573 // Add the image
574 s.config.Attachments[domainOrGroup] = append(existing, image)
575 s.mu.Unlock()
576
577 // Persist to disk - rollback on failure
578 if err := s.Save(ctx); err != nil {
579 log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
580 s.mu.Lock()
581 if hadKey {
582 s.config.Attachments[domainOrGroup] = previousImages
583 } else {
584 delete(s.config.Attachments, domainOrGroup)
585 }
586 s.mu.Unlock()
587 return err
588 }
589
590 log.Info().Msg("attachment added to configuration")
591 return nil
592}
593
594// RemoveAttachment removes an image from a domain/group's attachments.
595func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
596 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
597 zerowrap.FieldLayer: "usecase",
598 zerowrap.FieldUseCase: "RemoveAttachment",
599 "target": domainOrGroup,
600 "image": image,
601 })
602 log := zerowrap.FromCtx(ctx)
603
604 // Validate input
605 if domainOrGroup == "" {
606 return domain.ErrAttachmentTargetEmpty
607 }
608 if image == "" {
609 return domain.ErrAttachmentImageEmpty
610 }
611
612 s.mu.Lock()
613
614 existing, exists := s.config.Attachments[domainOrGroup]
615 if !exists {
616 s.mu.Unlock()
617 return domain.ErrAttachmentNotFound
618 }
619
620 // Find and remove the image
621 found := false
622 newImages := make([]string, 0, len(existing))
623 for _, img := range existing {
624 if img == image {
625 found = true
626 } else {
627 newImages = append(newImages, img)
628 }
629 }
630
631 if !found {
632 s.mu.Unlock()
633 return domain.ErrAttachmentNotFound
634 }
635
636 // Store previous value for rollback
637 previousImages := existing
638
639 // Update or remove the key
640 if len(newImages) == 0 {
641 delete(s.config.Attachments, domainOrGroup)
642 } else {
643 s.config.Attachments[domainOrGroup] = newImages
644 }
645 s.mu.Unlock()
646
647 // Persist to disk - rollback on failure
648 if err := s.Save(ctx); err != nil {
649 log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
650 s.mu.Lock()
651 s.config.Attachments[domainOrGroup] = previousImages
652 s.mu.Unlock()
653 return err
654 }
655
656 log.Info().Msg("attachment removed from configuration")
657 return nil
658}
659
660// GetExternalRoutes returns all configured external routes.
661func (s *Service) GetExternalRoutes() map[string]string {
662 s.mu.RLock()
663 defer s.mu.RUnlock()
664
665 result := make(map[string]string)
666 for k, v := range s.config.ExternalRoutes {
667 result[k] = v
668 }
669 return result
670}
671
672// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
673func ExtractDomainFromImageName(imageName string) (string, bool) {
674 parts := strings.Split(imageName, ":")
675 imageNamePart := parts[0]
676
677 // Simple domain check - contains at least one dot and valid characters
678 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
679 // Additional check: should not look like a registry path
680 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
681 return imageNamePart, true
682 }
683 }
684
685 return "", false
686}