v2.31.0-dev6 8056b57

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// GetAllAttachments returns all configured attachments.
937func (s *Service) GetAllAttachments(_ context.Context) map[string][]string {
938	s.mu.RLock()
939	defer s.mu.RUnlock()
940
941	result := make(map[string][]string)
942	for k, v := range s.config.Attachments {
943		result[k] = append([]string{}, v...)
944	}
945	return result
946}
947
948// GetAttachmentsFor returns attachments for a specific domain or network group.
949func (s *Service) GetAttachmentsFor(_ context.Context, domainOrGroup string) ([]string, error) {
950	s.mu.RLock()
951	defer s.mu.RUnlock()
952
953	images, exists := s.config.Attachments[domainOrGroup]
954	if !exists {
955		return nil, domain.ErrAttachmentNotFound
956	}
957
958	return append([]string{}, images...), nil
959}
960
961// AddAttachment adds an image to a domain/group's attachments.
962func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
963	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
964		zerowrap.FieldLayer:   "usecase",
965		zerowrap.FieldUseCase: "AddAttachment",
966		"target":              domainOrGroup,
967		"image":               image,
968	})
969	log := zerowrap.FromCtx(ctx)
970
971	// Validate input
972	if domainOrGroup == "" {
973		return domain.ErrAttachmentTargetEmpty
974	}
975	if image == "" {
976		return domain.ErrAttachmentImageEmpty
977	}
978
979	s.mu.Lock()
980
981	// Initialize map if needed
982	if s.config.Attachments == nil {
983		s.config.Attachments = make(map[string][]string)
984	}
985
986	// Check if already exists
987	existing := s.config.Attachments[domainOrGroup]
988	for _, img := range existing {
989		if img == image {
990			s.mu.Unlock()
991			return domain.ErrAttachmentExists
992		}
993	}
994
995	// Store previous value for rollback
996	previousImages := append([]string{}, existing...)
997	hadKey := len(existing) > 0
998
999	// Add the image
1000	s.config.Attachments[domainOrGroup] = append(existing, image)
1001	s.mu.Unlock()
1002
1003	// Persist to disk - rollback on failure
1004	if err := s.Save(ctx); err != nil {
1005		log.Warn().Err(err).Msg("failed to persist attachment to disk, rolling back")
1006		s.mu.Lock()
1007		if hadKey {
1008			s.config.Attachments[domainOrGroup] = previousImages
1009		} else {
1010			delete(s.config.Attachments, domainOrGroup)
1011		}
1012		s.mu.Unlock()
1013		return err
1014	}
1015
1016	log.Info().Msg("attachment added to configuration")
1017	return nil
1018}
1019
1020// RemoveAttachment removes an image from a domain/group's attachments.
1021func (s *Service) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
1022	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1023		zerowrap.FieldLayer:   "usecase",
1024		zerowrap.FieldUseCase: "RemoveAttachment",
1025		"target":              domainOrGroup,
1026		"image":               image,
1027	})
1028	log := zerowrap.FromCtx(ctx)
1029
1030	// Validate input
1031	if domainOrGroup == "" {
1032		return domain.ErrAttachmentTargetEmpty
1033	}
1034	if image == "" {
1035		return domain.ErrAttachmentImageEmpty
1036	}
1037
1038	s.mu.Lock()
1039
1040	existing, exists := s.config.Attachments[domainOrGroup]
1041	if !exists {
1042		s.mu.Unlock()
1043		return domain.ErrAttachmentNotFound
1044	}
1045
1046	// Find and remove the image
1047	found := false
1048	newImages := make([]string, 0, len(existing))
1049	for _, img := range existing {
1050		if img == image {
1051			found = true
1052		} else {
1053			newImages = append(newImages, img)
1054		}
1055	}
1056
1057	if !found {
1058		s.mu.Unlock()
1059		return domain.ErrAttachmentNotFound
1060	}
1061
1062	// Store previous value for rollback
1063	previousImages := existing
1064
1065	// Update or remove the key
1066	if len(newImages) == 0 {
1067		delete(s.config.Attachments, domainOrGroup)
1068	} else {
1069		s.config.Attachments[domainOrGroup] = newImages
1070	}
1071	s.mu.Unlock()
1072
1073	// Persist to disk - rollback on failure
1074	if err := s.Save(ctx); err != nil {
1075		log.Warn().Err(err).Msg("failed to persist attachment removal to disk, rolling back")
1076		s.mu.Lock()
1077		s.config.Attachments[domainOrGroup] = previousImages
1078		s.mu.Unlock()
1079		return err
1080	}
1081
1082	log.Info().Msg("attachment removed from configuration")
1083	return nil
1084}
1085
1086// GetPreviewConfig returns the preview environment configuration.
1087func (s *Service) GetPreviewConfig() domain.PreviewConfig {
1088	s.mu.RLock()
1089	defer s.mu.RUnlock()
1090	return domain.PreviewConfig{
1091		Enabled:     s.config.PreviewEnabled,
1092		TTL:         s.config.PreviewTTL,
1093		Separator:   s.config.PreviewSeparator,
1094		TagPatterns: s.config.PreviewTagPatterns,
1095		DataCopy:    s.config.PreviewDataCopy,
1096		EnvCopy:     s.config.PreviewEnvCopy,
1097	}
1098}
1099
1100// IsPreviewEnabled returns whether preview environments are enabled.
1101func (s *Service) IsPreviewEnabled() bool {
1102	s.mu.RLock()
1103	defer s.mu.RUnlock()
1104	return s.config.PreviewEnabled
1105}
1106
1107// IsAutoEnabled returns whether the auto feature is enabled (alias for IsAutoRouteEnabled).
1108func (s *Service) IsAutoEnabled() bool {
1109	return s.IsAutoRouteEnabled()
1110}
1111
1112// GetPreviewTagPatterns returns the configured tag patterns for preview environments.
1113func (s *Service) GetPreviewTagPatterns() []string {
1114	s.mu.RLock()
1115	defer s.mu.RUnlock()
1116	return append([]string{}, s.config.PreviewTagPatterns...)
1117}
1118
1119// GetAllowedDomains returns the auto-route allowed domain patterns.
1120func (s *Service) GetAllowedDomains() []string {
1121	s.mu.RLock()
1122	defer s.mu.RUnlock()
1123	return append([]string{}, s.config.AutoRouteAllowedDomains...)
1124}
1125
1126// GetAutoRouteAllowedDomains returns the configured auto-route domain allowlist.
1127func (s *Service) GetAutoRouteAllowedDomains(_ context.Context) ([]string, error) {
1128	s.mu.RLock()
1129	defer s.mu.RUnlock()
1130
1131	return append([]string{}, s.config.AutoRouteAllowedDomains...), nil
1132}
1133
1134// AddAutoRouteAllowedDomain adds a domain pattern to the auto-route allowlist.
1135func (s *Service) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1136	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1137		zerowrap.FieldLayer:   "usecase",
1138		zerowrap.FieldUseCase: "AddAutoRouteAllowedDomain",
1139		"pattern":             pattern,
1140	})
1141	log := zerowrap.FromCtx(ctx)
1142
1143	if err := validateDomainPattern(pattern); err != nil {
1144		return err
1145	}
1146
1147	s.mu.Lock()
1148	for _, existing := range s.config.AutoRouteAllowedDomains {
1149		if existing == pattern {
1150			s.mu.Unlock()
1151			return nil
1152		}
1153	}
1154	snapshot := make([]string, len(s.config.AutoRouteAllowedDomains))
1155	copy(snapshot, s.config.AutoRouteAllowedDomains)
1156	s.config.AutoRouteAllowedDomains = append(s.config.AutoRouteAllowedDomains, pattern)
1157	s.mu.Unlock()
1158
1159	if err := s.Save(ctx); err != nil {
1160		log.Warn().Err(err).Msg("failed to persist allowed domain, rolling back")
1161		s.mu.Lock()
1162		s.config.AutoRouteAllowedDomains = snapshot
1163		s.mu.Unlock()
1164		return err
1165	}
1166
1167	log.Info().Msg("auto-route allowed domain added")
1168	return nil
1169}
1170
1171// RemoveAutoRouteAllowedDomain removes a domain pattern from the auto-route allowlist.
1172func (s *Service) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
1173	ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1174		zerowrap.FieldLayer:   "usecase",
1175		zerowrap.FieldUseCase: "RemoveAutoRouteAllowedDomain",
1176		"pattern":             pattern,
1177	})
1178	log := zerowrap.FromCtx(ctx)
1179
1180	s.mu.Lock()
1181	previous := append([]string{}, s.config.AutoRouteAllowedDomains...)
1182	filtered := make([]string, 0, len(s.config.AutoRouteAllowedDomains))
1183	removed := false
1184	for _, existing := range s.config.AutoRouteAllowedDomains {
1185		if existing == pattern {
1186			removed = true
1187			continue
1188		}
1189		filtered = append(filtered, existing)
1190	}
1191	s.config.AutoRouteAllowedDomains = filtered
1192	s.mu.Unlock()
1193
1194	if !removed {
1195		log.Debug().Msg("pattern not found in allowlist")
1196		return nil
1197	}
1198
1199	if err := s.Save(ctx); err != nil {
1200		log.Warn().Err(err).Msg("failed to persist allowed domain removal, rolling back")
1201		s.mu.Lock()
1202		s.config.AutoRouteAllowedDomains = previous
1203		s.mu.Unlock()
1204		return err
1205	}
1206
1207	log.Info().Msg("auto-route allowed domain removed")
1208	return nil
1209}
1210
1211func validateDomainPattern(pattern string) error {
1212	if pattern == "" {
1213		return fmt.Errorf("%w: pattern is required", domain.ErrInvalidDomainPattern)
1214	}
1215	if pattern == "*" {
1216		return nil
1217	}
1218	if pattern != strings.ToLower(pattern) {
1219		return fmt.Errorf("%w: must be lowercase", domain.ErrInvalidDomainPattern)
1220	}
1221	if strings.HasSuffix(pattern, ".") {
1222		return fmt.Errorf("%w: must not have trailing dots", domain.ErrInvalidDomainPattern)
1223	}
1224	if strings.Contains(pattern, "**") {
1225		return fmt.Errorf("%w: must not contain double wildcards", domain.ErrInvalidDomainPattern)
1226	}
1227	if strings.Count(pattern, "*") > 1 {
1228		return fmt.Errorf("%w: must not contain multiple wildcards", domain.ErrInvalidDomainPattern)
1229	}
1230	if strings.Contains(pattern, "*") {
1231		if !strings.HasPrefix(pattern, "*.") {
1232			return fmt.Errorf("%w: wildcard must be in *.domain form", domain.ErrInvalidDomainPattern)
1233		}
1234		if !isValidExactDomain(strings.TrimPrefix(pattern, "*.")) {
1235			return fmt.Errorf("%w: invalid domain after wildcard", domain.ErrInvalidDomainPattern)
1236		}
1237		return nil
1238	}
1239	if !isValidExactDomain(pattern) {
1240		return fmt.Errorf("%w: invalid domain format", domain.ErrInvalidDomainPattern)
1241	}
1242	return nil
1243}
1244
1245var 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])?)+$`)
1246
1247func isValidExactDomain(pattern string) bool {
1248	return exactDomainPattern.MatchString(pattern)
1249}
1250
1251// GetExternalRoutes returns all configured external routes.
1252func (s *Service) GetExternalRoutes() map[string]string {
1253	s.mu.RLock()
1254	defer s.mu.RUnlock()
1255
1256	result := make(map[string]string)
1257	for k, v := range s.config.ExternalRoutes {
1258		result[k] = v
1259	}
1260	return result
1261}
1262
1263// ExtractDomainFromImageName extracts domain from image names like "myapp.bamen.dev:latest".
1264func ExtractDomainFromImageName(imageName string) (string, bool) {
1265	parts := strings.Split(imageName, ":")
1266	imageNamePart := parts[0]
1267
1268	// Simple domain check - contains at least one dot and valid characters
1269	if strings.Contains(imageNamePart, ".") && !strings.HasPrefix(imageNamePart, ".") && !strings.HasSuffix(imageNamePart, ".") {
1270		// Additional check: should not look like a registry path
1271		if !strings.Contains(imageNamePart, "/") || strings.Count(imageNamePart, ".") > 0 {
1272			return imageNamePart, true
1273		}
1274	}
1275
1276	return "", false
1277}

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