1// Package config implements the configuration management use case.
2package config
3
4import (
5 "context"
6 "strings"
7 "sync"
8
9 "github.com/bnema/zerowrap"
10 "github.com/fsnotify/fsnotify"
11 "github.com/spf13/viper"
12
13 "gordon/internal/boundaries/out"
14 "gordon/internal/domain"
15)
16
17// Config holds the loaded configuration.
18type Config struct {
19 ServerPort int
20 RegistryPort int
21 RegistryDomain string
22 DataDir string
23 AutoRouteEnabled bool
24 NetworkIsolation bool
25 NetworkPrefix string
26 Routes map[string]string
27 RegistryAuthEnabled bool
28 RegistryAuthUsername string
29 RegistryAuthPassword string
30 VolumeAutoCreate bool
31 VolumePrefix string
32 VolumePreserve bool
33 NetworkGroups map[string][]string
34 Attachments map[string][]string
35}
36
37// Service implements the ConfigService interface.
38type Service struct {
39 viper *viper.Viper
40 eventBus out.EventPublisher
41 config Config
42 mu sync.RWMutex
43}
44
45// NewService creates a new config service.
46func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
47 return &Service{
48 viper: v,
49 eventBus: eventBus,
50 }
51}
52
53// Load loads the configuration from the configured source.
54func (s *Service) Load(ctx context.Context) error {
55 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
56 zerowrap.FieldLayer: "usecase",
57 zerowrap.FieldUseCase: "Load",
58 })
59 log := zerowrap.FromCtx(ctx)
60
61 s.mu.Lock()
62 defer s.mu.Unlock()
63
64 // Load configuration from viper
65 s.config = s.loadConfigValues()
66
67 // Load complex nested structures
68 s.config.Routes = loadStringMap(s.viper.Get("routes"))
69 s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
70 s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
71
72 log.Info().
73 Int("server_port", s.config.ServerPort).
74 Int("registry_port", s.config.RegistryPort).
75 Int(zerowrap.FieldCount, len(s.config.Routes)).
76 Msg("configuration loaded")
77
78 return nil
79}
80
81// loadConfigValues loads simple config values from viper.
82func (s *Service) loadConfigValues() Config {
83 return Config{
84 ServerPort: s.viper.GetInt("server.port"),
85 RegistryPort: s.viper.GetInt("server.registry_port"),
86 RegistryDomain: s.viper.GetString("server.registry_domain"),
87 DataDir: s.viper.GetString("server.data_dir"),
88 AutoRouteEnabled: s.viper.GetBool("auto_route.enabled"),
89 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
90 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
91 RegistryAuthEnabled: s.viper.GetBool("registry_auth.enabled"),
92 RegistryAuthUsername: s.viper.GetString("registry_auth.username"),
93 RegistryAuthPassword: s.viper.GetString("registry_auth.password"),
94 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
95 VolumePrefix: s.viper.GetString("volumes.prefix"),
96 VolumePreserve: s.viper.GetBool("volumes.preserve"),
97 Routes: make(map[string]string),
98 NetworkGroups: make(map[string][]string),
99 Attachments: make(map[string][]string),
100 }
101}
102
103// loadStringMap loads a map[string]string from a viper value.
104func loadStringMap(raw any) map[string]string {
105 result := make(map[string]string)
106 if raw == nil {
107 return result
108 }
109 m, ok := raw.(map[string]any)
110 if !ok {
111 return result
112 }
113 for k, v := range m {
114 if vs, ok := v.(string); ok {
115 result[k] = vs
116 }
117 }
118 return result
119}
120
121// loadStringArrayMap loads a map[string][]string from a viper value.
122func loadStringArrayMap(raw any) map[string][]string {
123 result := make(map[string][]string)
124 if raw == nil {
125 return result
126 }
127 m, ok := raw.(map[string]any)
128 if !ok {
129 return result
130 }
131 for k, v := range m {
132 if arr, ok := v.([]any); ok {
133 var strs []string
134 for _, item := range arr {
135 if s, ok := item.(string); ok {
136 strs = append(strs, s)
137 }
138 }
139 result[k] = strs
140 }
141 }
142 return result
143}
144
145// GetRoutes returns all configured routes.
146func (s *Service) GetRoutes(_ context.Context) []domain.Route {
147 s.mu.RLock()
148 defer s.mu.RUnlock()
149
150 var routes []domain.Route
151 for domainName, image := range s.config.Routes {
152 route := domain.Route{
153 Image: image,
154 HTTPS: true,
155 }
156
157 // Check if domain has http:// prefix
158 if strings.HasPrefix(domainName, "http://") {
159 route.Domain = strings.TrimPrefix(domainName, "http://")
160 route.HTTPS = false
161 } else {
162 route.Domain = domainName
163 }
164
165 routes = append(routes, route)
166 }
167
168 return routes
169}
170
171// AddRoute adds a new route to the configuration.
172func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
173 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
174 zerowrap.FieldLayer: "usecase",
175 zerowrap.FieldUseCase: "AddRoute",
176 "domain": route.Domain,
177 })
178 log := zerowrap.FromCtx(ctx)
179
180 s.mu.Lock()
181 defer s.mu.Unlock()
182
183 if s.config.Routes == nil {
184 s.config.Routes = make(map[string]string)
185 }
186
187 s.config.Routes[route.Domain] = route.Image
188
189 log.Info().Str("image", route.Image).Msg("route added to configuration")
190 return nil
191}
192
193// UpdateRoute updates an existing route.
194func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
195 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
196 zerowrap.FieldLayer: "usecase",
197 zerowrap.FieldUseCase: "UpdateRoute",
198 "domain": route.Domain,
199 })
200 log := zerowrap.FromCtx(ctx)
201
202 s.mu.Lock()
203 defer s.mu.Unlock()
204
205 if _, exists := s.config.Routes[route.Domain]; !exists {
206 return domain.ErrRouteNotFound
207 }
208
209 s.config.Routes[route.Domain] = route.Image
210
211 log.Info().Str("image", route.Image).Msg("route updated")
212 return nil
213}
214
215// RemoveRoute removes a route from the configuration.
216func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
217 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
218 zerowrap.FieldLayer: "usecase",
219 zerowrap.FieldUseCase: "RemoveRoute",
220 "domain": domainName,
221 })
222 log := zerowrap.FromCtx(ctx)
223
224 s.mu.Lock()
225 defer s.mu.Unlock()
226
227 if _, exists := s.config.Routes[domainName]; !exists {
228 return domain.ErrRouteNotFound
229 }
230
231 delete(s.config.Routes, domainName)
232
233 log.Info().Msg("route removed")
234 return nil
235}
236
237// Watch starts watching for configuration changes.
238func (s *Service) Watch(ctx context.Context, onChange func()) error {
239 log := zerowrap.FromCtx(ctx)
240
241 s.viper.OnConfigChange(func(e fsnotify.Event) {
242 log.Info().Str("file", e.Name).Msg("config file changed")
243
244 if err := s.viper.ReadInConfig(); err != nil {
245 log.WrapErr(err, "failed to reload config")
246 return
247 }
248
249 if err := s.Load(ctx); err != nil {
250 log.WrapErr(err, "failed to load updated config")
251 return
252 }
253
254 // Publish config reload event
255 if s.eventBus != nil {
256 if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
257 Source: "file",
258 }); err != nil {
259 log.WrapErr(err, "failed to publish config reload event")
260 }
261 }
262
263 if onChange != nil {
264 onChange()
265 }
266 })
267
268 s.viper.WatchConfig()
269 log.Info().Msg("watching for configuration changes")
270
271 return nil
272}
273
274// GetServerPort returns the configured server port.
275func (s *Service) GetServerPort() int {
276 s.mu.RLock()
277 defer s.mu.RUnlock()
278 return s.config.ServerPort
279}
280
281// GetRegistryPort returns the configured registry port.
282func (s *Service) GetRegistryPort() int {
283 s.mu.RLock()
284 defer s.mu.RUnlock()
285 return s.config.RegistryPort
286}
287
288// GetRegistryDomain returns the configured registry domain.
289func (s *Service) GetRegistryDomain() string {
290 s.mu.RLock()
291 defer s.mu.RUnlock()
292 return s.config.RegistryDomain
293}
294
295// GetDataDir returns the configured data directory.
296func (s *Service) GetDataDir() string {
297 s.mu.RLock()
298 defer s.mu.RUnlock()
299 return s.config.DataDir
300}
301
302// IsAutoRouteEnabled returns whether auto-route is enabled.
303func (s *Service) IsAutoRouteEnabled() bool {
304 s.mu.RLock()
305 defer s.mu.RUnlock()
306 return s.config.AutoRouteEnabled
307}
308
309// IsNetworkIsolationEnabled returns whether network isolation is enabled.
310func (s *Service) IsNetworkIsolationEnabled() bool {
311 s.mu.RLock()
312 defer s.mu.RUnlock()
313 return s.config.NetworkIsolation
314}
315
316// GetNetworkPrefix returns the prefix for created networks.
317func (s *Service) GetNetworkPrefix() string {
318 s.mu.RLock()
319 defer s.mu.RUnlock()
320 return s.config.NetworkPrefix
321}
322
323// GetConfig returns a copy of the current configuration.
324func (s *Service) GetConfig() Config {
325 s.mu.RLock()
326 defer s.mu.RUnlock()
327 return s.config
328}
329
330// GetRegistryAuthConfig returns registry authentication configuration.
331func (s *Service) GetRegistryAuthConfig() (enabled bool, username, password string) {
332 s.mu.RLock()
333 defer s.mu.RUnlock()
334 return s.config.RegistryAuthEnabled, s.config.RegistryAuthUsername, s.config.RegistryAuthPassword
335}
336
337// GetVolumeConfig returns volume configuration.
338func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
339 s.mu.RLock()
340 defer s.mu.RUnlock()
341 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
342}
343
344// GetNetworkGroups returns network group configuration.
345func (s *Service) GetNetworkGroups() map[string][]string {
346 s.mu.RLock()
347 defer s.mu.RUnlock()
348
349 result := make(map[string][]string)
350 for k, v := range s.config.NetworkGroups {
351 result[k] = append([]string{}, v...)
352 }
353 return result
354}
355
356// GetAttachments returns attachment configuration.
357func (s *Service) GetAttachments() map[string][]string {
358 s.mu.RLock()
359 defer s.mu.RUnlock()
360
361 result := make(map[string][]string)
362 for k, v := range s.config.Attachments {
363 result[k] = append([]string{}, v...)
364 }
365 return result
366}
367
368// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
369func ExtractDomainFromImageName(imageName string) (string, bool) {
370 parts := strings.Split(imageName, ":")
371 imageNamePart := parts[0]
372
373 // Simple domain check - contains at least one dot and valid characters
374 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
375 // Additional check: should not look like a registry path
376 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
377 return imageNamePart, true
378 }
379 }
380
381 return "", false
382}