v2.30.0-pre4 15d9fe3

service.go RawHistoryFolder

1// Package config implements the configuration management use case.
2package config
3
4import (
5	"cmp"
6	"context"
7	"fmt"
8	"os"
9	"path/filepath"
10	"reflect"
11	"regexp"
12	"slices"
13	"strconv"
14	"strings"
15	"sync"
16	"sync/atomic"
17	"time"
18
19	"github.com/bnema/zerowrap"
20	"github.com/fsnotify/fsnotify"
21	"github.com/pelletier/go-toml/v2"
22	"github.com/spf13/viper"
23
24	"github.com/bnema/gordon/internal/boundaries/out"
25	"github.com/bnema/gordon/internal/domain"
26)
27
28// Config holds the loaded configuration.
29type Config struct {
30	ServerPort              int
31	RegistryPort            int
32	RegistryDomain          string
33	DataDir                 string
34	AutoRouteEnabled        bool
35	AutoRouteAllowedDomains []string `mapstructure:"auto_route_allowed_domains" json:"auto_route_allowed_domains,omitempty"`
36	PreviewEnabled          bool
37	PreviewTTL              time.Duration
38	PreviewSeparator        string
39	PreviewTagPatterns      []string
40	PreviewDataCopy         bool
41	PreviewEnvCopy          bool
42	NetworkIsolation        bool
43	NetworkPrefix           string
44	Routes                  map[string]string
45	ExternalRoutes          map[string]string // domain -> "host:port"
46	RegistryAuthEnabled     bool
47	RegistryAuthUsername    string
48	RegistryAuthPassword    string
49	VolumeAutoCreate        bool
50	VolumePrefix            string
51	VolumePreserve          bool
52	NetworkGroups           map[string][]string
53	Attachments             map[string][]string
54}
55
56// Service implements the ConfigService interface.
57type Service struct {
58	viper         *viper.Viper
59	eventBus      out.EventPublisher
60	config        Config
61	mu            sync.RWMutex
62	lastSaveTime  int64 // Unix nano timestamp of last save (to debounce file watcher)
63	debounceDelay int64 // Debounce delay in nanoseconds (default 500ms)
64}
65
66// NewService creates a new config service.
67func NewService(v *viper.Viper, eventBus out.EventPublisher) *Service {
68	return &Service{
69		viper:         v,
70		eventBus:      eventBus,
71		debounceDelay: int64(500 * time.Millisecond), // 500ms debounce for file watcher
72	}
73}
74
75// Load loads the configuration from the configured source.
76func (s *Service) Load(ctx context.Context) error {
77	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
78		zerowrap.FieldLayer:   "usecase",
79		zerowrap.FieldUseCase: "Load",
80	})
81	log := zerowrap.FromCtx(ctx)
82
83	s.mu.Lock()
84	defer s.mu.Unlock()
85
86	// Load configuration from viper
87	s.config = s.loadConfigValues()
88
89	// Load complex nested structures
90	s.config.Routes = loadStringMap(s.viper.Get("routes"))
91	s.config.ExternalRoutes = loadStringMap(s.viper.Get("external_routes"))
92	s.config.NetworkGroups = loadStringArrayMap(s.viper.Get("network_groups"))
93	s.config.Attachments = loadStringArrayMap(s.viper.Get("attachments"))
94
95	log.Info().
96		Int("server_port", s.config.ServerPort).
97		Int("registry_port", s.config.RegistryPort).
98		Int(zerowrap.FieldCount, len(s.config.Routes)).
99		Msg("configuration loaded")
100
101	return nil
102}
103
104// Reload re-reads the configuration file from disk and loads it into memory.
105// This should be used when you want to pick up external changes to the config file.
106// It differs from Load() which only loads from cached viper values.
107func (s *Service) Reload(ctx context.Context) error {
108	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
109		zerowrap.FieldLayer:   "usecase",
110		zerowrap.FieldUseCase: "Reload",
111	})
112	log := zerowrap.FromCtx(ctx)
113
114	// Re-read the config file from disk
115	if err := s.viper.ReadInConfig(); err != nil {
116		return log.WrapErr(err, "failed to read config file")
117	}
118
119	// Load the values into memory
120	return s.Load(ctx)
121}
122
123// loadConfigValues loads simple config values from viper.
124func (s *Service) loadConfigValues() Config {
125	// Prefer gordon_domain over registry_domain
126	registryDomain := s.viper.GetString("server.gordon_domain")
127	if registryDomain == "" {
128		registryDomain = s.viper.GetString("server.registry_domain")
129	}
130
131	// Backward compat: try new keys first, fall back to old
132	autoEnabled := s.viper.GetBool("auto.enabled")
133	if !autoEnabled {
134		autoEnabled = s.viper.GetBool("auto_route.enabled")
135	}
136	allowedDomains := s.viper.GetStringSlice("auto.allowed_domains")
137	if len(allowedDomains) == 0 {
138		allowedDomains = s.viper.GetStringSlice("auto_route_allowed_domains")
139	}
140
141	// Preview config
142	previewTTLStr := s.viper.GetString("auto.preview.ttl")
143	previewTTL, err := time.ParseDuration(previewTTLStr)
144	if err != nil && previewTTLStr != "" {
145		log := zerowrap.FromCtx(context.Background())
146		log.Warn().Err(err).Str("ttl", previewTTLStr).Msg("invalid preview TTL format, using default 48h (use Go duration strings, e.g. \"168h\" for 7 days)")
147	}
148	if previewTTL == 0 {
149		previewTTL = 48 * time.Hour
150	}
151	previewSep := s.viper.GetString("auto.preview.separator")
152	if previewSep == "" {
153		previewSep = "--"
154	}
155
156	return Config{
157		ServerPort:              s.viper.GetInt("server.port"),
158		RegistryPort:            s.viper.GetInt("server.registry_port"),
159		RegistryDomain:          registryDomain,
160		DataDir:                 s.viper.GetString("server.data_dir"),
161		AutoRouteEnabled:        autoEnabled,
162		AutoRouteAllowedDomains: append([]string{}, allowedDomains...),
163		PreviewEnabled:          s.viper.GetBool("auto.preview.enabled"),
164		PreviewTTL:              previewTTL,
165		PreviewSeparator:        previewSep,
166		PreviewTagPatterns:      append([]string{}, s.viper.GetStringSlice("auto.preview.tag_patterns")...),
167		PreviewDataCopy:         s.viper.GetBool("auto.preview.data_copy"),
168		PreviewEnvCopy:          !s.viper.IsSet("auto.preview.env_copy") || s.viper.GetBool("auto.preview.env_copy"),
169		NetworkIsolation:        s.viper.GetBool("network_isolation.enabled"),
170		NetworkPrefix:           s.viper.GetString("network_isolation.network_prefix"),
171		RegistryAuthEnabled:     s.viper.GetBool("auth.enabled"),
172		RegistryAuthUsername:    s.viper.GetString("auth.username"),
173		RegistryAuthPassword:    s.viper.GetString("auth.password"),
174		VolumeAutoCreate:        s.viper.GetBool("volumes.auto_create"),
175		VolumePrefix:            s.viper.GetString("volumes.prefix"),
176		VolumePreserve:          s.viper.GetBool("volumes.preserve"),
177		Routes:                  make(map[string]string),
178		ExternalRoutes:          make(map[string]string),
179		NetworkGroups:           make(map[string][]string),
180		Attachments:             make(map[string][]string),
181	}
182}
183
184// loadStringMap loads a map[string]string from a viper value.
185func loadStringMap(raw any) map[string]string {
186	result := make(map[string]string)
187	if raw == nil {
188		return result
189	}
190	m, ok := raw.(map[string]any)
191	if !ok {
192		return result
193	}
194	for k, v := range m {
195		if vs, ok := v.(string); ok {
196			result[k] = vs
197		}
198	}
199	return result
200}
201
202// loadStringArrayMap loads a map[string][]string from a viper value.
203func loadStringArrayMap(raw any) map[string][]string {
204	result := make(map[string][]string)
205	if raw == nil {
206		return result
207	}
208	m, ok := raw.(map[string]any)
209	if !ok {
210		return result
211	}
212	for k, v := range m {
213		if arr, ok := v.([]any); ok {
214			var strs []string
215			for _, item := range arr {
216				if s, ok := item.(string); ok {
217					strs = append(strs, s)
218				}
219			}
220			result[k] = strs
221		}
222	}
223	return result
224}
225
226// GetRoutes returns all configured routes.
227func (s *Service) GetRoutes(_ context.Context) []domain.Route {
228	s.mu.RLock()
229	defer s.mu.RUnlock()
230
231	var routes []domain.Route
232	for domainName, image := range s.config.Routes {
233		route := domain.Route{
234			Image: image,
235			HTTPS: true,
236		}
237
238		// Check if domain has http:// prefix
239		if strings.HasPrefix(domainName, "http://") {
240			route.Domain = strings.TrimPrefix(domainName, "http://")
241			route.HTTPS = false
242		} else {
243			route.Domain = domainName
244		}
245
246		routes = append(routes, route)
247	}
248
249	return routes
250}
251
252// GetRoute returns a single route by domain.
253func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, error) {
254	s.mu.RLock()
255	defer s.mu.RUnlock()
256
257	image, exists := s.config.Routes[domainName]
258	if !exists {
259		return nil, domain.ErrRouteNotFound
260	}
261
262	route := &domain.Route{
263		Domain: domainName,
264		Image:  image,
265		HTTPS:  true,
266	}
267
268	// Check if domain has http:// prefix
269	if strings.HasPrefix(domainName, "http://") {
270		route.Domain = strings.TrimPrefix(domainName, "http://")
271		route.HTTPS = false
272	}
273
274	return route, nil
275}
276
277// FindRoutesByImage returns all routes whose image matches the given image name.
278// Image names are normalized by stripping the registry domain prefix before comparison.
279// When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest").
280func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route {
281	s.mu.RLock()
282	defer s.mu.RUnlock()
283
284	var routes []domain.Route
285	for domainName, image := range s.config.Routes {
286		if matchesImageName(imageName, image, s.config.RegistryDomain) {
287			route := domain.Route{
288				Image: image,
289				HTTPS: true,
290			}
291			if strings.HasPrefix(domainName, "http://") {
292				route.Domain = strings.TrimPrefix(domainName, "http://")
293				route.HTTPS = false
294			} else {
295				route.Domain = domainName
296			}
297			routes = append(routes, route)
298		}
299	}
300
301	return routes
302}
303
304// FindAttachmentTargetsByImage returns all attachment targets whose image matches the given image name.
305func (s *Service) FindAttachmentTargetsByImage(_ context.Context, imageName string) []string {
306	s.mu.RLock()
307	defer s.mu.RUnlock()
308
309	var targets []string
310	for target, images := range s.config.Attachments {
311		for _, image := range images {
312			if matchesImageName(imageName, image, s.config.RegistryDomain) {
313				targets = append(targets, target)
314				break
315			}
316		}
317	}
318
319	return targets
320}
321
322func matchesImageName(inputImage, candidateImage, registryDomain string) bool {
323	normalizedInput := NormalizeRegistryImage(inputImage, registryDomain)
324	inputName, inputHasTag := splitImageNameTag(normalizedInput)
325	normalizedCandidate := NormalizeRegistryImage(candidateImage, registryDomain)
326
327	if inputHasTag {
328		return strings.EqualFold(normalizedCandidate, normalizedInput)
329	}
330
331	candidateName, _ := splitImageNameTag(normalizedCandidate)
332	return strings.EqualFold(candidateName, inputName)
333}
334
335// splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present.
336func splitImageNameTag(image string) (name string, hasTag bool) {
337	if idx := strings.LastIndex(image, ":"); idx != -1 {
338		return image[:idx], true
339	}
340	return image, false
341}
342
343// NormalizeRegistryImage strips the registry domain prefix from an image name for comparison.
344func NormalizeRegistryImage(imageName, registryDomain string) string {
345	registryDomain = strings.TrimSuffix(registryDomain, "/")
346	if registryDomain == "" {
347		return imageName
348	}
349
350	prefix := registryDomain + "/"
351	if strings.HasPrefix(imageName, prefix) {
352		return strings.TrimPrefix(imageName, prefix)
353	}
354
355	return imageName
356}
357
358// NormalizeBootstrapImage converts a user-supplied image argument into
359// the canonical "registry/name" format expected by push.
360// Bare names get the registry domain prepended; tags are stripped.
361func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
362	if image == "" {
363		return "", fmt.Errorf("image is required")
364	}
365	if registryDomain == "" {
366		return "", fmt.Errorf("registry domain is not configured")
367	}
368
369	registryDomain = strings.TrimSuffix(registryDomain, "/")
370
371	if slashIdx := strings.LastIndex(image, "/"); slashIdx != -1 {
372		nameTag := image[slashIdx+1:]
373		if colonIdx := strings.LastIndex(nameTag, ":"); colonIdx != -1 {
374			image = image[:slashIdx+1] + nameTag[:colonIdx]
375		}
376	} else {
377		if colonIdx := strings.LastIndex(image, ":"); colonIdx != -1 {
378			image = image[:colonIdx]
379		}
380	}
381
382	if !strings.Contains(image, "/") {
383		image = registryDomain + "/" + image
384	}
385
386	return image, nil
387}
388
389// AddRoute adds a new route to the configuration and persists it.
390func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
391	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
392		zerowrap.FieldLayer:   "usecase",
393		zerowrap.FieldUseCase: "AddRoute",
394		"domain":              route.Domain,
395	})
396	log := zerowrap.FromCtx(ctx)
397
398	// Validate route
399	if route.Domain == "" {
400		return domain.ErrRouteDomainEmpty
401	}
402	if route.Image == "" {
403		return domain.ErrRouteImageEmpty
404	}
405
406	// Store previous value for rollback
407	s.mu.Lock()
408	previousImage, existed := s.config.Routes[route.Domain]
409	if s.config.Routes == nil {
410		s.config.Routes = make(map[string]string)
411	}
412	if existed && previousImage == route.Image {
413		s.mu.Unlock()
414		return nil
415	}
416	s.config.Routes[route.Domain] = route.Image
417	s.mu.Unlock()
418
419	// Persist to disk - rollback on failure
420	if err := s.Save(ctx); err != nil {
421		log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
422		s.mu.Lock()
423		if existed {
424			s.config.Routes[route.Domain] = previousImage
425		} else {
426			delete(s.config.Routes, route.Domain)
427		}
428		s.mu.Unlock()
429		return err
430	}
431
432	log.Info().Str("image", route.Image).Msg("route added to configuration")
433	return nil
434}
435
436// UpdateRoute updates an existing route and persists it.
437func (s *Service) UpdateRoute(ctx context.Context, route domain.Route) error {
438	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
439		zerowrap.FieldLayer:   "usecase",
440		zerowrap.FieldUseCase: "UpdateRoute",
441		"domain":              route.Domain,
442	})
443	log := zerowrap.FromCtx(ctx)
444
445	// Validate route
446	if route.Domain == "" {
447		return domain.ErrRouteDomainEmpty
448	}
449	if route.Image == "" {
450		return domain.ErrRouteImageEmpty
451	}
452
453	// Store previous value for rollback
454	s.mu.Lock()
455	previousImage, exists := s.config.Routes[route.Domain]
456	if !exists {
457		s.mu.Unlock()
458		return domain.ErrRouteNotFound
459	}
460	s.config.Routes[route.Domain] = route.Image
461	s.mu.Unlock()
462
463	// Persist to disk - rollback on failure
464	if err := s.Save(ctx); err != nil {
465		log.Warn().Err(err).Msg("failed to persist route update to disk, rolling back")
466		s.mu.Lock()
467		s.config.Routes[route.Domain] = previousImage
468		s.mu.Unlock()
469		return err
470	}
471
472	log.Info().Str("image", route.Image).Msg("route updated")
473	return nil
474}
475
476// RemoveRoute removes a route from the configuration and persists it.
477func (s *Service) RemoveRoute(ctx context.Context, domainName string) error {
478	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
479		zerowrap.FieldLayer:   "usecase",
480		zerowrap.FieldUseCase: "RemoveRoute",
481		"domain":              domainName,
482	})
483	log := zerowrap.FromCtx(ctx)
484
485	// Store previous value for rollback
486	s.mu.Lock()
487	previousImage, exists := s.config.Routes[domainName]
488	if !exists {
489		s.mu.Unlock()
490		return domain.ErrRouteNotFound
491	}
492	delete(s.config.Routes, domainName)
493	s.mu.Unlock()
494
495	// Persist to disk - rollback on failure
496	if err := s.Save(ctx); err != nil {
497		log.Warn().Err(err).Msg("failed to persist route removal to disk, rolling back")
498		s.mu.Lock()
499		s.config.Routes[domainName] = previousImage
500		s.mu.Unlock()
501		return err
502	}
503
504	log.Info().Msg("route removed")
505	return nil
506}
507
508// Save persists the current configuration to disk.
509func (s *Service) Save(ctx context.Context) error {
510	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
511		zerowrap.FieldLayer:   "usecase",
512		zerowrap.FieldUseCase: "SaveConfig",
513	})
514	log := zerowrap.FromCtx(ctx)
515
516	s.mu.Lock()
517	defer s.mu.Unlock()
518
519	configFile := s.viper.ConfigFileUsed()
520	if configFile == "" {
521		return log.WrapErr(fmt.Errorf("no config file path"), "cannot save config")
522	}
523
524	if err := backupConfigFile(configFile); err != nil {
525		log.Warn().Err(err).Msg("failed to create config backup")
526	}
527
528	snapshot := s.snapshotCriticalFields()
529
530	if err := s.writeConfigSurgical(configFile); err != nil {
531		return log.WrapErr(err, "failed to write config")
532	}
533
534	atomic.StoreInt64(&s.lastSaveTime, time.Now().UnixNano())
535
536	if err := s.viper.ReadInConfig(); err != nil {
537		return log.WrapErr(err, "failed to re-read config after save")
538	}
539
540	if err := s.verifyCriticalFields(snapshot); err != nil {
541		log.Error().Err(err).Msg("config corruption detected after save, restoring backup")
542		if restoreErr := restoreConfigBackup(configFile); restoreErr != nil {
543			log.Error().Err(restoreErr).Msg("CRITICAL: failed to restore config backup")
544		} else if reloadErr := s.viper.ReadInConfig(); reloadErr != nil {
545			log.Error().Err(reloadErr).Msg("failed to reload config after restore")
546		}
547		return log.WrapErr(err, "config verification failed after save")
548	}
549
550	log.Info().Msg("configuration saved to disk")
551	return nil
552}
553
554type configSnapshot struct {
555	values map[string]any
556}
557
558// mutableSections lists config sections that are intentionally modified by service operations.
559var mutableSections = []string{"routes.", "external_routes.", "attachments.", "network_groups.", "auto_route_allowed_domains", "auto."}
560
561func isMutableKey(key string) bool {
562	for _, prefix := range mutableSections {
563		if strings.HasPrefix(key, prefix) {
564			return true
565		}
566	}
567
568	switch key {
569	case "routes", "external_routes", "attachments", "network_groups", "auto_route_allowed_domains", "auto":
570		return true
571	}
572
573	return false
574}
575
576func (s *Service) snapshotCriticalFields() configSnapshot {
577	snap := configSnapshot{values: make(map[string]any)}
578	for _, key := range s.viper.AllKeys() {
579		if isMutableKey(key) {
580			continue
581		}
582		snap.values[key] = s.viper.Get(key)
583	}
584	return snap
585}
586
587func (s *Service) verifyCriticalFields(snap configSnapshot) error {
588	for key, oldVal := range snap.values {
589		if oldVal == nil {
590			continue
591		}
592		newVal := s.viper.Get(key)
593		if !reflect.DeepEqual(oldVal, newVal) {
594			return fmt.Errorf("config key %q changed from %v to %v after save", key, oldVal, newVal)
595		}
596	}
597
598	return nil
599}
600
601func (s *Service) writeConfigSurgical(configFile string) error {
602	data, err := os.ReadFile(configFile)
603	if err != nil {
604		if os.IsNotExist(err) {
605			s.viper.Set("routes", s.config.Routes)
606			s.viper.Set("external_routes", s.config.ExternalRoutes)
607			s.viper.Set("attachments", s.config.Attachments)
608			s.viper.Set("network_groups", s.config.NetworkGroups)
609			s.viper.Set("auto_route_allowed_domains", s.config.AutoRouteAllowedDomains)
610			s.viper.Set("auto.allowed_domains", s.config.AutoRouteAllowedDomains)
611			return s.viper.WriteConfigAs(configFile)
612		}
613		return fmt.Errorf("failed to read config file: %w", err)
614	}
615
616	var config map[string]any
617	if len(data) > 0 {
618		if err := toml.Unmarshal(data, &config); err != nil {
619			return fmt.Errorf("failed to parse config file: %w", err)
620		}
621	}
622	if config == nil {
623		config = make(map[string]any)
624	}
625
626	config["routes"] = s.config.Routes
627	config["external_routes"] = s.config.ExternalRoutes
628	config["attachments"] = s.config.Attachments
629	config["network_groups"] = s.config.NetworkGroups
630	// Write to both old and new keys for backward compat
631	config["auto_route_allowed_domains"] = s.config.AutoRouteAllowedDomains
632	if autoSection, ok := config["auto"].(map[string]any); ok {
633		autoSection["allowed_domains"] = s.config.AutoRouteAllowedDomains
634	} else {
635		config["auto"] = map[string]any{
636			"allowed_domains": s.config.AutoRouteAllowedDomains,
637		}
638	}
639
640	out, err := toml.Marshal(config)
641	if err != nil {
642		return fmt.Errorf("failed to marshal config: %w", err)
643	}
644
645	configDir := filepath.Dir(configFile)
646	tmpFile, err := os.CreateTemp(configDir, ".gordon-config-*.tmp")
647	if err != nil {
648		return fmt.Errorf("failed to create temp config file: %w", err)
649	}
650	tmpPath := tmpFile.Name()
651
652	if _, writeErr := tmpFile.Write(out); writeErr != nil {
653		_ = tmpFile.Close()
654		_ = os.Remove(tmpPath)
655		return fmt.Errorf("failed to write temp config: %w", writeErr)
656	}
657	if chmodErr := tmpFile.Chmod(0600); chmodErr != nil {
658		_ = tmpFile.Close()
659		_ = os.Remove(tmpPath)
660		return fmt.Errorf("failed to set temp config permissions: %w", chmodErr)
661	}
662	if closeErr := tmpFile.Close(); closeErr != nil {
663		_ = os.Remove(tmpPath)
664		return fmt.Errorf("failed to close temp config: %w", closeErr)
665	}
666	if renameErr := os.Rename(tmpPath, configFile); renameErr != nil {
667		_ = os.Remove(tmpPath)
668		return fmt.Errorf("failed to rename temp config: %w", renameErr)
669	}
670
671	return nil
672}
673
674func backupConfigFile(configFile string) error {
675	src, err := os.ReadFile(configFile)
676	if err != nil {
677		if os.IsNotExist(err) {
678			return nil
679		}
680		return fmt.Errorf("failed to read config for backup: %w", err)
681	}
682
683	backupPath := fmt.Sprintf("%s.bak.%d", configFile, time.Now().UnixNano())
684	if err := os.WriteFile(backupPath, src, 0600); err != nil {
685		return fmt.Errorf("failed to write backup config: %w", err)
686	}
687	if err := cleanupOldBackups(configFile, 5); err != nil {
688		return fmt.Errorf("failed to clean up old backups: %w", err)
689	}
690
691	return nil
692}
693
694// cleanupOldBackups removes old backup files, keeping only the most recent `keep` backups.
695// It only removes files matching the pattern `<configFile>.bak.<numeric-timestamp>`.
696func cleanupOldBackups(configFile string, keep int) error {
697	dir := filepath.Dir(configFile)
698	base := filepath.Base(configFile)
699	entries, err := os.ReadDir(dir)
700	if err != nil {
701		return fmt.Errorf("failed to read backup directory: %w", err)
702	}
703
704	prefix := base + ".bak."
705	type tsBackup struct {
706		ts   int64
707		name string
708	}
709	var backups []tsBackup
710	for _, entry := range entries {
711		if entry.IsDir() {
712			continue
713		}
714		name := entry.Name()
715		if !strings.HasPrefix(name, prefix) {
716			continue
717		}
718		suffix := strings.TrimPrefix(name, prefix)
719		ts, err := strconv.ParseInt(suffix, 10, 64)
720		if err != nil {
721			continue
722		}
723		backups = append(backups, tsBackup{ts: ts, name: name})
724	}
725
726	slices.SortFunc(backups, func(a, b tsBackup) int {
727		return cmp.Compare(b.ts, a.ts)
728	})
729	if len(backups) <= keep {
730		return nil
731	}
732
733	var firstErr error
734	for _, backup := range backups[keep:] {
735		backupPath := filepath.Join(dir, backup.name)
736		if err := os.Remove(backupPath); err != nil && firstErr == nil {
737			firstErr = fmt.Errorf("failed to remove old backup %s: %w", backupPath, err)
738		}
739	}
740
741	return firstErr
742}
743
744func restoreConfigBackup(configFile string) error {
745	dir := filepath.Dir(configFile)
746	base := filepath.Base(configFile)
747	entries, err := os.ReadDir(dir)
748	if err != nil {
749		return fmt.Errorf("failed to read backup directory: %w", err)
750	}
751
752	prefix := base + ".bak."
753	type tsBackup struct {
754		ts   int64
755		name string
756	}
757	var backups []tsBackup
758	for _, entry := range entries {
759		if entry.IsDir() {
760			continue
761		}
762		name := entry.Name()
763		if !strings.HasPrefix(name, prefix) {
764			continue
765		}
766		suffix := strings.TrimPrefix(name, prefix)
767		ts, err := strconv.ParseInt(suffix, 10, 64)
768		if err != nil {
769			continue
770		}
771		backups = append(backups, tsBackup{ts: ts, name: name})
772	}
773
774	slices.SortFunc(backups, func(a, b tsBackup) int {
775		return cmp.Compare(b.ts, a.ts)
776	})
777
778	backupPath := configFile + ".bak"
779	if len(backups) > 0 {
780		backupPath = filepath.Join(dir, backups[0].name)
781	}
782
783	src, err := os.ReadFile(backupPath)
784	if err != nil {
785		return fmt.Errorf("failed to read backup: %w", err)
786	}
787
788	if err := os.WriteFile(configFile, src, 0600); err != nil {
789		return fmt.Errorf("failed to restore backup: %w", err)
790	}
791
792	return nil
793}
794
795// Watch starts watching for configuration changes.
796func (s *Service) Watch(ctx context.Context, onChange func()) error {
797	log := zerowrap.FromCtx(ctx)
798
799	s.viper.OnConfigChange(func(e fsnotify.Event) {
800		// Check if this event is within the debounce window of our own Save
801		lastSave := atomic.LoadInt64(&s.lastSaveTime)
802		if lastSave > 0 && time.Now().UnixNano()-lastSave < s.debounceDelay {
803			log.Debug().Str("file", e.Name).Msg("skipping config reload (triggered by save)")
804			return
805		}
806
807		log.Info().Str("file", e.Name).Msg("config file changed")
808
809		if err := s.viper.ReadInConfig(); err != nil {
810			log.WrapErr(err, "failed to reload config")
811			return
812		}
813
814		if err := s.Load(ctx); err != nil {
815			log.WrapErr(err, "failed to load updated config")
816			return
817		}
818
819		// Publish config reload event
820		if s.eventBus != nil {
821			if err := s.eventBus.Publish(domain.EventConfigReload, domain.ConfigReloadPayload{
822				Source: "file",
823			}); err != nil {
824				log.WrapErr(err, "failed to publish config reload event")
825			}
826		}
827
828		if onChange != nil {
829			onChange()
830		}
831	})
832
833	s.viper.WatchConfig()
834	log.Info().Msg("watching for configuration changes")
835
836	return nil
837}
838
839// GetServerPort returns the configured server port.
840func (s *Service) GetServerPort() int {
841	s.mu.RLock()
842	defer s.mu.RUnlock()
843	return s.config.ServerPort
844}
845
846// GetRegistryPort returns the configured registry port.
847func (s *Service) GetRegistryPort() int {
848	s.mu.RLock()
849	defer s.mu.RUnlock()
850	return s.config.RegistryPort
851}
852
853// GetRegistryDomain returns the configured registry domain.
854func (s *Service) GetRegistryDomain() string {
855	s.mu.RLock()
856	defer s.mu.RUnlock()
857	return s.config.RegistryDomain
858}
859
860// GetDataDir returns the configured data directory.
861func (s *Service) GetDataDir() string {
862	s.mu.RLock()
863	defer s.mu.RUnlock()
864	return s.config.DataDir
865}
866
867// IsAutoRouteEnabled returns whether auto-route is enabled.
868func (s *Service) IsAutoRouteEnabled() bool {
869	s.mu.RLock()
870	defer s.mu.RUnlock()
871	return s.config.AutoRouteEnabled
872}
873
874// IsNetworkIsolationEnabled returns whether network isolation is enabled.
875func (s *Service) IsNetworkIsolationEnabled() bool {
876	s.mu.RLock()
877	defer s.mu.RUnlock()
878	return s.config.NetworkIsolation
879}
880
881// GetNetworkPrefix returns the prefix for created networks.
882func (s *Service) GetNetworkPrefix() string {
883	s.mu.RLock()
884	defer s.mu.RUnlock()
885	return s.config.NetworkPrefix
886}
887
888// GetConfig returns a copy of the current configuration.
889func (s *Service) GetConfig() Config {
890	s.mu.RLock()
891	defer s.mu.RUnlock()
892	return s.config
893}
894
895// GetVolumeConfig returns volume configuration.
896func (s *Service) GetVolumeConfig() (autoCreate bool, prefix string, preserve bool) {
897	s.mu.RLock()
898	defer s.mu.RUnlock()
899	return s.config.VolumeAutoCreate, s.config.VolumePrefix, s.config.VolumePreserve
900}
901
902// GetNetworkGroups returns network group configuration.
903func (s *Service) GetNetworkGroups() map[string][]string {
904	s.mu.RLock()
905	defer s.mu.RUnlock()
906
907	result := make(map[string][]string)
908	for k, v := range s.config.NetworkGroups {
909		result[k] = append([]string{}, v...)
910	}
911	return result
912}
913
914// GetAttachmentConfig returns a consistent snapshot of attachments and network
915// groups under a single lock, preventing cross-field races during config reloads.
916func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot {
917	s.mu.RLock()
918	defer s.mu.RUnlock()
919
920	attachments := make(map[string][]string, len(s.config.Attachments))
921	for k, v := range s.config.Attachments {
922		attachments[k] = append([]string{}, v...)
923	}
924
925	networkGroups := make(map[string][]string, len(s.config.NetworkGroups))
926	for k, v := range s.config.NetworkGroups {
927		networkGroups[k] = append([]string{}, v...)
928	}
929
930	return out.AttachmentConfigSnapshot{
931		Attachments:   attachments,
932		NetworkGroups: networkGroups,
933	}
934}
935
936// GetAttachments returns attachment configuration.
937// Deprecated: Use GetAllAttachments instead.
938func (s *Service) GetAttachments() map[string][]string {
939	s.mu.RLock()
940	defer s.mu.RUnlock()
941
942	result := make(map[string][]string)
943	for k, v := range s.config.Attachments {
944		result[k] = append([]string{}, v...)
945	}
946	return result
947}
948
949// GetAllAttachments returns all configured attachments.
950func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
951	s.mu.RLock()
952	defer s.mu.RUnlock()
953
954	result := make(map[string][]string)
955	for k, v := range s.config.Attachments {
956		result[k] = append([]string{}, v...)
957	}
958	return result
959}
960
961// GetAttachmentsFor returns attachments for a specific domain or network group.
962func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
963	s.mu.RLock()
964	defer s.mu.RUnlock()
965
966	images, exists := s.config.Attachments[domainOrGroup]
967	if !exists {
968		return nil, domain.ErrAttachmentNotFound
969	}
970
971	return append([]string{}, images...), nil
972}
973
974// AddAttachment adds an image to a domain/group's attachments.
975func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
976	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
977		zerowrap.FieldLayer:   "usecase",
978		zerowrap.FieldUseCase: "AddAttachment",
979		"target":              domainOrGroup,
980		"image":               image,
981	})
982	log := zerowrap.FromCtx(ctx)
983
984	// Validate input
985	if domainOrGroup == "" {
986		return domain.ErrAttachmentTargetEmpty
987	}
988	if image == "" {
989		return domain.ErrAttachmentImageEmpty
990	}
991
992	s.mu.Lock()
993
994	// Initialize map if needed
995	if s.config.Attachments == nil {
996		s.config.Attachments = make(map[string][]string)
997	}
998
999	// Check if already exists
1000	existing := s.config.Attachments[domainOrGroup]
1001	for _, img := range existing {
1002		if img == image {
1003			s.mu.Unlock()
1004			return domain.ErrAttachmentExists
1005		}
1006	}
1007
1008	// Store previous value for rollback
1009	previousImages := append([]string{}, existing...)
1010	hadKey := len(existing) > 0
1011
1012	// Add the image
1013	s.config.Attachments[domainOrGroup] = append(existing, image)
1014	s.mu.Unlock()
1015
1016	// Persist to disk - rollback on failure
1017	if err := s.Save(ctx); err != nil {
1018		log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
1019		s.mu.Lock()
1020		if hadKey {
1021			s.config.Attachments[domainOrGroup] = previousImages
1022		} else {
1023			delete(s.config.Attachments, domainOrGroup)
1024		}
1025		s.mu.Unlock()
1026		return err
1027	}
1028
1029	log.Info().Msg("attachment added to configuration")
1030	return nil
1031}
1032
1033// RemoveAttachment removes an image from a domain/group's attachments.
1034func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1035	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1036		zerowrap.FieldLayer:   "usecase",
1037		zerowrap.FieldUseCase: "RemoveAttachment",
1038		"target":              domainOrGroup,
1039		"image":               image,
1040	})
1041	log := zerowrap.FromCtx(ctx)
1042
1043	// Validate input
1044	if domainOrGroup == "" {
1045		return domain.ErrAttachmentTargetEmpty
1046	}
1047	if image == "" {
1048		return domain.ErrAttachmentImageEmpty
1049	}
1050
1051	s.mu.Lock()
1052
1053	existing, exists := s.config.Attachments[domainOrGroup]
1054	if !exists {
1055		s.mu.Unlock()
1056		return domain.ErrAttachmentNotFound
1057	}
1058
1059	// Find and remove the image
1060	found := false
1061	newImages := make([]string, 0, len(existing))
1062	for _, img := range existing {
1063		if img == image {
1064			found = true
1065		} else {
1066			newImages = append(newImages, img)
1067		}
1068	}
1069
1070	if !found {
1071		s.mu.Unlock()
1072		return domain.ErrAttachmentNotFound
1073	}
1074
1075	// Store previous value for rollback
1076	previousImages := existing
1077
1078	// Update or remove the key
1079	if len(newImages) == 0 {
1080		delete(s.config.Attachments, domainOrGroup)
1081	} else {
1082		s.config.Attachments[domainOrGroup] = newImages
1083	}
1084	s.mu.Unlock()
1085
1086	// Persist to disk - rollback on failure
1087	if err := s.Save(ctx); err != nil {
1088		log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
1089		s.mu.Lock()
1090		s.config.Attachments[domainOrGroup] = previousImages
1091		s.mu.Unlock()
1092		return err
1093	}
1094
1095	log.Info().Msg("attachment removed from configuration")
1096	return nil
1097}
1098
1099// GetPreviewConfig returns the preview environment configuration.
1100func (s *Service) GetPreviewConfig() domain.PreviewConfig {
1101	s.mu.RLock()
1102	defer s.mu.RUnlock()
1103	return domain.PreviewConfig{
1104		Enabled:     s.config.PreviewEnabled,
1105		TTL:         s.config.PreviewTTL,
1106		Separator:   s.config.PreviewSeparator,
1107		TagPatterns: s.config.PreviewTagPatterns,
1108		DataCopy:    s.config.PreviewDataCopy,
1109		EnvCopy:     s.config.PreviewEnvCopy,
1110	}
1111}
1112
1113// IsPreviewEnabled returns whether preview environments are enabled.
1114func (s *Service) IsPreviewEnabled() bool {
1115	s.mu.RLock()
1116	defer s.mu.RUnlock()
1117	return s.config.PreviewEnabled
1118}
1119
1120// IsAutoEnabled returns whether the auto feature is enabled (alias for IsAutoRouteEnabled).
1121func (s *Service) IsAutoEnabled() bool {
1122	return s.IsAutoRouteEnabled()
1123}
1124
1125// GetPreviewTagPatterns returns the configured tag patterns for preview environments.
1126func (s *Service) GetPreviewTagPatterns() []string {
1127	s.mu.RLock()
1128	defer s.mu.RUnlock()
1129	return append([]string{}, s.config.PreviewTagPatterns...)
1130}
1131
1132// GetAllowedDomains returns the auto-route allowed domain patterns.
1133func (s *Service) GetAllowedDomains() []string {
1134	s.mu.RLock()
1135	defer s.mu.RUnlock()
1136	return append([]string{}, s.config.AutoRouteAllowedDomains...)
1137}
1138
1139// GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1140func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1141	s.mu.RLock()
1142	defer s.mu.RUnlock()
1143
1144	return append([]string{}, s.config.AutoRouteAllowedDomains...), nil
1145}
1146
1147// AddAutoRouteAllowedDomain adds a domain pattern to the auto-route allowlist.
1148func (s *Service) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1149	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1150		zerowrap.FieldLayer:   "usecase",
1151		zerowrap.FieldUseCase: "AddAutoRouteAllowedDomain",
1152		"pattern":             pattern,
1153	})
1154	log := zerowrap.FromCtx(ctx)
1155
1156	if err := validateDomainPattern(pattern); err != nil {
1157		return err
1158	}
1159
1160	s.mu.Lock()
1161	for _, existing := range s.config.AutoRouteAllowedDomains {
1162		if existing == pattern {
1163			s.mu.Unlock()
1164			return nil
1165		}
1166	}
1167	snapshot := make([]string, len(s.config.AutoRouteAllowedDomains))
1168	copy(snapshot, s.config.AutoRouteAllowedDomains)
1169	s.config.AutoRouteAllowedDomains = append(s.config.AutoRouteAllowedDomains, pattern)
1170	s.mu.Unlock()
1171
1172	if err := s.Save(ctx); err != nil {
1173		log.Warn().Err(err).Msg("failed to persist allowed domain, rolling back")
1174		s.mu.Lock()
1175		s.config.AutoRouteAllowedDomains = snapshot
1176		s.mu.Unlock()
1177		return err
1178	}
1179
1180	log.Info().Msg("auto-route allowed domain added")
1181	return nil
1182}
1183
1184// RemoveAutoRouteAllowedDomain removes a domain pattern from the auto-route allowlist.
1185func (s *Service) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1186	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1187		zerowrap.FieldLayer:   "usecase",
1188		zerowrap.FieldUseCase: "RemoveAutoRouteAllowedDomain",
1189		"pattern":             pattern,
1190	})
1191	log := zerowrap.FromCtx(ctx)
1192
1193	s.mu.Lock()
1194	previous := append([]string{}, s.config.AutoRouteAllowedDomains...)
1195	filtered := make([]string, 0, len(s.config.AutoRouteAllowedDomains))
1196	removed := false
1197	for _, existing := range s.config.AutoRouteAllowedDomains {
1198		if existing == pattern {
1199			removed = true
1200			continue
1201		}
1202		filtered = append(filtered, existing)
1203	}
1204	s.config.AutoRouteAllowedDomains = filtered
1205	s.mu.Unlock()
1206
1207	if !removed {
1208		log.Debug().Msg("pattern not found in allowlist")
1209		return nil
1210	}
1211
1212	if err := s.Save(ctx); err != nil {
1213		log.Warn().Err(err).Msg("failed to persist allowed domain removal, rolling back")
1214		s.mu.Lock()
1215		s.config.AutoRouteAllowedDomains = previous
1216		s.mu.Unlock()
1217		return err
1218	}
1219
1220	log.Info().Msg("auto-route allowed domain removed")
1221	return nil
1222}
1223
1224func validateDomainPattern(pattern string) error {
1225	if pattern == "" {
1226		return fmt.Errorf("%w: pattern is required", domain.ErrInvalidDomainPattern)
1227	}
1228	if pattern == "*" {
1229		return nil
1230	}
1231	if pattern != strings.ToLower(pattern) {
1232		return fmt.Errorf("%w: must be lowercase", domain.ErrInvalidDomainPattern)
1233	}
1234	if strings.HasSuffix(pattern, ".") {
1235		return fmt.Errorf("%w: must not have trailing dots", domain.ErrInvalidDomainPattern)
1236	}
1237	if strings.Contains(pattern, "**") {
1238		return fmt.Errorf("%w: must not contain double wildcards", domain.ErrInvalidDomainPattern)
1239	}
1240	if strings.Count(pattern, "*") > 1 {
1241		return fmt.Errorf("%w: must not contain multiple wildcards", domain.ErrInvalidDomainPattern)
1242	}
1243	if strings.Contains(pattern, "*") {
1244		if !strings.HasPrefix(pattern, "*.") {
1245			return fmt.Errorf("%w: wildcard must be in *.domain form", domain.ErrInvalidDomainPattern)
1246		}
1247		if !isValidExactDomain(strings.TrimPrefix(pattern, "*.")) {
1248			return fmt.Errorf("%w: invalid domain after wildcard", domain.ErrInvalidDomainPattern)
1249		}
1250		return nil
1251	}
1252	if !isValidExactDomain(pattern) {
1253		return fmt.Errorf("%w: invalid domain format", domain.ErrInvalidDomainPattern)
1254	}
1255	return nil
1256}
1257
1258var exactDomainPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$`)
1259
1260func isValidExactDomain(pattern string) bool {
1261	return exactDomainPattern.MatchString(pattern)
1262}
1263
1264// GetExternalRoutes returns all configured external routes.
1265func (s *Service) GetExternalRoutes() map[string]string {
1266	s.mu.RLock()
1267	defer s.mu.RUnlock()
1268
1269	result := make(map[string]string)
1270	for k, v := range s.config.ExternalRoutes {
1271		result[k] = v
1272	}
1273	return result
1274}
1275
1276// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
1277func ExtractDomainFromImageName(imageName string) (string, bool) {
1278	parts := strings.Split(imageName, ":")
1279	imageNamePart := parts[0]
1280
1281	// Simple domain check - contains at least one dot and valid characters
1282	if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
1283		// Additional check: should not look like a registry path
1284		if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
1285			return imageNamePart, true
1286		}
1287	}
1288
1289	return "", false
1290}

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