1// Package config implements the configuration management use case.
2package config
3
4import (
5 "context"
6 "fmt"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "time"
11
12 "github.com/bnema/zerowrap"
13 "github.com/fsnotify/fsnotify"
14 "github.com/spf13/viper"
15
16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// Config holds the loaded configuration.
21type Config struct {
22 ServerPort int
23 RegistryPort int
24 RegistryDomain string
25 LegacyRegistryDomains []string
26 DataDir string
27 NetworkIsolation bool
28 NetworkPrefix 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}
37
38// Service implements the ConfigService interface.
39type Service struct {
40 viper *viper.Viper
41 eventBus out.EventPublisher
42 config Config
43 mu sync.RWMutex
44 lastSaveTime int64 // Unix nano timestamp of last save (to debounce file watcher)
45 debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
46}
47
48// NewService creates a new config service.
49func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
50 return &Service{
51 viper: v,
52 eventBus: eventBus,
53 debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
54 }
55}
56
57// Load loads the configuration from the configured source.
58func (s *Service) Load(ctx context.Context) error {
59 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
60 zerowrap.FieldLayer: "usecase",
61 zerowrap.FieldUseCase: "Load",
62 })
63 log := zerowrap.FromCtx(ctx)
64
65 s.mu.Lock()
66 defer s.mu.Unlock()
67
68 newConfig := s.loadConfigValues()
69
70 externalRoutes, err := loadExternalRoutes(s.viper.Get("external_routes"))
71 if err != nil {
72 return log.WrapErr(err, "failed to load external routes")
73 }
74 newConfig.ExternalRoutes = externalRoutes
75
76 s.config = newConfig
77
78 log.Info().
79 Int("server_port", s.config.ServerPort).
80 Int("registry_port", s.config.RegistryPort).
81 Msg("configuration loaded")
82
83 return nil
84}
85
86// Reload re-reads the configuration file from disk and loads it into memory.
87// This should be used when you want to pick up external changes to the config file.
88// It differs from Load() which only loads from cached viper values.
89func (s *Service) Reload(ctx context.Context) error {
90 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
91 zerowrap.FieldLayer: "usecase",
92 zerowrap.FieldUseCase: "Reload",
93 })
94 log := zerowrap.FromCtx(ctx)
95
96 // Re-read the config file from disk
97 if err := s.viper.ReadInConfig(); err != nil {
98 return log.WrapErr(err, "failed to read config file")
99 }
100
101 // Load the values into memory
102 return s.Load(ctx)
103}
104
105// loadConfigValues loads installation config values from viper.
106// Retired application keys (routes, attachments, network_groups, auto,
107// preview) are NOT loaded here: presence fails startup/reload closed
108// with a config-retired diagnostic before any mutation.
109func (s *Service) loadConfigValues() Config {
110 // RegistryDomain identifies the registry in image references. It may be a
111 // DNS name or an IP address (with an optional port). Fall back to the
112 // Gordon domain only for installations that share one public endpoint.
113 registryDomain := s.viper.GetString("server.registry_domain")
114 if registryDomain == "" {
115 registryDomain = s.viper.GetString("server.gordon_domain")
116 }
117
118 legacyRegistryDomains := append([]string{}, s.viper.GetStringSlice("server.legacy_registry_domains")...)
119
120 return Config{
121 ServerPort: s.viper.GetInt("server.port"),
122 RegistryPort: s.viper.GetInt("server.registry_port"),
123 RegistryDomain: registryDomain,
124 LegacyRegistryDomains: legacyRegistryDomains,
125 DataDir: s.viper.GetString("server.data_dir"),
126 NetworkIsolation: s.viper.GetBool("network_isolation.enabled"),
127 NetworkPrefix: s.viper.GetString("network_isolation.network_prefix"),
128 VolumeAutoCreate: s.viper.GetBool("volumes.auto_create"),
129 VolumePrefix: s.viper.GetString("volumes.prefix"),
130 VolumePreserve: s.viper.GetBool("volumes.preserve"),
131 ExternalRoutes: make(map[string]string),
132 }
133}
134
135// loadStringMap loads a map[string]string from a viper value.
136func loadStringMap(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 vs, ok := v.(string); ok {
147 result[k] = vs
148 }
149 }
150 return result
151}
152
153// loadExternalRoutes loads and canonicalizes external route mappings.
154func loadExternalRoutes(raw any) (map[string]string, error) {
155 result := make(map[string]string)
156 if raw == nil {
157 return result, nil
158 }
159 m, ok := raw.(map[string]any)
160 if !ok {
161 return result, nil
162 }
163 for k, v := range m {
164 vs, ok := v.(string)
165 if !ok {
166 continue
167 }
168 canonicalKey, ok := domain.CanonicalRouteDomain(k)
169 if !ok {
170 return nil, fmt.Errorf("invalid external route key %q: %w", k, domain.ErrRouteDomainInvalid)
171 }
172 if _, exists := result[canonicalKey]; exists {
173 return nil, fmt.Errorf("duplicate external route key %q canonicalizes to %q", k, canonicalKey)
174 }
175 result[canonicalKey] = vs
176 }
177 return result, nil
178}
179
180// loadStringArrayMap loads a map[string][]string from a viper value.
181func (s *Service) Watch(ctx context.Context, onChange func()) error {
182 log := zerowrap.FromCtx(ctx)
183
184 s.viper.OnConfigChange(func(e fsnotify.Event) {
185 // Check if this event is within the debounce window of our own Save
186 lastSave := atomic.LoadInt64(&s.lastSaveTime)
187 if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
188 log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
189 return
190 }
191
192 log.Info().Str("file", e.Name).Msg("config file changed")
193
194 if err := s.viper.ReadInConfig(); err != nil {
195 log.WrapErr(err, "failed to reload config")
196 return
197 }
198
199 if err := s.Load(ctx); err != nil {
200 log.WrapErr(err, "failed to load updated config")
201 return
202 }
203
204 if onChange != nil {
205 onChange()
206 }
207 })
208
209 s.viper.WatchConfig()
210 log.Info().Msg("watching for configuration changes")
211
212 return nil
213}
214
215// GetServerPort returns the configured server port.
216func (s *Service) GetServerPort() int {
217 s.mu.RLock()
218 defer s.mu.RUnlock()
219 return s.config.ServerPort
220}
221
222// GetRegistryPort returns the configured registry port.
223func (s *Service) GetRegistryPort() int {
224 s.mu.RLock()
225 defer s.mu.RUnlock()
226 return s.config.RegistryPort
227}
228
229// GetRegistryDomain returns the configured registry domain.
230func (s *Service) GetRegistryDomain() string {
231 s.mu.RLock()
232 defer s.mu.RUnlock()
233 return s.config.RegistryDomain
234}
235
236// GetLegacyRegistryDomains returns the configured legacy registry domains.
237func (s *Service) GetLegacyRegistryDomains() []string {
238 s.mu.RLock()
239 defer s.mu.RUnlock()
240 return append([]string{}, s.config.LegacyRegistryDomains...)
241}
242
243// GetDataDir returns the configured data directory.
244func (s *Service) GetDataDir() string {
245 s.mu.RLock()
246 defer s.mu.RUnlock()
247 return s.config.DataDir
248}
249
250// IsNetworkIsolationEnabled returns whether network isolation is enabled.
251func (s *Service) IsNetworkIsolationEnabled() bool {
252 s.mu.RLock()
253 defer s.mu.RUnlock()
254 return s.config.NetworkIsolation
255}
256
257// GetNetworkPrefix returns the prefix for created networks.
258func (s *Service) GetNetworkPrefix() string {
259 s.mu.RLock()
260 defer s.mu.RUnlock()
261 return s.config.NetworkPrefix
262}
263
264// GetConfig returns a copy of the current configuration.
265func (s *Service) GetConfig() Config {
266 s.mu.RLock()
267 defer s.mu.RUnlock()
268 return s.config
269}
270
271// GetVolumeConfig returns volume configuration.
272func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
273 s.mu.RLock()
274 defer s.mu.RUnlock()
275 return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
276}
277
278// GetExternalRoutes returns all configured external routes.
279func (s *Service) GetExternalRoutes() map[string]string {
280 s.mu.RLock()
281 defer s.mu.RUnlock()
282
283 result := make(map[string]string, len(s.config.ExternalRoutes))
284 for k, v := range s.config.ExternalRoutes {
285 result[k] = v
286 }
287 return result
288}
289
290// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
291func ExtractDomainFromImageName(imageName string) (string, bool) {
292 parts := strings.Split(imageName, ":")
293 imageNamePart := parts[0]
294
295 // Simple domain check - contains at least one dot and valid characters
296 if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
297 // Additional check: should not look like a registry path
298 if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
299 return imageNamePart, true
300 }
301 }
302
303 return "", false
304}