feat(services): reconcile standalone service containers
- commit
- 8d2c54f8bb6f5237e636747839f7b75aa46bbcb4
- author
- brice · · 2026-06-30 15:28 UTC
- parent
- d9c6a56
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 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 | 175 | // Convert volumes to Docker format | |
| 165 | 176 | 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 format | 175 | // Convert volumes to Docker format | ||
| 165 | var binds []string | 176 | var binds []string | ||
M internal/adapters/out/docker/runtime_create_test.go +74 -11
| @@ -15,7 +15,78 @@ import ( | |||
| 15 | 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 | 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 | 90 | var createBody map[string]any | |
| 20 | 91 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 21 | 92 | switch { | |
| @@ -45,18 +116,10 @@ func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) { | |||
| 45 | 116 | require.NoError(t, err) | |
| 46 | 117 | ||
| 47 | 118 | 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) | |
| 53 | 120 | require.NoError(t, err) | |
| 54 | 121 | assert.Equal(t, "abc123", created.ID) | |
| 55 | - | ||
| 56 | 122 | 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 | |
| 62 | 125 | } | |
| @@ -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]any | 90 | 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 { | |||
| 263 | 263 | ||
| 264 | 264 | // services holds all the services used by the application. | |
| 265 | 265 | 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 { | |
| 308 | 310 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 309 | 311 | } | |
| 310 | 312 | } | |
| @@ -716,6 +718,7 @@ func (si *serviceInit) initSecrets() error { | |||
| 716 | 718 | return err | |
| 717 | 719 | } | |
| 718 | 720 | ||
| 721 | + | si.svc.serviceSecretProvider = createStandaloneServiceSecretProvider(backend, resolveDataDir(si.cfg.Server.DataDir), si.log) | |
| 719 | 722 | si.svc.secretSvc = secretsSvc.NewService(domainSecretStore, si.log, si.svc.eventBus) | |
| 720 | 723 | return nil | |
| 721 | 724 | } | |
| @@ -756,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 756 | 759 | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | |
| 757 | 760 | si.svc.maxBlobSize = proxyCfg.maxBlobSize | |
| 758 | 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 | 764 | // Wire synchronous proxy cache invalidation for zero-downtime deployments. | |
| 761 | 765 | // The proxy service implements out.ProxyCacheInvalidator via InvalidateTarget(). | |
| @@ -785,6 +789,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 785 | 789 | } | |
| 786 | 790 | si.svc.tlsHTTPEntryPoints = registerTLSMuxHTTPServers(si.svc.trafficManager, reloadCfg, si.svc.httpsProxyHandler, tlsConfig, si.svc.tlsHTTPEntryPoints) | |
| 787 | 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 | 795 | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |
| 789 | 796 | return err | |
| 790 | 797 | } | |
| @@ -1657,6 +1664,31 @@ func readFileBeneath(root, cleanedRelPath string) ([]byte, error) { | |||
| 1657 | 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 | 1692 | func readUnsafeSecret(dataDir, secretPath string) (string, error) { | |
| 1661 | 1693 | if filepath.IsAbs(secretPath) { | |
| 1662 | 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 | 2931 | if err := waitForServerReady(proxyReady, errChan); err != nil { | |
| 2900 | 2932 | return err | |
| 2901 | 2933 | } | |
| 2934 | + | if err := reconcileStandaloneServices(ctx, svc.standaloneServiceSvc, cfg); err != nil { | |
| 2935 | + | return err | |
| 2936 | + | } | |
| 2902 | 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 | 2954 | func shutdownTrafficManagerForStartupCleanup(manager *trafficadapter.Manager, log zerowrap.Logger) { | |
| 2906 | 2955 | if manager == nil { | |
| 2907 | 2956 | 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.Runtime | 266 | + | runtime *docker.Runtime |
| 267 | - | eventBus *eventbus.InMemory | 267 | + | eventBus *eventbus.InMemory |
| 268 | - | blobStorage *filesystem.BlobStorage | 268 | + | blobStorage *filesystem.BlobStorage |
| 269 | - | manifestStorage *filesystem.ManifestStorage | 269 | + | manifestStorage *filesystem.ManifestStorage |
| 270 | - | backupStorage *filesystem.BackupStorage | 270 | + | backupStorage *filesystem.BackupStorage |
| 271 | - | volumeBackupStore out.VolumeBackupStorage | 271 | + | volumeBackupStore out.VolumeBackupStorage |
| 272 | - | volumeBackupCfg domain.VolumeBackupConfig | 272 | + | volumeBackupCfg domain.VolumeBackupConfig |
| 273 | - | envLoader out.EnvLoader | 273 | + | envLoader out.EnvLoader |
| 274 | - | logWriter *logwriter.LogWriter | 274 | + | logWriter *logwriter.LogWriter |
| 275 | - | tokenStore out.TokenStore | 275 | + | tokenStore out.TokenStore |
| 276 | - | configSvc *config.Service | 276 | + | configSvc *config.Service |
| 277 | - | secretSvc *secretsSvc.Service | 277 | + | secretSvc *secretsSvc.Service |
| 278 | - | containerSvc *container.Service | 278 | + | containerSvc *container.Service |
| 279 | - | backupSvc *backup.Service | 279 | + | backupSvc *backup.Service |
| 280 | - | volumeBackupSvc *backup.VolumeService | 280 | + | volumeBackupSvc *backup.VolumeService |
| 281 | - | registrySvc *registrySvc.Service | 281 | + | registrySvc *registrySvc.Service |
| 282 | - | healthSvc *health.Service | 282 | + | healthSvc *health.Service |
| 283 | - | logSvc *logs.Service | 283 | + | logSvc *logs.Service |
| 284 | - | imageSvc *images.Service | 284 | + | imageSvc *images.Service |
| 285 | - | volumeSvc *volumesSvc.Service | 285 | + | volumeSvc *volumesSvc.Service |
| 286 | - | proxySvc *proxy.Service | 286 | + | proxySvc *proxy.Service |
| 287 | - | authSvc *auth.Service | 287 | + | standaloneServiceSvc in.StandaloneServiceService |
| 288 | - | authHandler *authhandler.Handler | 288 | + | serviceSecretProvider out.SecretProvider |
| 289 | - | adminHandler *admin.Handler | 289 | + | authSvc *auth.Service |
| 290 | - | httpProxyHandler http.Handler | 290 | + | authHandler *authhandler.Handler |
| 291 | - | httpsProxyHandler http.Handler | 291 | + | adminHandler *admin.Handler |
| 292 | - | internalRegUser string | 292 | + | httpProxyHandler http.Handler |
| 293 | - | internalRegPass string | 293 | + | httpsProxyHandler http.Handler |
| 294 | - | previewStore *filesystem.PreviewStore | 294 | + | internalRegUser string |
| 295 | - | previewService *preview.Service | 295 | + | internalRegPass string |
| 296 | - | envDir string | 296 | + | previewStore *filesystem.PreviewStore |
| 297 | - | maxBlobChunkSize int64 | 297 | + | previewService *preview.Service |
| 298 | - | maxBlobSize int64 | 298 | + | envDir string |
| 299 | - | caAdapter *pkiadapter.CA | 299 | + | maxBlobChunkSize int64 |
| 300 | - | pkiSvc *pkiusecase.Service | 300 | + | maxBlobSize int64 |
| 301 | - | reloadCoordinator *reloadCoordinator | 301 | + | caAdapter *pkiadapter.CA |
| 302 | - | publicTLSSvc in.PublicTLSService | 302 | + | pkiSvc *pkiusecase.Service |
| 303 | - | publicTLSRuntime publicTLSRuntime | 303 | + | reloadCoordinator *reloadCoordinator |
| 304 | - | trafficManager *trafficadapter.Manager | 304 | + | 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 err | 718 | 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 nil | 723 | return nil | ||
| 721 | } | 724 | } | ||
| @@ -756,6 +759,7 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||||
| 756 | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | 759 | si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize | ||
| 757 | si.svc.maxBlobSize = proxyCfg.maxBlobSize | 760 | 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 err | 796 | 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 err | 2932 | 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 | return | 2956 | return | ||
M internal/app/run_reload_test.go +145 -0
| @@ -5,7 +5,10 @@ import ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 8 | 10 | "strconv" | |
| 11 | + | "strings" | |
| 9 | 12 | "sync" | |
| 10 | 13 | "testing" | |
| 11 | 14 | "time" | |
| @@ -13,14 +16,17 @@ import ( | |||
| 13 | 16 | "github.com/bnema/zerowrap" | |
| 14 | 17 | "github.com/spf13/viper" | |
| 15 | 18 | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 16 | 20 | "github.com/stretchr/testify/require" | |
| 17 | 21 | ||
| 18 | 22 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 19 | 23 | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 24 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 20 | 25 | "github.com/bnema/gordon/internal/domain" | |
| 21 | 26 | cfgusecase "github.com/bnema/gordon/internal/usecase/config" | |
| 22 | 27 | "github.com/bnema/gordon/internal/usecase/container" | |
| 23 | 28 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 29 | + | servicecfg "github.com/bnema/gordon/internal/usecase/services" | |
| 24 | 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 | 138 | 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 | 172 | type eventBusRecorder struct { | |
| 136 | 173 | mu sync.Mutex | |
| 137 | 174 | calls int | |
| @@ -316,6 +353,114 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier( | |||
| 316 | 353 | ||
| 317 | 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 | 464 | func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |
| 320 | 465 | ctx := context.Background() | |
| 321 | 466 | 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 nil | 138 | 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.Mutex | 173 | mu sync.Mutex | ||
| 137 | calls int | 174 | 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 { | |||
| 63 | 63 | 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 | 74 | // ContainerConfig holds configuration for creating a container. | |
| 67 | 75 | type ContainerConfig struct { | |
| 68 | 76 | Image string | |
| 69 | 77 | Name string | |
| 70 | 78 | Env []string | |
| 71 | 79 | Ports []int | |
| 80 | + | PortPublishes []ContainerPortPublish | |
| 72 | 81 | Labels map[string]string | |
| 73 | 82 | WorkingDir string | |
| 74 | 83 | Cmd []string | |
| @@ -63,12 +63,21 @@ type RouteInfo struct { | |||||
| 63 | Attachments []Attachment | 63 | 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 string | 76 | Image string | ||
| 69 | Name string | 77 | Name string | ||
| 70 | Env []string | 78 | Env []string | ||
| 71 | Ports []int | 79 | Ports []int | ||
| 80 | + | PortPublishes []ContainerPortPublish | |||
| 72 | Labels map[string]string | 81 | Labels map[string]string | ||
| 73 | WorkingDir string | 82 | WorkingDir string | ||
| 74 | Cmd []string | 83 | Cmd []string | ||
M internal/domain/labels.go +8 -0
| @@ -15,6 +15,14 @@ const ( | |||
| 15 | 15 | // exposing secret values. | |
| 16 | 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 | 26 | // LabelProxyPort specifies the container port to proxy HTTP traffic to. | |
| 19 | 27 | LabelProxyPort = "gordon.proxy.port" | |
| 20 | 28 | ||
| @@ -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 { | |||
| 28 | 28 | 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 | 39 | type StandaloneServicePort struct { | |
| 32 | 40 | Name string | |
| 33 | 41 | Container int | |
| @@ -28,6 +28,14 @@ type StandaloneService struct { | |||||
| 28 | Volumes []StandaloneServiceVolume | 28 | 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 string | 40 | Name string | ||
| 33 | Container int | 41 | Container int | ||
M internal/usecase/services/config.go +2 -1
| @@ -40,6 +40,7 @@ type ResolvedVolumeMount struct { | |||
| 40 | 40 | Source string | |
| 41 | 41 | Target string | |
| 42 | 42 | ReadOnly bool | |
| 43 | + | Managed bool | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | 46 | type SecretRefConfig struct { | |
| @@ -173,7 +174,7 @@ func ResolveVolumeMounts(prefix, serviceName string, explicitVolumes []domain.St | |||
| 173 | 174 | ||
| 174 | 175 | mounts := make([]ResolvedVolumeMount, 0, len(imageVolumePaths)) | |
| 175 | 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 | 179 | return mounts | |
| 179 | 180 | } | |
| @@ -40,6 +40,7 @@ type ResolvedVolumeMount struct { | |||||
| 40 | Source string | 40 | Source string | ||
| 41 | Target string | 41 | Target string | ||
| 42 | ReadOnly bool | 42 | 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 mounts | 179 | return mounts | ||
| 179 | } | 180 | } | ||
M internal/usecase/services/config_test.go +2 -2
| @@ -59,8 +59,8 @@ func TestResolveVolumeMountsUsesImageDiscoveredVolumesWhenExplicitOmitted(t *tes | |||
| 59 | 59 | mounts := ResolveVolumeMounts("gordon", "rust.example.com", nil, []string{"/steamcmd/rust", "/var/lib/data"}) | |
| 60 | 60 | ||
| 61 | 61 | assert.Equal(t, []ResolvedVolumeMount{ | |
| 62 | - | {Source: "gordon-rust-example-com-steamcmd-rust", Target: "/steamcmd/rust"}, | |
| 63 | - | {Source: "gordon-rust-example-com-var-lib-data", Target: "/var/lib/data"}, | |
| 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}, | |
| 64 | 64 | }, mounts) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| @@ -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 | + | } | |||