v2.11.0 dca6537

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	"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}

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