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("registry_auth.enabled"),
105 RegistryAuthUsername: s.viper.GetString("registry_auth.username"),
106 RegistryAuthPassword: s.viper.GetString("registry_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 routes
337 s.viper.Set("routes", s.config.Routes)
338
339 // Record save time to debounce file watcher events
340 atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
341
342 // Write config to disk
343 if err := s.viper.WriteConfig(); err != nil {
344 return log.WrapErr(err, "failed to write config")
345 }
346
347 log.Info().Msg("configuration saved to disk")
348 return nil
349}
350
351// Watch starts watching for configuration changes.
352func (s *Service) Watch(ctx context.Context, onChange func()) error {
353 log := zerowrap.FromCtx(ctx)
354
355 s.viper.OnConfigChange(func(e fsnotify.Event) {
356 // Check if this event is within the debounce window of our own Save
357 lastSave := atomic.LoadInt64(&s.lastSaveTime)
358 if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
359 log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
360 return
361 }
362
363 log.Info().Str("file", e.Name).Msg("config file changed")
364
365 if err := s.viper.ReadInConfig(); err != nil {
366 log.WrapErr(err, "failed to reload config")
367 return
368 }
369
370 if err := s.Load(ctx); err != nil {
371 log.WrapErr(err, "failed to load updated config")
372 return
373 }
374
375 // Publish config reload event
376 if s.eventBus != nil {
377 if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
378 Source: "file",
379 }); err != nil {
380 log.WrapErr(err, "failed to publish config reload event")
381 }
382 }
383
384 if onChange != nil {
385 onChange()
386 }
387 })
388
389 s.viper.WatchConfig()
390 log.Info().Msg("watching for configuration changes")
391
392 return nil
393}
394
395// GetServerPort returns the configured server port.
396func (s *Service) GetServerPort() int {
397 s.mu.RLock()
398 defer s.mu.RUnlock()
399 return s.config.ServerPort
400}
401
402// GetRegistryPort returns the configured registry port.
403func (s *Service) GetRegistryPort() int {
404 s.mu.RLock()
405 defer s.mu.RUnlock()
406 return s.config.RegistryPort
407}
408
409// GetRegistryDomain returns the configured registry domain.
410func (s *Service) GetRegistryDomain() string {
411 s.mu.RLock()
412 defer s.mu.RUnlock()
413 return s.config.RegistryDomain
414}
415
416// GetDataDir returns the configured data directory.
417func (s *Service) GetDataDir() string {
418 s.mu.RLock()
419 defer s.mu.RUnlock()
420 return s.config.DataDir
421}
422
423// IsAutoRouteEnabled returns whether auto-route is enabled.
424func (s *Service) IsAutoRouteEnabled() bool {
425 s.mu.RLock()
426 defer s.mu.RUnlock()
427 return s.config.AutoRouteEnabled
428}
429
430// IsNetworkIsolationEnabled returns whether network isolation is enabled.
431func (s *Service) IsNetworkIsolationEnabled() bool {
432 s.mu.RLock()
433 defer s.mu.RUnlock()
434 return s.config.NetworkIsolation
435}
436
437// GetNetworkPrefix returns the prefix for created networks.
438func (s *Service) GetNetworkPrefix() string {
439 s.mu.RLock()
440 defer s.mu.RUnlock()
441 return s.config.NetworkPrefix
442}
443
444// GetConfig returns a copy of the current configuration.
445func (s *Service) GetConfig() Config {
446 s.mu.RLock()
447 defer s.mu.RUnlock()
448 return s.config
449}
450
451// GetRegistryAuthConfig returns registry authentication configuration.
452func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
453 s.mu.RLock()
454 defer s.mu.RUnlock()
455 return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
456}
457
458// GetVolumeConfig returns volume configuration.
459func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
460 s.mu.RLock()
461 defer s.mu.RUnlock()
462 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
463}
464
465// GetNetworkGroups returns network group configuration.
466func (s *Service) GetNetworkGroups() map[string][]string {
467 s.mu.RLock()
468 defer s.mu.RUnlock()
469
470 result := make(map[string][]string)
471 for k, v := range s.config.NetworkGroups {
472 result[k] = append([]string{}, v...)
473 }
474 return result
475}
476
477// GetAttachments returns attachment configuration.
478func (s *Service) GetAttachments() map[string][]string {
479 s.mu.RLock()
480 defer s.mu.RUnlock()
481
482 result := make(map[string][]string)
483 for k, v := range s.config.Attachments {
484 result[k] = append([]string{}, v...)
485 }
486 return result
487}
488
489// GetExternalRoutes returns all configured external routes.
490func (s *Service) GetExternalRoutes() map[string]string {
491 s.mu.RLock()
492 defer s.mu.RUnlock()
493
494 result := make(map[string]string)
495 for k, v := range s.config.ExternalRoutes {
496 result[k] = v
497 }
498 return result
499}
500
501// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
502func ExtractDomainFromImageName(imageName string) (string, bool) {
503 parts := strings.Split(imageName, ":")
504 imageNamePart := parts[0]
505
506 // Simple domain check - contains at least one dot and valid characters
507 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
508 // Additional check: should not look like a registry path
509 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
510 return imageNamePart, true
511 }
512 }
513
514 return "", false
515}