v2.4.2 7da87b2

service.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close