detached 8d2c54f

feat(services): reconcile standalone service containers

commit
8d2c54f8bb6f5237e636747839f7b75aa46bbcb4
author
brice · · 2026-06-30 15:28 UTC
parent
d9c6a56
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1278 -56
M internal/adapters/out/docker/runtime.go +11 -0
@@ -160,6 +160,17 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
160160 },
161161 }
162162 }
163+ for _, publish := range config.PortPublishes {
164+ if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
165+ return nil, fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
166+ }
167+ containerPort := nat.Port(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
168+ exposedPorts[containerPort] = struct{}{}
169+ portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{
170+ HostIP: publish.HostIP,
171+ HostPort: strconv.Itoa(publish.HostPort),
172+ })
173+ }
163174
164175 // Convert volumes to Docker format
165176 var binds []string
@@ -160,6 +160,17 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
160 },160 },
161 }161 }
162 }162 }
163+ for _, publish := range config.PortPublishes {
164+ if publish.Protocol != domain.NetworkProtocolTCP && publish.Protocol != domain.NetworkProtocolUDP {
165+ return nil, fmt.Errorf("unsupported container port publish protocol %q", publish.Protocol)
166+ }
167+ containerPort := nat.Port(fmt.Sprintf("%d/%s", publish.ContainerPort, publish.Protocol))
168+ exposedPorts[containerPort] = struct{}{}
169+ portBindings[containerPort] = append(portBindings[containerPort], nat.PortBinding{
170+ HostIP: publish.HostIP,
171+ HostPort: strconv.Itoa(publish.HostPort),
172+ })
173+ }
163 174
164 // Convert volumes to Docker format175 // Convert volumes to Docker format
165 var binds []string176 var binds []string
M internal/adapters/out/docker/runtime_create_test.go +74 -11
@@ -15,7 +15,78 @@ import (
1515 "github.com/bnema/gordon/internal/domain"
1616 )
1717
18+func TestRuntime_CreateContainerPublishesExplicitUDPPort(t *testing.T) {
19+ createBody := createTestContainer(t, &domain.ContainerConfig{
20+ Image: "rust:latest",
21+ Name: "gordon-rust",
22+ PortPublishes: []domain.ContainerPortPublish{
23+ {
24+ HostIP: "127.0.0.1",
25+ HostPort: 38015,
26+ ContainerPort: 28015,
27+ Protocol: domain.NetworkProtocolUDP,
28+ },
29+ },
30+ })
31+
32+ exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
33+ require.True(t, ok)
34+ assert.Contains(t, exposedPorts, "28015/udp")
35+
36+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
37+ require.True(t, ok)
38+ portBindings, ok := hostConfig["PortBindings"].(map[string]any)
39+ require.True(t, ok)
40+ bindings, ok := portBindings["28015/udp"].([]any)
41+ require.True(t, ok)
42+ require.Len(t, bindings, 1)
43+ binding, ok := bindings[0].(map[string]any)
44+ require.True(t, ok)
45+ assert.Equal(t, "127.0.0.1", binding["HostIp"])
46+ assert.Equal(t, "38015", binding["HostPort"])
47+}
48+
49+func TestRuntime_CreateContainerKeepsLegacyPortsRandomLoopbackTCP(t *testing.T) {
50+ createBody := createTestContainer(t, &domain.ContainerConfig{
51+ Image: "nginx:latest",
52+ Name: "gordon-app.example.com",
53+ Ports: []int{8080},
54+ })
55+
56+ exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
57+ require.True(t, ok)
58+ assert.Contains(t, exposedPorts, "8080/tcp")
59+
60+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
61+ require.True(t, ok)
62+ portBindings, ok := hostConfig["PortBindings"].(map[string]any)
63+ require.True(t, ok)
64+ bindings, ok := portBindings["8080/tcp"].([]any)
65+ require.True(t, ok)
66+ require.Len(t, bindings, 1)
67+ binding, ok := bindings[0].(map[string]any)
68+ require.True(t, ok)
69+ assert.Equal(t, "127.0.0.1", binding["HostIp"])
70+ assert.Equal(t, "0", binding["HostPort"])
71+}
72+
1873 func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
74+ createBody := createTestContainer(t, &domain.ContainerConfig{
75+ Image: "nginx:latest",
76+ Name: "gordon-app.example.com",
77+ RestartPolicy: domain.RestartPolicyAlways,
78+ })
79+
80+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
81+ require.True(t, ok)
82+ restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
83+ require.True(t, ok)
84+ assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
85+}
86+
87+func createTestContainer(t *testing.T, config *domain.ContainerConfig) map[string]any {
88+ t.Helper()
89+
1990 var createBody map[string]any
2091 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2192 switch {
@@ -45,18 +116,10 @@ func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
45116 require.NoError(t, err)
46117
47118 runtime := NewRuntimeWithClient(cli)
48- created, err := runtime.CreateContainer(context.Background(), &domain.ContainerConfig{
49- Image: "nginx:latest",
50- Name: "gordon-app.example.com",
51- RestartPolicy: domain.RestartPolicyAlways,
52- })
119+ created, err := runtime.CreateContainer(context.Background(), config)
53120 require.NoError(t, err)
54121 assert.Equal(t, "abc123", created.ID)
55-
56122 require.NotNil(t, createBody)
57- hostConfig, ok := createBody["HostConfig"].(map[string]any)
58- require.True(t, ok)
59- restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
60- require.True(t, ok)
61- assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
123+
124+ return createBody
62125 }
@@ -15,7 +15,78 @@ import (
15 "github.com/bnema/gordon/internal/domain"15 "github.com/bnema/gordon/internal/domain"
16 )16 )
17 17
18+func TestRuntime_CreateContainerPublishesExplicitUDPPort(t *testing.T) {
19+ createBody := createTestContainer(t, &domain.ContainerConfig{
20+ Image: "rust:latest",
21+ Name: "gordon-rust",
22+ PortPublishes: []domain.ContainerPortPublish{
23+ {
24+ HostIP: "127.0.0.1",
25+ HostPort: 38015,
26+ ContainerPort: 28015,
27+ Protocol: domain.NetworkProtocolUDP,
28+ },
29+ },
30+ })
31+
32+ exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
33+ require.True(t, ok)
34+ assert.Contains(t, exposedPorts, "28015/udp")
35+
36+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
37+ require.True(t, ok)
38+ portBindings, ok := hostConfig["PortBindings"].(map[string]any)
39+ require.True(t, ok)
40+ bindings, ok := portBindings["28015/udp"].([]any)
41+ require.True(t, ok)
42+ require.Len(t, bindings, 1)
43+ binding, ok := bindings[0].(map[string]any)
44+ require.True(t, ok)
45+ assert.Equal(t, "127.0.0.1", binding["HostIp"])
46+ assert.Equal(t, "38015", binding["HostPort"])
47+}
48+
49+func TestRuntime_CreateContainerKeepsLegacyPortsRandomLoopbackTCP(t *testing.T) {
50+ createBody := createTestContainer(t, &domain.ContainerConfig{
51+ Image: "nginx:latest",
52+ Name: "gordon-app.example.com",
53+ Ports: []int{8080},
54+ })
55+
56+ exposedPorts, ok := createBody["ExposedPorts"].(map[string]any)
57+ require.True(t, ok)
58+ assert.Contains(t, exposedPorts, "8080/tcp")
59+
60+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
61+ require.True(t, ok)
62+ portBindings, ok := hostConfig["PortBindings"].(map[string]any)
63+ require.True(t, ok)
64+ bindings, ok := portBindings["8080/tcp"].([]any)
65+ require.True(t, ok)
66+ require.Len(t, bindings, 1)
67+ binding, ok := bindings[0].(map[string]any)
68+ require.True(t, ok)
69+ assert.Equal(t, "127.0.0.1", binding["HostIp"])
70+ assert.Equal(t, "0", binding["HostPort"])
71+}
72+
18 func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {73 func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
74+ createBody := createTestContainer(t, &domain.ContainerConfig{
75+ Image: "nginx:latest",
76+ Name: "gordon-app.example.com",
77+ RestartPolicy: domain.RestartPolicyAlways,
78+ })
79+
80+ hostConfig, ok := createBody["HostConfig"].(map[string]any)
81+ require.True(t, ok)
82+ restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
83+ require.True(t, ok)
84+ assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
85+}
86+
87+func createTestContainer(t *testing.T, config *domain.ContainerConfig) map[string]any {
88+ t.Helper()
89+
19 var createBody map[string]any90 var createBody map[string]any
20 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {91 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 switch {92 switch {
@@ -45,18 +116,10 @@ func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
45 require.NoError(t, err)116 require.NoError(t, err)
46 117
47 runtime := NewRuntimeWithClient(cli)118 runtime := NewRuntimeWithClient(cli)
48- created, err := runtime.CreateContainer(context.Background(), &domain.ContainerConfig{119+ created, err := runtime.CreateContainer(context.Background(), config)
49- Image: "nginx:latest",
50- Name: "gordon-app.example.com",
51- RestartPolicy: domain.RestartPolicyAlways,
52- })
53 require.NoError(t, err)120 require.NoError(t, err)
54 assert.Equal(t, "abc123", created.ID)121 assert.Equal(t, "abc123", created.ID)
55-
56 require.NotNil(t, createBody)122 require.NotNil(t, createBody)
57- hostConfig, ok := createBody["HostConfig"].(map[string]any)123+
58- require.True(t, ok)124+ return createBody
59- restartPolicy, ok := hostConfig["RestartPolicy"].(map[string]any)
60- require.True(t, ok)
61- assert.Equal(t, domain.RestartPolicyAlways, restartPolicy["Name"])
62 }125 }
M internal/app/run.go +91 -42
@@ -263,48 +263,50 @@ type Config struct {
263263
264264 // services holds all the services used by the application.
265265 type services struct {
266- runtime *docker.Runtime
267- eventBus *eventbus.InMemory
268- blobStorage *filesystem.BlobStorage
269- manifestStorage *filesystem.ManifestStorage
270- backupStorage *filesystem.BackupStorage
271- volumeBackupStore out.VolumeBackupStorage
272- volumeBackupCfg domain.VolumeBackupConfig
273- envLoader out.EnvLoader
274- logWriter *logwriter.LogWriter
275- tokenStore out.TokenStore
276- configSvc *config.Service
277- secretSvc *secretsSvc.Service
278- containerSvc *container.Service
279- backupSvc *backup.Service
280- volumeBackupSvc *backup.VolumeService
281- registrySvc *registrySvc.Service
282- healthSvc *health.Service
283- logSvc *logs.Service
284- imageSvc *images.Service
285- volumeSvc *volumesSvc.Service
286- proxySvc *proxy.Service
287- authSvc *auth.Service
288- authHandler *authhandler.Handler
289- adminHandler *admin.Handler
290- httpProxyHandler http.Handler
291- httpsProxyHandler http.Handler
292- internalRegUser string
293- internalRegPass string
294- previewStore *filesystem.PreviewStore
295- previewService *preview.Service
296- envDir string
297- maxBlobChunkSize int64
298- maxBlobSize int64
299- caAdapter *pkiadapter.CA
300- pkiSvc *pkiusecase.Service
301- reloadCoordinator *reloadCoordinator
302- publicTLSSvc in.PublicTLSService
303- publicTLSRuntime publicTLSRuntime
304- trafficManager *trafficadapter.Manager
305- tlsHTTPEntryPoints map[string]struct{}
306- smartHTTPEntryPoints map[string]struct{}
307- registryHandler interface {
266+ runtime *docker.Runtime
267+ eventBus *eventbus.InMemory
268+ blobStorage *filesystem.BlobStorage
269+ manifestStorage *filesystem.ManifestStorage
270+ backupStorage *filesystem.BackupStorage
271+ volumeBackupStore out.VolumeBackupStorage
272+ volumeBackupCfg domain.VolumeBackupConfig
273+ envLoader out.EnvLoader
274+ logWriter *logwriter.LogWriter
275+ tokenStore out.TokenStore
276+ configSvc *config.Service
277+ secretSvc *secretsSvc.Service
278+ containerSvc *container.Service
279+ backupSvc *backup.Service
280+ volumeBackupSvc *backup.VolumeService
281+ registrySvc *registrySvc.Service
282+ healthSvc *health.Service
283+ logSvc *logs.Service
284+ imageSvc *images.Service
285+ volumeSvc *volumesSvc.Service
286+ proxySvc *proxy.Service
287+ standaloneServiceSvc in.StandaloneServiceService
288+ serviceSecretProvider out.SecretProvider
289+ authSvc *auth.Service
290+ authHandler *authhandler.Handler
291+ adminHandler *admin.Handler
292+ httpProxyHandler http.Handler
293+ httpsProxyHandler http.Handler
294+ internalRegUser string
295+ internalRegPass string
296+ previewStore *filesystem.PreviewStore
297+ previewService *preview.Service
298+ envDir string
299+ maxBlobChunkSize int64
300+ maxBlobSize int64
301+ caAdapter *pkiadapter.CA
302+ pkiSvc *pkiusecase.Service
303+ reloadCoordinator *reloadCoordinator
304+ publicTLSSvc in.PublicTLSService
305+ publicTLSRuntime publicTLSRuntime
306+ trafficManager *trafficadapter.Manager
307+ tlsHTTPEntryPoints map[string]struct{}
308+ smartHTTPEntryPoints map[string]struct{}
309+ registryHandler interface {
308310 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
309311 }
310312 }
@@ -716,6 +718,7 @@ func (si *serviceInit) initSecrets() error {
716718 return err
717719 }
718720
721+ si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
719722 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
720723 return nil
721724 }
@@ -756,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
756759 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
757760 si.svc.maxBlobSize = proxyCfg.maxBlobSize
758761 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
762+ si.svc.standaloneServiceSvc = servicecfg.NewServiceWithSecretProvider(si.svc.runtime, si.svc.serviceSecretProvider)
759763
760764 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
761765 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
@@ -785,6 +789,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
785789 }
786790 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
787791 si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
792+ if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil {
793+ return err
794+ }
788795 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
789796 return err
790797 }
@@ -1657,6 +1664,31 @@ func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
16571664 return nil, fmt.Errorf("invalid secret path: empty path")
16581665 }
16591666
1667+type unsafeSecretProvider struct {
1668+ dataDir string
1669+}
1670+
1671+func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) }
1672+
1673+func (p unsafeSecretProvider) IsAvailable() bool { return true }
1674+
1675+func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) {
1676+ return readUnsafeSecret(p.dataDir, path)
1677+}
1678+
1679+func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider {
1680+ switch backend {
1681+ case domain.SecretsBackendPass:
1682+ return secrets.NewPassProvider(log)
1683+ case domain.SecretsBackendSops:
1684+ return secrets.NewSopsProvider(log)
1685+ case domain.SecretsBackendUnsafe:
1686+ return unsafeSecretProvider{dataDir: dataDir}
1687+ default:
1688+ return nil
1689+ }
1690+}
1691+
16601692 func readUnsafeSecret(dataDir, secretPath string) (string, error) {
16611693 if filepath.IsAbs(secretPath) {
16621694 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
@@ -2899,9 +2931,26 @@ func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *
28992931 if err := waitForServerReady(proxyReady, errChan); err != nil {
29002932 return err
29012933 }
2934+ if err := reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg); err != nil {
2935+ return err
2936+ }
29022937 return applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc)
29032938 }
29042939
2940+func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error {
2941+ if serviceSvc == nil {
2942+ return nil
2943+ }
2944+ standaloneServices, err := servicecfg.ToDomain(cfg.Services)
2945+ if err != nil {
2946+ return fmt.Errorf("convert standalone service config: %w", err)
2947+ }
2948+ if err := serviceSvc.Reconcile(ctx, standaloneServices); err != nil {
2949+ return fmt.Errorf("reconcile standalone services: %w", err)
2950+ }
2951+ return nil
2952+}
2953+
29052954 func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {
29062955 if manager == nil {
29072956 return
@@ -263,48 +263,50 @@ type Config struct {
263 263
264 // services holds all the services used by the application.264 // services holds all the services used by the application.
265 type services struct {265 type services struct {
266- runtime *docker.Runtime266+ runtime *docker.Runtime
267- eventBus *eventbus.InMemory267+ eventBus *eventbus.InMemory
268- blobStorage *filesystem.BlobStorage268+ blobStorage *filesystem.BlobStorage
269- manifestStorage *filesystem.ManifestStorage269+ manifestStorage *filesystem.ManifestStorage
270- backupStorage *filesystem.BackupStorage270+ backupStorage *filesystem.BackupStorage
271- volumeBackupStore out.VolumeBackupStorage271+ volumeBackupStore out.VolumeBackupStorage
272- volumeBackupCfg domain.VolumeBackupConfig272+ volumeBackupCfg domain.VolumeBackupConfig
273- envLoader out.EnvLoader273+ envLoader out.EnvLoader
274- logWriter *logwriter.LogWriter274+ logWriter *logwriter.LogWriter
275- tokenStore out.TokenStore275+ tokenStore out.TokenStore
276- configSvc *config.Service276+ configSvc *config.Service
277- secretSvc *secretsSvc.Service277+ secretSvc *secretsSvc.Service
278- containerSvc *container.Service278+ containerSvc *container.Service
279- backupSvc *backup.Service279+ backupSvc *backup.Service
280- volumeBackupSvc *backup.VolumeService280+ volumeBackupSvc *backup.VolumeService
281- registrySvc *registrySvc.Service281+ registrySvc *registrySvc.Service
282- healthSvc *health.Service282+ healthSvc *health.Service
283- logSvc *logs.Service283+ logSvc *logs.Service
284- imageSvc *images.Service284+ imageSvc *images.Service
285- volumeSvc *volumesSvc.Service285+ volumeSvc *volumesSvc.Service
286- proxySvc *proxy.Service286+ proxySvc *proxy.Service
287- authSvc *auth.Service287+ standaloneServiceSvc in.StandaloneServiceService
288- authHandler *authhandler.Handler288+ serviceSecretProvider out.SecretProvider
289- adminHandler *admin.Handler289+ authSvc *auth.Service
290- httpProxyHandler http.Handler290+ authHandler *authhandler.Handler
291- httpsProxyHandler http.Handler291+ adminHandler *admin.Handler
292- internalRegUser string292+ httpProxyHandler http.Handler
293- internalRegPass string293+ httpsProxyHandler http.Handler
294- previewStore *filesystem.PreviewStore294+ internalRegUser string
295- previewService *preview.Service295+ internalRegPass string
296- envDir string296+ previewStore *filesystem.PreviewStore
297- maxBlobChunkSize int64297+ previewService *preview.Service
298- maxBlobSize int64298+ envDir string
299- caAdapter *pkiadapter.CA299+ maxBlobChunkSize int64
300- pkiSvc *pkiusecase.Service300+ maxBlobSize int64
301- reloadCoordinator *reloadCoordinator301+ caAdapter *pkiadapter.CA
302- publicTLSSvc in.PublicTLSService302+ pkiSvc *pkiusecase.Service
303- publicTLSRuntime publicTLSRuntime303+ reloadCoordinator *reloadCoordinator
304- trafficManager *trafficadapter.Manager304+ publicTLSSvc in.PublicTLSService
305- tlsHTTPEntryPoints map[string]struct{}305+ publicTLSRuntime publicTLSRuntime
306- smartHTTPEntryPoints map[string]struct{}306+ trafficManager *trafficadapter.Manager
307- registryHandler interface {307+ tlsHTTPEntryPoints map[string]struct{}
308+ smartHTTPEntryPoints map[string]struct{}
309+ registryHandler interface {
308 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)310 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
309 }311 }
310 }312 }
@@ -716,6 +718,7 @@ func (si *serviceInit) initSecrets() error {
716 return err718 return err
717 }719 }
718 720
721+ si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log)
719 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)722 si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus)
720 return nil723 return nil
721 }724 }
@@ -756,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
756 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize759 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
757 si.svc.maxBlobSize = proxyCfg.maxBlobSize760 si.svc.maxBlobSize = proxyCfg.maxBlobSize
758 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)761 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
762+ si.svc.standaloneServiceSvc = servicecfg.NewServiceWithSecretProvider(si.svc.runtime, si.svc.serviceSecretProvider)
759 763
760 // Wire synchronous proxy cache invalidation for zero-downtime deployments.764 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
761 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().765 // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget().
@@ -785,6 +789,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
785 }789 }
786 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)790 si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints)
787 si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)791 si.svc.smartHTTPEntryPoints = registerSmartTCPHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpProxyHandler, si.svc.httpsProxyHandler, tlsConfig, si.svc.smartHTTPEntryPoints)
792+ if err := reconcileStandaloneServices(reloadCtx, si.svc.standaloneServiceSvc, reloadCfg); err != nil {
793+ return err
794+ }
788 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {795 if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil {
789 return err796 return err
790 }797 }
@@ -1657,6 +1664,31 @@ func readFileBeneath(root, cleanedRelPath string) ([]byte, error) {
1657 return nil, fmt.Errorf("invalid secret path: empty path")1664 return nil, fmt.Errorf("invalid secret path: empty path")
1658 }1665 }
1659 1666
1667+type unsafeSecretProvider struct {
1668+ dataDir string
1669+}
1670+
1671+func (p unsafeSecretProvider) Name() string { return string(domain.SecretsBackendUnsafe) }
1672+
1673+func (p unsafeSecretProvider) IsAvailable() bool { return true }
1674+
1675+func (p unsafeSecretProvider) GetSecret(_ context.Context, path string) (string, error) {
1676+ return readUnsafeSecret(p.dataDir, path)
1677+}
1678+
1679+func createStandaloneServiceSecretProvider(backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) out.SecretProvider {
1680+ switch backend {
1681+ case domain.SecretsBackendPass:
1682+ return secrets.NewPassProvider(log)
1683+ case domain.SecretsBackendSops:
1684+ return secrets.NewSopsProvider(log)
1685+ case domain.SecretsBackendUnsafe:
1686+ return unsafeSecretProvider{dataDir: dataDir}
1687+ default:
1688+ return nil
1689+ }
1690+}
1691+
1660 func readUnsafeSecret(dataDir, secretPath string) (string, error) {1692 func readUnsafeSecret(dataDir, secretPath string) (string, error) {
1661 if filepath.IsAbs(secretPath) {1693 if filepath.IsAbs(secretPath) {
1662 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")1694 return "", fmt.Errorf("invalid secret path: absolute paths are not allowed")
@@ -2899,9 +2931,26 @@ func waitForCoreProxyReadyAndApplyTraffic(ctx context.Context, cfg Config, svc *
2899 if err := waitForServerReady(proxyReady, errChan); err != nil {2931 if err := waitForServerReady(proxyReady, errChan); err != nil {
2900 return err2932 return err
2901 }2933 }
2934+ if err := reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg); err != nil {
2935+ return err
2936+ }
2902 return applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc)2937 return applyTrafficRuntimeConfig(ctx, svc.trafficManager, cfg, svc.configSvc)
2903 }2938 }
2904 2939
2940+func reconcileStandaloneServices(ctx context.Context, serviceSvc in.StandaloneServiceService, cfg Config) error {
2941+ if serviceSvc == nil {
2942+ return nil
2943+ }
2944+ standaloneServices, err := servicecfg.ToDomain(cfg.Services)
2945+ if err != nil {
2946+ return fmt.Errorf("convert standalone service config: %w", err)
2947+ }
2948+ if err := serviceSvc.Reconcile(ctx, standaloneServices); err != nil {
2949+ return fmt.Errorf("reconcile standalone services: %w", err)
2950+ }
2951+ return nil
2952+}
2953+
2905 func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {2954 func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) {
2906 if manager == nil {2955 if manager == nil {
2907 return2956 return
M internal/app/run_reload_test.go +145 -0
@@ -5,7 +5,10 @@ import (
55 "errors"
66 "net"
77 "net/http"
8+ "os"
9+ "path/filepath"
810 "strconv"
11+ "strings"
912 "sync"
1013 "testing"
1114 "time"
@@ -13,14 +16,17 @@ import (
1316 "github.com/bnema/zerowrap"
1417 "github.com/spf13/viper"
1518 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
1620 "github.com/stretchr/testify/require"
1721
1822 "github.com/bnema/gordon/internal/adapters/in/http/registry"
1923 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
24+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2025 "github.com/bnema/gordon/internal/domain"
2126 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
2227 "github.com/bnema/gordon/internal/usecase/container"
2328 "github.com/bnema/gordon/internal/usecase/proxy"
29+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
2430 traffic "github.com/bnema/gordon/internal/usecase/traffic"
2531 )
2632
@@ -132,6 +138,37 @@ func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) er
132138 return nil
133139 }
134140
141+type standaloneServiceRecorder struct {
142+ mu sync.Mutex
143+ calls int
144+ services []domain.StandaloneService
145+ err error
146+ onReconcile func()
147+}
148+
149+func (r *standaloneServiceRecorder) Reconcile(_ context.Context, services []domain.StandaloneService) error {
150+ r.mu.Lock()
151+ r.calls++
152+ r.services = append([]domain.StandaloneService(nil), services...)
153+ err := r.err
154+ onReconcile := r.onReconcile
155+ r.mu.Unlock()
156+ if onReconcile != nil {
157+ onReconcile()
158+ }
159+ return err
160+}
161+
162+func (r *standaloneServiceRecorder) Status(context.Context) ([]domain.StandaloneServiceStatus, error) {
163+ return nil, nil
164+}
165+
166+func (r *standaloneServiceRecorder) Calls() int {
167+ r.mu.Lock()
168+ defer r.mu.Unlock()
169+ return r.calls
170+}
171+
135172 type eventBusRecorder struct {
136173 mu sync.Mutex
137174 calls int
@@ -316,6 +353,114 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
316353
317354 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
318355 }
356+func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t *testing.T) {
357+ ctx := context.Background()
358+ dataDir := t.TempDir()
359+ secretPath := "service/game/rcon"
360+ require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "secrets", "service", "game"), 0700))
361+ require.NoError(t, os.WriteFile(filepath.Join(dataDir, "secrets", secretPath), []byte("test-secret-value"), 0600))
362+
363+ rt := outmocks.NewMockContainerRuntime(t)
364+ var sawSecretEnv bool
365+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
366+ rt.On("InspectImageVolumes", mock.Anything, "game:latest").Return([]string(nil), nil).Once()
367+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
368+ config := args.Get(1).(*domain.ContainerConfig)
369+ for _, entry := range config.Env {
370+ if strings.HasPrefix(entry, "RCON_PASSWORD=") {
371+ sawSecretEnv = true
372+ }
373+ }
374+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
375+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
376+
377+ provider := createStandaloneServiceSecretProvider(domain.SecretsBackendUnsafe, dataDir, zerowrap.Default())
378+ reconciler := servicecfg.NewServiceWithSecretProvider(rt, provider)
379+ err := reconciler.Reconcile(ctx, []domain.StandaloneService{{Name: "game", Image: "game:latest", Enabled: true, Secrets: []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}}})
380+
381+ require.NoError(t, err)
382+ assert.True(t, sawSecretEnv)
383+}
384+
385+func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) {
386+ ctx := context.Background()
387+ v := viper.New()
388+ v.Set("server.gordon_domain", "reload.example.com")
389+ v.Set("server.registry_port", 5000)
390+
391+ configSvc := cfgusecase.NewService(v, nil)
392+ require.NoError(t, configSvc.Load(ctx))
393+ manager := trafficadapter.NewManager()
394+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
395+
396+ reconciler := &standaloneServiceRecorder{onReconcile: func() {
397+ assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled")
398+ }}
399+ si := &serviceInit{
400+ ctx: ctx,
401+ v: v,
402+ cfg: Config{},
403+ log: zerowrap.Default(),
404+ svc: &services{
405+ configSvc: configSvc,
406+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
407+ internalRegUser: "gordon",
408+ internalRegPass: "secret",
409+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
410+ trafficManager: manager,
411+ standaloneServiceSvc: reconciler,
412+ },
413+ }
414+ si.registerReloadCoordinatorHooks()
415+
416+ reloadCfg := Config{}
417+ reloadCfg.Server.GordonDomain = "reload.example.com"
418+ reloadCfg.Server.RegistryPort = 5000
419+ reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
420+ reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
421+ reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
422+ reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
423+
424+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
425+ require.Equal(t, 1, reconciler.Calls())
426+ require.Len(t, reconciler.services, 1)
427+ assert.Equal(t, "game", reconciler.services[0].Name)
428+ assert.NotEmpty(t, manager.Status().EntryPoints)
429+}
430+
431+func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) {
432+ ctx := context.Background()
433+ v := viper.New()
434+ configSvc := cfgusecase.NewService(v, nil)
435+ require.NoError(t, configSvc.Load(ctx))
436+ manager := trafficadapter.NewManager()
437+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
438+
439+ reconciler := &standaloneServiceRecorder{onReconcile: func() {
440+ assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled")
441+ }}
442+ cfg := Config{}
443+ cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
444+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
445+ cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
446+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
447+
448+ ready := make(chan struct{})
449+ close(ready)
450+ svc := &services{configSvc: configSvc, trafficManager: manager, standaloneServiceSvc: reconciler}
451+ require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil))
452+ require.Equal(t, 1, reconciler.Calls())
453+ assert.NotEmpty(t, manager.Status().EntryPoints)
454+}
455+
456+func TestReconcileStandaloneServices_ConfigConversionErrorIsClear(t *testing.T) {
457+ reconciler := &standaloneServiceRecorder{}
458+ err := reconcileStandaloneServices(context.Background(), reconciler, Config{Services: []servicecfg.Config{{Name: "broken", Image: "", Enabled: true}}})
459+ require.Error(t, err)
460+ assert.ErrorContains(t, err, "convert standalone service config")
461+ assert.Equal(t, 0, reconciler.Calls())
462+}
463+
319464 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
320465 ctx := context.Background()
321466 v := viper.New()
@@ -5,7 +5,10 @@ import (
5 "errors"5 "errors"
6 "net"6 "net"
7 "net/http"7 "net/http"
8+ "os"
9+ "path/filepath"
8 "strconv"10 "strconv"
11+ "strings"
9 "sync"12 "sync"
10 "testing"13 "testing"
11 "time"14 "time"
@@ -13,14 +16,17 @@ import (
13 "github.com/bnema/zerowrap"16 "github.com/bnema/zerowrap"
14 "github.com/spf13/viper"17 "github.com/spf13/viper"
15 "github.com/stretchr/testify/assert"18 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
17 21
18 "github.com/bnema/gordon/internal/adapters/in/http/registry"22 "github.com/bnema/gordon/internal/adapters/in/http/registry"
19 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"23 trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic"
24+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
20 "github.com/bnema/gordon/internal/domain"25 "github.com/bnema/gordon/internal/domain"
21 cfgusecase "github.com/bnema/gordon/internal/usecase/config"26 cfgusecase "github.com/bnema/gordon/internal/usecase/config"
22 "github.com/bnema/gordon/internal/usecase/container"27 "github.com/bnema/gordon/internal/usecase/container"
23 "github.com/bnema/gordon/internal/usecase/proxy"28 "github.com/bnema/gordon/internal/usecase/proxy"
29+ servicecfg "github.com/bnema/gordon/internal/usecase/services"
24 traffic "github.com/bnema/gordon/internal/usecase/traffic"30 traffic "github.com/bnema/gordon/internal/usecase/traffic"
25 )31 )
26 32
@@ -132,6 +138,37 @@ func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) er
132 return nil138 return nil
133 }139 }
134 140
141+type standaloneServiceRecorder struct {
142+ mu sync.Mutex
143+ calls int
144+ services []domain.StandaloneService
145+ err error
146+ onReconcile func()
147+}
148+
149+func (r *standaloneServiceRecorder) Reconcile(_ context.Context, services []domain.StandaloneService) error {
150+ r.mu.Lock()
151+ r.calls++
152+ r.services = append([]domain.StandaloneService(nil), services...)
153+ err := r.err
154+ onReconcile := r.onReconcile
155+ r.mu.Unlock()
156+ if onReconcile != nil {
157+ onReconcile()
158+ }
159+ return err
160+}
161+
162+func (r *standaloneServiceRecorder) Status(context.Context) ([]domain.StandaloneServiceStatus, error) {
163+ return nil, nil
164+}
165+
166+func (r *standaloneServiceRecorder) Calls() int {
167+ r.mu.Lock()
168+ defer r.mu.Unlock()
169+ return r.calls
170+}
171+
135 type eventBusRecorder struct {172 type eventBusRecorder struct {
136 mu sync.Mutex173 mu sync.Mutex
137 calls int174 calls int
@@ -316,6 +353,114 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
316 353
317 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))354 require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
318 }355 }
356+func TestStandaloneServiceSecretProviderUnsafeBackendReconcilesServiceSecrets(t *testing.T) {
357+ ctx := context.Background()
358+ dataDir := t.TempDir()
359+ secretPath := "service/game/rcon"
360+ require.NoError(t, os.MkdirAll(filepath.Join(dataDir, "secrets", "service", "game"), 0700))
361+ require.NoError(t, os.WriteFile(filepath.Join(dataDir, "secrets", secretPath), []byte("test-secret-value"), 0600))
362+
363+ rt := outmocks.NewMockContainerRuntime(t)
364+ var sawSecretEnv bool
365+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
366+ rt.On("InspectImageVolumes", mock.Anything, "game:latest").Return([]string(nil), nil).Once()
367+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
368+ config := args.Get(1).(*domain.ContainerConfig)
369+ for _, entry := range config.Env {
370+ if strings.HasPrefix(entry, "RCON_PASSWORD=") {
371+ sawSecretEnv = true
372+ }
373+ }
374+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
375+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
376+
377+ provider := createStandaloneServiceSecretProvider(domain.SecretsBackendUnsafe, dataDir, zerowrap.Default())
378+ reconciler := servicecfg.NewServiceWithSecretProvider(rt, provider)
379+ err := reconciler.Reconcile(ctx, []domain.StandaloneService{{Name: "game", Image: "game:latest", Enabled: true, Secrets: []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}}})
380+
381+ require.NoError(t, err)
382+ assert.True(t, sawSecretEnv)
383+}
384+
385+func TestServiceInit_RegisterReloadCoordinatorHooks_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) {
386+ ctx := context.Background()
387+ v := viper.New()
388+ v.Set("server.gordon_domain", "reload.example.com")
389+ v.Set("server.registry_port", 5000)
390+
391+ configSvc := cfgusecase.NewService(v, nil)
392+ require.NoError(t, configSvc.Load(ctx))
393+ manager := trafficadapter.NewManager()
394+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
395+
396+ reconciler := &standaloneServiceRecorder{onReconcile: func() {
397+ assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled")
398+ }}
399+ si := &serviceInit{
400+ ctx: ctx,
401+ v: v,
402+ cfg: Config{},
403+ log: zerowrap.Default(),
404+ svc: &services{
405+ configSvc: configSvc,
406+ containerSvc: container.NewService(nil, nil, nil, nil, container.Config{}, configSvc),
407+ internalRegUser: "gordon",
408+ internalRegPass: "secret",
409+ reloadCoordinator: newReloadCoordinator(v, &reloadRecorder{}, &proxyRecorder{}, nil, nil, nil, zerowrap.Default()),
410+ trafficManager: manager,
411+ standaloneServiceSvc: reconciler,
412+ },
413+ }
414+ si.registerReloadCoordinatorHooks()
415+
416+ reloadCfg := Config{}
417+ reloadCfg.Server.GordonDomain = "reload.example.com"
418+ reloadCfg.Server.RegistryPort = 5000
419+ reloadCfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
420+ reloadCfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
421+ reloadCfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
422+ reloadCfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
423+
424+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
425+ require.Equal(t, 1, reconciler.Calls())
426+ require.Len(t, reconciler.services, 1)
427+ assert.Equal(t, "game", reconciler.services[0].Name)
428+ assert.NotEmpty(t, manager.Status().EntryPoints)
429+}
430+
431+func TestWaitForCoreProxyReadyAndApplyTraffic_ReconcilesStandaloneServicesBeforeTrafficApply(t *testing.T) {
432+ ctx := context.Background()
433+ v := viper.New()
434+ configSvc := cfgusecase.NewService(v, nil)
435+ require.NoError(t, configSvc.Load(ctx))
436+ manager := trafficadapter.NewManager()
437+ defer func() { require.NoError(t, manager.Shutdown(ctx)) }()
438+
439+ reconciler := &standaloneServiceRecorder{onReconcile: func() {
440+ assert.Empty(t, manager.Status().EntryPoints, "traffic graph applied before standalone services reconciled")
441+ }}
442+ cfg := Config{}
443+ cfg.Services = []servicecfg.Config{{Name: "game", Image: "game:latest", Enabled: true}}
444+ cfg.EntryPoints = map[string]traffic.EntryPointConfig{"game": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}}
445+ cfg.NetworkServices = []traffic.NetworkServiceConfig{{Name: "game", Ports: []traffic.PortConfig{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolTCP}}}}
446+ cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "game", EntryPoint: "game", Service: "network_service:game:game"}}
447+
448+ ready := make(chan struct{})
449+ close(ready)
450+ svc := &services{configSvc: configSvc, trafficManager: manager, standaloneServiceSvc: reconciler}
451+ require.NoError(t, waitForCoreProxyReadyAndApplyTraffic(ctx, cfg, svc, ready, ready, nil))
452+ require.Equal(t, 1, reconciler.Calls())
453+ assert.NotEmpty(t, manager.Status().EntryPoints)
454+}
455+
456+func TestReconcileStandaloneServices_ConfigConversionErrorIsClear(t *testing.T) {
457+ reconciler := &standaloneServiceRecorder{}
458+ err := reconcileStandaloneServices(context.Background(), reconciler, Config{Services: []servicecfg.Config{{Name: "broken", Image: "", Enabled: true}}})
459+ require.Error(t, err)
460+ assert.ErrorContains(t, err, "convert standalone service config")
461+ assert.Equal(t, 0, reconciler.Calls())
462+}
463+
319 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {464 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
320 ctx := context.Background()465 ctx := context.Background()
321 v := viper.New()466 v := viper.New()
A internal/boundaries/in/services.go +16 -0
@@ -0,0 +1,16 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// StandaloneServiceService defines standalone service lifecycle operations.
13+type StandaloneServiceService interface {
14+ Reconcile(ctx context.Context, services []domain.StandaloneService) error
15+ Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error)
16+}
@@ -0,0 +1,16 @@
1+// Package in defines input ports (interfaces) for use cases.
2+// These interfaces define the contract between driving adapters (HTTP, CLI)
3+// and the business logic (use cases).
4+package in
5+
6+import (
7+ "context"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+// StandaloneServiceService defines standalone service lifecycle operations.
13+type StandaloneServiceService interface {
14+ Reconcile(ctx context.Context, services []domain.StandaloneService) error
15+ Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error)
16+}
M internal/domain/container.go +9 -0
@@ -63,12 +63,21 @@ type RouteInfo struct {
6363 Attachments []Attachment
6464 }
6565
66+// ContainerPortPublish describes an explicit host port binding for a container port.
67+type ContainerPortPublish struct {
68+ HostIP string
69+ HostPort int
70+ ContainerPort int
71+ Protocol NetworkProtocol
72+}
73+
6674 // ContainerConfig holds configuration for creating a container.
6775 type ContainerConfig struct {
6876 Image string
6977 Name string
7078 Env []string
7179 Ports []int
80+ PortPublishes []ContainerPortPublish
7281 Labels map[string]string
7382 WorkingDir string
7483 Cmd []string
@@ -63,12 +63,21 @@ type RouteInfo struct {
63 Attachments []Attachment63 Attachments []Attachment
64 }64 }
65 65
66+// ContainerPortPublish describes an explicit host port binding for a container port.
67+type ContainerPortPublish struct {
68+ HostIP string
69+ HostPort int
70+ ContainerPort int
71+ Protocol NetworkProtocol
72+}
73+
66 // ContainerConfig holds configuration for creating a container.74 // ContainerConfig holds configuration for creating a container.
67 type ContainerConfig struct {75 type ContainerConfig struct {
68 Image string76 Image string
69 Name string77 Name string
70 Env []string78 Env []string
71 Ports []int79 Ports []int
80+ PortPublishes []ContainerPortPublish
72 Labels map[string]string81 Labels map[string]string
73 WorkingDir string82 WorkingDir string
74 Cmd []string83 Cmd []string
M internal/domain/labels.go +8 -0
@@ -15,6 +15,14 @@ const (
1515 // exposing secret values.
1616 LabelEnvHash = "gordon.env-hash"
1717
18+ // Standalone service labels identify Gordon-managed L4 service containers.
19+ LabelService = "gordon.service"
20+ LabelServiceName = "gordon.service.name"
21+ LabelServiceConfigHash = "gordon.service.config-hash"
22+ LabelServiceManagedVolumes = "gordon.service.managed-volumes"
23+ LabelServiceCleanupPreserveVolumes = "gordon.service.cleanup.preserve-volumes"
24+ LabelServiceCleanupRemoveContainer = "gordon.service.cleanup.remove-container"
25+
1826 // LabelProxyPort specifies the container port to proxy HTTP traffic to.
1927 LabelProxyPort = "gordon.proxy.port"
2028
@@ -15,6 +15,14 @@ const (
15 // exposing secret values.15 // exposing secret values.
16 LabelEnvHash = "gordon.env-hash"16 LabelEnvHash = "gordon.env-hash"
17 17
18+ // Standalone service labels identify Gordon-managed L4 service containers.
19+ LabelService = "gordon.service"
20+ LabelServiceName = "gordon.service.name"
21+ LabelServiceConfigHash = "gordon.service.config-hash"
22+ LabelServiceManagedVolumes = "gordon.service.managed-volumes"
23+ LabelServiceCleanupPreserveVolumes = "gordon.service.cleanup.preserve-volumes"
24+ LabelServiceCleanupRemoveContainer = "gordon.service.cleanup.remove-container"
25+
18 // LabelProxyPort specifies the container port to proxy HTTP traffic to.26 // LabelProxyPort specifies the container port to proxy HTTP traffic to.
19 LabelProxyPort = "gordon.proxy.port"27 LabelProxyPort = "gordon.proxy.port"
20 28
M internal/domain/service.go +8 -0
@@ -28,6 +28,14 @@ type StandaloneService struct {
2828 Volumes []StandaloneServiceVolume
2929 }
3030
31+type StandaloneServiceStatus struct {
32+ Name string
33+ ContainerID string
34+ ContainerName string
35+ Status ContainerStatus
36+ ConfigHash string
37+}
38+
3139 type StandaloneServicePort struct {
3240 Name string
3341 Container int
@@ -28,6 +28,14 @@ type StandaloneService struct {
28 Volumes []StandaloneServiceVolume28 Volumes []StandaloneServiceVolume
29 }29 }
30 30
31+type StandaloneServiceStatus struct {
32+ Name string
33+ ContainerID string
34+ ContainerName string
35+ Status ContainerStatus
36+ ConfigHash string
37+}
38+
31 type StandaloneServicePort struct {39 type StandaloneServicePort struct {
32 Name string40 Name string
33 Container int41 Container int
M internal/usecase/services/config.go +2 -1
@@ -40,6 +40,7 @@ type ResolvedVolumeMount struct {
4040 Source string
4141 Target string
4242 ReadOnly bool
43+ Managed bool
4344 }
4445
4546 type SecretRefConfig struct {
@@ -173,7 +174,7 @@ func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.St
173174
174175 mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths))
175176 for _, path := range imageVolumePaths {
176- mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path})
177+ mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path, Managed: true})
177178 }
178179 return mounts
179180 }
@@ -40,6 +40,7 @@ type ResolvedVolumeMount struct {
40 Source string40 Source string
41 Target string41 Target string
42 ReadOnly bool42 ReadOnly bool
43+ Managed bool
43 }44 }
44 45
45 type SecretRefConfig struct {46 type SecretRefConfig struct {
@@ -173,7 +174,7 @@ func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.St
173 174
174 mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths))175 mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths))
175 for _, path := range imageVolumePaths {176 for _, path := range imageVolumePaths {
176- mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path})177+ mounts = append(mounts, ResolvedVolumeMount{Source: ManagedServiceVolumeName(prefix, serviceName, path), Target: path, Managed: true})
177 }178 }
178 return mounts179 return mounts
179 }180 }
M internal/usecase/services/config_test.go +2 -2
@@ -59,8 +59,8 @@ func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *tes
5959 mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"})
6060
6161 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"},
62+ {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust", Managed: true},
63+ {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data", Managed: true},
6464 }, mounts)
6565 }
6666
@@ -59,8 +59,8 @@ func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *tes
59 mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"})59 mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"})
60 60
61 assert.Equal(t, []ResolvedVolumeMount{61 assert.Equal(t, []ResolvedVolumeMount{
62- {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust"},62+ {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust", Managed: true},
63- {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data"},63+ {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data", Managed: true},
64 }, mounts)64 }, mounts)
65 }65 }
66 66
A internal/usecase/services/service.go +569 -0
@@ -0,0 +1,569 @@
1+package services
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "maps"
13+ "net"
14+ "os"
15+ "sort"
16+ "strconv"
17+ "strings"
18+ "time"
19+
20+ "github.com/bnema/gordon/internal/boundaries/out"
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const defaultVolumePrefix = "gordon-service"
25+
26+type Service struct {
27+ runtime out.ContainerRuntime
28+ secretProvider out.SecretProvider
29+ volumePrefix string
30+}
31+
32+func NewService(runtime out.ContainerRuntime) *Service {
33+ return &Service{runtime: runtime, volumePrefix: defaultVolumePrefix}
34+}
35+
36+func NewServiceWithSecretProvider(runtime out.ContainerRuntime, secretProvider out.SecretProvider) *Service {
37+ return &Service{runtime: runtime, secretProvider: secretProvider, volumePrefix: defaultVolumePrefix}
38+}
39+
40+func NewServiceWithVolumePrefix(runtime out.ContainerRuntime, volumePrefix string) *Service {
41+ if volumePrefix == "" {
42+ volumePrefix = defaultVolumePrefix
43+ }
44+ return &Service{runtime: runtime, volumePrefix: volumePrefix}
45+}
46+
47+func (s *Service) Reconcile(ctx context.Context, configured []domain.StandaloneService) error {
48+ containers, err := s.runtime.ListContainers(ctx, true)
49+ if err != nil {
50+ return fmt.Errorf("list standalone service containers: %w", err)
51+ }
52+ existing := managedServiceContainers(containers)
53+ configuredNames := make(map[string]struct{}, len(configured))
54+ for _, svc := range configured {
55+ configuredNames[svc.Name] = struct{}{}
56+ if err := s.reconcileOne(ctx, svc, existing[svc.Name]); err != nil {
57+ return err
58+ }
59+ }
60+ for name, containers := range existing {
61+ if _, ok := configuredNames[name]; ok {
62+ continue
63+ }
64+ if err := s.stopRemoved(ctx, name, containers); err != nil {
65+ return err
66+ }
67+ }
68+ return nil
69+}
70+
71+func (s *Service) Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error) {
72+ containers, err := s.runtime.ListContainers(ctx, true)
73+ if err != nil {
74+ return nil, fmt.Errorf("list standalone service containers: %w", err)
75+ }
76+ statuses := make([]domain.StandaloneServiceStatus, 0)
77+ for _, container := range containers {
78+ if container.Labels[domain.LabelService] != "true" {
79+ continue
80+ }
81+ statuses = append(statuses, domain.StandaloneServiceStatus{
82+ Name: container.Labels[domain.LabelServiceName],
83+ ContainerID: container.ID,
84+ ContainerName: container.Name,
85+ Status: containerStatus(container),
86+ ConfigHash: container.Labels[domain.LabelServiceConfigHash],
87+ })
88+ }
89+ sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
90+ return statuses, nil
91+}
92+
93+func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService, existing []*domain.Container) error {
94+ cleanup := normalizeCleanup(svc.Cleanup)
95+ if !svc.Enabled {
96+ return s.stopDisabled(ctx, svc.Name, cleanup, existing)
97+ }
98+ hash, err := serviceConfigHash(svc)
99+ if err != nil {
100+ return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)
101+ }
102+ if len(existing) == 0 {
103+ return s.createAndStart(ctx, svc, hash)
104+ }
105+ current := existing[0]
106+ if current.Labels[domain.LabelServiceConfigHash] != hash {
107+ if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {
108+ return err
109+ }
110+ return nil
111+ }
112+ if containerStatus(current) != domain.ContainerStatusRunning {
113+ if err := s.runtime.StartContainer(ctx, current.ID); err != nil {
114+ return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
115+ }
116+ }
117+ if err := s.waitReadiness(ctx, current.ID, svc); err != nil {
118+ return err
119+ }
120+ return nil
121+}
122+
123+func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string) error {
124+ for _, container := range existing {
125+ if containerStatus(container) == domain.ContainerStatusRunning {
126+ if err := s.runtime.StopContainer(ctx, container.ID); err != nil {
127+ return fmt.Errorf("stop stale standalone service %q container: %w", svc.Name, err)
128+ }
129+ }
130+ if cleanup.RemoveContainer {
131+ if err := s.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
132+ return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err)
133+ }
134+ }
135+ if err := s.removeManagedVolumes(ctx, svc.Name, cleanup, container); err != nil {
136+ return err
137+ }
138+ }
139+ return s.createAndStart(ctx, svc, hash)
140+}
141+
142+func (s *Service) stopDisabled(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container) error {
143+ for _, container := range existing {
144+ if err := s.cleanupContainer(ctx, name, "disabled", cleanup, container); err != nil {
145+ return err
146+ }
147+ }
148+ return nil
149+}
150+
151+func (s *Service) stopRemoved(ctx context.Context, name string, existing []*domain.Container) error {
152+ for _, container := range existing {
153+ cleanup := cleanupFromLabels(container.Labels)
154+ if err := s.cleanupContainer(ctx, name, "removed", cleanup, container); err != nil {
155+ return err
156+ }
157+ }
158+ return nil
159+}
160+
161+func (s *Service) cleanupContainer(ctx context.Context, name, reason string, cleanup domain.StandaloneServiceCleanup, container *domain.Container) error {
162+ if containerStatus(container) == domain.ContainerStatusRunning {
163+ if err := s.runtime.StopContainer(ctx, container.ID); err != nil {
164+ return fmt.Errorf("stop %s standalone service %q container: %w", reason, name, err)
165+ }
166+ }
167+ if cleanup.RemoveContainer {
168+ if err := s.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
169+ return fmt.Errorf("remove %s standalone service %q container: %w", reason, name, err)
170+ }
171+ }
172+ if err := s.removeManagedVolumes(ctx, name, cleanup, container); err != nil {
173+ return err
174+ }
175+ return nil
176+}
177+
178+func (s *Service) removeManagedVolumes(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, container *domain.Container) error {
179+ if cleanup.PreserveVolumes {
180+ return nil
181+ }
182+ managed := managedVolumeSet(container.Labels)
183+ for _, mount := range container.VolumeMounts {
184+ if mount.Type != "volume" || mount.Name == "" {
185+ continue
186+ }
187+ if _, ok := managed[mount.Name]; !ok {
188+ continue
189+ }
190+ if err := s.runtime.RemoveVolume(ctx, mount.Name, true); err != nil {
191+ return fmt.Errorf("remove standalone service %q volume %q: %w", name, mount.Name, err)
192+ }
193+ }
194+ return nil
195+}
196+
197+func managedVolumeSet(labels map[string]string) map[string]struct{} {
198+ values := strings.Split(labels[domain.LabelServiceManagedVolumes], ",")
199+ managed := make(map[string]struct{}, len(values))
200+ for _, value := range values {
201+ name := strings.TrimSpace(value)
202+ if name == "" {
203+ continue
204+ }
205+ managed[name] = struct{}{}
206+ }
207+ return managed
208+}
209+
210+func cleanupFromLabels(labels map[string]string) domain.StandaloneServiceCleanup {
211+ cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
212+ if value, ok := labels[domain.LabelServiceCleanupPreserveVolumes]; ok {
213+ if parsed, err := strconv.ParseBool(value); err == nil {
214+ cleanup.PreserveVolumes = parsed
215+ }
216+ }
217+ if value, ok := labels[domain.LabelServiceCleanupRemoveContainer]; ok {
218+ if parsed, err := strconv.ParseBool(value); err == nil {
219+ cleanup.RemoveContainer = parsed
220+ }
221+ }
222+ return cleanup
223+}
224+
225+func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string) error {
226+ config, err := s.containerConfig(ctx, svc, hash)
227+ if err != nil {
228+ return err
229+ }
230+ container, err := s.runtime.CreateContainer(ctx, config)
231+ if err != nil {
232+ return fmt.Errorf("create standalone service %q container: %w", svc.Name, err)
233+ }
234+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
235+ return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
236+ }
237+ if err := s.waitReadiness(ctx, container.ID, svc); err != nil {
238+ return err
239+ }
240+ return nil
241+}
242+
243+func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) {
244+ imageVolumes, err := s.runtime.InspectImageVolumes(ctx, svc.Image)
245+ if err != nil {
246+ return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)
247+ }
248+ mounts := ResolveVolumeMounts(s.volumePrefix, svc.Name, svc.Volumes, imageVolumes)
249+ volumes := make(map[string]string)
250+ readOnlyVolumes := make(map[string]string)
251+ managedVolumes := make([]string, 0)
252+ for _, mount := range mounts {
253+ if mount.Managed {
254+ managedVolumes = append(managedVolumes, mount.Source)
255+ }
256+ if mount.ReadOnly {
257+ readOnlyVolumes[mount.Target] = mount.Source
258+ continue
259+ }
260+ volumes[mount.Target] = mount.Source
261+ }
262+ publishes, err := portPublishes(svc)
263+ if err != nil {
264+ return nil, err
265+ }
266+ env, err := s.serviceEnv(ctx, svc)
267+ if err != nil {
268+ return nil, err
269+ }
270+ return &domain.ContainerConfig{
271+ Image: svc.Image,
272+ Name: serviceContainerName(svc.Name),
273+ Env: env,
274+ PortPublishes: publishes,
275+ Labels: serviceLabels(svc.Name, hash, normalizeCleanup(svc.Cleanup), managedVolumes),
276+ AutoRemove: false,
277+ RestartPolicy: domain.RestartPolicyAlways,
278+ Volumes: emptyToNil(volumes),
279+ ReadOnlyVolumes: emptyToNil(readOnlyVolumes),
280+ }, nil
281+}
282+
283+func managedServiceContainers(containers []*domain.Container) map[string][]*domain.Container {
284+ managed := make(map[string][]*domain.Container)
285+ for _, container := range containers {
286+ if container == nil || container.Labels[domain.LabelService] != "true" {
287+ continue
288+ }
289+ name := container.Labels[domain.LabelServiceName]
290+ if name == "" {
291+ continue
292+ }
293+ managed[name] = append(managed[name], container)
294+ }
295+ return managed
296+}
297+
298+func serviceLabels(name, hash string, cleanup domain.StandaloneServiceCleanup, managedVolumes []string) map[string]string {
299+ sort.Strings(managedVolumes)
300+ return map[string]string{
301+ domain.LabelManaged: "true",
302+ domain.LabelService: "true",
303+ domain.LabelServiceName: name,
304+ domain.LabelServiceConfigHash: hash,
305+ domain.LabelServiceManagedVolumes: strings.Join(managedVolumes, ","),
306+ domain.LabelServiceCleanupPreserveVolumes: strconv.FormatBool(cleanup.PreserveVolumes),
307+ domain.LabelServiceCleanupRemoveContainer: strconv.FormatBool(cleanup.RemoveContainer),
308+ }
309+}
310+
311+func portPublishes(svc domain.StandaloneService) ([]domain.ContainerPortPublish, error) {
312+ publishes := make([]domain.ContainerPortPublish, 0, len(svc.Ports))
313+ for _, port := range svc.Ports {
314+ if port.Publish == "" {
315+ continue
316+ }
317+ host, hostPort, err := net.SplitHostPort(port.Publish)
318+ if err != nil {
319+ return nil, fmt.Errorf("parse standalone service %q port %q publish: %w", svc.Name, port.Name, err)
320+ }
321+ hostPortNumber, err := strconv.Atoi(hostPort)
322+ if err != nil {
323+ return nil, fmt.Errorf("parse standalone service %q port %q publish port: %w", svc.Name, port.Name, err)
324+ }
325+ publishes = append(publishes, domain.ContainerPortPublish{
326+ HostIP: host, HostPort: hostPortNumber, ContainerPort: port.Container, Protocol: port.Protocol,
327+ })
328+ }
329+ return publishes, nil
330+}
331+
332+func serviceConfigHash(svc domain.StandaloneService) (string, error) {
333+ payload := struct {
334+ Image string
335+ Env []string
336+ EnvFile string
337+ Secrets []domain.StandaloneServiceSecretRef
338+ Readiness domain.StandaloneServiceReadiness
339+ Ports []domain.StandaloneServicePort
340+ Volumes []domain.StandaloneServiceVolume
341+ Cleanup domain.StandaloneServiceCleanup
342+ }{svc.Image, append([]string(nil), svc.Env...), svc.EnvFile, append([]domain.StandaloneServiceSecretRef(nil), svc.Secrets...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
343+ sort.Strings(payload.Env)
344+ bytes, err := json.Marshal(payload)
345+ if err != nil {
346+ return "", err
347+ }
348+ sum := sha256.Sum256(bytes)
349+ return hex.EncodeToString(sum[:]), nil
350+}
351+
352+func (s *Service) serviceEnv(ctx context.Context, svc domain.StandaloneService) ([]string, error) {
353+ envMap := make(map[string]string)
354+ if svc.EnvFile != "" {
355+ fileEnv, err := loadEnvFile(ctx, svc.EnvFile)
356+ if err != nil {
357+ return nil, fmt.Errorf("load standalone service %q env file: %w", svc.Name, err)
358+ }
359+ maps.Copy(envMap, fileEnv)
360+ }
361+ for _, entry := range svc.Env {
362+ key, value, ok := strings.Cut(entry, "=")
363+ if !ok || key == "" {
364+ return nil, fmt.Errorf("parse standalone service %q env entry", svc.Name)
365+ }
366+ envMap[key] = value
367+ }
368+ if len(svc.Secrets) > 0 && s.secretProvider == nil {
369+ return nil, fmt.Errorf("resolve standalone service %q secrets: secret provider is not configured", svc.Name)
370+ }
371+ for _, secret := range svc.Secrets {
372+ value, err := s.secretProvider.GetSecret(ctx, serviceSecretPath(svc.Name, secret.Name))
373+ if err != nil {
374+ return nil, fmt.Errorf("resolve standalone service %q secret %q: %w", svc.Name, secret.Name, err)
375+ }
376+ envMap[secret.Key] = value
377+ }
378+ return envMapToList(envMap), nil
379+}
380+
381+func loadEnvFile(ctx context.Context, path string) (map[string]string, error) {
382+ if err := ctx.Err(); err != nil {
383+ return nil, err
384+ }
385+ file, err := os.Open(path)
386+ if err != nil {
387+ return nil, err
388+ }
389+ defer file.Close()
390+ values := make(map[string]string)
391+ scanner := bufio.NewScanner(file)
392+ for scanner.Scan() {
393+ if err := ctx.Err(); err != nil {
394+ return nil, err
395+ }
396+ line := strings.TrimSpace(scanner.Text())
397+ if line == "" || strings.HasPrefix(line, "#") {
398+ continue
399+ }
400+ key, value, ok := strings.Cut(line, "=")
401+ if !ok || strings.TrimSpace(key) == "" {
402+ return nil, fmt.Errorf("invalid env file line")
403+ }
404+ values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
405+ }
406+ if err := scanner.Err(); err != nil {
407+ return nil, err
408+ }
409+ return values, nil
410+}
411+
412+func serviceSecretPath(serviceName, secretName string) string {
413+ if strings.Contains(secretName, ":") {
414+ return secretName
415+ }
416+ return "service/" + serviceName + "/" + secretName
417+}
418+
419+func envMapToList(values map[string]string) []string {
420+ if len(values) == 0 {
421+ return nil
422+ }
423+ keys := make([]string, 0, len(values))
424+ for key := range values {
425+ keys = append(keys, key)
426+ }
427+ sort.Strings(keys)
428+ env := make([]string, 0, len(keys))
429+ for _, key := range keys {
430+ env = append(env, key+"="+values[key])
431+ }
432+ return env
433+}
434+
435+func (s *Service) waitReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
436+ readiness := svc.Readiness
437+ if readiness.Type == "" || readiness.Type == domain.StandaloneServiceReadinessNone {
438+ return nil
439+ }
440+ readyCtx := ctx
441+ cancel := func() {}
442+ if readiness.Timeout > 0 {
443+ readyCtx, cancel = context.WithTimeout(ctx, readiness.Timeout)
444+ }
445+ defer cancel()
446+ switch readiness.Type {
447+ case domain.StandaloneServiceReadinessTCP:
448+ return waitTCPReadiness(readyCtx, svc)
449+ case domain.StandaloneServiceReadinessLog:
450+ return s.waitLogReadiness(readyCtx, containerID, svc)
451+ default:
452+ return fmt.Errorf("unsupported standalone service %q readiness type %q", svc.Name, readiness.Type)
453+ }
454+}
455+
456+func waitTCPReadiness(ctx context.Context, svc domain.StandaloneService) error {
457+ address, err := tcpReadinessAddress(svc)
458+ if err != nil {
459+ return err
460+ }
461+ ticker := time.NewTicker(10 * time.Millisecond)
462+ defer ticker.Stop()
463+ for {
464+ if err := ctx.Err(); err != nil {
465+ return fmt.Errorf("standalone service %q tcp readiness timed out: %w", svc.Name, err)
466+ }
467+ dialer := net.Dialer{Timeout: 100 * time.Millisecond}
468+ conn, err := dialer.DialContext(ctx, "tcp", address)
469+ if err == nil {
470+ _ = conn.Close()
471+ return nil
472+ }
473+ select {
474+ case <-ctx.Done():
475+ return fmt.Errorf("standalone service %q tcp readiness timed out: %w", svc.Name, ctx.Err())
476+ case <-ticker.C:
477+ }
478+ }
479+}
480+
481+func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
482+ for _, port := range svc.Ports {
483+ if port.Protocol != domain.NetworkProtocolTCP || port.Publish == "" {
484+ continue
485+ }
486+ host, hostPort, err := net.SplitHostPort(port.Publish)
487+ if err != nil {
488+ return "", fmt.Errorf("parse standalone service %q tcp readiness publish: %w", svc.Name, err)
489+ }
490+ if host == "0.0.0.0" || host == "::" || host == "" {
491+ host = "127.0.0.1"
492+ }
493+ return net.JoinHostPort(host, hostPort), nil
494+ }
495+ return "", fmt.Errorf("standalone service %q tcp readiness requires a published tcp port", svc.Name)
496+}
497+
498+func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
499+ ticker := time.NewTicker(10 * time.Millisecond)
500+ defer ticker.Stop()
501+ for {
502+ if err := ctx.Err(); err != nil {
503+ return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, err)
504+ }
505+ found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains)
506+ if err == nil && found {
507+ return nil
508+ }
509+ if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
510+ // Continue polling until timeout; many services create the log after start.
511+ }
512+ select {
513+ case <-ctx.Done():
514+ return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, ctx.Err())
515+ case <-ticker.C:
516+ }
517+ }
518+}
519+
520+func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
521+ reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
522+ if err != nil {
523+ return false, err
524+ }
525+ defer reader.Close()
526+ content, err := io.ReadAll(reader)
527+ if err != nil {
528+ return false, err
529+ }
530+ return strings.Contains(string(content), contains), nil
531+}
532+
533+func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
534+ if !cleanup.PreserveVolumes && !cleanup.RemoveContainer {
535+ return domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
536+ }
537+ return cleanup
538+}
539+
540+func containerStatus(container *domain.Container) domain.ContainerStatus {
541+ status := strings.ToLower(container.Status)
542+ if strings.Contains(status, string(domain.ContainerStatusRunning)) {
543+ return domain.ContainerStatusRunning
544+ }
545+ if strings.Contains(status, string(domain.ContainerStatusExited)) {
546+ return domain.ContainerStatusExited
547+ }
548+ if strings.Contains(status, string(domain.ContainerStatusCreated)) {
549+ return domain.ContainerStatusCreated
550+ }
551+ if strings.Contains(status, string(domain.ContainerStatusPaused)) {
552+ return domain.ContainerStatusPaused
553+ }
554+ if strings.Contains(status, string(domain.ContainerStatusStopped)) {
555+ return domain.ContainerStatusStopped
556+ }
557+ return domain.ContainerStatusUnknown
558+}
559+
560+func serviceContainerName(name string) string {
561+ return "gordon-service-" + strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
562+}
563+
564+func emptyToNil(values map[string]string) map[string]string {
565+ if len(values) == 0 {
566+ return nil
567+ }
568+ return values
569+}
@@ -0,0 +1,569 @@
1+package services
2+
3+import (
4+ "bufio"
5+ "context"
6+ "crypto/sha256"
7+ "encoding/hex"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "io"
12+ "maps"
13+ "net"
14+ "os"
15+ "sort"
16+ "strconv"
17+ "strings"
18+ "time"
19+
20+ "github.com/bnema/gordon/internal/boundaries/out"
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const defaultVolumePrefix = "gordon-service"
25+
26+type Service struct {
27+ runtime out.ContainerRuntime
28+ secretProvider out.SecretProvider
29+ volumePrefix string
30+}
31+
32+func NewService(runtime out.ContainerRuntime) *Service {
33+ return &Service{runtime: runtime, volumePrefix: defaultVolumePrefix}
34+}
35+
36+func NewServiceWithSecretProvider(runtime out.ContainerRuntime, secretProvider out.SecretProvider) *Service {
37+ return &Service{runtime: runtime, secretProvider: secretProvider, volumePrefix: defaultVolumePrefix}
38+}
39+
40+func NewServiceWithVolumePrefix(runtime out.ContainerRuntime, volumePrefix string) *Service {
41+ if volumePrefix == "" {
42+ volumePrefix = defaultVolumePrefix
43+ }
44+ return &Service{runtime: runtime, volumePrefix: volumePrefix}
45+}
46+
47+func (s *Service) Reconcile(ctx context.Context, configured []domain.StandaloneService) error {
48+ containers, err := s.runtime.ListContainers(ctx, true)
49+ if err != nil {
50+ return fmt.Errorf("list standalone service containers: %w", err)
51+ }
52+ existing := managedServiceContainers(containers)
53+ configuredNames := make(map[string]struct{}, len(configured))
54+ for _, svc := range configured {
55+ configuredNames[svc.Name] = struct{}{}
56+ if err := s.reconcileOne(ctx, svc, existing[svc.Name]); err != nil {
57+ return err
58+ }
59+ }
60+ for name, containers := range existing {
61+ if _, ok := configuredNames[name]; ok {
62+ continue
63+ }
64+ if err := s.stopRemoved(ctx, name, containers); err != nil {
65+ return err
66+ }
67+ }
68+ return nil
69+}
70+
71+func (s *Service) Status(ctx context.Context) ([]domain.StandaloneServiceStatus, error) {
72+ containers, err := s.runtime.ListContainers(ctx, true)
73+ if err != nil {
74+ return nil, fmt.Errorf("list standalone service containers: %w", err)
75+ }
76+ statuses := make([]domain.StandaloneServiceStatus, 0)
77+ for _, container := range containers {
78+ if container.Labels[domain.LabelService] != "true" {
79+ continue
80+ }
81+ statuses = append(statuses, domain.StandaloneServiceStatus{
82+ Name: container.Labels[domain.LabelServiceName],
83+ ContainerID: container.ID,
84+ ContainerName: container.Name,
85+ Status: containerStatus(container),
86+ ConfigHash: container.Labels[domain.LabelServiceConfigHash],
87+ })
88+ }
89+ sort.Slice(statuses, func(i, j int) bool { return statuses[i].Name < statuses[j].Name })
90+ return statuses, nil
91+}
92+
93+func (s *Service) reconcileOne(ctx context.Context, svc domain.StandaloneService, existing []*domain.Container) error {
94+ cleanup := normalizeCleanup(svc.Cleanup)
95+ if !svc.Enabled {
96+ return s.stopDisabled(ctx, svc.Name, cleanup, existing)
97+ }
98+ hash, err := serviceConfigHash(svc)
99+ if err != nil {
100+ return fmt.Errorf("hash standalone service %q config: %w", svc.Name, err)
101+ }
102+ if len(existing) == 0 {
103+ return s.createAndStart(ctx, svc, hash)
104+ }
105+ current := existing[0]
106+ if current.Labels[domain.LabelServiceConfigHash] != hash {
107+ if err := s.recreate(ctx, svc, cleanup, existing, hash); err != nil {
108+ return err
109+ }
110+ return nil
111+ }
112+ if containerStatus(current) != domain.ContainerStatusRunning {
113+ if err := s.runtime.StartContainer(ctx, current.ID); err != nil {
114+ return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
115+ }
116+ }
117+ if err := s.waitReadiness(ctx, current.ID, svc); err != nil {
118+ return err
119+ }
120+ return nil
121+}
122+
123+func (s *Service) recreate(ctx context.Context, svc domain.StandaloneService, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container, hash string) error {
124+ for _, container := range existing {
125+ if containerStatus(container) == domain.ContainerStatusRunning {
126+ if err := s.runtime.StopContainer(ctx, container.ID); err != nil {
127+ return fmt.Errorf("stop stale standalone service %q container: %w", svc.Name, err)
128+ }
129+ }
130+ if cleanup.RemoveContainer {
131+ if err := s.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
132+ return fmt.Errorf("remove stale standalone service %q container: %w", svc.Name, err)
133+ }
134+ }
135+ if err := s.removeManagedVolumes(ctx, svc.Name, cleanup, container); err != nil {
136+ return err
137+ }
138+ }
139+ return s.createAndStart(ctx, svc, hash)
140+}
141+
142+func (s *Service) stopDisabled(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, existing []*domain.Container) error {
143+ for _, container := range existing {
144+ if err := s.cleanupContainer(ctx, name, "disabled", cleanup, container); err != nil {
145+ return err
146+ }
147+ }
148+ return nil
149+}
150+
151+func (s *Service) stopRemoved(ctx context.Context, name string, existing []*domain.Container) error {
152+ for _, container := range existing {
153+ cleanup := cleanupFromLabels(container.Labels)
154+ if err := s.cleanupContainer(ctx, name, "removed", cleanup, container); err != nil {
155+ return err
156+ }
157+ }
158+ return nil
159+}
160+
161+func (s *Service) cleanupContainer(ctx context.Context, name, reason string, cleanup domain.StandaloneServiceCleanup, container *domain.Container) error {
162+ if containerStatus(container) == domain.ContainerStatusRunning {
163+ if err := s.runtime.StopContainer(ctx, container.ID); err != nil {
164+ return fmt.Errorf("stop %s standalone service %q container: %w", reason, name, err)
165+ }
166+ }
167+ if cleanup.RemoveContainer {
168+ if err := s.runtime.RemoveContainer(ctx, container.ID, true); err != nil {
169+ return fmt.Errorf("remove %s standalone service %q container: %w", reason, name, err)
170+ }
171+ }
172+ if err := s.removeManagedVolumes(ctx, name, cleanup, container); err != nil {
173+ return err
174+ }
175+ return nil
176+}
177+
178+func (s *Service) removeManagedVolumes(ctx context.Context, name string, cleanup domain.StandaloneServiceCleanup, container *domain.Container) error {
179+ if cleanup.PreserveVolumes {
180+ return nil
181+ }
182+ managed := managedVolumeSet(container.Labels)
183+ for _, mount := range container.VolumeMounts {
184+ if mount.Type != "volume" || mount.Name == "" {
185+ continue
186+ }
187+ if _, ok := managed[mount.Name]; !ok {
188+ continue
189+ }
190+ if err := s.runtime.RemoveVolume(ctx, mount.Name, true); err != nil {
191+ return fmt.Errorf("remove standalone service %q volume %q: %w", name, mount.Name, err)
192+ }
193+ }
194+ return nil
195+}
196+
197+func managedVolumeSet(labels map[string]string) map[string]struct{} {
198+ values := strings.Split(labels[domain.LabelServiceManagedVolumes], ",")
199+ managed := make(map[string]struct{}, len(values))
200+ for _, value := range values {
201+ name := strings.TrimSpace(value)
202+ if name == "" {
203+ continue
204+ }
205+ managed[name] = struct{}{}
206+ }
207+ return managed
208+}
209+
210+func cleanupFromLabels(labels map[string]string) domain.StandaloneServiceCleanup {
211+ cleanup := domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
212+ if value, ok := labels[domain.LabelServiceCleanupPreserveVolumes]; ok {
213+ if parsed, err := strconv.ParseBool(value); err == nil {
214+ cleanup.PreserveVolumes = parsed
215+ }
216+ }
217+ if value, ok := labels[domain.LabelServiceCleanupRemoveContainer]; ok {
218+ if parsed, err := strconv.ParseBool(value); err == nil {
219+ cleanup.RemoveContainer = parsed
220+ }
221+ }
222+ return cleanup
223+}
224+
225+func (s *Service) createAndStart(ctx context.Context, svc domain.StandaloneService, hash string) error {
226+ config, err := s.containerConfig(ctx, svc, hash)
227+ if err != nil {
228+ return err
229+ }
230+ container, err := s.runtime.CreateContainer(ctx, config)
231+ if err != nil {
232+ return fmt.Errorf("create standalone service %q container: %w", svc.Name, err)
233+ }
234+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
235+ return fmt.Errorf("start standalone service %q container: %w", svc.Name, err)
236+ }
237+ if err := s.waitReadiness(ctx, container.ID, svc); err != nil {
238+ return err
239+ }
240+ return nil
241+}
242+
243+func (s *Service) containerConfig(ctx context.Context, svc domain.StandaloneService, hash string) (*domain.ContainerConfig, error) {
244+ imageVolumes, err := s.runtime.InspectImageVolumes(ctx, svc.Image)
245+ if err != nil {
246+ return nil, fmt.Errorf("inspect standalone service %q image volumes: %w", svc.Name, err)
247+ }
248+ mounts := ResolveVolumeMounts(s.volumePrefix, svc.Name, svc.Volumes, imageVolumes)
249+ volumes := make(map[string]string)
250+ readOnlyVolumes := make(map[string]string)
251+ managedVolumes := make([]string, 0)
252+ for _, mount := range mounts {
253+ if mount.Managed {
254+ managedVolumes = append(managedVolumes, mount.Source)
255+ }
256+ if mount.ReadOnly {
257+ readOnlyVolumes[mount.Target] = mount.Source
258+ continue
259+ }
260+ volumes[mount.Target] = mount.Source
261+ }
262+ publishes, err := portPublishes(svc)
263+ if err != nil {
264+ return nil, err
265+ }
266+ env, err := s.serviceEnv(ctx, svc)
267+ if err != nil {
268+ return nil, err
269+ }
270+ return &domain.ContainerConfig{
271+ Image: svc.Image,
272+ Name: serviceContainerName(svc.Name),
273+ Env: env,
274+ PortPublishes: publishes,
275+ Labels: serviceLabels(svc.Name, hash, normalizeCleanup(svc.Cleanup), managedVolumes),
276+ AutoRemove: false,
277+ RestartPolicy: domain.RestartPolicyAlways,
278+ Volumes: emptyToNil(volumes),
279+ ReadOnlyVolumes: emptyToNil(readOnlyVolumes),
280+ }, nil
281+}
282+
283+func managedServiceContainers(containers []*domain.Container) map[string][]*domain.Container {
284+ managed := make(map[string][]*domain.Container)
285+ for _, container := range containers {
286+ if container == nil || container.Labels[domain.LabelService] != "true" {
287+ continue
288+ }
289+ name := container.Labels[domain.LabelServiceName]
290+ if name == "" {
291+ continue
292+ }
293+ managed[name] = append(managed[name], container)
294+ }
295+ return managed
296+}
297+
298+func serviceLabels(name, hash string, cleanup domain.StandaloneServiceCleanup, managedVolumes []string) map[string]string {
299+ sort.Strings(managedVolumes)
300+ return map[string]string{
301+ domain.LabelManaged: "true",
302+ domain.LabelService: "true",
303+ domain.LabelServiceName: name,
304+ domain.LabelServiceConfigHash: hash,
305+ domain.LabelServiceManagedVolumes: strings.Join(managedVolumes, ","),
306+ domain.LabelServiceCleanupPreserveVolumes: strconv.FormatBool(cleanup.PreserveVolumes),
307+ domain.LabelServiceCleanupRemoveContainer: strconv.FormatBool(cleanup.RemoveContainer),
308+ }
309+}
310+
311+func portPublishes(svc domain.StandaloneService) ([]domain.ContainerPortPublish, error) {
312+ publishes := make([]domain.ContainerPortPublish, 0, len(svc.Ports))
313+ for _, port := range svc.Ports {
314+ if port.Publish == "" {
315+ continue
316+ }
317+ host, hostPort, err := net.SplitHostPort(port.Publish)
318+ if err != nil {
319+ return nil, fmt.Errorf("parse standalone service %q port %q publish: %w", svc.Name, port.Name, err)
320+ }
321+ hostPortNumber, err := strconv.Atoi(hostPort)
322+ if err != nil {
323+ return nil, fmt.Errorf("parse standalone service %q port %q publish port: %w", svc.Name, port.Name, err)
324+ }
325+ publishes = append(publishes, domain.ContainerPortPublish{
326+ HostIP: host, HostPort: hostPortNumber, ContainerPort: port.Container, Protocol: port.Protocol,
327+ })
328+ }
329+ return publishes, nil
330+}
331+
332+func serviceConfigHash(svc domain.StandaloneService) (string, error) {
333+ payload := struct {
334+ Image string
335+ Env []string
336+ EnvFile string
337+ Secrets []domain.StandaloneServiceSecretRef
338+ Readiness domain.StandaloneServiceReadiness
339+ Ports []domain.StandaloneServicePort
340+ Volumes []domain.StandaloneServiceVolume
341+ Cleanup domain.StandaloneServiceCleanup
342+ }{svc.Image, append([]string(nil), svc.Env...), svc.EnvFile, append([]domain.StandaloneServiceSecretRef(nil), svc.Secrets...), svc.Readiness, append([]domain.StandaloneServicePort(nil), svc.Ports...), append([]domain.StandaloneServiceVolume(nil), svc.Volumes...), normalizeCleanup(svc.Cleanup)}
343+ sort.Strings(payload.Env)
344+ bytes, err := json.Marshal(payload)
345+ if err != nil {
346+ return "", err
347+ }
348+ sum := sha256.Sum256(bytes)
349+ return hex.EncodeToString(sum[:]), nil
350+}
351+
352+func (s *Service) serviceEnv(ctx context.Context, svc domain.StandaloneService) ([]string, error) {
353+ envMap := make(map[string]string)
354+ if svc.EnvFile != "" {
355+ fileEnv, err := loadEnvFile(ctx, svc.EnvFile)
356+ if err != nil {
357+ return nil, fmt.Errorf("load standalone service %q env file: %w", svc.Name, err)
358+ }
359+ maps.Copy(envMap, fileEnv)
360+ }
361+ for _, entry := range svc.Env {
362+ key, value, ok := strings.Cut(entry, "=")
363+ if !ok || key == "" {
364+ return nil, fmt.Errorf("parse standalone service %q env entry", svc.Name)
365+ }
366+ envMap[key] = value
367+ }
368+ if len(svc.Secrets) > 0 && s.secretProvider == nil {
369+ return nil, fmt.Errorf("resolve standalone service %q secrets: secret provider is not configured", svc.Name)
370+ }
371+ for _, secret := range svc.Secrets {
372+ value, err := s.secretProvider.GetSecret(ctx, serviceSecretPath(svc.Name, secret.Name))
373+ if err != nil {
374+ return nil, fmt.Errorf("resolve standalone service %q secret %q: %w", svc.Name, secret.Name, err)
375+ }
376+ envMap[secret.Key] = value
377+ }
378+ return envMapToList(envMap), nil
379+}
380+
381+func loadEnvFile(ctx context.Context, path string) (map[string]string, error) {
382+ if err := ctx.Err(); err != nil {
383+ return nil, err
384+ }
385+ file, err := os.Open(path)
386+ if err != nil {
387+ return nil, err
388+ }
389+ defer file.Close()
390+ values := make(map[string]string)
391+ scanner := bufio.NewScanner(file)
392+ for scanner.Scan() {
393+ if err := ctx.Err(); err != nil {
394+ return nil, err
395+ }
396+ line := strings.TrimSpace(scanner.Text())
397+ if line == "" || strings.HasPrefix(line, "#") {
398+ continue
399+ }
400+ key, value, ok := strings.Cut(line, "=")
401+ if !ok || strings.TrimSpace(key) == "" {
402+ return nil, fmt.Errorf("invalid env file line")
403+ }
404+ values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`)
405+ }
406+ if err := scanner.Err(); err != nil {
407+ return nil, err
408+ }
409+ return values, nil
410+}
411+
412+func serviceSecretPath(serviceName, secretName string) string {
413+ if strings.Contains(secretName, ":") {
414+ return secretName
415+ }
416+ return "service/" + serviceName + "/" + secretName
417+}
418+
419+func envMapToList(values map[string]string) []string {
420+ if len(values) == 0 {
421+ return nil
422+ }
423+ keys := make([]string, 0, len(values))
424+ for key := range values {
425+ keys = append(keys, key)
426+ }
427+ sort.Strings(keys)
428+ env := make([]string, 0, len(keys))
429+ for _, key := range keys {
430+ env = append(env, key+"="+values[key])
431+ }
432+ return env
433+}
434+
435+func (s *Service) waitReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
436+ readiness := svc.Readiness
437+ if readiness.Type == "" || readiness.Type == domain.StandaloneServiceReadinessNone {
438+ return nil
439+ }
440+ readyCtx := ctx
441+ cancel := func() {}
442+ if readiness.Timeout > 0 {
443+ readyCtx, cancel = context.WithTimeout(ctx, readiness.Timeout)
444+ }
445+ defer cancel()
446+ switch readiness.Type {
447+ case domain.StandaloneServiceReadinessTCP:
448+ return waitTCPReadiness(readyCtx, svc)
449+ case domain.StandaloneServiceReadinessLog:
450+ return s.waitLogReadiness(readyCtx, containerID, svc)
451+ default:
452+ return fmt.Errorf("unsupported standalone service %q readiness type %q", svc.Name, readiness.Type)
453+ }
454+}
455+
456+func waitTCPReadiness(ctx context.Context, svc domain.StandaloneService) error {
457+ address, err := tcpReadinessAddress(svc)
458+ if err != nil {
459+ return err
460+ }
461+ ticker := time.NewTicker(10 * time.Millisecond)
462+ defer ticker.Stop()
463+ for {
464+ if err := ctx.Err(); err != nil {
465+ return fmt.Errorf("standalone service %q tcp readiness timed out: %w", svc.Name, err)
466+ }
467+ dialer := net.Dialer{Timeout: 100 * time.Millisecond}
468+ conn, err := dialer.DialContext(ctx, "tcp", address)
469+ if err == nil {
470+ _ = conn.Close()
471+ return nil
472+ }
473+ select {
474+ case <-ctx.Done():
475+ return fmt.Errorf("standalone service %q tcp readiness timed out: %w", svc.Name, ctx.Err())
476+ case <-ticker.C:
477+ }
478+ }
479+}
480+
481+func tcpReadinessAddress(svc domain.StandaloneService) (string, error) {
482+ for _, port := range svc.Ports {
483+ if port.Protocol != domain.NetworkProtocolTCP || port.Publish == "" {
484+ continue
485+ }
486+ host, hostPort, err := net.SplitHostPort(port.Publish)
487+ if err != nil {
488+ return "", fmt.Errorf("parse standalone service %q tcp readiness publish: %w", svc.Name, err)
489+ }
490+ if host == "0.0.0.0" || host == "::" || host == "" {
491+ host = "127.0.0.1"
492+ }
493+ return net.JoinHostPort(host, hostPort), nil
494+ }
495+ return "", fmt.Errorf("standalone service %q tcp readiness requires a published tcp port", svc.Name)
496+}
497+
498+func (s *Service) waitLogReadiness(ctx context.Context, containerID string, svc domain.StandaloneService) error {
499+ ticker := time.NewTicker(10 * time.Millisecond)
500+ defer ticker.Stop()
501+ for {
502+ if err := ctx.Err(); err != nil {
503+ return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, err)
504+ }
505+ found, err := s.logContains(ctx, containerID, svc.Readiness.Path, svc.Readiness.Contains)
506+ if err == nil && found {
507+ return nil
508+ }
509+ if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
510+ // Continue polling until timeout; many services create the log after start.
511+ }
512+ select {
513+ case <-ctx.Done():
514+ return fmt.Errorf("standalone service %q log readiness timed out: %w", svc.Name, ctx.Err())
515+ case <-ticker.C:
516+ }
517+ }
518+}
519+
520+func (s *Service) logContains(ctx context.Context, containerID, path, contains string) (bool, error) {
521+ reader, err := s.runtime.CopyFromContainer(ctx, containerID, path)
522+ if err != nil {
523+ return false, err
524+ }
525+ defer reader.Close()
526+ content, err := io.ReadAll(reader)
527+ if err != nil {
528+ return false, err
529+ }
530+ return strings.Contains(string(content), contains), nil
531+}
532+
533+func normalizeCleanup(cleanup domain.StandaloneServiceCleanup) domain.StandaloneServiceCleanup {
534+ if !cleanup.PreserveVolumes && !cleanup.RemoveContainer {
535+ return domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true}
536+ }
537+ return cleanup
538+}
539+
540+func containerStatus(container *domain.Container) domain.ContainerStatus {
541+ status := strings.ToLower(container.Status)
542+ if strings.Contains(status, string(domain.ContainerStatusRunning)) {
543+ return domain.ContainerStatusRunning
544+ }
545+ if strings.Contains(status, string(domain.ContainerStatusExited)) {
546+ return domain.ContainerStatusExited
547+ }
548+ if strings.Contains(status, string(domain.ContainerStatusCreated)) {
549+ return domain.ContainerStatusCreated
550+ }
551+ if strings.Contains(status, string(domain.ContainerStatusPaused)) {
552+ return domain.ContainerStatusPaused
553+ }
554+ if strings.Contains(status, string(domain.ContainerStatusStopped)) {
555+ return domain.ContainerStatusStopped
556+ }
557+ return domain.ContainerStatusUnknown
558+}
559+
560+func serviceContainerName(name string) string {
561+ return "gordon-service-" + strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
562+}
563+
564+func emptyToNil(values map[string]string) map[string]string {
565+ if len(values) == 0 {
566+ return nil
567+ }
568+ return values
569+}
A internal/usecase/services/service_test.go +343 -0
@@ -0,0 +1,343 @@
1+package services
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "net"
8+ "os"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/adapters/out/secrets"
18+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
19+ "github.com/bnema/gordon/internal/domain"
20+)
21+
22+func TestService_ReconcileCreatesMissingEnabledService(t *testing.T) {
23+ rt := outmocks.NewMockContainerRuntime(t)
24+ svc := sampleService()
25+ var created *domain.ContainerConfig
26+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
27+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/data"}, nil).Once()
28+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
29+ created = args.Get(1).(*domain.ContainerConfig)
30+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
31+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
32+
33+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
34+
35+ require.NoError(t, err)
36+ require.NotNil(t, created)
37+ assert.Equal(t, "game:latest", created.Image)
38+ assert.Equal(t, "gordon-service-game", created.Name)
39+ assert.Equal(t, []string{"PUBLIC=value"}, created.Env)
40+ assert.Equal(t, domain.RestartPolicyAlways, created.RestartPolicy)
41+ assert.Equal(t, "true", created.Labels[domain.LabelManaged])
42+ assert.Equal(t, "true", created.Labels[domain.LabelService])
43+ assert.Equal(t, "game", created.Labels[domain.LabelServiceName])
44+ assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupPreserveVolumes])
45+ assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupRemoveContainer])
46+ assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash])
47+ assert.Equal(t, "gordon-service-game-data", created.Labels[domain.LabelServiceManagedVolumes])
48+ assert.Equal(t, []domain.ContainerPortPublish{{HostIP: "127.0.0.1", HostPort: 38015, ContainerPort: 28015, Protocol: domain.NetworkProtocolUDP}}, created.PortPublishes)
49+ assert.Equal(t, map[string]string{"/data": "gordon-service-game-data"}, created.Volumes)
50+}
51+
52+func TestService_ReconcileStartsStoppedExistingService(t *testing.T) {
53+ rt := outmocks.NewMockContainerRuntime(t)
54+ svc := sampleService()
55+ hash, err := serviceConfigHash(svc)
56+ require.NoError(t, err)
57+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "exited")}, nil).Once()
58+ rt.On("StartContainer", mock.Anything, "existing-1").Return(nil).Once()
59+
60+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
61+
62+ require.NoError(t, err)
63+}
64+
65+func TestService_ReconcileChecksReadinessForExistingMatchingService(t *testing.T) {
66+ rt := outmocks.NewMockContainerRuntime(t)
67+ svc := sampleService()
68+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
69+ hash, err := serviceConfigHash(svc)
70+ require.NoError(t, err)
71+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "running")}, nil).Once()
72+ rt.On("CopyFromContainer", mock.Anything, "existing-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("ready")), nil).Once()
73+
74+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
75+
76+ require.NoError(t, err)
77+}
78+
79+func TestService_ReconcileRecreatesStaleService(t *testing.T) {
80+ rt := outmocks.NewMockContainerRuntime(t)
81+ svc := sampleService()
82+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("old-1", svc.Name, "old-hash", "running")}, nil).Once()
83+ rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once()
84+ rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once()
85+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
86+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once()
87+ rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once()
88+
89+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
90+
91+ require.NoError(t, err)
92+}
93+
94+func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
95+ rt := outmocks.NewMockContainerRuntime(t)
96+ svc := sampleService()
97+ svc.Enabled = false
98+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("disabled-1", svc.Name, "old-hash", "running")}, nil).Once()
99+ rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
100+ rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
101+
102+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
103+
104+ require.NoError(t, err)
105+}
106+
107+func TestService_ReconcileStopsAndRemovesOmittedManagedService(t *testing.T) {
108+ rt := outmocks.NewMockContainerRuntime(t)
109+ container := managedContainer("removed-1", "removed", "old-hash", "running")
110+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
111+ rt.On("StopContainer", mock.Anything, "removed-1").Return(nil).Once()
112+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
113+
114+ err := NewService(rt).Reconcile(context.Background(), nil)
115+
116+ require.NoError(t, err)
117+}
118+
119+func TestService_ReconcileRemovesNamedVolumeMountsWhenCleanupDoesNotPreserveVolumes(t *testing.T) {
120+ rt := outmocks.NewMockContainerRuntime(t)
121+ svc := sampleService()
122+ svc.Enabled = false
123+ svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true}
124+ container := managedContainer("disabled-1", svc.Name, "old-hash", "running")
125+ container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data"
126+ container.VolumeMounts = []domain.ContainerVolumeMount{
127+ {Name: "gordon-service-game-data", Type: "volume", Destination: "/data"},
128+ {Name: "user-shared-data", Type: "volume", Destination: "/shared"},
129+ {Name: "", Type: "volume", Destination: "/anonymous"},
130+ {Name: "host-data", Type: "bind", Destination: "/host"},
131+ }
132+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
133+ rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
134+ rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
135+ rt.On("RemoveVolume", mock.Anything, "gordon-service-game-data", true).Return(nil).Once()
136+
137+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
138+
139+ require.NoError(t, err)
140+}
141+
142+func TestService_ReconcileUsesCleanupLabelsForOmittedService(t *testing.T) {
143+ rt := outmocks.NewMockContainerRuntime(t)
144+ container := managedContainer("removed-1", "removed", "old-hash", "exited")
145+ container.Labels[domain.LabelServiceCleanupPreserveVolumes] = "false"
146+ container.Labels[domain.LabelServiceCleanupRemoveContainer] = "true"
147+ container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-removed-data"
148+ container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
149+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
150+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
151+ rt.On("RemoveVolume", mock.Anything, "gordon-service-removed-data", true).Return(nil).Once()
152+
153+ err := NewService(rt).Reconcile(context.Background(), nil)
154+
155+ require.NoError(t, err)
156+}
157+
158+func TestService_ReconcileDefaultsOmittedServiceCleanupToPreserveVolumes(t *testing.T) {
159+ rt := outmocks.NewMockContainerRuntime(t)
160+ container := managedContainer("removed-1", "removed", "old-hash", "exited")
161+ container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
162+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
163+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
164+
165+ err := NewService(rt).Reconcile(context.Background(), nil)
166+
167+ require.NoError(t, err)
168+}
169+
170+func TestService_ReconcileUsesExplicitReadOnlyVolumes(t *testing.T) {
171+ rt := outmocks.NewMockContainerRuntime(t)
172+ svc := sampleService()
173+ svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}
174+ var created *domain.ContainerConfig
175+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
176+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/ignored"}, nil).Once()
177+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
178+ created = args.Get(1).(*domain.ContainerConfig)
179+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
180+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
181+
182+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
183+
184+ require.NoError(t, err)
185+ assert.Nil(t, created.Volumes)
186+ assert.Equal(t, map[string]string{"/cfg": "cfg"}, created.ReadOnlyVolumes)
187+}
188+
189+func TestService_StatusReturnsManagedServices(t *testing.T) {
190+ rt := outmocks.NewMockContainerRuntime(t)
191+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{
192+ managedContainer("id-1", "alpha", "hash-1", "running"),
193+ {ID: "other", Labels: map[string]string{}},
194+ }, nil).Once()
195+
196+ statuses, err := NewService(rt).Status(context.Background())
197+
198+ require.NoError(t, err)
199+ require.Len(t, statuses, 1)
200+ assert.Equal(t, "alpha", statuses[0].Name)
201+ assert.Equal(t, "id-1", statuses[0].ContainerID)
202+ assert.Equal(t, domain.ContainerStatusRunning, statuses[0].Status)
203+ assert.Equal(t, "hash-1", statuses[0].ConfigHash)
204+}
205+
206+func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) {
207+ envFile := writeTempEnvFile(t, "FROM_FILE=file\nOVERRIDE=file\n# ignored\n")
208+ svc := sampleService()
209+ svc.EnvFile = envFile
210+ svc.Env = []string{"OVERRIDE=inline", "INLINE=value"}
211+
212+ env, err := NewService(nil).serviceEnv(context.Background(), svc)
213+
214+ require.NoError(t, err)
215+ assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)
216+}
217+
218+func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
219+ rt := outmocks.NewMockContainerRuntime(t)
220+ provider := outmocks.NewMockSecretProvider(t)
221+ svc := sampleService()
222+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
223+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("super-secret-value", errors.New("provider failed")).Once()
224+
225+ env, err := NewServiceWithSecretProvider(rt, provider).serviceEnv(context.Background(), svc)
226+
227+ require.Error(t, err)
228+ assert.Nil(t, env)
229+ assert.NotContains(t, err.Error(), "super-secret-value")
230+}
231+
232+func TestServiceSecretPathIsPassProviderSafe(t *testing.T) {
233+ path := serviceSecretPath("game", "rcon")
234+
235+ require.NoError(t, secrets.ValidatePath(path))
236+ assert.Equal(t, "service/game/rcon", path)
237+}
238+
239+func TestServiceSecretPathPreservesExplicitProviderPath(t *testing.T) {
240+ path := serviceSecretPath("game", "secrets/service.yaml:game.rcon")
241+
242+ assert.Equal(t, "secrets/service.yaml:game.rcon", path)
243+}
244+
245+func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) {
246+ provider := outmocks.NewMockSecretProvider(t)
247+ svc := sampleService()
248+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "secrets/service.yaml:game.rcon", Key: "RCON_PASSWORD"}}
249+ provider.On("GetSecret", mock.Anything, "secrets/service.yaml:game.rcon").Return("secret-value", nil).Once()
250+
251+ env, err := NewServiceWithSecretProvider(nil, provider).serviceEnv(context.Background(), svc)
252+
253+ require.NoError(t, err)
254+ assert.Contains(t, env, "RCON_PASSWORD=secret-value")
255+}
256+
257+func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {
258+ listener, err := net.Listen("tcp", "127.0.0.1:0")
259+ require.NoError(t, err)
260+ defer listener.Close()
261+ accepted := make(chan struct{})
262+ go func() {
263+ conn, acceptErr := listener.Accept()
264+ if acceptErr == nil {
265+ _ = conn.Close()
266+ close(accepted)
267+ }
268+ }()
269+ svc := sampleService()
270+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP, Timeout: time.Second}
271+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: listener.Addr().String()}}
272+
273+ err = NewService(nil).waitReadiness(context.Background(), "container-1", svc)
274+
275+ require.NoError(t, err)
276+ <-accepted
277+}
278+
279+func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
280+ rt := outmocks.NewMockContainerRuntime(t)
281+ svc := sampleService()
282+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
283+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("starting")), nil).Once()
284+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("server ready")), nil).Once()
285+
286+ err := NewService(rt).waitReadiness(context.Background(), "container-1", svc)
287+
288+ require.NoError(t, err)
289+}
290+
291+func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
292+ svc := sampleService()
293+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}
294+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:1"}}
295+ ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
296+ defer cancel()
297+
298+ err := NewService(nil).waitReadiness(ctx, "container-1", svc)
299+
300+ require.Error(t, err)
301+ assert.ErrorIs(t, err, context.DeadlineExceeded)
302+}
303+
304+func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) {
305+ cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{})
306+
307+ assert.True(t, cleanup.PreserveVolumes)
308+ assert.True(t, cleanup.RemoveContainer)
309+}
310+
311+func sampleService() domain.StandaloneService {
312+ return domain.StandaloneService{
313+ Name: "game",
314+ Image: "game:latest",
315+ Enabled: true,
316+ Env: []string{"PUBLIC=value"},
317+ Cleanup: domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true},
318+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
319+ }
320+}
321+
322+func managedContainer(id, name, hash, status string) *domain.Container {
323+ return &domain.Container{
324+ ID: id,
325+ Name: "gordon-service-" + name,
326+ Status: status,
327+ Labels: map[string]string{
328+ domain.LabelService: "true",
329+ domain.LabelServiceName: name,
330+ domain.LabelServiceConfigHash: hash,
331+ },
332+ }
333+}
334+
335+func writeTempEnvFile(t *testing.T, content string) string {
336+ t.Helper()
337+ file, err := os.CreateTemp(t.TempDir(), "service-*.env")
338+ require.NoError(t, err)
339+ _, err = file.WriteString(content)
340+ require.NoError(t, err)
341+ require.NoError(t, file.Close())
342+ return file.Name()
343+}
@@ -0,0 +1,343 @@
1+package services
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "net"
8+ "os"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/gordon/internal/adapters/out/secrets"
18+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
19+ "github.com/bnema/gordon/internal/domain"
20+)
21+
22+func TestService_ReconcileCreatesMissingEnabledService(t *testing.T) {
23+ rt := outmocks.NewMockContainerRuntime(t)
24+ svc := sampleService()
25+ var created *domain.ContainerConfig
26+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
27+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/data"}, nil).Once()
28+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
29+ created = args.Get(1).(*domain.ContainerConfig)
30+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
31+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
32+
33+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
34+
35+ require.NoError(t, err)
36+ require.NotNil(t, created)
37+ assert.Equal(t, "game:latest", created.Image)
38+ assert.Equal(t, "gordon-service-game", created.Name)
39+ assert.Equal(t, []string{"PUBLIC=value"}, created.Env)
40+ assert.Equal(t, domain.RestartPolicyAlways, created.RestartPolicy)
41+ assert.Equal(t, "true", created.Labels[domain.LabelManaged])
42+ assert.Equal(t, "true", created.Labels[domain.LabelService])
43+ assert.Equal(t, "game", created.Labels[domain.LabelServiceName])
44+ assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupPreserveVolumes])
45+ assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupRemoveContainer])
46+ assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash])
47+ assert.Equal(t, "gordon-service-game-data", created.Labels[domain.LabelServiceManagedVolumes])
48+ assert.Equal(t, []domain.ContainerPortPublish{{HostIP: "127.0.0.1", HostPort: 38015, ContainerPort: 28015, Protocol: domain.NetworkProtocolUDP}}, created.PortPublishes)
49+ assert.Equal(t, map[string]string{"/data": "gordon-service-game-data"}, created.Volumes)
50+}
51+
52+func TestService_ReconcileStartsStoppedExistingService(t *testing.T) {
53+ rt := outmocks.NewMockContainerRuntime(t)
54+ svc := sampleService()
55+ hash, err := serviceConfigHash(svc)
56+ require.NoError(t, err)
57+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "exited")}, nil).Once()
58+ rt.On("StartContainer", mock.Anything, "existing-1").Return(nil).Once()
59+
60+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
61+
62+ require.NoError(t, err)
63+}
64+
65+func TestService_ReconcileChecksReadinessForExistingMatchingService(t *testing.T) {
66+ rt := outmocks.NewMockContainerRuntime(t)
67+ svc := sampleService()
68+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
69+ hash, err := serviceConfigHash(svc)
70+ require.NoError(t, err)
71+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "running")}, nil).Once()
72+ rt.On("CopyFromContainer", mock.Anything, "existing-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("ready")), nil).Once()
73+
74+ err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
75+
76+ require.NoError(t, err)
77+}
78+
79+func TestService_ReconcileRecreatesStaleService(t *testing.T) {
80+ rt := outmocks.NewMockContainerRuntime(t)
81+ svc := sampleService()
82+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("old-1", svc.Name, "old-hash", "running")}, nil).Once()
83+ rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once()
84+ rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once()
85+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
86+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once()
87+ rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once()
88+
89+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
90+
91+ require.NoError(t, err)
92+}
93+
94+func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
95+ rt := outmocks.NewMockContainerRuntime(t)
96+ svc := sampleService()
97+ svc.Enabled = false
98+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("disabled-1", svc.Name, "old-hash", "running")}, nil).Once()
99+ rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
100+ rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
101+
102+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
103+
104+ require.NoError(t, err)
105+}
106+
107+func TestService_ReconcileStopsAndRemovesOmittedManagedService(t *testing.T) {
108+ rt := outmocks.NewMockContainerRuntime(t)
109+ container := managedContainer("removed-1", "removed", "old-hash", "running")
110+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
111+ rt.On("StopContainer", mock.Anything, "removed-1").Return(nil).Once()
112+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
113+
114+ err := NewService(rt).Reconcile(context.Background(), nil)
115+
116+ require.NoError(t, err)
117+}
118+
119+func TestService_ReconcileRemovesNamedVolumeMountsWhenCleanupDoesNotPreserveVolumes(t *testing.T) {
120+ rt := outmocks.NewMockContainerRuntime(t)
121+ svc := sampleService()
122+ svc.Enabled = false
123+ svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true}
124+ container := managedContainer("disabled-1", svc.Name, "old-hash", "running")
125+ container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data"
126+ container.VolumeMounts = []domain.ContainerVolumeMount{
127+ {Name: "gordon-service-game-data", Type: "volume", Destination: "/data"},
128+ {Name: "user-shared-data", Type: "volume", Destination: "/shared"},
129+ {Name: "", Type: "volume", Destination: "/anonymous"},
130+ {Name: "host-data", Type: "bind", Destination: "/host"},
131+ }
132+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
133+ rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
134+ rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
135+ rt.On("RemoveVolume", mock.Anything, "gordon-service-game-data", true).Return(nil).Once()
136+
137+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
138+
139+ require.NoError(t, err)
140+}
141+
142+func TestService_ReconcileUsesCleanupLabelsForOmittedService(t *testing.T) {
143+ rt := outmocks.NewMockContainerRuntime(t)
144+ container := managedContainer("removed-1", "removed", "old-hash", "exited")
145+ container.Labels[domain.LabelServiceCleanupPreserveVolumes] = "false"
146+ container.Labels[domain.LabelServiceCleanupRemoveContainer] = "true"
147+ container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-removed-data"
148+ container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
149+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
150+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
151+ rt.On("RemoveVolume", mock.Anything, "gordon-service-removed-data", true).Return(nil).Once()
152+
153+ err := NewService(rt).Reconcile(context.Background(), nil)
154+
155+ require.NoError(t, err)
156+}
157+
158+func TestService_ReconcileDefaultsOmittedServiceCleanupToPreserveVolumes(t *testing.T) {
159+ rt := outmocks.NewMockContainerRuntime(t)
160+ container := managedContainer("removed-1", "removed", "old-hash", "exited")
161+ container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
162+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
163+ rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
164+
165+ err := NewService(rt).Reconcile(context.Background(), nil)
166+
167+ require.NoError(t, err)
168+}
169+
170+func TestService_ReconcileUsesExplicitReadOnlyVolumes(t *testing.T) {
171+ rt := outmocks.NewMockContainerRuntime(t)
172+ svc := sampleService()
173+ svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}
174+ var created *domain.ContainerConfig
175+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
176+ rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/ignored"}, nil).Once()
177+ rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
178+ created = args.Get(1).(*domain.ContainerConfig)
179+ }).Return(&domain.Container{ID: "created-1"}, nil).Once()
180+ rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
181+
182+ err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
183+
184+ require.NoError(t, err)
185+ assert.Nil(t, created.Volumes)
186+ assert.Equal(t, map[string]string{"/cfg": "cfg"}, created.ReadOnlyVolumes)
187+}
188+
189+func TestService_StatusReturnsManagedServices(t *testing.T) {
190+ rt := outmocks.NewMockContainerRuntime(t)
191+ rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{
192+ managedContainer("id-1", "alpha", "hash-1", "running"),
193+ {ID: "other", Labels: map[string]string{}},
194+ }, nil).Once()
195+
196+ statuses, err := NewService(rt).Status(context.Background())
197+
198+ require.NoError(t, err)
199+ require.Len(t, statuses, 1)
200+ assert.Equal(t, "alpha", statuses[0].Name)
201+ assert.Equal(t, "id-1", statuses[0].ContainerID)
202+ assert.Equal(t, domain.ContainerStatusRunning, statuses[0].Status)
203+ assert.Equal(t, "hash-1", statuses[0].ConfigHash)
204+}
205+
206+func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) {
207+ envFile := writeTempEnvFile(t, "FROM_FILE=file\nOVERRIDE=file\n# ignored\n")
208+ svc := sampleService()
209+ svc.EnvFile = envFile
210+ svc.Env = []string{"OVERRIDE=inline", "INLINE=value"}
211+
212+ env, err := NewService(nil).serviceEnv(context.Background(), svc)
213+
214+ require.NoError(t, err)
215+ assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)
216+}
217+
218+func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
219+ rt := outmocks.NewMockContainerRuntime(t)
220+ provider := outmocks.NewMockSecretProvider(t)
221+ svc := sampleService()
222+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
223+ provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("super-secret-value", errors.New("provider failed")).Once()
224+
225+ env, err := NewServiceWithSecretProvider(rt, provider).serviceEnv(context.Background(), svc)
226+
227+ require.Error(t, err)
228+ assert.Nil(t, env)
229+ assert.NotContains(t, err.Error(), "super-secret-value")
230+}
231+
232+func TestServiceSecretPathIsPassProviderSafe(t *testing.T) {
233+ path := serviceSecretPath("game", "rcon")
234+
235+ require.NoError(t, secrets.ValidatePath(path))
236+ assert.Equal(t, "service/game/rcon", path)
237+}
238+
239+func TestServiceSecretPathPreservesExplicitProviderPath(t *testing.T) {
240+ path := serviceSecretPath("game", "secrets/service.yaml:game.rcon")
241+
242+ assert.Equal(t, "secrets/service.yaml:game.rcon", path)
243+}
244+
245+func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) {
246+ provider := outmocks.NewMockSecretProvider(t)
247+ svc := sampleService()
248+ svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "secrets/service.yaml:game.rcon", Key: "RCON_PASSWORD"}}
249+ provider.On("GetSecret", mock.Anything, "secrets/service.yaml:game.rcon").Return("secret-value", nil).Once()
250+
251+ env, err := NewServiceWithSecretProvider(nil, provider).serviceEnv(context.Background(), svc)
252+
253+ require.NoError(t, err)
254+ assert.Contains(t, env, "RCON_PASSWORD=secret-value")
255+}
256+
257+func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {
258+ listener, err := net.Listen("tcp", "127.0.0.1:0")
259+ require.NoError(t, err)
260+ defer listener.Close()
261+ accepted := make(chan struct{})
262+ go func() {
263+ conn, acceptErr := listener.Accept()
264+ if acceptErr == nil {
265+ _ = conn.Close()
266+ close(accepted)
267+ }
268+ }()
269+ svc := sampleService()
270+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP, Timeout: time.Second}
271+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: listener.Addr().String()}}
272+
273+ err = NewService(nil).waitReadiness(context.Background(), "container-1", svc)
274+
275+ require.NoError(t, err)
276+ <-accepted
277+}
278+
279+func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
280+ rt := outmocks.NewMockContainerRuntime(t)
281+ svc := sampleService()
282+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
283+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("starting")), nil).Once()
284+ rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("server ready")), nil).Once()
285+
286+ err := NewService(rt).waitReadiness(context.Background(), "container-1", svc)
287+
288+ require.NoError(t, err)
289+}
290+
291+func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
292+ svc := sampleService()
293+ svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}
294+ svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:1"}}
295+ ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
296+ defer cancel()
297+
298+ err := NewService(nil).waitReadiness(ctx, "container-1", svc)
299+
300+ require.Error(t, err)
301+ assert.ErrorIs(t, err, context.DeadlineExceeded)
302+}
303+
304+func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) {
305+ cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{})
306+
307+ assert.True(t, cleanup.PreserveVolumes)
308+ assert.True(t, cleanup.RemoveContainer)
309+}
310+
311+func sampleService() domain.StandaloneService {
312+ return domain.StandaloneService{
313+ Name: "game",
314+ Image: "game:latest",
315+ Enabled: true,
316+ Env: []string{"PUBLIC=value"},
317+ Cleanup: domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true},
318+ Ports: []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
319+ }
320+}
321+
322+func managedContainer(id, name, hash, status string) *domain.Container {
323+ return &domain.Container{
324+ ID: id,
325+ Name: "gordon-service-" + name,
326+ Status: status,
327+ Labels: map[string]string{
328+ domain.LabelService: "true",
329+ domain.LabelServiceName: name,
330+ domain.LabelServiceConfigHash: hash,
331+ },
332+ }
333+}
334+
335+func writeTempEnvFile(t *testing.T, content string) string {
336+ t.Helper()
337+ file, err := os.CreateTemp(t.TempDir(), "service-*.env")
338+ require.NoError(t, err)
339+ _, err = file.WriteString(content)
340+ require.NoError(t, err)
341+ require.NoError(t, file.Close())
342+ return file.Name()
343+}

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