detached d9c6a56

feat(services): add standalone service config model

commit
d9c6a560d05d670da7c4c860cd31b5acf6594213
author
brice · · 2026-06-30 14:46 UTC
parent
f7cd760
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +552 -0
M internal/app/run.go +2 -0
@@ -85,6 +85,7 @@ import (
8585 "github.com/bnema/gordon/internal/usecase/publictls"
8686 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
8787 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
88+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
8889 "github.com/bnema/gordon/internal/usecase/traffic"
8990 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
9091
@@ -179,6 +180,7 @@ type Config struct {
179180 EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
180181 Traffic traffic.Config `mapstructure:"traffic"`
181182 NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
183+ Services []servicecfg.Config `mapstructure:"services"`
182184
183185 Backups struct {
184186 // Legacy database backup keys. Prefer backups.databases.* for new configs.
@@ -85,6 +85,7 @@ import (
85 "github.com/bnema/gordon/internal/usecase/publictls"85 "github.com/bnema/gordon/internal/usecase/publictls"
86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"86 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
87 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"87 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
88+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
88 "github.com/bnema/gordon/internal/usecase/traffic"89 "github.com/bnema/gordon/internal/usecase/traffic"
89 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"90 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
90 91
@@ -179,6 +180,7 @@ type Config struct {
179 EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`180 EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
180 Traffic traffic.Config `mapstructure:"traffic"`181 Traffic traffic.Config `mapstructure:"traffic"`
181 NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`182 NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
183+ Services []servicecfg.Config `mapstructure:"services"`
182 184
183 Backups struct {185 Backups struct {
184 // Legacy database backup keys. Prefer backups.databases.* for new configs.186 // Legacy database backup keys. Prefer backups.databases.* for new configs.
A internal/domain/service.go +198 -0
@@ -0,0 +1,198 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "net"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ StandaloneServiceReadinessNone = "none"
14+ StandaloneServiceReadinessTCP = "tcp"
15+ StandaloneServiceReadinessLog = "log"
16+)
17+
18+type StandaloneService struct {
19+ Name string
20+ Image string
21+ Enabled bool
22+ Env []string
23+ EnvFile string
24+ Secrets []StandaloneServiceSecretRef
25+ Readiness StandaloneServiceReadiness
26+ Cleanup StandaloneServiceCleanup
27+ Ports []StandaloneServicePort
28+ Volumes []StandaloneServiceVolume
29+}
30+
31+type StandaloneServicePort struct {
32+ Name string
33+ Container int
34+ Protocol NetworkProtocol
35+ Publish string
36+ Private bool
37+ TrustedCIDRs []string
38+}
39+
40+type StandaloneServiceVolume struct {
41+ Source string
42+ Target string
43+ ReadOnly bool
44+}
45+
46+type StandaloneServiceSecretRef struct {
47+ Name string
48+ Key string
49+}
50+
51+type StandaloneServiceReadiness struct {
52+ Type string
53+ Path string
54+ Contains string
55+ Timeout time.Duration
56+ TimeoutSet bool
57+}
58+
59+type StandaloneServiceCleanup struct {
60+ PreserveVolumes bool
61+ RemoveContainer bool
62+}
63+
64+func (s StandaloneService) WithDefaults() StandaloneService {
65+ if s.Readiness.Type == "" {
66+ s.Readiness.Type = StandaloneServiceReadinessNone
67+ }
68+ s.Cleanup = s.Cleanup.WithDefaults()
69+ return s
70+}
71+
72+func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
73+ return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
74+}
75+
76+func (s StandaloneService) Validate() error {
77+ s = s.WithDefaults()
78+ if strings.TrimSpace(s.Name) == "" {
79+ return fmt.Errorf("standalone service name is required")
80+ }
81+ if s.Enabled && strings.TrimSpace(s.Image) == "" {
82+ return fmt.Errorf("standalone service %q image is required when enabled", s.Name)
83+ }
84+ if s.EnvFile != "" && strings.TrimSpace(s.EnvFile) == "" {
85+ return fmt.Errorf("standalone service %q env_file must be non-empty when set", s.Name)
86+ }
87+ if err := validateStandaloneServicePorts(s); err != nil {
88+ return err
89+ }
90+ if err := validateStandaloneServiceVolumes(s); err != nil {
91+ return err
92+ }
93+ if err := validateStandaloneServiceSecrets(s); err != nil {
94+ return err
95+ }
96+ if err := validateStandaloneServiceReadiness(s); err != nil {
97+ return err
98+ }
99+ return nil
100+}
101+
102+func validateStandaloneServicePorts(s StandaloneService) error {
103+ seen := make(map[string]struct{}, len(s.Ports))
104+ for i, port := range s.Ports {
105+ name := strings.TrimSpace(port.Name)
106+ if name == "" {
107+ return fmt.Errorf("standalone service %q port %d name is required", s.Name, i)
108+ }
109+ if _, ok := seen[name]; ok {
110+ return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)
111+ }
112+ seen[name] = struct{}{}
113+ if port.Container <= 0 || port.Container > 65535 {
114+ return fmt.Errorf("standalone service %q port %q container port must be 1-65535", s.Name, name)
115+ }
116+ if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
117+ return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)
118+ }
119+ if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {
120+ return err
121+ }
122+ for _, cidr := range port.TrustedCIDRs {
123+ if strings.TrimSpace(cidr) == "" {
124+ return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", s.Name, name)
125+ }
126+ if _, _, err := net.ParseCIDR(cidr); err != nil {
127+ return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", s.Name, name, cidr, err)
128+ }
129+ }
130+ }
131+ return nil
132+}
133+
134+func validateStandaloneServicePublish(serviceName, portName, publish string) error {
135+ if publish == "" {
136+ return nil
137+ }
138+ host, port, err := net.SplitHostPort(publish)
139+ if err != nil {
140+ return fmt.Errorf("standalone service %q port %q publish address %q is invalid: %w", serviceName, portName, publish, err)
141+ }
142+ if strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" {
143+ return fmt.Errorf("standalone service %q port %q publish address %q must include host and port", serviceName, portName, publish)
144+ }
145+ portNumber, err := strconv.Atoi(port)
146+ if err != nil || portNumber < 1 || portNumber > 65535 {
147+ return fmt.Errorf("standalone service %q port %q publish address %q must include a valid port", serviceName, portName, publish)
148+ }
149+ return nil
150+}
151+
152+func validateStandaloneServiceVolumes(s StandaloneService) error {
153+ for i, volume := range s.Volumes {
154+ if strings.TrimSpace(volume.Target) == "" {
155+ return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
156+ }
157+ if !filepath.IsAbs(volume.Target) {
158+ return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
159+ }
160+ }
161+ return nil
162+}
163+
164+func validateStandaloneServiceSecrets(s StandaloneService) error {
165+ for i, secret := range s.Secrets {
166+ if strings.TrimSpace(secret.Name) == "" {
167+ return fmt.Errorf("standalone service %q secret %d name is required", s.Name, i)
168+ }
169+ if strings.TrimSpace(secret.Key) == "" {
170+ return fmt.Errorf("standalone service %q secret %d key is required", s.Name, i)
171+ }
172+ }
173+ return nil
174+}
175+
176+func validateStandaloneServiceReadiness(s StandaloneService) error {
177+ readinessType := s.Readiness.Type
178+ if readinessType == "" {
179+ readinessType = StandaloneServiceReadinessNone
180+ }
181+ if s.Readiness.TimeoutSet && s.Readiness.Timeout <= 0 {
182+ return fmt.Errorf("standalone service %q readiness timeout must be positive when set", s.Name)
183+ }
184+ switch readinessType {
185+ case StandaloneServiceReadinessNone, StandaloneServiceReadinessTCP:
186+ return nil
187+ case StandaloneServiceReadinessLog:
188+ if strings.TrimSpace(s.Readiness.Path) == "" {
189+ return fmt.Errorf("standalone service %q log readiness path is required", s.Name)
190+ }
191+ if strings.TrimSpace(s.Readiness.Contains) == "" {
192+ return fmt.Errorf("standalone service %q log readiness contains is required", s.Name)
193+ }
194+ return nil
195+ default:
196+ return fmt.Errorf("standalone service %q readiness type must be none, tcp, or log", s.Name)
197+ }
198+}
@@ -0,0 +1,198 @@
1+package domain
2+
3+import (
4+ "fmt"
5+ "net"
6+ "path/filepath"
7+ "strconv"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ StandaloneServiceReadinessNone = "none"
14+ StandaloneServiceReadinessTCP = "tcp"
15+ StandaloneServiceReadinessLog = "log"
16+)
17+
18+type StandaloneService struct {
19+ Name string
20+ Image string
21+ Enabled bool
22+ Env []string
23+ EnvFile string
24+ Secrets []StandaloneServiceSecretRef
25+ Readiness StandaloneServiceReadiness
26+ Cleanup StandaloneServiceCleanup
27+ Ports []StandaloneServicePort
28+ Volumes []StandaloneServiceVolume
29+}
30+
31+type StandaloneServicePort struct {
32+ Name string
33+ Container int
34+ Protocol NetworkProtocol
35+ Publish string
36+ Private bool
37+ TrustedCIDRs []string
38+}
39+
40+type StandaloneServiceVolume struct {
41+ Source string
42+ Target string
43+ ReadOnly bool
44+}
45+
46+type StandaloneServiceSecretRef struct {
47+ Name string
48+ Key string
49+}
50+
51+type StandaloneServiceReadiness struct {
52+ Type string
53+ Path string
54+ Contains string
55+ Timeout time.Duration
56+ TimeoutSet bool
57+}
58+
59+type StandaloneServiceCleanup struct {
60+ PreserveVolumes bool
61+ RemoveContainer bool
62+}
63+
64+func (s StandaloneService) WithDefaults() StandaloneService {
65+ if s.Readiness.Type == "" {
66+ s.Readiness.Type = StandaloneServiceReadinessNone
67+ }
68+ s.Cleanup = s.Cleanup.WithDefaults()
69+ return s
70+}
71+
72+func (c StandaloneServiceCleanup) WithDefaults() StandaloneServiceCleanup {
73+ return StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
74+}
75+
76+func (s StandaloneService) Validate() error {
77+ s = s.WithDefaults()
78+ if strings.TrimSpace(s.Name) == "" {
79+ return fmt.Errorf("standalone service name is required")
80+ }
81+ if s.Enabled && strings.TrimSpace(s.Image) == "" {
82+ return fmt.Errorf("standalone service %q image is required when enabled", s.Name)
83+ }
84+ if s.EnvFile != "" && strings.TrimSpace(s.EnvFile) == "" {
85+ return fmt.Errorf("standalone service %q env_file must be non-empty when set", s.Name)
86+ }
87+ if err := validateStandaloneServicePorts(s); err != nil {
88+ return err
89+ }
90+ if err := validateStandaloneServiceVolumes(s); err != nil {
91+ return err
92+ }
93+ if err := validateStandaloneServiceSecrets(s); err != nil {
94+ return err
95+ }
96+ if err := validateStandaloneServiceReadiness(s); err != nil {
97+ return err
98+ }
99+ return nil
100+}
101+
102+func validateStandaloneServicePorts(s StandaloneService) error {
103+ seen := make(map[string]struct{}, len(s.Ports))
104+ for i, port := range s.Ports {
105+ name := strings.TrimSpace(port.Name)
106+ if name == "" {
107+ return fmt.Errorf("standalone service %q port %d name is required", s.Name, i)
108+ }
109+ if _, ok := seen[name]; ok {
110+ return fmt.Errorf("standalone service %q duplicate port name %q", s.Name, name)
111+ }
112+ seen[name] = struct{}{}
113+ if port.Container <= 0 || port.Container > 65535 {
114+ return fmt.Errorf("standalone service %q port %q container port must be 1-65535", s.Name, name)
115+ }
116+ if port.Protocol != NetworkProtocolTCP && port.Protocol != NetworkProtocolUDP {
117+ return fmt.Errorf("standalone service %q port %q protocol must be tcp or udp", s.Name, name)
118+ }
119+ if err := validateStandaloneServicePublish(s.Name, name, port.Publish); err != nil {
120+ return err
121+ }
122+ for _, cidr := range port.TrustedCIDRs {
123+ if strings.TrimSpace(cidr) == "" {
124+ return fmt.Errorf("standalone service %q port %q trusted_cidrs must not contain empty values", s.Name, name)
125+ }
126+ if _, _, err := net.ParseCIDR(cidr); err != nil {
127+ return fmt.Errorf("standalone service %q port %q trusted_cidr %q is invalid: %w", s.Name, name, cidr, err)
128+ }
129+ }
130+ }
131+ return nil
132+}
133+
134+func validateStandaloneServicePublish(serviceName, portName, publish string) error {
135+ if publish == "" {
136+ return nil
137+ }
138+ host, port, err := net.SplitHostPort(publish)
139+ if err != nil {
140+ return fmt.Errorf("standalone service %q port %q publish address %q is invalid: %w", serviceName, portName, publish, err)
141+ }
142+ if strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" {
143+ return fmt.Errorf("standalone service %q port %q publish address %q must include host and port", serviceName, portName, publish)
144+ }
145+ portNumber, err := strconv.Atoi(port)
146+ if err != nil || portNumber < 1 || portNumber > 65535 {
147+ return fmt.Errorf("standalone service %q port %q publish address %q must include a valid port", serviceName, portName, publish)
148+ }
149+ return nil
150+}
151+
152+func validateStandaloneServiceVolumes(s StandaloneService) error {
153+ for i, volume := range s.Volumes {
154+ if strings.TrimSpace(volume.Target) == "" {
155+ return fmt.Errorf("standalone service %q volume %d target is required", s.Name, i)
156+ }
157+ if !filepath.IsAbs(volume.Target) {
158+ return fmt.Errorf("standalone service %q volume %d target %q must be an absolute container path", s.Name, i, volume.Target)
159+ }
160+ }
161+ return nil
162+}
163+
164+func validateStandaloneServiceSecrets(s StandaloneService) error {
165+ for i, secret := range s.Secrets {
166+ if strings.TrimSpace(secret.Name) == "" {
167+ return fmt.Errorf("standalone service %q secret %d name is required", s.Name, i)
168+ }
169+ if strings.TrimSpace(secret.Key) == "" {
170+ return fmt.Errorf("standalone service %q secret %d key is required", s.Name, i)
171+ }
172+ }
173+ return nil
174+}
175+
176+func validateStandaloneServiceReadiness(s StandaloneService) error {
177+ readinessType := s.Readiness.Type
178+ if readinessType == "" {
179+ readinessType = StandaloneServiceReadinessNone
180+ }
181+ if s.Readiness.TimeoutSet && s.Readiness.Timeout <= 0 {
182+ return fmt.Errorf("standalone service %q readiness timeout must be positive when set", s.Name)
183+ }
184+ switch readinessType {
185+ case StandaloneServiceReadinessNone, StandaloneServiceReadinessTCP:
186+ return nil
187+ case StandaloneServiceReadinessLog:
188+ if strings.TrimSpace(s.Readiness.Path) == "" {
189+ return fmt.Errorf("standalone service %q log readiness path is required", s.Name)
190+ }
191+ if strings.TrimSpace(s.Readiness.Contains) == "" {
192+ return fmt.Errorf("standalone service %q log readiness contains is required", s.Name)
193+ }
194+ return nil
195+ default:
196+ return fmt.Errorf("standalone service %q readiness type must be none, tcp, or log", s.Name)
197+ }
198+}
A internal/domain/service_test.go +90 -0
@@ -0,0 +1,90 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T) {
11+ svc := StandaloneService{Enabled: true, Image: "redis:7"}
12+ require.ErrorContains(t, svc.Validate(), "service name")
13+
14+ svc = StandaloneService{Enabled: true, Name: "cache"}
15+ require.ErrorContains(t, svc.Validate(), "image")
16+}
17+
18+func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) {
19+ svc := StandaloneService{Name: "cache", Enabled: false}
20+ require.NoError(t, svc.Validate())
21+}
22+
23+func TestStandaloneServiceValidatePorts(t *testing.T) {
24+ valid := StandaloneService{
25+ Name: "game",
26+ Image: "game:latest",
27+ Enabled: true,
28+ Ports: []StandaloneServicePort{
29+ {Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:38015"},
30+ {Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true},
31+ },
32+ }
33+ require.NoError(t, valid.Validate())
34+
35+ duplicate := valid
36+ duplicate.Ports = append(duplicate.Ports, StandaloneServicePort{Name: "game", Container: 28017, Protocol: NetworkProtocolUDP})
37+ require.ErrorContains(t, duplicate.Validate(), "duplicate port")
38+
39+ badProtocol := valid
40+ badProtocol.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocol("sctp")}}
41+ require.ErrorContains(t, badProtocol.Validate(), "protocol")
42+
43+ badPublish := valid
44+ badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}}
45+ require.ErrorContains(t, badPublish.Validate(), "valid port")
46+}
47+
48+func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
49+ svc := StandaloneService{Name: "cache", Image: "redis:7", Enabled: true}
50+ require.NoError(t, svc.Validate(), "volumes may be omitted")
51+
52+ svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}}
53+ require.ErrorContains(t, svc.Validate(), "absolute")
54+
55+ svc.Volumes = nil
56+ svc.EnvFile = " "
57+ require.ErrorContains(t, svc.Validate(), "env_file")
58+}
59+
60+func TestStandaloneServiceValidateReadiness(t *testing.T) {
61+ svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}
62+ for _, readinessType := range []string{"", "none", "tcp", "log"} {
63+ t.Run(readinessType, func(t *testing.T) {
64+ candidate := svc
65+ candidate.Readiness = StandaloneServiceReadiness{Type: readinessType, Path: "/tmp/server.log", Contains: "ready", Timeout: time.Minute}
66+ require.NoError(t, candidate.Validate())
67+ })
68+ }
69+
70+ svc.Readiness = StandaloneServiceReadiness{Type: "udp"}
71+ require.ErrorContains(t, svc.Validate(), "readiness")
72+
73+ for _, timeout := range []time.Duration{0, -time.Second} {
74+ svc.Readiness = StandaloneServiceReadiness{Type: "tcp", Timeout: timeout, TimeoutSet: true}
75+ require.ErrorContains(t, svc.Validate(), "readiness timeout")
76+ }
77+
78+ svc.Readiness = StandaloneServiceReadiness{Type: "log", Path: "/tmp/server.log"}
79+ require.ErrorContains(t, svc.Validate(), "contains")
80+
81+ svc.Readiness = StandaloneServiceReadiness{Type: "log", Contains: "ready"}
82+ require.ErrorContains(t, svc.Validate(), "path")
83+}
84+
85+func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) {
86+ svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}.WithDefaults()
87+ require.True(t, svc.Cleanup.PreserveVolumes)
88+ require.True(t, svc.Cleanup.RemoveContainer)
89+ require.NoError(t, svc.Validate())
90+}
@@ -0,0 +1,90 @@
1+package domain
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestStandaloneServiceValidateRequiresNameAndImageWhenEnabled(t *testing.T) {
11+ svc := StandaloneService{Enabled: true, Image: "redis:7"}
12+ require.ErrorContains(t, svc.Validate(), "service name")
13+
14+ svc = StandaloneService{Enabled: true, Name: "cache"}
15+ require.ErrorContains(t, svc.Validate(), "image")
16+}
17+
18+func TestStandaloneServiceValidateAllowsDisabledServiceWithoutImage(t *testing.T) {
19+ svc := StandaloneService{Name: "cache", Enabled: false}
20+ require.NoError(t, svc.Validate())
21+}
22+
23+func TestStandaloneServiceValidatePorts(t *testing.T) {
24+ valid := StandaloneService{
25+ Name: "game",
26+ Image: "game:latest",
27+ Enabled: true,
28+ Ports: []StandaloneServicePort{
29+ {Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:38015"},
30+ {Name: "rcon", Container: 28016, Protocol: NetworkProtocolTCP, Publish: "127.0.0.1:38016", Private: true},
31+ },
32+ }
33+ require.NoError(t, valid.Validate())
34+
35+ duplicate := valid
36+ duplicate.Ports = append(duplicate.Ports, StandaloneServicePort{Name: "game", Container: 28017, Protocol: NetworkProtocolUDP})
37+ require.ErrorContains(t, duplicate.Validate(), "duplicate port")
38+
39+ badProtocol := valid
40+ badProtocol.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocol("sctp")}}
41+ require.ErrorContains(t, badProtocol.Validate(), "protocol")
42+
43+ badPublish := valid
44+ badPublish.Ports = []StandaloneServicePort{{Name: "game", Container: 28015, Protocol: NetworkProtocolUDP, Publish: "127.0.0.1:not-a-port"}}
45+ require.ErrorContains(t, badPublish.Validate(), "valid port")
46+}
47+
48+func TestStandaloneServiceValidateVolumesAndEnvFile(t *testing.T) {
49+ svc := StandaloneService{Name: "cache", Image: "redis:7", Enabled: true}
50+ require.NoError(t, svc.Validate(), "volumes may be omitted")
51+
52+ svc.Volumes = []StandaloneServiceVolume{{Source: "cache-data", Target: "var/lib/redis"}}
53+ require.ErrorContains(t, svc.Validate(), "absolute")
54+
55+ svc.Volumes = nil
56+ svc.EnvFile = " "
57+ require.ErrorContains(t, svc.Validate(), "env_file")
58+}
59+
60+func TestStandaloneServiceValidateReadiness(t *testing.T) {
61+ svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}
62+ for _, readinessType := range []string{"", "none", "tcp", "log"} {
63+ t.Run(readinessType, func(t *testing.T) {
64+ candidate := svc
65+ candidate.Readiness = StandaloneServiceReadiness{Type: readinessType, Path: "/tmp/server.log", Contains: "ready", Timeout: time.Minute}
66+ require.NoError(t, candidate.Validate())
67+ })
68+ }
69+
70+ svc.Readiness = StandaloneServiceReadiness{Type: "udp"}
71+ require.ErrorContains(t, svc.Validate(), "readiness")
72+
73+ for _, timeout := range []time.Duration{0, -time.Second} {
74+ svc.Readiness = StandaloneServiceReadiness{Type: "tcp", Timeout: timeout, TimeoutSet: true}
75+ require.ErrorContains(t, svc.Validate(), "readiness timeout")
76+ }
77+
78+ svc.Readiness = StandaloneServiceReadiness{Type: "log", Path: "/tmp/server.log"}
79+ require.ErrorContains(t, svc.Validate(), "contains")
80+
81+ svc.Readiness = StandaloneServiceReadiness{Type: "log", Contains: "ready"}
82+ require.ErrorContains(t, svc.Validate(), "path")
83+}
84+
85+func TestStandaloneServiceWithDefaultsCleanup(t *testing.T) {
86+ svc := StandaloneService{Name: "game", Image: "game:latest", Enabled: true}.WithDefaults()
87+ require.True(t, svc.Cleanup.PreserveVolumes)
88+ require.True(t, svc.Cleanup.RemoveContainer)
89+ require.NoError(t, svc.Validate())
90+}
A internal/usecase/services/config.go +186 -0
@@ -0,0 +1,186 @@
1+package services
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+type Config struct {
12+ Name string `mapstructure:"name"`
13+ Image string `mapstructure:"image"`
14+ Enabled bool `mapstructure:"enabled"`
15+ Env []string `mapstructure:"env"`
16+ EnvFile string `mapstructure:"env_file"`
17+ Secrets []SecretRefConfig `mapstructure:"secrets"`
18+ Readiness ReadinessConfig `mapstructure:"readiness"`
19+ Cleanup CleanupConfig `mapstructure:"cleanup"`
20+ Ports []PortConfig `mapstructure:"ports"`
21+ Volumes []VolumeConfig `mapstructure:"volumes"`
22+}
23+
24+type PortConfig struct {
25+ Name string `mapstructure:"name"`
26+ Container int `mapstructure:"container"`
27+ Protocol domain.NetworkProtocol `mapstructure:"protocol"`
28+ Publish string `mapstructure:"publish"`
29+ Private bool `mapstructure:"private"`
30+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
31+}
32+
33+type VolumeConfig struct {
34+ Source string `mapstructure:"source"`
35+ Target string `mapstructure:"target"`
36+ ReadOnly bool `mapstructure:"read_only"`
37+}
38+
39+type ResolvedVolumeMount struct {
40+ Source string
41+ Target string
42+ ReadOnly bool
43+}
44+
45+type SecretRefConfig struct {
46+ Name string `mapstructure:"name"`
47+ Key string `mapstructure:"key"`
48+}
49+
50+type ReadinessConfig struct {
51+ Type string `mapstructure:"type"`
52+ Path string `mapstructure:"path"`
53+ Contains string `mapstructure:"contains"`
54+ Timeout string `mapstructure:"timeout"`
55+}
56+
57+type CleanupConfig struct {
58+ PreserveVolumes *bool `mapstructure:"preserve_volumes"`
59+ RemoveContainer *bool `mapstructure:"remove_container"`
60+}
61+
62+func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
63+ services := make([]domain.StandaloneService, 0, len(configs))
64+ seenNames := make(map[string]struct{}, len(configs))
65+ for i, cfg := range configs {
66+ svc, err := cfg.ToDomain()
67+ if err != nil {
68+ return nil, fmt.Errorf("service config %d: %w", i, err)
69+ }
70+ name := strings.TrimSpace(svc.Name)
71+ if _, ok := seenNames[name]; ok {
72+ return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name)
73+ }
74+ seenNames[name] = struct{}{}
75+ services = append(services, svc)
76+ }
77+ return services, nil
78+}
79+
80+func (c Config) ToDomain() (domain.StandaloneService, error) {
81+ readiness, err := c.readinessToDomain()
82+ if err != nil {
83+ return domain.StandaloneService{}, err
84+ }
85+ svc := domain.StandaloneService{
86+ Name: c.Name,
87+ Image: c.Image,
88+ Enabled: c.Enabled,
89+ Env: append([]string(nil), c.Env...),
90+ EnvFile: c.EnvFile,
91+ Secrets: secretRefsToDomain(c.Secrets),
92+ Readiness: readiness,
93+ Cleanup: c.Cleanup.toDomain(),
94+ Ports: portsToDomain(c.Ports),
95+ Volumes: volumesToDomain(c.Volumes),
96+ }
97+ if err := svc.Validate(); err != nil {
98+ return domain.StandaloneService{}, err
99+ }
100+ return svc, nil
101+}
102+
103+func (c Config) readinessToDomain() (domain.StandaloneServiceReadiness, error) {
104+ readinessType := c.Readiness.Type
105+ if readinessType == "" {
106+ readinessType = domain.StandaloneServiceReadinessNone
107+ }
108+ readiness := domain.StandaloneServiceReadiness{Type: readinessType, Path: c.Readiness.Path, Contains: c.Readiness.Contains}
109+ if c.Readiness.Timeout != "" {
110+ timeout, err := time.ParseDuration(c.Readiness.Timeout)
111+ if err != nil {
112+ return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout %q is invalid: %w", c.Readiness.Timeout, err)
113+ }
114+ if timeout <= 0 {
115+ return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout must be positive when set")
116+ }
117+ readiness.Timeout = timeout
118+ readiness.TimeoutSet = true
119+ }
120+ return readiness, nil
121+}
122+
123+func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup {
124+ cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
125+ if c.PreserveVolumes != nil {
126+ cleanup.PreserveVolumes = *c.PreserveVolumes
127+ }
128+ if c.RemoveContainer != nil {
129+ cleanup.RemoveContainer = *c.RemoveContainer
130+ }
131+ return cleanup
132+}
133+
134+func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
135+ ports := make([]domain.StandaloneServicePort, 0, len(configs))
136+ for _, cfg := range configs {
137+ ports = append(ports, domain.StandaloneServicePort{
138+ Name: cfg.Name,
139+ Container: cfg.Container,
140+ Protocol: cfg.Protocol,
141+ Publish: cfg.Publish,
142+ Private: cfg.Private,
143+ TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
144+ })
145+ }
146+ return ports
147+}
148+
149+func volumesToDomain(configs []VolumeConfig) []domain.StandaloneServiceVolume {
150+ volumes := make([]domain.StandaloneServiceVolume, 0, len(configs))
151+ for _, cfg := range configs {
152+ volumes = append(volumes, domain.StandaloneServiceVolume{Source: cfg.Source, Target: cfg.Target, ReadOnly: cfg.ReadOnly})
153+ }
154+ return volumes
155+}
156+
157+func secretRefsToDomain(configs []SecretRefConfig) []domain.StandaloneServiceSecretRef {
158+ secrets := make([]domain.StandaloneServiceSecretRef, 0, len(configs))
159+ for _, cfg := range configs {
160+ secrets = append(secrets, domain.StandaloneServiceSecretRef{Name: cfg.Name, Key: cfg.Key})
161+ }
162+ return secrets
163+}
164+
165+func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.StandaloneServiceVolume, imageVolumePaths []string) []ResolvedVolumeMount {
166+ if len(explicitVolumes) > 0 {
167+ mounts := make([]ResolvedVolumeMount, 0, len(explicitVolumes))
168+ for _, volume := range explicitVolumes {
169+ mounts = append(mounts, ResolvedVolumeMount{Source: volume.Source, Target: volume.Target, ReadOnly: volume.ReadOnly})
170+ }
171+ return mounts
172+ }
173+
174+ mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths))
175+ for _, path := range imageVolumePaths {
176+ mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path})
177+ }
178+ return mounts
179+}
180+
181+func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string {
182+ return fmt.Sprintf("%s-%s-%s",
183+ prefix,
184+ strings.ReplaceAll(serviceName, ".", "-"),
185+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
186+}
@@ -0,0 +1,186 @@
1+package services
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+type Config struct {
12+ Name string `mapstructure:"name"`
13+ Image string `mapstructure:"image"`
14+ Enabled bool `mapstructure:"enabled"`
15+ Env []string `mapstructure:"env"`
16+ EnvFile string `mapstructure:"env_file"`
17+ Secrets []SecretRefConfig `mapstructure:"secrets"`
18+ Readiness ReadinessConfig `mapstructure:"readiness"`
19+ Cleanup CleanupConfig `mapstructure:"cleanup"`
20+ Ports []PortConfig `mapstructure:"ports"`
21+ Volumes []VolumeConfig `mapstructure:"volumes"`
22+}
23+
24+type PortConfig struct {
25+ Name string `mapstructure:"name"`
26+ Container int `mapstructure:"container"`
27+ Protocol domain.NetworkProtocol `mapstructure:"protocol"`
28+ Publish string `mapstructure:"publish"`
29+ Private bool `mapstructure:"private"`
30+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
31+}
32+
33+type VolumeConfig struct {
34+ Source string `mapstructure:"source"`
35+ Target string `mapstructure:"target"`
36+ ReadOnly bool `mapstructure:"read_only"`
37+}
38+
39+type ResolvedVolumeMount struct {
40+ Source string
41+ Target string
42+ ReadOnly bool
43+}
44+
45+type SecretRefConfig struct {
46+ Name string `mapstructure:"name"`
47+ Key string `mapstructure:"key"`
48+}
49+
50+type ReadinessConfig struct {
51+ Type string `mapstructure:"type"`
52+ Path string `mapstructure:"path"`
53+ Contains string `mapstructure:"contains"`
54+ Timeout string `mapstructure:"timeout"`
55+}
56+
57+type CleanupConfig struct {
58+ PreserveVolumes *bool `mapstructure:"preserve_volumes"`
59+ RemoveContainer *bool `mapstructure:"remove_container"`
60+}
61+
62+func ToDomain(configs []Config) ([]domain.StandaloneService, error) {
63+ services := make([]domain.StandaloneService, 0, len(configs))
64+ seenNames := make(map[string]struct{}, len(configs))
65+ for i, cfg := range configs {
66+ svc, err := cfg.ToDomain()
67+ if err != nil {
68+ return nil, fmt.Errorf("service config %d: %w", i, err)
69+ }
70+ name := strings.TrimSpace(svc.Name)
71+ if _, ok := seenNames[name]; ok {
72+ return nil, fmt.Errorf("service config %d: duplicate service name %q", i, name)
73+ }
74+ seenNames[name] = struct{}{}
75+ services = append(services, svc)
76+ }
77+ return services, nil
78+}
79+
80+func (c Config) ToDomain() (domain.StandaloneService, error) {
81+ readiness, err := c.readinessToDomain()
82+ if err != nil {
83+ return domain.StandaloneService{}, err
84+ }
85+ svc := domain.StandaloneService{
86+ Name: c.Name,
87+ Image: c.Image,
88+ Enabled: c.Enabled,
89+ Env: append([]string(nil), c.Env...),
90+ EnvFile: c.EnvFile,
91+ Secrets: secretRefsToDomain(c.Secrets),
92+ Readiness: readiness,
93+ Cleanup: c.Cleanup.toDomain(),
94+ Ports: portsToDomain(c.Ports),
95+ Volumes: volumesToDomain(c.Volumes),
96+ }
97+ if err := svc.Validate(); err != nil {
98+ return domain.StandaloneService{}, err
99+ }
100+ return svc, nil
101+}
102+
103+func (c Config) readinessToDomain() (domain.StandaloneServiceReadiness, error) {
104+ readinessType := c.Readiness.Type
105+ if readinessType == "" {
106+ readinessType = domain.StandaloneServiceReadinessNone
107+ }
108+ readiness := domain.StandaloneServiceReadiness{Type: readinessType, Path: c.Readiness.Path, Contains: c.Readiness.Contains}
109+ if c.Readiness.Timeout != "" {
110+ timeout, err := time.ParseDuration(c.Readiness.Timeout)
111+ if err != nil {
112+ return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout %q is invalid: %w", c.Readiness.Timeout, err)
113+ }
114+ if timeout <= 0 {
115+ return domain.StandaloneServiceReadiness{}, fmt.Errorf("readiness timeout must be positive when set")
116+ }
117+ readiness.Timeout = timeout
118+ readiness.TimeoutSet = true
119+ }
120+ return readiness, nil
121+}
122+
123+func (c CleanupConfig) toDomain() domain.StandaloneServiceCleanup {
124+ cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
125+ if c.PreserveVolumes != nil {
126+ cleanup.PreserveVolumes = *c.PreserveVolumes
127+ }
128+ if c.RemoveContainer != nil {
129+ cleanup.RemoveContainer = *c.RemoveContainer
130+ }
131+ return cleanup
132+}
133+
134+func portsToDomain(configs []PortConfig) []domain.StandaloneServicePort {
135+ ports := make([]domain.StandaloneServicePort, 0, len(configs))
136+ for _, cfg := range configs {
137+ ports = append(ports, domain.StandaloneServicePort{
138+ Name: cfg.Name,
139+ Container: cfg.Container,
140+ Protocol: cfg.Protocol,
141+ Publish: cfg.Publish,
142+ Private: cfg.Private,
143+ TrustedCIDRs: append([]string(nil), cfg.TrustedCIDRs...),
144+ })
145+ }
146+ return ports
147+}
148+
149+func volumesToDomain(configs []VolumeConfig) []domain.StandaloneServiceVolume {
150+ volumes := make([]domain.StandaloneServiceVolume, 0, len(configs))
151+ for _, cfg := range configs {
152+ volumes = append(volumes, domain.StandaloneServiceVolume{Source: cfg.Source, Target: cfg.Target, ReadOnly: cfg.ReadOnly})
153+ }
154+ return volumes
155+}
156+
157+func secretRefsToDomain(configs []SecretRefConfig) []domain.StandaloneServiceSecretRef {
158+ secrets := make([]domain.StandaloneServiceSecretRef, 0, len(configs))
159+ for _, cfg := range configs {
160+ secrets = append(secrets, domain.StandaloneServiceSecretRef{Name: cfg.Name, Key: cfg.Key})
161+ }
162+ return secrets
163+}
164+
165+func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.StandaloneServiceVolume, imageVolumePaths []string) []ResolvedVolumeMount {
166+ if len(explicitVolumes) > 0 {
167+ mounts := make([]ResolvedVolumeMount, 0, len(explicitVolumes))
168+ for _, volume := range explicitVolumes {
169+ mounts = append(mounts, ResolvedVolumeMount{Source: volume.Source, Target: volume.Target, ReadOnly: volume.ReadOnly})
170+ }
171+ return mounts
172+ }
173+
174+ mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths))
175+ for _, path := range imageVolumePaths {
176+ mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path})
177+ }
178+ return mounts
179+}
180+
181+func ManagedServiceVolumeName(prefix, serviceName, volumePath string) string {
182+ return fmt.Sprintf("%s-%s-%s",
183+ prefix,
184+ strings.ReplaceAll(serviceName, ".", "-"),
185+ strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
186+}
A internal/usecase/services/config_test.go +76 -0
@@ -0,0 +1,76 @@
1+package services
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestToDomainRejectsDuplicateServiceNames(t *testing.T) {
13+ configs := []Config{
14+ {Name: "cache", Image: "redis:7", Enabled: true},
15+ {Name: "cache", Image: "redis:7", Enabled: true},
16+ }
17+
18+ _, err := ToDomain(configs)
19+
20+ require.ErrorContains(t, err, "duplicate service name")
21+}
22+
23+func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
24+ for _, timeout := range []string{"0s", "-1s"} {
25+ t.Run(timeout, func(t *testing.T) {
26+ cfg := Config{
27+ Name: "cache",
28+ Image: "redis:7",
29+ Enabled: true,
30+ Readiness: ReadinessConfig{
31+ Type: domain.StandaloneServiceReadinessTCP,
32+ Timeout: timeout,
33+ },
34+ }
35+
36+ _, err := cfg.ToDomain()
37+
38+ require.ErrorContains(t, err, "readiness timeout")
39+ require.ErrorContains(t, err, "positive")
40+ })
41+ }
42+}
43+
44+func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) {
45+ explicit := []domain.StandaloneServiceVolume{
46+ {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
47+ {Source: "/srv/config", Target: "/config", ReadOnly: true},
48+ }
49+
50+ mounts := ResolveVolumeMounts("gordon", "rust.example.com", explicit, []string{"/ignored"})
51+
52+ assert.Equal(t, []ResolvedVolumeMount{
53+ {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
54+ {Source: "/srv/config", Target: "/config", ReadOnly: true},
55+ }, mounts)
56+}
57+
58+func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *testing.T) {
59+ mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"})
60+
61+ assert.Equal(t, []ResolvedVolumeMount{
62+ {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust"},
63+ {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data"},
64+ }, mounts)
65+}
66+
67+func TestResolveVolumeMountsReturnsEmptyForStatelessImage(t *testing.T) {
68+ mounts := ResolveVolumeMounts("gordon", "stateless.example.com", nil, nil)
69+
70+ assert.Empty(t, mounts)
71+}
72+
73+func TestManagedServiceVolumeNameIsDeterministicAndGordonCompatible(t *testing.T) {
74+ assert.Equal(t, "gordon-rust-example-com-var-lib-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/var/lib/data"))
75+ assert.Equal(t, "gordon-rust-example-com-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/data"))
76+}
@@ -0,0 +1,76 @@
1+package services
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestToDomainRejectsDuplicateServiceNames(t *testing.T) {
13+ configs := []Config{
14+ {Name: "cache", Image: "redis:7", Enabled: true},
15+ {Name: "cache", Image: "redis:7", Enabled: true},
16+ }
17+
18+ _, err := ToDomain(configs)
19+
20+ require.ErrorContains(t, err, "duplicate service name")
21+}
22+
23+func TestConfigToDomainRejectsNonPositiveReadinessTimeout(t *testing.T) {
24+ for _, timeout := range []string{"0s", "-1s"} {
25+ t.Run(timeout, func(t *testing.T) {
26+ cfg := Config{
27+ Name: "cache",
28+ Image: "redis:7",
29+ Enabled: true,
30+ Readiness: ReadinessConfig{
31+ Type: domain.StandaloneServiceReadinessTCP,
32+ Timeout: timeout,
33+ },
34+ }
35+
36+ _, err := cfg.ToDomain()
37+
38+ require.ErrorContains(t, err, "readiness timeout")
39+ require.ErrorContains(t, err, "positive")
40+ })
41+ }
42+}
43+
44+func TestResolveVolumeMountsUsesExplicitVolumesAsIs(t *testing.T) {
45+ explicit := []domain.StandaloneServiceVolume{
46+ {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
47+ {Source: "/srv/config", Target: "/config", ReadOnly: true},
48+ }
49+
50+ mounts := ResolveVolumeMounts("gordon", "rust.example.com", explicit, []string{"/ignored"})
51+
52+ assert.Equal(t, []ResolvedVolumeMount{
53+ {Source: "rust-data", Target: "/steamcmd/rust", ReadOnly: false},
54+ {Source: "/srv/config", Target: "/config", ReadOnly: true},
55+ }, mounts)
56+}
57+
58+func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *testing.T) {
59+ mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"})
60+
61+ assert.Equal(t, []ResolvedVolumeMount{
62+ {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust"},
63+ {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data"},
64+ }, mounts)
65+}
66+
67+func TestResolveVolumeMountsReturnsEmptyForStatelessImage(t *testing.T) {
68+ mounts := ResolveVolumeMounts("gordon", "stateless.example.com", nil, nil)
69+
70+ assert.Empty(t, mounts)
71+}
72+
73+func TestManagedServiceVolumeNameIsDeterministicAndGordonCompatible(t *testing.T) {
74+ assert.Equal(t, "gordon-rust-example-com-var-lib-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/var/lib/data"))
75+ assert.Equal(t, "gordon-rust-example-com-data", ManagedServiceVolumeName("gordon", "rust.example.com", "/data"))
76+}

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