v3.0.0 f19f453

service.go RawHistoryFolder

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}

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