detached 3751047

Merge pull request #191 from bnema/fix/managed-container-restart-policy

Set restart policy for managed containers
commit
37510478b144f08113d07963b392c27217490819
author
bnema · · 2026-05-05 17:54 UTC
committer
GitHub · · 2026-05-05 17:54 UTC
merge of
3a157b3 + 7132cc9 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev14
9 files · +565 -55
M internal/adapters/out/docker/runtime.go +41 -0
@@ -43,6 +43,8 @@ type Runtime struct {
4343 client *client.Client
4444 }
4545
46+var _ out.ContainerRuntime = (*Runtime)(nil)
47+
4648 type pipeReadCloser struct {
4749 pr *io.PipeReader
4850 pw *io.PipeWriter
@@ -208,6 +210,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
208210 CapAdd: capAdd,
209211 ReadonlyRootfs: config.ReadOnlyRootFS,
210212 }
213+ if config.RestartPolicy != "" {
214+ hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
215+ }
211216
212217 // Create network configuration for container
213218 var networkConfig *network.NetworkingConfig
@@ -358,6 +363,42 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri
358363 return nil
359364 }
360365
366+// EnsureContainerRestartPolicy updates a container restart policy when it does not match.
367+func (r *Runtime) EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error {
368+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
369+ zerowrap.FieldLayer: "adapter",
370+ zerowrap.FieldAdapter: "docker",
371+ zerowrap.FieldAction: "EnsureContainerRestartPolicy",
372+ zerowrap.FieldEntityID: containerID,
373+ "restart_policy": policy,
374+ })
375+ log := zerowrap.FromCtx(ctx)
376+
377+ inspect, err := r.client.ContainerInspect(ctx, containerID)
378+ if err != nil {
379+ return log.WrapErr(err, "failed to inspect container")
380+ }
381+ if inspect.HostConfig == nil {
382+ return fmt.Errorf("container inspect response missing host config: %s", containerID)
383+ }
384+ if string(inspect.HostConfig.RestartPolicy.Name) == policy {
385+ return nil
386+ }
387+
388+ updateConfig := container.UpdateConfig{
389+ Resources: inspect.HostConfig.Resources,
390+ RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(policy)},
391+ }
392+ resp, err := r.client.ContainerUpdate(ctx, containerID, updateConfig)
393+ if err != nil {
394+ return log.WrapErr(err, "failed to update container restart policy")
395+ }
396+ for _, warning := range resp.Warnings {
397+ log.Warn().Str("warning", warning).Msg("container restart policy update warning")
398+ }
399+ return nil
400+}
401+
361402 // ListContainers lists containers.
362403 func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
363404 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -43,6 +43,8 @@ type Runtime struct {
43 client *client.Client43 client *client.Client
44 }44 }
45 45
46+var _ out.ContainerRuntime = (*Runtime)(nil)
47+
46 type pipeReadCloser struct {48 type pipeReadCloser struct {
47 pr *io.PipeReader49 pr *io.PipeReader
48 pw *io.PipeWriter50 pw *io.PipeWriter
@@ -208,6 +210,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
208 CapAdd: capAdd,210 CapAdd: capAdd,
209 ReadonlyRootfs: config.ReadOnlyRootFS,211 ReadonlyRootfs: config.ReadOnlyRootFS,
210 }212 }
213+ if config.RestartPolicy != "" {
214+ hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
215+ }
211 216
212 // Create network configuration for container217 // Create network configuration for container
213 var networkConfig *network.NetworkingConfig218 var networkConfig *network.NetworkingConfig
@@ -358,6 +363,42 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri
358 return nil363 return nil
359 }364 }
360 365
366+// EnsureContainerRestartPolicy updates a container restart policy when it does not match.
367+func (r *Runtime) EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error {
368+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
369+ zerowrap.FieldLayer: "adapter",
370+ zerowrap.FieldAdapter: "docker",
371+ zerowrap.FieldAction: "EnsureContainerRestartPolicy",
372+ zerowrap.FieldEntityID: containerID,
373+ "restart_policy": policy,
374+ })
375+ log := zerowrap.FromCtx(ctx)
376+
377+ inspect, err := r.client.ContainerInspect(ctx, containerID)
378+ if err != nil {
379+ return log.WrapErr(err, "failed to inspect container")
380+ }
381+ if inspect.HostConfig == nil {
382+ return fmt.Errorf("container inspect response missing host config: %s", containerID)
383+ }
384+ if string(inspect.HostConfig.RestartPolicy.Name) == policy {
385+ return nil
386+ }
387+
388+ updateConfig := container.UpdateConfig{
389+ Resources: inspect.HostConfig.Resources,
390+ RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyMode(policy)},
391+ }
392+ resp, err := r.client.ContainerUpdate(ctx, containerID, updateConfig)
393+ if err != nil {
394+ return log.WrapErr(err, "failed to update container restart policy")
395+ }
396+ for _, warning := range resp.Warnings {
397+ log.Warn().Str("warning", warning).Msg("container restart policy update warning")
398+ }
399+ return nil
400+}
401+
361 // ListContainers lists containers.402 // ListContainers lists containers.
362 func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {403 func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) {
363 ctx = zerowrap.CtxWithFields(ctx, map[string]any{404 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
A internal/adapters/out/docker/runtime_create_test.go +62 -0
@@ -0,0 +1,62 @@
1+package docker
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "github.com/docker/docker/client"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
19+ var createBody map[string]any
20+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ switch {
22+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/create":
23+ require.NoError(t, json.NewDecoder(r.Body).Decode(&createBody))
24+ w.Header().Set("Content-Type", "application/json")
25+ _, _ = w.Write([]byte(`{"Id":"abc123","Warnings":null}`))
26+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(`{
29+ "Id":"abc123",
30+ "Name":"/gordon-app.example.com",
31+ "Image":"sha256:image",
32+ "Created":"2026-05-05T00:00:00Z",
33+ "Config":{"Image":"nginx:latest","Labels":{}},
34+ "State":{"Status":"created","ExitCode":0},
35+ "NetworkSettings":{"Ports":{}}
36+ }`))
37+ default:
38+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
39+ }
40+ }))
41+ defer server.Close()
42+
43+ host := strings.TrimPrefix(server.URL, "http://")
44+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
45+ require.NoError(t, err)
46+
47+ 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+ })
53+ require.NoError(t, err)
54+ assert.Equal(t, "abc123", created.ID)
55+
56+ 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"])
62+}
@@ -0,0 +1,62 @@
1+package docker
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "github.com/docker/docker/client"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestRuntime_CreateContainerAppliesRestartPolicy(t *testing.T) {
19+ var createBody map[string]any
20+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ switch {
22+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/create":
23+ require.NoError(t, json.NewDecoder(r.Body).Decode(&createBody))
24+ w.Header().Set("Content-Type", "application/json")
25+ _, _ = w.Write([]byte(`{"Id":"abc123","Warnings":null}`))
26+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(`{
29+ "Id":"abc123",
30+ "Name":"/gordon-app.example.com",
31+ "Image":"sha256:image",
32+ "Created":"2026-05-05T00:00:00Z",
33+ "Config":{"Image":"nginx:latest","Labels":{}},
34+ "State":{"Status":"created","ExitCode":0},
35+ "NetworkSettings":{"Ports":{}}
36+ }`))
37+ default:
38+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
39+ }
40+ }))
41+ defer server.Close()
42+
43+ host := strings.TrimPrefix(server.URL, "http://")
44+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
45+ require.NoError(t, err)
46+
47+ 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+ })
53+ require.NoError(t, err)
54+ assert.Equal(t, "abc123", created.ID)
55+
56+ 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"])
62+}
A internal/adapters/out/docker/runtime_restart_policy_test.go +129 -0
@@ -0,0 +1,129 @@
1+package docker
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "github.com/docker/docker/client"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestRuntime_EnsureContainerRestartPolicy_SkipsWhenAlreadySet(t *testing.T) {
19+ var updateCalled bool
20+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ switch {
22+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
23+ w.Header().Set("Content-Type", "application/json")
24+ _, _ = w.Write([]byte(`{
25+ "Id": "abc123",
26+ "HostConfig": {
27+ "RestartPolicy": {
28+ "Name": "always"
29+ }
30+ }
31+ }`))
32+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
33+ updateCalled = true
34+ w.WriteHeader(http.StatusInternalServerError)
35+ default:
36+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
37+ }
38+ }))
39+ defer server.Close()
40+
41+ runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
42+ err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
43+ require.NoError(t, err)
44+ assert.False(t, updateCalled, "update should not be called when restart policy is already set")
45+}
46+
47+func TestRuntime_EnsureContainerRestartPolicy_UpdatesAndPreservesResources(t *testing.T) {
48+ var updateBody map[string]any
49+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+ switch {
51+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
52+ w.Header().Set("Content-Type", "application/json")
53+ _, _ = w.Write([]byte(`{
54+ "Id": "abc123",
55+ "HostConfig": {
56+ "RestartPolicy": {
57+ "Name": ""
58+ },
59+ "Memory": 268435456,
60+ "NanoCpus": 500000000,
61+ "PidsLimit": 128,
62+ "Ulimits": [
63+ {
64+ "Name": "nofile",
65+ "Soft": 65536,
66+ "Hard": 65536
67+ }
68+ ]
69+ }
70+ }`))
71+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
72+ require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody))
73+ w.Header().Set("Content-Type", "application/json")
74+ _, _ = w.Write([]byte(`{"Warnings":null}`))
75+ default:
76+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
77+ }
78+ }))
79+ defer server.Close()
80+
81+ runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
82+ err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
83+ require.NoError(t, err)
84+ require.NotNil(t, updateBody)
85+ assert.Equal(t, domain.RestartPolicyAlways, updateBody["RestartPolicy"].(map[string]any)["Name"])
86+ assert.Equal(t, float64(268435456), updateBody["Memory"])
87+ assert.Equal(t, float64(500000000), updateBody["NanoCpus"])
88+ assert.Equal(t, float64(128), updateBody["PidsLimit"])
89+ ulimits, ok := updateBody["Ulimits"].([]any)
90+ require.True(t, ok, "Ulimits should be an array")
91+ require.Greater(t, len(ulimits), 0)
92+ firstUlimit, ok := ulimits[0].(map[string]any)
93+ require.True(t, ok, "first Ulimit should be a map")
94+ assert.Equal(t, "nofile", firstUlimit["Name"])
95+ assert.Equal(t, float64(65536), firstUlimit["Soft"])
96+ assert.Equal(t, float64(65536), firstUlimit["Hard"])
97+}
98+
99+func TestRuntime_EnsureContainerRestartPolicy_ReturnsErrorWhenHostConfigMissing(t *testing.T) {
100+ var updateCalled bool
101+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+ switch {
103+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
104+ w.Header().Set("Content-Type", "application/json")
105+ _, _ = w.Write([]byte(`{
106+ "Id": "abc123"
107+ }`))
108+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
109+ updateCalled = true
110+ w.WriteHeader(http.StatusInternalServerError)
111+ default:
112+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
113+ }
114+ }))
115+ defer server.Close()
116+
117+ runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
118+ err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
119+ require.Error(t, err)
120+ assert.Contains(t, err.Error(), "missing host config")
121+ assert.False(t, updateCalled, "update should not be called when host config is missing")
122+}
123+
124+func newRuntimeForRestartPolicyHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
125+ host := strings.TrimPrefix(server.URL, "http://")
126+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
127+ require.NoError(t, err)
128+ return NewRuntimeWithClient(cli)
129+}
@@ -0,0 +1,129 @@
1+package docker
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ "github.com/docker/docker/client"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func TestRuntime_EnsureContainerRestartPolicy_SkipsWhenAlreadySet(t *testing.T) {
19+ var updateCalled bool
20+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21+ switch {
22+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
23+ w.Header().Set("Content-Type", "application/json")
24+ _, _ = w.Write([]byte(`{
25+ "Id": "abc123",
26+ "HostConfig": {
27+ "RestartPolicy": {
28+ "Name": "always"
29+ }
30+ }
31+ }`))
32+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
33+ updateCalled = true
34+ w.WriteHeader(http.StatusInternalServerError)
35+ default:
36+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
37+ }
38+ }))
39+ defer server.Close()
40+
41+ runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
42+ err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
43+ require.NoError(t, err)
44+ assert.False(t, updateCalled, "update should not be called when restart policy is already set")
45+}
46+
47+func TestRuntime_EnsureContainerRestartPolicy_UpdatesAndPreservesResources(t *testing.T) {
48+ var updateBody map[string]any
49+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+ switch {
51+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
52+ w.Header().Set("Content-Type", "application/json")
53+ _, _ = w.Write([]byte(`{
54+ "Id": "abc123",
55+ "HostConfig": {
56+ "RestartPolicy": {
57+ "Name": ""
58+ },
59+ "Memory": 268435456,
60+ "NanoCpus": 500000000,
61+ "PidsLimit": 128,
62+ "Ulimits": [
63+ {
64+ "Name": "nofile",
65+ "Soft": 65536,
66+ "Hard": 65536
67+ }
68+ ]
69+ }
70+ }`))
71+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
72+ require.NoError(t, json.NewDecoder(r.Body).Decode(&updateBody))
73+ w.Header().Set("Content-Type", "application/json")
74+ _, _ = w.Write([]byte(`{"Warnings":null}`))
75+ default:
76+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
77+ }
78+ }))
79+ defer server.Close()
80+
81+ runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
82+ err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
83+ require.NoError(t, err)
84+ require.NotNil(t, updateBody)
85+ assert.Equal(t, domain.RestartPolicyAlways, updateBody["RestartPolicy"].(map[string]any)["Name"])
86+ assert.Equal(t, float64(268435456), updateBody["Memory"])
87+ assert.Equal(t, float64(500000000), updateBody["NanoCpus"])
88+ assert.Equal(t, float64(128), updateBody["PidsLimit"])
89+ ulimits, ok := updateBody["Ulimits"].([]any)
90+ require.True(t, ok, "Ulimits should be an array")
91+ require.Greater(t, len(ulimits), 0)
92+ firstUlimit, ok := ulimits[0].(map[string]any)
93+ require.True(t, ok, "first Ulimit should be a map")
94+ assert.Equal(t, "nofile", firstUlimit["Name"])
95+ assert.Equal(t, float64(65536), firstUlimit["Soft"])
96+ assert.Equal(t, float64(65536), firstUlimit["Hard"])
97+}
98+
99+func TestRuntime_EnsureContainerRestartPolicy_ReturnsErrorWhenHostConfigMissing(t *testing.T) {
100+ var updateCalled bool
101+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
102+ switch {
103+ case r.Method == http.MethodGet && r.URL.Path == "/v1.41/containers/abc123/json":
104+ w.Header().Set("Content-Type", "application/json")
105+ _, _ = w.Write([]byte(`{
106+ "Id": "abc123"
107+ }`))
108+ case r.Method == http.MethodPost && r.URL.Path == "/v1.41/containers/abc123/update":
109+ updateCalled = true
110+ w.WriteHeader(http.StatusInternalServerError)
111+ default:
112+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
113+ }
114+ }))
115+ defer server.Close()
116+
117+ runtime := newRuntimeForRestartPolicyHTTPServer(t, server)
118+ err := runtime.EnsureContainerRestartPolicy(context.Background(), "abc123", domain.RestartPolicyAlways)
119+ require.Error(t, err)
120+ assert.Contains(t, err.Error(), "missing host config")
121+ assert.False(t, updateCalled, "update should not be called when host config is missing")
122+}
123+
124+func newRuntimeForRestartPolicyHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
125+ host := strings.TrimPrefix(server.URL, "http://")
126+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
127+ require.NoError(t, err)
128+ return NewRuntimeWithClient(cli)
129+}
M internal/app/run.go +4 -0
@@ -2015,6 +2015,10 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina
20152015
20162016 // syncAndAutoStart syncs existing containers and auto-starts if configured.
20172017 func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
2018+ if err := svc.containerSvc.EnsureManagedContainerRestartPolicies(ctx); err != nil {
2019+ log.Warn().Err(err).Msg("failed to migrate managed container restart policies")
2020+ }
2021+
20182022 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
20192023 log.Warn().Err(err).Msg("failed to sync existing containers")
20202024 }
@@ -2015,6 +2015,10 @@ func setupConfigHotReload(ctx context.Context, configSvc configWatcher, coordina
2015 2015
2016 // syncAndAutoStart syncs existing containers and auto-starts if configured.2016 // syncAndAutoStart syncs existing containers and auto-starts if configured.
2017 func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {2017 func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
2018+ if err := svc.containerSvc.EnsureManagedContainerRestartPolicies(ctx); err != nil {
2019+ log.Warn().Err(err).Msg("failed to migrate managed container restart policies")
2020+ }
2021+
2018 if err := svc.containerSvc.SyncContainers(ctx); err != nil {2022 if err := svc.containerSvc.SyncContainers(ctx); err != nil {
2019 log.Warn().Err(err).Msg("failed to sync existing containers")2023 log.Warn().Err(err).Msg("failed to sync existing containers")
2020 }2024 }
M internal/boundaries/out/mocks/mock_container_runtime.go +63 -0
@@ -428,6 +428,69 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn
428428 return _c
429429 }
430430
431+// EnsureContainerRestartPolicy provides a mock function for the type MockContainerRuntime
432+func (_mock *MockContainerRuntime) EnsureContainerRestartPolicy(ctx context.Context, containerID string, policy string) error {
433+ ret := _mock.Called(ctx, containerID, policy)
434+
435+ if len(ret) == 0 {
436+ panic("no return value specified for EnsureContainerRestartPolicy")
437+ }
438+
439+ var r0 error
440+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
441+ r0 = returnFunc(ctx, containerID, policy)
442+ } else {
443+ r0 = ret.Error(0)
444+ }
445+ return r0
446+}
447+
448+// MockContainerRuntime_EnsureContainerRestartPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureContainerRestartPolicy'
449+type MockContainerRuntime_EnsureContainerRestartPolicy_Call struct {
450+ *mock.Call
451+}
452+
453+// EnsureContainerRestartPolicy is a helper method to define mock.On call
454+// - ctx context.Context
455+// - containerID string
456+// - policy string
457+func (_e *MockContainerRuntime_Expecter) EnsureContainerRestartPolicy(ctx interface{}, containerID interface{}, policy interface{}) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
458+ return &MockContainerRuntime_EnsureContainerRestartPolicy_Call{Call: _e.mock.On("EnsureContainerRestartPolicy", ctx, containerID, policy)}
459+}
460+
461+func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Run(run func(ctx context.Context, containerID string, policy string)) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
462+ _c.Call.Run(func(args mock.Arguments) {
463+ var arg0 context.Context
464+ if args[0] != nil {
465+ arg0 = args[0].(context.Context)
466+ }
467+ var arg1 string
468+ if args[1] != nil {
469+ arg1 = args[1].(string)
470+ }
471+ var arg2 string
472+ if args[2] != nil {
473+ arg2 = args[2].(string)
474+ }
475+ run(
476+ arg0,
477+ arg1,
478+ arg2,
479+ )
480+ })
481+ return _c
482+}
483+
484+func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Return(err error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
485+ _c.Call.Return(err)
486+ return _c
487+}
488+
489+func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) RunAndReturn(run func(ctx context.Context, containerID string, policy string) error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
490+ _c.Call.Return(run)
491+ return _c
492+}
493+
431494 // ExecInContainer provides a mock function for the type MockContainerRuntime
432495 func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
433496 ret := _mock.Called(ctx, containerID, cmd)
@@ -428,6 +428,69 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn
428 return _c428 return _c
429 }429 }
430 430
431+// EnsureContainerRestartPolicy provides a mock function for the type MockContainerRuntime
432+func (_mock *MockContainerRuntime) EnsureContainerRestartPolicy(ctx context.Context, containerID string, policy string) error {
433+ ret := _mock.Called(ctx, containerID, policy)
434+
435+ if len(ret) == 0 {
436+ panic("no return value specified for EnsureContainerRestartPolicy")
437+ }
438+
439+ var r0 error
440+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
441+ r0 = returnFunc(ctx, containerID, policy)
442+ } else {
443+ r0 = ret.Error(0)
444+ }
445+ return r0
446+}
447+
448+// MockContainerRuntime_EnsureContainerRestartPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnsureContainerRestartPolicy'
449+type MockContainerRuntime_EnsureContainerRestartPolicy_Call struct {
450+ *mock.Call
451+}
452+
453+// EnsureContainerRestartPolicy is a helper method to define mock.On call
454+// - ctx context.Context
455+// - containerID string
456+// - policy string
457+func (_e *MockContainerRuntime_Expecter) EnsureContainerRestartPolicy(ctx interface{}, containerID interface{}, policy interface{}) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
458+ return &MockContainerRuntime_EnsureContainerRestartPolicy_Call{Call: _e.mock.On("EnsureContainerRestartPolicy", ctx, containerID, policy)}
459+}
460+
461+func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Run(run func(ctx context.Context, containerID string, policy string)) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
462+ _c.Call.Run(func(args mock.Arguments) {
463+ var arg0 context.Context
464+ if args[0] != nil {
465+ arg0 = args[0].(context.Context)
466+ }
467+ var arg1 string
468+ if args[1] != nil {
469+ arg1 = args[1].(string)
470+ }
471+ var arg2 string
472+ if args[2] != nil {
473+ arg2 = args[2].(string)
474+ }
475+ run(
476+ arg0,
477+ arg1,
478+ arg2,
479+ )
480+ })
481+ return _c
482+}
483+
484+func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) Return(err error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
485+ _c.Call.Return(err)
486+ return _c
487+}
488+
489+func (_c *MockContainerRuntime_EnsureContainerRestartPolicy_Call) RunAndReturn(run func(ctx context.Context, containerID string, policy string) error) *MockContainerRuntime_EnsureContainerRestartPolicy_Call {
490+ _c.Call.Return(run)
491+ return _c
492+}
493+
431 // ExecInContainer provides a mock function for the type MockContainerRuntime494 // ExecInContainer provides a mock function for the type MockContainerRuntime
432 func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {495 func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
433 ret := _mock.Called(ctx, containerID, cmd)496 ret := _mock.Called(ctx, containerID, cmd)
M internal/boundaries/out/runtime.go +1 -0
@@ -20,6 +20,7 @@ type ContainerRuntime interface {
2020 RestartContainer(ctx context.Context, containerID string) error
2121 RemoveContainer(ctx context.Context, containerID string, force bool) error
2222 RenameContainer(ctx context.Context, containerID, newName string) error
23+ EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error
2324
2425 // Container inspection
2526 ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
@@ -20,6 +20,7 @@ type ContainerRuntime interface {
20 RestartContainer(ctx context.Context, containerID string) error20 RestartContainer(ctx context.Context, containerID string) error
21 RemoveContainer(ctx context.Context, containerID string, force bool) error21 RemoveContainer(ctx context.Context, containerID string, force bool) error
22 RenameContainer(ctx context.Context, containerID, newName string) error22 RenameContainer(ctx context.Context, containerID, newName string) error
23+ EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error
23 24
24 // Container inspection25 // Container inspection
25 ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)26 ListContainers(ctx context.Context, all bool) ([]*domain.Container, error)
M internal/domain/container.go +5 -0
@@ -63,6 +63,7 @@ type ContainerConfig struct {
6363 WorkingDir string
6464 Cmd []string
6565 AutoRemove bool
66+ RestartPolicy string
6667 Volumes map[string]string // map[containerPath]volumeName
6768 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
6869 NetworkMode string // Network to join
@@ -88,3 +89,7 @@ const (
8889 ContainerStatusPaused ContainerStatus = "paused"
8990 ContainerStatusUnknown ContainerStatus = "unknown"
9091 )
92+
93+const (
94+ RestartPolicyAlways = "always"
95+)
@@ -63,6 +63,7 @@ type ContainerConfig struct {
63 WorkingDir string63 WorkingDir string
64 Cmd []string64 Cmd []string
65 AutoRemove bool65 AutoRemove bool
66+ RestartPolicy string
66 Volumes map[string]string // map[containerPath]volumeName67 Volumes map[string]string // map[containerPath]volumeName
67 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)68 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
68 NetworkMode string // Network to join69 NetworkMode string // Network to join
@@ -88,3 +89,7 @@ const (
88 ContainerStatusPaused ContainerStatus = "paused"89 ContainerStatusPaused ContainerStatus = "paused"
89 ContainerStatusUnknown ContainerStatus = "unknown"90 ContainerStatusUnknown ContainerStatus = "unknown"
90 )91 )
92+
93+const (
94+ RestartPolicyAlways = "always"
95+)
M internal/usecase/container/service.go +104 -40
@@ -654,6 +654,10 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
654654 return nil, deployErr
655655 }
656656 }
657+ if err := s.runtime.EnsureContainerRestartPolicy(ctx, newContainer.ID, domain.RestartPolicyAlways); err != nil {
658+ s.cleanupFailedContainer(ctx, newContainer.ID)
659+ return nil, log.WrapErr(err, "failed to set container restart policy")
660+ }
657661
658662 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
659663 if err != nil {
@@ -1404,6 +1408,41 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
14041408 return health
14051409 }
14061410
1411+// EnsureManagedContainerRestartPolicies updates restart policy for all managed containers.
1412+func (s *Service) EnsureManagedContainerRestartPolicies(ctx context.Context) error {
1413+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1414+ zerowrap.FieldLayer: "usecase",
1415+ zerowrap.FieldUseCase: "EnsureManagedContainerRestartPolicies",
1416+ })
1417+ log := zerowrap.FromCtx(ctx)
1418+
1419+ allContainers, err := s.runtime.ListContainers(ctx, true)
1420+ if err != nil {
1421+ return log.WrapErr(err, "failed to list containers for restart policy migration")
1422+ }
1423+
1424+ return s.ensureManagedContainerRestartPolicies(ctx, allContainers)
1425+}
1426+
1427+func (s *Service) ensureManagedContainerRestartPolicies(ctx context.Context, allContainers []*domain.Container) error {
1428+ log := zerowrap.FromCtx(ctx)
1429+
1430+ var errs []error
1431+ for _, c := range allContainers {
1432+ if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
1433+ continue
1434+ }
1435+ if err := s.runtime.EnsureContainerRestartPolicy(ctx, c.ID, domain.RestartPolicyAlways); err != nil {
1436+ log.Warn().Err(err).
1437+ Str(zerowrap.FieldEntityID, c.ID).
1438+ Str("container_name", c.Name).
1439+ Msg("failed to ensure managed container restart policy")
1440+ errs = append(errs, fmt.Errorf("container %s: %w", c.ID, err))
1441+ }
1442+ }
1443+ return errors.Join(errs...)
1444+}
1445+
14071446 // SyncContainers synchronizes containers with runtime state.
14081447 func (s *Service) SyncContainers(ctx context.Context) error {
14091448 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1412,8 +1451,10 @@ func (s *Service) SyncContainers(ctx context.Context) error {
14121451 })
14131452 log := zerowrap.FromCtx(ctx)
14141453
1415- // List containers without holding lock to avoid blocking during Docker API call
1416- allContainers, err := s.runtime.ListContainers(ctx, false)
1454+ // List containers without holding lock to avoid blocking during Docker API call.
1455+ // Use all=true so the same runtime snapshot can migrate stopped containers and
1456+ // rebuild the running-container maps without a second Docker list call.
1457+ allContainers, err := s.runtime.ListContainers(ctx, true)
14171458 if err != nil {
14181459 return log.WrapErr(err, "failed to list containers")
14191460 }
@@ -1421,6 +1462,9 @@ func (s *Service) SyncContainers(ctx context.Context) error {
14211462 managed := make(map[string]*domain.Container)
14221463 attachments := make(map[string][]string)
14231464 for _, c := range allContainers {
1465+ if c.Status != "" && c.Status != string(domain.ContainerStatusRunning) {
1466+ continue
1467+ }
14241468 if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
14251469 continue
14261470 }
@@ -2577,46 +2621,85 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
25772621
25782622 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
25792623
2580- // Ensure image (canonical ref is returned; internal pulls may use the local registry).
2581- imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2624+ config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName)
25822625 if err != nil {
25832626 return err
25842627 }
2585- actualImageRef, err := s.ensureImage(ctx, imageRef)
2628+
2629+ container, err := s.runtime.CreateContainer(ctx, config)
25862630 if err != nil {
2631+ return log.WrapErr(err, "failed to create attachment container")
2632+ }
2633+
2634+ // Start container
2635+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
2636+ s.runtime.RemoveContainer(ctx, container.ID, true)
2637+ return log.WrapErr(err, "failed to start attachment container")
2638+ }
2639+
2640+ // Wait for attachment to be ready before proceeding
2641+ if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil {
2642+ log.WrapErr(err, "attachment readiness check failed, cleaning up")
2643+ if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil {
2644+ log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment")
2645+ }
2646+ s.runtime.RemoveContainer(ctx, container.ID, true)
2647+ return fmt.Errorf("attachment %q not ready: %w", serviceName, err)
2648+ }
2649+ if err := s.ensureAttachmentRestartPolicy(ctx, container.ID); err != nil {
25872650 return err
25882651 }
25892652
2653+ // Track attachment
2654+ s.mu.Lock()
2655+ s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
2656+ s.mu.Unlock()
2657+
2658+ // Start log collection for attachment
2659+ s.startLogCollection(ctx, container.ID, containerName)
2660+
2661+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
2662+ return nil
2663+}
2664+
2665+func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomain, serviceName, serviceImage, containerName, networkName string) (*domain.ContainerConfig, error) {
2666+ imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2667+ if err != nil {
2668+ return nil, err
2669+ }
2670+ actualImageRef, err := s.ensureImage(ctx, imageRef)
2671+ if err != nil {
2672+ return nil, err
2673+ }
2674+
25902675 exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
25912676 volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)
25922677 if err != nil {
2593- return err
2678+ return nil, err
25942679 }
25952680 envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef)
25962681 if err != nil {
2597- return err
2682+ return nil, err
25982683 }
2599- envHash := hashEnvironment(envVars)
26002684
26012685 s.mu.RLock()
26022686 cfg := s.config
26032687 s.mu.RUnlock()
26042688
2605- // Create container with attachment labels (use actual ref, track original in labels)
26062689 config := &domain.ContainerConfig{
26072690 Image: actualImageRef,
26082691 Name: containerName,
2609- Hostname: serviceName, // Internal DNS: postgres, redis, etc.
2692+ Hostname: serviceName,
26102693 Aliases: []string{serviceName},
26112694 Ports: exposedPorts,
26122695 Env: envVars,
26132696 Volumes: volumes,
2614- NetworkMode: networkName, // Same network as main app
2697+ NetworkMode: networkName,
26152698 Labels: map[string]string{
26162699 domain.LabelManaged: "true",
26172700 domain.LabelAttachment: "true",
26182701 domain.LabelAttachedTo: ownerDomain,
2619- domain.LabelEnvHash: envHash,
2702+ domain.LabelEnvHash: hashEnvironment(envVars),
26202703 domain.LabelImage: serviceImage,
26212704 },
26222705 MemoryLimit: cfg.DefaultMemoryLimit,
@@ -2624,37 +2707,18 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
26242707 PidsLimit: cfg.DefaultPidsLimit,
26252708 }
26262709 applySecurityProfile(config, cfg)
2710+ return config, nil
2711+}
26272712
2628- container, err := s.runtime.CreateContainer(ctx, config)
2629- if err != nil {
2630- return log.WrapErr(err, "failed to create attachment container")
2631- }
2632-
2633- // Start container
2634- if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
2635- s.runtime.RemoveContainer(ctx, container.ID, true)
2636- return log.WrapErr(err, "failed to start attachment container")
2637- }
2638-
2639- // Wait for attachment to be ready before proceeding
2640- if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil {
2641- log.WrapErr(err, "attachment readiness check failed, cleaning up")
2642- if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil {
2643- log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment")
2713+func (s *Service) ensureAttachmentRestartPolicy(ctx context.Context, containerID string) error {
2714+ log := zerowrap.FromCtx(ctx)
2715+ if err := s.runtime.EnsureContainerRestartPolicy(ctx, containerID, domain.RestartPolicyAlways); err != nil {
2716+ if stopErr := s.runtime.StopContainer(ctx, containerID); stopErr != nil {
2717+ log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop attachment after restart policy update failure")
26442718 }
2645- s.runtime.RemoveContainer(ctx, container.ID, true)
2646- return fmt.Errorf("attachment %q not ready: %w", serviceName, err)
2719+ s.runtime.RemoveContainer(ctx, containerID, true)
2720+ return log.WrapErr(err, "failed to set attachment restart policy")
26472721 }
2648-
2649- // Track attachment
2650- s.mu.Lock()
2651- s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
2652- s.mu.Unlock()
2653-
2654- // Start log collection for attachment
2655- s.startLogCollection(ctx, container.ID, containerName)
2656-
2657- log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
26582722 return nil
26592723 }
26602724
@@ -654,6 +654,10 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
654 return nil, deployErr654 return nil, deployErr
655 }655 }
656 }656 }
657+ if err := s.runtime.EnsureContainerRestartPolicy(ctx, newContainer.ID, domain.RestartPolicyAlways); err != nil {
658+ s.cleanupFailedContainer(ctx, newContainer.ID)
659+ return nil, log.WrapErr(err, "failed to set container restart policy")
660+ }
657 661
658 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)662 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
659 if err != nil {663 if err != nil {
@@ -1404,6 +1408,41 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
1404 return health1408 return health
1405 }1409 }
1406 1410
1411+// EnsureManagedContainerRestartPolicies updates restart policy for all managed containers.
1412+func (s *Service) EnsureManagedContainerRestartPolicies(ctx context.Context) error {
1413+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1414+ zerowrap.FieldLayer: "usecase",
1415+ zerowrap.FieldUseCase: "EnsureManagedContainerRestartPolicies",
1416+ })
1417+ log := zerowrap.FromCtx(ctx)
1418+
1419+ allContainers, err := s.runtime.ListContainers(ctx, true)
1420+ if err != nil {
1421+ return log.WrapErr(err, "failed to list containers for restart policy migration")
1422+ }
1423+
1424+ return s.ensureManagedContainerRestartPolicies(ctx, allContainers)
1425+}
1426+
1427+func (s *Service) ensureManagedContainerRestartPolicies(ctx context.Context, allContainers []*domain.Container) error {
1428+ log := zerowrap.FromCtx(ctx)
1429+
1430+ var errs []error
1431+ for _, c := range allContainers {
1432+ if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
1433+ continue
1434+ }
1435+ if err := s.runtime.EnsureContainerRestartPolicy(ctx, c.ID, domain.RestartPolicyAlways); err != nil {
1436+ log.Warn().Err(err).
1437+ Str(zerowrap.FieldEntityID, c.ID).
1438+ Str("container_name", c.Name).
1439+ Msg("failed to ensure managed container restart policy")
1440+ errs = append(errs, fmt.Errorf("container %s: %w", c.ID, err))
1441+ }
1442+ }
1443+ return errors.Join(errs...)
1444+}
1445+
1407 // SyncContainers synchronizes containers with runtime state.1446 // SyncContainers synchronizes containers with runtime state.
1408 func (s *Service) SyncContainers(ctx context.Context) error {1447 func (s *Service) SyncContainers(ctx context.Context) error {
1409 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1448 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -1412,8 +1451,10 @@ func (s *Service) SyncContainers(ctx context.Context) error {
1412 })1451 })
1413 log := zerowrap.FromCtx(ctx)1452 log := zerowrap.FromCtx(ctx)
1414 1453
1415- // List containers without holding lock to avoid blocking during Docker API call1454+ // List containers without holding lock to avoid blocking during Docker API call.
1416- allContainers, err := s.runtime.ListContainers(ctx, false)1455+ // Use all=true so the same runtime snapshot can migrate stopped containers and
1456+ // rebuild the running-container maps without a second Docker list call.
1457+ allContainers, err := s.runtime.ListContainers(ctx, true)
1417 if err != nil {1458 if err != nil {
1418 return log.WrapErr(err, "failed to list containers")1459 return log.WrapErr(err, "failed to list containers")
1419 }1460 }
@@ -1421,6 +1462,9 @@ func (s *Service) SyncContainers(ctx context.Context) error {
1421 managed := make(map[string]*domain.Container)1462 managed := make(map[string]*domain.Container)
1422 attachments := make(map[string][]string)1463 attachments := make(map[string][]string)
1423 for _, c := range allContainers {1464 for _, c := range allContainers {
1465+ if c.Status != "" && c.Status != string(domain.ContainerStatusRunning) {
1466+ continue
1467+ }
1424 if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {1468 if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
1425 continue1469 continue
1426 }1470 }
@@ -2577,46 +2621,85 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2577 2621
2578 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")2622 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
2579 2623
2580- // Ensure image (canonical ref is returned; internal pulls may use the local registry).2624+ config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName)
2581- imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2582 if err != nil {2625 if err != nil {
2583 return err2626 return err
2584 }2627 }
2585- actualImageRef, err := s.ensureImage(ctx, imageRef)2628+
2629+ container, err := s.runtime.CreateContainer(ctx, config)
2586 if err != nil {2630 if err != nil {
2631+ return log.WrapErr(err, "failed to create attachment container")
2632+ }
2633+
2634+ // Start container
2635+ if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
2636+ s.runtime.RemoveContainer(ctx, container.ID, true)
2637+ return log.WrapErr(err, "failed to start attachment container")
2638+ }
2639+
2640+ // Wait for attachment to be ready before proceeding
2641+ if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil {
2642+ log.WrapErr(err, "attachment readiness check failed, cleaning up")
2643+ if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil {
2644+ log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment")
2645+ }
2646+ s.runtime.RemoveContainer(ctx, container.ID, true)
2647+ return fmt.Errorf("attachment %q not ready: %w", serviceName, err)
2648+ }
2649+ if err := s.ensureAttachmentRestartPolicy(ctx, container.ID); err != nil {
2587 return err2650 return err
2588 }2651 }
2589 2652
2653+ // Track attachment
2654+ s.mu.Lock()
2655+ s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
2656+ s.mu.Unlock()
2657+
2658+ // Start log collection for attachment
2659+ s.startLogCollection(ctx, container.ID, containerName)
2660+
2661+ log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
2662+ return nil
2663+}
2664+
2665+func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomain, serviceName, serviceImage, containerName, networkName string) (*domain.ContainerConfig, error) {
2666+ imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2667+ if err != nil {
2668+ return nil, err
2669+ }
2670+ actualImageRef, err := s.ensureImage(ctx, imageRef)
2671+ if err != nil {
2672+ return nil, err
2673+ }
2674+
2590 exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)2675 exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2591 volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)2676 volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)
2592 if err != nil {2677 if err != nil {
2593- return err2678+ return nil, err
2594 }2679 }
2595 envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef)2680 envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef)
2596 if err != nil {2681 if err != nil {
2597- return err2682+ return nil, err
2598 }2683 }
2599- envHash := hashEnvironment(envVars)
2600 2684
2601 s.mu.RLock()2685 s.mu.RLock()
2602 cfg := s.config2686 cfg := s.config
2603 s.mu.RUnlock()2687 s.mu.RUnlock()
2604 2688
2605- // Create container with attachment labels (use actual ref, track original in labels)
2606 config := &domain.ContainerConfig{2689 config := &domain.ContainerConfig{
2607 Image: actualImageRef,2690 Image: actualImageRef,
2608 Name: containerName,2691 Name: containerName,
2609- Hostname: serviceName, // Internal DNS: postgres, redis, etc.2692+ Hostname: serviceName,
2610 Aliases: []string{serviceName},2693 Aliases: []string{serviceName},
2611 Ports: exposedPorts,2694 Ports: exposedPorts,
2612 Env: envVars,2695 Env: envVars,
2613 Volumes: volumes,2696 Volumes: volumes,
2614- NetworkMode: networkName, // Same network as main app2697+ NetworkMode: networkName,
2615 Labels: map[string]string{2698 Labels: map[string]string{
2616 domain.LabelManaged: "true",2699 domain.LabelManaged: "true",
2617 domain.LabelAttachment: "true",2700 domain.LabelAttachment: "true",
2618 domain.LabelAttachedTo: ownerDomain,2701 domain.LabelAttachedTo: ownerDomain,
2619- domain.LabelEnvHash: envHash,2702+ domain.LabelEnvHash: hashEnvironment(envVars),
2620 domain.LabelImage: serviceImage,2703 domain.LabelImage: serviceImage,
2621 },2704 },
2622 MemoryLimit: cfg.DefaultMemoryLimit,2705 MemoryLimit: cfg.DefaultMemoryLimit,
@@ -2624,37 +2707,18 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2624 PidsLimit: cfg.DefaultPidsLimit,2707 PidsLimit: cfg.DefaultPidsLimit,
2625 }2708 }
2626 applySecurityProfile(config, cfg)2709 applySecurityProfile(config, cfg)
2710+ return config, nil
2711+}
2627 2712
2628- container, err := s.runtime.CreateContainer(ctx, config)2713+func (s *Service) ensureAttachmentRestartPolicy(ctx context.Context, containerID string) error {
2629- if err != nil {2714+ log := zerowrap.FromCtx(ctx)
2630- return log.WrapErr(err, "failed to create attachment container")2715+ if err := s.runtime.EnsureContainerRestartPolicy(ctx, containerID, domain.RestartPolicyAlways); err != nil {
2631- }2716+ if stopErr := s.runtime.StopContainer(ctx, containerID); stopErr != nil {
2632-2717+ log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop attachment after restart policy update failure")
2633- // Start container
2634- if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
2635- s.runtime.RemoveContainer(ctx, container.ID, true)
2636- return log.WrapErr(err, "failed to start attachment container")
2637- }
2638-
2639- // Wait for attachment to be ready before proceeding
2640- if err := s.waitForAttachmentReady(ctx, container.ID, config); err != nil {
2641- log.WrapErr(err, "attachment readiness check failed, cleaning up")
2642- if stopErr := s.runtime.StopContainer(ctx, container.ID); stopErr != nil {
2643- log.Warn().Err(stopErr).Str(zerowrap.FieldEntityID, container.ID).Msg("failed to stop unready attachment")
2644 }2718 }
2645- s.runtime.RemoveContainer(ctx, container.ID, true)2719+ s.runtime.RemoveContainer(ctx, containerID, true)
2646- return fmt.Errorf("attachment %q not ready: %w", serviceName, err)2720+ return log.WrapErr(err, "failed to set attachment restart policy")
2647 }2721 }
2648-
2649- // Track attachment
2650- s.mu.Lock()
2651- s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
2652- s.mu.Unlock()
2653-
2654- // Start log collection for attachment
2655- s.startLogCollection(ctx, container.ID, containerName)
2656-
2657- log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
2658 return nil2722 return nil
2659 }2723 }
2660 2724
M internal/usecase/container/service_test.go +156 -15
@@ -57,6 +57,11 @@ func testMinDelayConfig() Config {
5757 }
5858 }
5959
60+func allowRestartPolicyEnsure(t *testing.T, runtime *mocks.MockContainerRuntime) {
61+ t.Helper()
62+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, mock.Anything, domain.RestartPolicyAlways).Return(nil).Maybe()
63+}
64+
6065 func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
6166 t.Helper()
6267
@@ -255,8 +260,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
255260 ctx := testContext()
256261 metrics, reader := setupMetricsTest(t)
257262 svc.SetMetrics(metrics)
258-
259- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
263+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
260264 {
261265 ID: "container-1",
262266 Labels: map[string]string{
@@ -280,7 +284,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
280284 assert.Equal(t, 0, points[0].Attributes.Len())
281285
282286 // No runtime change: metric should remain stable.
283- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
287+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
284288 {
285289 ID: "container-1",
286290 Labels: map[string]string{
@@ -304,7 +308,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
304308 assert.Equal(t, 0, points[0].Attributes.Len())
305309
306310 // One container removed in runtime: metric should decrement by one.
307- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
311+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
308312 {
309313 ID: "container-1",
310314 Labels: map[string]string{
@@ -378,6 +382,8 @@ func TestService_Deploy_Success(t *testing.T) {
378382 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
379383 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
380384
385+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "container-123", domain.RestartPolicyAlways).Return(nil).Once()
386+
381387 // Re-inspect after start
382388 runningContainer := &domain.Container{
383389 ID: "container-123",
@@ -406,6 +412,7 @@ func TestService_Deploy_Success(t *testing.T) {
406412
407413 func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
408414 runtime := mocks.NewMockContainerRuntime(t)
415+ allowRestartPolicyEnsure(t, runtime)
409416 envLoader := mocks.NewMockEnvLoader(t)
410417 eventBus := mocks.NewMockEventPublisher(t)
411418
@@ -472,6 +479,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
472479
473480 func TestService_Deploy_ImagePullFailure(t *testing.T) {
474481 runtime := mocks.NewMockContainerRuntime(t)
482+ allowRestartPolicyEnsure(t, runtime)
475483 envLoader := mocks.NewMockEnvLoader(t)
476484 eventBus := mocks.NewMockEventPublisher(t)
477485
@@ -514,6 +522,7 @@ func TestIsPullFailure_ErrImagePullFailed(t *testing.T) {
514522
515523 func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
516524 runtime := mocks.NewMockContainerRuntime(t)
525+ allowRestartPolicyEnsure(t, runtime)
517526 envLoader := mocks.NewMockEnvLoader(t)
518527 eventBus := mocks.NewMockEventPublisher(t)
519528
@@ -554,6 +563,7 @@ func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
554563
555564 func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {
556565 runtime := mocks.NewMockContainerRuntime(t)
566+ allowRestartPolicyEnsure(t, runtime)
557567 envLoader := mocks.NewMockEnvLoader(t)
558568 eventBus := mocks.NewMockEventPublisher(t)
559569
@@ -613,6 +623,7 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.
613623
614624 func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {
615625 runtime := mocks.NewMockContainerRuntime(t)
626+ allowRestartPolicyEnsure(t, runtime)
616627 envLoader := mocks.NewMockEnvLoader(t)
617628 eventBus := mocks.NewMockEventPublisher(t)
618629
@@ -724,6 +735,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
724735
725736 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
726737 runtime := mocks.NewMockContainerRuntime(t)
738+ allowRestartPolicyEnsure(t, runtime)
727739 envLoader := mocks.NewMockEnvLoader(t)
728740 eventBus := mocks.NewMockEventPublisher(t)
729741 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -807,6 +819,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
807819 // from killing the real active container after a Gordon restart.
808820 func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) {
809821 runtime := mocks.NewMockContainerRuntime(t)
822+ allowRestartPolicyEnsure(t, runtime)
810823 envLoader := mocks.NewMockEnvLoader(t)
811824 eventBus := mocks.NewMockEventPublisher(t)
812825 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -906,6 +919,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
906919
907920 func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
908921 runtime := mocks.NewMockContainerRuntime(t)
922+ allowRestartPolicyEnsure(t, runtime)
909923 envLoader := mocks.NewMockEnvLoader(t)
910924 eventBus := mocks.NewMockEventPublisher(t)
911925
@@ -968,6 +982,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
968982
969983 func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
970984 runtime := mocks.NewMockContainerRuntime(t)
985+ allowRestartPolicyEnsure(t, runtime)
971986 envLoader := mocks.NewMockEnvLoader(t)
972987 eventBus := mocks.NewMockEventPublisher(t)
973988
@@ -1026,6 +1041,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
10261041
10271042 func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
10281043 runtime := mocks.NewMockContainerRuntime(t)
1044+ allowRestartPolicyEnsure(t, runtime)
10291045 envLoader := mocks.NewMockEnvLoader(t)
10301046 eventBus := mocks.NewMockEventPublisher(t)
10311047
@@ -1238,6 +1254,97 @@ func TestService_HealthCheck(t *testing.T) {
12381254 assert.False(t, result["unhealthy.example.com"])
12391255 }
12401256
1257+func TestService_EnsureManagedContainerRestartPolicies_EnsuresRoutesAttachmentsAndStoppedContainers(t *testing.T) {
1258+ runtime := mocks.NewMockContainerRuntime(t)
1259+ envLoader := mocks.NewMockEnvLoader(t)
1260+ eventBus := mocks.NewMockEventPublisher(t)
1261+
1262+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1263+ ctx := testContext()
1264+
1265+ routeContainer := &domain.Container{
1266+ ID: "route-1",
1267+ Name: "gordon-app.example.com",
1268+ Status: string(domain.ContainerStatusRunning),
1269+ Labels: map[string]string{
1270+ domain.LabelManaged: "true",
1271+ domain.LabelDomain: "app.example.com",
1272+ },
1273+ }
1274+ attachmentContainer := &domain.Container{
1275+ ID: "attachment-1",
1276+ Name: "gordon-app-example-com-postgres",
1277+ Status: string(domain.ContainerStatusRunning),
1278+ Labels: map[string]string{
1279+ domain.LabelManaged: "true",
1280+ domain.LabelAttachment: "true",
1281+ domain.LabelAttachedTo: "app.example.com",
1282+ },
1283+ }
1284+ stoppedContainer := &domain.Container{
1285+ ID: "stopped-1",
1286+ Name: "gordon-stopped.example.com",
1287+ Status: string(domain.ContainerStatusExited),
1288+ Labels: map[string]string{
1289+ domain.LabelManaged: "true",
1290+ domain.LabelDomain: "stopped.example.com",
1291+ },
1292+ }
1293+ unmanagedContainer := &domain.Container{
1294+ ID: "other-1",
1295+ Labels: map[string]string{},
1296+ }
1297+
1298+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1299+ routeContainer,
1300+ attachmentContainer,
1301+ stoppedContainer,
1302+ unmanagedContainer,
1303+ }, nil).Once()
1304+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(nil).Once()
1305+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "attachment-1", domain.RestartPolicyAlways).Return(nil).Once()
1306+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "stopped-1", domain.RestartPolicyAlways).Return(nil).Once()
1307+ err := svc.EnsureManagedContainerRestartPolicies(ctx)
1308+
1309+ require.NoError(t, err)
1310+ runtime.AssertNumberOfCalls(t, "EnsureContainerRestartPolicy", 3)
1311+}
1312+
1313+func TestService_EnsureManagedContainerRestartPolicies_ContinuesAndReturnsError(t *testing.T) {
1314+ runtime := mocks.NewMockContainerRuntime(t)
1315+ envLoader := mocks.NewMockEnvLoader(t)
1316+ eventBus := mocks.NewMockEventPublisher(t)
1317+
1318+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1319+ ctx := testContext()
1320+
1321+ routeContainer := &domain.Container{
1322+ ID: "route-1",
1323+ Name: "gordon-app.example.com",
1324+ Labels: map[string]string{
1325+ domain.LabelManaged: "true",
1326+ domain.LabelDomain: "app.example.com",
1327+ },
1328+ }
1329+ routeContainer2 := &domain.Container{
1330+ ID: "route-2",
1331+ Name: "gordon-api.example.com",
1332+ Labels: map[string]string{
1333+ domain.LabelManaged: "true",
1334+ domain.LabelDomain: "api.example.com",
1335+ },
1336+ }
1337+
1338+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{routeContainer, routeContainer2}, nil).Once()
1339+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(errors.New("update failed")).Once()
1340+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-2", domain.RestartPolicyAlways).Return(nil).Once()
1341+ err := svc.EnsureManagedContainerRestartPolicies(ctx)
1342+
1343+ require.Error(t, err)
1344+ assert.Contains(t, err.Error(), "route-1")
1345+ assert.Contains(t, err.Error(), "update failed")
1346+}
1347+
12411348 func TestService_SyncContainers(t *testing.T) {
12421349 runtime := mocks.NewMockContainerRuntime(t)
12431350 envLoader := mocks.NewMockEnvLoader(t)
@@ -1246,7 +1353,7 @@ func TestService_SyncContainers(t *testing.T) {
12461353 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
12471354 ctx := testContext()
12481355
1249- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1356+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
12501357 {
12511358 ID: "container-1",
12521359 Name: "gordon-app1.example.com",
@@ -1372,7 +1479,7 @@ func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
13721479 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
13731480 Return(errors.New("no container with name or ID \"stale-container-id\" found")).
13741481 Once()
1375- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1482+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
13761483 {
13771484 ID: "fresh-container-id",
13781485 Name: "gordon-test.example.com",
@@ -1406,7 +1513,7 @@ func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing
14061513 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
14071514 Return(errors.New("no such container")).
14081515 Once()
1409- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()
1516+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
14101517
14111518 err := svc.Restart(ctx, "test.example.com", false)
14121519
@@ -1428,7 +1535,7 @@ func TestService_Restart_WithAttachments(t *testing.T) {
14281535 Status: "running",
14291536 }
14301537 svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
1431- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1538+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
14321539 {
14331540 ID: "container-123",
14341541 Labels: map[string]string{
@@ -1453,7 +1560,6 @@ func TestService_Restart_WithAttachments(t *testing.T) {
14531560 },
14541561 },
14551562 }, nil).Maybe()
1456-
14571563 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
14581564 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
14591565 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
@@ -1482,7 +1588,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t
14821588 Status: "running",
14831589 }
14841590 // No attachment containers tracked — s.attachments is empty
1485- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1591+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Maybe()
14861592
14871593 err := svc.Restart(testContext(), "test.example.com", true)
14881594
@@ -1512,7 +1618,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.
15121618 }
15131619 // Only 1 of 2 attachments deployed
15141620 svc.attachments["test.example.com"] = []string{"attach-postgres"}
1515- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1621+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
15161622 {
15171623 ID: "container-123",
15181624 Labels: map[string]string{
@@ -1529,7 +1635,6 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.
15291635 },
15301636 },
15311637 }, nil).Maybe()
1532-
15331638 err := svc.Restart(testContext(), "test.example.com", true)
15341639
15351640 assert.Error(t, err)
@@ -1558,7 +1663,7 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
15581663 Status: "running",
15591664 }
15601665 svc.attachments["test.example.com"] = []string{"attach-456"}
1561- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
1666+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
15621667 {
15631668 ID: "container-123",
15641669 Labels: map[string]string{
@@ -1575,7 +1680,6 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
15751680 },
15761681 },
15771682 }, nil).Maybe()
1578-
15791683 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
15801684 runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
15811685
@@ -1626,6 +1730,7 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test
16261730 Name: "gordon-test.example.com",
16271731 Status: "running",
16281732 }
1733+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Maybe()
16291734 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
16301735
16311736 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
@@ -1950,6 +2055,7 @@ func TestRewriteToLocalRegistry(t *testing.T) {
19502055
19512056 func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
19522057 runtime := mocks.NewMockContainerRuntime(t)
2058+ allowRestartPolicyEnsure(t, runtime)
19532059 envLoader := mocks.NewMockEnvLoader(t)
19542060 eventBus := mocks.NewMockEventPublisher(t)
19552061
@@ -2030,6 +2136,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
20302136
20312137 func TestService_AutoStart_StartsNewContainers(t *testing.T) {
20322138 runtime := mocks.NewMockContainerRuntime(t)
2139+ allowRestartPolicyEnsure(t, runtime)
20332140 envLoader := mocks.NewMockEnvLoader(t)
20342141 eventBus := mocks.NewMockEventPublisher(t)
20352142
@@ -2079,6 +2186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
20792186
20802187 func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
20812188 runtime := mocks.NewMockContainerRuntime(t)
2189+ allowRestartPolicyEnsure(t, runtime)
20822190 envLoader := mocks.NewMockEnvLoader(t)
20832191 eventBus := mocks.NewMockEventPublisher(t)
20842192
@@ -2128,6 +2236,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
21282236
21292237 func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
21302238 runtime := mocks.NewMockContainerRuntime(t)
2239+ allowRestartPolicyEnsure(t, runtime)
21312240 envLoader := mocks.NewMockEnvLoader(t)
21322241 eventBus := mocks.NewMockEventPublisher(t)
21332242
@@ -2156,6 +2265,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
21562265
21572266 func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
21582267 runtime := mocks.NewMockContainerRuntime(t)
2268+ allowRestartPolicyEnsure(t, runtime)
21592269 envLoader := mocks.NewMockEnvLoader(t)
21602270 eventBus := mocks.NewMockEventPublisher(t)
21612271
@@ -2208,6 +2318,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
22082318 // until the new container is ready and traffic is switched.
22092319 func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
22102320 runtime := mocks.NewMockContainerRuntime(t)
2321+ allowRestartPolicyEnsure(t, runtime)
22112322 envLoader := mocks.NewMockEnvLoader(t)
22122323 eventBus := mocks.NewMockEventPublisher(t)
22132324 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2304,6 +2415,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
23042415 // Running canonical containers are preserved (see NeverKillsRunningCanonical test).
23052416 func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
23062417 runtime := mocks.NewMockContainerRuntime(t)
2418+ allowRestartPolicyEnsure(t, runtime)
23072419 envLoader := mocks.NewMockEnvLoader(t)
23082420 eventBus := mocks.NewMockEventPublisher(t)
23092421
@@ -2384,6 +2496,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
23842496
23852497 func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
23862498 runtime := mocks.NewMockContainerRuntime(t)
2499+ allowRestartPolicyEnsure(t, runtime)
23872500 envLoader := mocks.NewMockEnvLoader(t)
23882501 eventBus := mocks.NewMockEventPublisher(t)
23892502 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2456,6 +2569,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
24562569
24572570 func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
24582571 runtime := mocks.NewMockContainerRuntime(t)
2572+ allowRestartPolicyEnsure(t, runtime)
24592573 envLoader := mocks.NewMockEnvLoader(t)
24602574 eventBus := mocks.NewMockEventPublisher(t)
24612575 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2645,6 +2759,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
26452759 // Verify that concurrent Deploy calls for the same domain are serialized
26462760 // and both succeed without container name conflicts.
26472761 runtime := mocks.NewMockContainerRuntime(t)
2762+ allowRestartPolicyEnsure(t, runtime)
26482763 envLoader := mocks.NewMockEnvLoader(t)
26492764 eventBus := mocks.NewMockEventPublisher(t)
26502765 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2755,6 +2870,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
27552870 func TestService_Deploy_ContextCancellation(t *testing.T) {
27562871 // Verify that deploy lock acquisition respects context cancellation.
27572872 runtime := mocks.NewMockContainerRuntime(t)
2873+ allowRestartPolicyEnsure(t, runtime)
27582874 envLoader := mocks.NewMockEnvLoader(t)
27592875 eventBus := mocks.NewMockEventPublisher(t)
27602876
@@ -2785,6 +2901,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
27852901 // BEFORE the old container is stopped, preventing 503 errors during deployment.
27862902 func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
27872903 runtime := mocks.NewMockContainerRuntime(t)
2904+ allowRestartPolicyEnsure(t, runtime)
27882905 envLoader := mocks.NewMockEnvLoader(t)
27892906 eventBus := mocks.NewMockEventPublisher(t)
27902907 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2865,6 +2982,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
28652982 // when no cache invalidator is set (e.g., in tests or minimal configurations).
28662983 func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
28672984 runtime := mocks.NewMockContainerRuntime(t)
2985+ allowRestartPolicyEnsure(t, runtime)
28682986 envLoader := mocks.NewMockEnvLoader(t)
28692987 eventBus := mocks.NewMockEventPublisher(t)
28702988
@@ -2914,6 +3032,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
29143032
29153033 func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
29163034 runtime := mocks.NewMockContainerRuntime(t)
3035+ allowRestartPolicyEnsure(t, runtime)
29173036 envLoader := mocks.NewMockEnvLoader(t)
29183037 eventBus := mocks.NewMockEventPublisher(t)
29193038
@@ -2974,6 +3093,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
29743093
29753094 func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
29763095 runtime := mocks.NewMockContainerRuntime(t)
3096+ allowRestartPolicyEnsure(t, runtime)
29773097 envLoader := mocks.NewMockEnvLoader(t)
29783098 eventBus := mocks.NewMockEventPublisher(t)
29793099
@@ -3035,6 +3155,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
30353155
30363156 func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
30373157 runtime := mocks.NewMockContainerRuntime(t)
3158+ allowRestartPolicyEnsure(t, runtime)
30383159 envLoader := mocks.NewMockEnvLoader(t)
30393160 eventBus := mocks.NewMockEventPublisher(t)
30403161 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3101,7 +3222,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
31013222 ctx := testContext()
31023223
31033224 // ListContainers returns one main container and one attachment container
3104- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
3225+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
31053226 {
31063227 ID: "main-container-1",
31073228 Labels: map[string]string{
@@ -3137,6 +3258,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
31373258
31383259 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
31393260 runtime := mocks.NewMockContainerRuntime(t)
3261+ allowRestartPolicyEnsure(t, runtime)
31403262 envLoader := mocks.NewMockEnvLoader(t)
31413263 eventBus := mocks.NewMockEventPublisher(t)
31423264
@@ -3200,6 +3322,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
32003322
32013323 func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
32023324 runtime := mocks.NewMockContainerRuntime(t)
3325+ allowRestartPolicyEnsure(t, runtime)
32033326 envLoader := mocks.NewMockEnvLoader(t)
32043327 eventBus := mocks.NewMockEventPublisher(t)
32053328
@@ -3258,6 +3381,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
32583381
32593382 func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
32603383 runtime := mocks.NewMockContainerRuntime(t)
3384+ allowRestartPolicyEnsure(t, runtime)
32613385 envLoader := mocks.NewMockEnvLoader(t)
32623386 eventBus := mocks.NewMockEventPublisher(t)
32633387
@@ -3384,6 +3508,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
33843508 // disallows creating a container with the same name as an existing running container.
33853509 func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
33863510 runtime := mocks.NewMockContainerRuntime(t)
3511+ allowRestartPolicyEnsure(t, runtime)
33873512 envLoader := mocks.NewMockEnvLoader(t)
33883513 eventBus := mocks.NewMockEventPublisher(t)
33893514
@@ -3480,6 +3605,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
34803605 // and the failed new container is cleaned up.
34813606 func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
34823607 runtime := mocks.NewMockContainerRuntime(t)
3608+ allowRestartPolicyEnsure(t, runtime)
34833609 envLoader := mocks.NewMockEnvLoader(t)
34843610 eventBus := mocks.NewMockEventPublisher(t)
34853611 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3574,6 +3700,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
35743700 // container is finalized (stopped and removed).
35753701 func TestService_Deploy_StabilizationSuccess(t *testing.T) {
35763702 runtime := mocks.NewMockContainerRuntime(t)
3703+ allowRestartPolicyEnsure(t, runtime)
35773704 envLoader := mocks.NewMockEnvLoader(t)
35783705 eventBus := mocks.NewMockEventPublisher(t)
35793706 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3662,6 +3789,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
36623789 // Uses testify's NotBefore() to enforce mock call ordering.
36633790 func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
36643791 runtime := mocks.NewMockContainerRuntime(t)
3792+ allowRestartPolicyEnsure(t, runtime)
36653793 envLoader := mocks.NewMockEnvLoader(t)
36663794 eventBus := mocks.NewMockEventPublisher(t)
36673795 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3758,6 +3886,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
37583886 // the old container remains as the tracked container.
37593887 func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
37603888 runtime := mocks.NewMockContainerRuntime(t)
3889+ allowRestartPolicyEnsure(t, runtime)
37613890 envLoader := mocks.NewMockEnvLoader(t)
37623891 eventBus := mocks.NewMockEventPublisher(t)
37633892
@@ -3848,6 +3977,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
38483977 // finalize/drain logic being triggered.
38493978 func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
38503979 runtime := mocks.NewMockContainerRuntime(t)
3980+ allowRestartPolicyEnsure(t, runtime)
38513981 envLoader := mocks.NewMockEnvLoader(t)
38523982 eventBus := mocks.NewMockEventPublisher(t)
38533983
@@ -3924,6 +4054,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
39244054
39254055 func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
39264056 runtime := mocks.NewMockContainerRuntime(t)
4057+ allowRestartPolicyEnsure(t, runtime)
39274058 envLoader := mocks.NewMockEnvLoader(t)
39284059 eventBus := mocks.NewMockEventPublisher(t)
39294060
@@ -4181,6 +4312,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
41814312 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")
41824313 assert.Equal(t, "postgres", cfg.Hostname)
41834314 assert.Equal(t, networkName, cfg.NetworkMode)
4315+ assert.Empty(t, cfg.RestartPolicy)
41844316 }).
41854317 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)
41864318
@@ -4193,6 +4325,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
41934325 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil)
41944326 // TCP probe: resolve endpoint fails -> falls back to delay
41954327 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network"))
4328+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "pg-container-1", domain.RestartPolicyAlways).Return(nil).Once()
41964329
41974330 err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName)
41984331 require.NoError(t, err)
@@ -4317,3 +4450,11 @@ func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T)
43174450 assert.Nil(t, cfg.CapDrop)
43184451 assert.Nil(t, cfg.CapAdd)
43194452 }
4453+
4454+func TestService_BuildContainerConfig_OmitsRestartPolicyBeforeReadiness(t *testing.T) {
4455+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4456+
4457+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4458+
4459+ assert.Empty(t, cfg.RestartPolicy)
4460+}
@@ -57,6 +57,11 @@ func testMinDelayConfig() Config {
57 }57 }
58 }58 }
59 59
60+func allowRestartPolicyEnsure(t *testing.T, runtime *mocks.MockContainerRuntime) {
61+ t.Helper()
62+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, mock.Anything, domain.RestartPolicyAlways).Return(nil).Maybe()
63+}
64+
60 func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {65 func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
61 t.Helper()66 t.Helper()
62 67
@@ -255,8 +260,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
255 ctx := testContext()260 ctx := testContext()
256 metrics, reader := setupMetricsTest(t)261 metrics, reader := setupMetricsTest(t)
257 svc.SetMetrics(metrics)262 svc.SetMetrics(metrics)
258-263+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
259- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
260 {264 {
261 ID: "container-1",265 ID: "container-1",
262 Labels: map[string]string{266 Labels: map[string]string{
@@ -280,7 +284,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
280 assert.Equal(t, 0, points[0].Attributes.Len())284 assert.Equal(t, 0, points[0].Attributes.Len())
281 285
282 // No runtime change: metric should remain stable.286 // No runtime change: metric should remain stable.
283- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{287+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
284 {288 {
285 ID: "container-1",289 ID: "container-1",
286 Labels: map[string]string{290 Labels: map[string]string{
@@ -304,7 +308,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
304 assert.Equal(t, 0, points[0].Attributes.Len())308 assert.Equal(t, 0, points[0].Attributes.Len())
305 309
306 // One container removed in runtime: metric should decrement by one.310 // One container removed in runtime: metric should decrement by one.
307- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{311+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
308 {312 {
309 ID: "container-1",313 ID: "container-1",
310 Labels: map[string]string{314 Labels: map[string]string{
@@ -378,6 +382,8 @@ func TestService_Deploy_Success(t *testing.T) {
378 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay382 // Wait for ready: IsContainerRunning (first check returns true) + verify after delay
379 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)383 runtime.EXPECT().IsContainerRunning(mock.Anything, "container-123").Return(true, nil).Times(2)
380 384
385+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "container-123", domain.RestartPolicyAlways).Return(nil).Once()
386+
381 // Re-inspect after start387 // Re-inspect after start
382 runningContainer := &domain.Container{388 runningContainer := &domain.Container{
383 ID: "container-123",389 ID: "container-123",
@@ -406,6 +412,7 @@ func TestService_Deploy_Success(t *testing.T) {
406 412
407 func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {413 func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) {
408 runtime := mocks.NewMockContainerRuntime(t)414 runtime := mocks.NewMockContainerRuntime(t)
415+ allowRestartPolicyEnsure(t, runtime)
409 envLoader := mocks.NewMockEnvLoader(t)416 envLoader := mocks.NewMockEnvLoader(t)
410 eventBus := mocks.NewMockEventPublisher(t)417 eventBus := mocks.NewMockEventPublisher(t)
411 418
@@ -472,6 +479,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
472 479
473 func TestService_Deploy_ImagePullFailure(t *testing.T) {480 func TestService_Deploy_ImagePullFailure(t *testing.T) {
474 runtime := mocks.NewMockContainerRuntime(t)481 runtime := mocks.NewMockContainerRuntime(t)
482+ allowRestartPolicyEnsure(t, runtime)
475 envLoader := mocks.NewMockEnvLoader(t)483 envLoader := mocks.NewMockEnvLoader(t)
476 eventBus := mocks.NewMockEventPublisher(t)484 eventBus := mocks.NewMockEventPublisher(t)
477 485
@@ -514,6 +522,7 @@ func TestIsPullFailure_ErrImagePullFailed(t *testing.T) {
514 522
515 func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {523 func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
516 runtime := mocks.NewMockContainerRuntime(t)524 runtime := mocks.NewMockContainerRuntime(t)
525+ allowRestartPolicyEnsure(t, runtime)
517 envLoader := mocks.NewMockEnvLoader(t)526 envLoader := mocks.NewMockEnvLoader(t)
518 eventBus := mocks.NewMockEventPublisher(t)527 eventBus := mocks.NewMockEventPublisher(t)
519 528
@@ -554,6 +563,7 @@ func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) {
554 563
555 func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {564 func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) {
556 runtime := mocks.NewMockContainerRuntime(t)565 runtime := mocks.NewMockContainerRuntime(t)
566+ allowRestartPolicyEnsure(t, runtime)
557 envLoader := mocks.NewMockEnvLoader(t)567 envLoader := mocks.NewMockEnvLoader(t)
558 eventBus := mocks.NewMockEventPublisher(t)568 eventBus := mocks.NewMockEventPublisher(t)
559 569
@@ -613,6 +623,7 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.
613 623
614 func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {624 func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) {
615 runtime := mocks.NewMockContainerRuntime(t)625 runtime := mocks.NewMockContainerRuntime(t)
626+ allowRestartPolicyEnsure(t, runtime)
616 envLoader := mocks.NewMockEnvLoader(t)627 envLoader := mocks.NewMockEnvLoader(t)
617 eventBus := mocks.NewMockEventPublisher(t)628 eventBus := mocks.NewMockEventPublisher(t)
618 629
@@ -724,6 +735,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
724 735
725 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {736 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
726 runtime := mocks.NewMockContainerRuntime(t)737 runtime := mocks.NewMockContainerRuntime(t)
738+ allowRestartPolicyEnsure(t, runtime)
727 envLoader := mocks.NewMockEnvLoader(t)739 envLoader := mocks.NewMockEnvLoader(t)
728 eventBus := mocks.NewMockEventPublisher(t)740 eventBus := mocks.NewMockEventPublisher(t)
729 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)741 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -807,6 +819,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
807 // from killing the real active container after a Gordon restart.819 // from killing the real active container after a Gordon restart.
808 func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) {820 func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) {
809 runtime := mocks.NewMockContainerRuntime(t)821 runtime := mocks.NewMockContainerRuntime(t)
822+ allowRestartPolicyEnsure(t, runtime)
810 envLoader := mocks.NewMockEnvLoader(t)823 envLoader := mocks.NewMockEnvLoader(t)
811 eventBus := mocks.NewMockEventPublisher(t)824 eventBus := mocks.NewMockEventPublisher(t)
812 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)825 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -906,6 +919,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
906 919
907 func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {920 func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
908 runtime := mocks.NewMockContainerRuntime(t)921 runtime := mocks.NewMockContainerRuntime(t)
922+ allowRestartPolicyEnsure(t, runtime)
909 envLoader := mocks.NewMockEnvLoader(t)923 envLoader := mocks.NewMockEnvLoader(t)
910 eventBus := mocks.NewMockEventPublisher(t)924 eventBus := mocks.NewMockEventPublisher(t)
911 925
@@ -968,6 +982,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
968 982
969 func TestService_Deploy_WithNetworkIsolation(t *testing.T) {983 func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
970 runtime := mocks.NewMockContainerRuntime(t)984 runtime := mocks.NewMockContainerRuntime(t)
985+ allowRestartPolicyEnsure(t, runtime)
971 envLoader := mocks.NewMockEnvLoader(t)986 envLoader := mocks.NewMockEnvLoader(t)
972 eventBus := mocks.NewMockEventPublisher(t)987 eventBus := mocks.NewMockEventPublisher(t)
973 988
@@ -1026,6 +1041,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
1026 1041
1027 func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {1042 func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
1028 runtime := mocks.NewMockContainerRuntime(t)1043 runtime := mocks.NewMockContainerRuntime(t)
1044+ allowRestartPolicyEnsure(t, runtime)
1029 envLoader := mocks.NewMockEnvLoader(t)1045 envLoader := mocks.NewMockEnvLoader(t)
1030 eventBus := mocks.NewMockEventPublisher(t)1046 eventBus := mocks.NewMockEventPublisher(t)
1031 1047
@@ -1238,6 +1254,97 @@ func TestService_HealthCheck(t *testing.T) {
1238 assert.False(t, result["unhealthy.example.com"])1254 assert.False(t, result["unhealthy.example.com"])
1239 }1255 }
1240 1256
1257+func TestService_EnsureManagedContainerRestartPolicies_EnsuresRoutesAttachmentsAndStoppedContainers(t *testing.T) {
1258+ runtime := mocks.NewMockContainerRuntime(t)
1259+ envLoader := mocks.NewMockEnvLoader(t)
1260+ eventBus := mocks.NewMockEventPublisher(t)
1261+
1262+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1263+ ctx := testContext()
1264+
1265+ routeContainer := &domain.Container{
1266+ ID: "route-1",
1267+ Name: "gordon-app.example.com",
1268+ Status: string(domain.ContainerStatusRunning),
1269+ Labels: map[string]string{
1270+ domain.LabelManaged: "true",
1271+ domain.LabelDomain: "app.example.com",
1272+ },
1273+ }
1274+ attachmentContainer := &domain.Container{
1275+ ID: "attachment-1",
1276+ Name: "gordon-app-example-com-postgres",
1277+ Status: string(domain.ContainerStatusRunning),
1278+ Labels: map[string]string{
1279+ domain.LabelManaged: "true",
1280+ domain.LabelAttachment: "true",
1281+ domain.LabelAttachedTo: "app.example.com",
1282+ },
1283+ }
1284+ stoppedContainer := &domain.Container{
1285+ ID: "stopped-1",
1286+ Name: "gordon-stopped.example.com",
1287+ Status: string(domain.ContainerStatusExited),
1288+ Labels: map[string]string{
1289+ domain.LabelManaged: "true",
1290+ domain.LabelDomain: "stopped.example.com",
1291+ },
1292+ }
1293+ unmanagedContainer := &domain.Container{
1294+ ID: "other-1",
1295+ Labels: map[string]string{},
1296+ }
1297+
1298+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1299+ routeContainer,
1300+ attachmentContainer,
1301+ stoppedContainer,
1302+ unmanagedContainer,
1303+ }, nil).Once()
1304+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(nil).Once()
1305+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "attachment-1", domain.RestartPolicyAlways).Return(nil).Once()
1306+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "stopped-1", domain.RestartPolicyAlways).Return(nil).Once()
1307+ err := svc.EnsureManagedContainerRestartPolicies(ctx)
1308+
1309+ require.NoError(t, err)
1310+ runtime.AssertNumberOfCalls(t, "EnsureContainerRestartPolicy", 3)
1311+}
1312+
1313+func TestService_EnsureManagedContainerRestartPolicies_ContinuesAndReturnsError(t *testing.T) {
1314+ runtime := mocks.NewMockContainerRuntime(t)
1315+ envLoader := mocks.NewMockEnvLoader(t)
1316+ eventBus := mocks.NewMockEventPublisher(t)
1317+
1318+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1319+ ctx := testContext()
1320+
1321+ routeContainer := &domain.Container{
1322+ ID: "route-1",
1323+ Name: "gordon-app.example.com",
1324+ Labels: map[string]string{
1325+ domain.LabelManaged: "true",
1326+ domain.LabelDomain: "app.example.com",
1327+ },
1328+ }
1329+ routeContainer2 := &domain.Container{
1330+ ID: "route-2",
1331+ Name: "gordon-api.example.com",
1332+ Labels: map[string]string{
1333+ domain.LabelManaged: "true",
1334+ domain.LabelDomain: "api.example.com",
1335+ },
1336+ }
1337+
1338+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{routeContainer, routeContainer2}, nil).Once()
1339+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-1", domain.RestartPolicyAlways).Return(errors.New("update failed")).Once()
1340+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "route-2", domain.RestartPolicyAlways).Return(nil).Once()
1341+ err := svc.EnsureManagedContainerRestartPolicies(ctx)
1342+
1343+ require.Error(t, err)
1344+ assert.Contains(t, err.Error(), "route-1")
1345+ assert.Contains(t, err.Error(), "update failed")
1346+}
1347+
1241 func TestService_SyncContainers(t *testing.T) {1348 func TestService_SyncContainers(t *testing.T) {
1242 runtime := mocks.NewMockContainerRuntime(t)1349 runtime := mocks.NewMockContainerRuntime(t)
1243 envLoader := mocks.NewMockEnvLoader(t)1350 envLoader := mocks.NewMockEnvLoader(t)
@@ -1246,7 +1353,7 @@ func TestService_SyncContainers(t *testing.T) {
1246 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)1353 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1247 ctx := testContext()1354 ctx := testContext()
1248 1355
1249- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{1356+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1250 {1357 {
1251 ID: "container-1",1358 ID: "container-1",
1252 Name: "gordon-app1.example.com",1359 Name: "gordon-app1.example.com",
@@ -1372,7 +1479,7 @@ func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
1372 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").1479 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
1373 Return(errors.New("no container with name or ID \"stale-container-id\" found")).1480 Return(errors.New("no container with name or ID \"stale-container-id\" found")).
1374 Once()1481 Once()
1375- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{1482+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1376 {1483 {
1377 ID: "fresh-container-id",1484 ID: "fresh-container-id",
1378 Name: "gordon-test.example.com",1485 Name: "gordon-test.example.com",
@@ -1406,7 +1513,7 @@ func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing
1406 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").1513 runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id").
1407 Return(errors.New("no such container")).1514 Return(errors.New("no such container")).
1408 Once()1515 Once()
1409- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Once()1516+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1410 1517
1411 err := svc.Restart(ctx, "test.example.com", false)1518 err := svc.Restart(ctx, "test.example.com", false)
1412 1519
@@ -1428,7 +1535,7 @@ func TestService_Restart_WithAttachments(t *testing.T) {
1428 Status: "running",1535 Status: "running",
1429 }1536 }
1430 svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}1537 svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
1431- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{1538+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1432 {1539 {
1433 ID: "container-123",1540 ID: "container-123",
1434 Labels: map[string]string{1541 Labels: map[string]string{
@@ -1453,7 +1560,6 @@ func TestService_Restart_WithAttachments(t *testing.T) {
1453 },1560 },
1454 },1561 },
1455 }, nil).Maybe()1562 }, nil).Maybe()
1456-
1457 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)1563 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1458 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)1564 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
1459 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))1565 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
@@ -1482,7 +1588,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t
1482 Status: "running",1588 Status: "running",
1483 }1589 }
1484 // No attachment containers tracked — s.attachments is empty1590 // No attachment containers tracked — s.attachments is empty
1485- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()1591+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Maybe()
1486 1592
1487 err := svc.Restart(testContext(), "test.example.com", true)1593 err := svc.Restart(testContext(), "test.example.com", true)
1488 1594
@@ -1512,7 +1618,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.
1512 }1618 }
1513 // Only 1 of 2 attachments deployed1619 // Only 1 of 2 attachments deployed
1514 svc.attachments["test.example.com"] = []string{"attach-postgres"}1620 svc.attachments["test.example.com"] = []string{"attach-postgres"}
1515- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{1621+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1516 {1622 {
1517 ID: "container-123",1623 ID: "container-123",
1518 Labels: map[string]string{1624 Labels: map[string]string{
@@ -1529,7 +1635,6 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.
1529 },1635 },
1530 },1636 },
1531 }, nil).Maybe()1637 }, nil).Maybe()
1532-
1533 err := svc.Restart(testContext(), "test.example.com", true)1638 err := svc.Restart(testContext(), "test.example.com", true)
1534 1639
1535 assert.Error(t, err)1640 assert.Error(t, err)
@@ -1558,7 +1663,7 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1558 Status: "running",1663 Status: "running",
1559 }1664 }
1560 svc.attachments["test.example.com"] = []string{"attach-456"}1665 svc.attachments["test.example.com"] = []string{"attach-456"}
1561- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{1666+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1562 {1667 {
1563 ID: "container-123",1668 ID: "container-123",
1564 Labels: map[string]string{1669 Labels: map[string]string{
@@ -1575,7 +1680,6 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1575 },1680 },
1576 },1681 },
1577 }, nil).Maybe()1682 }, nil).Maybe()
1578-
1579 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)1683 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1580 runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)1684 runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
1581 1685
@@ -1626,6 +1730,7 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test
1626 Name: "gordon-test.example.com",1730 Name: "gordon-test.example.com",
1627 Status: "running",1731 Status: "running",
1628 }1732 }
1733+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Maybe()
1629 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()1734 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1630 1735
1631 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)1736 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
@@ -1950,6 +2055,7 @@ func TestRewriteToLocalRegistry(t *testing.T) {
1950 2055
1951 func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {2056 func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1952 runtime := mocks.NewMockContainerRuntime(t)2057 runtime := mocks.NewMockContainerRuntime(t)
2058+ allowRestartPolicyEnsure(t, runtime)
1953 envLoader := mocks.NewMockEnvLoader(t)2059 envLoader := mocks.NewMockEnvLoader(t)
1954 eventBus := mocks.NewMockEventPublisher(t)2060 eventBus := mocks.NewMockEventPublisher(t)
1955 2061
@@ -2030,6 +2136,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
2030 2136
2031 func TestService_AutoStart_StartsNewContainers(t *testing.T) {2137 func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2032 runtime := mocks.NewMockContainerRuntime(t)2138 runtime := mocks.NewMockContainerRuntime(t)
2139+ allowRestartPolicyEnsure(t, runtime)
2033 envLoader := mocks.NewMockEnvLoader(t)2140 envLoader := mocks.NewMockEnvLoader(t)
2034 eventBus := mocks.NewMockEventPublisher(t)2141 eventBus := mocks.NewMockEventPublisher(t)
2035 2142
@@ -2079,6 +2186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
2079 2186
2080 func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {2187 func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
2081 runtime := mocks.NewMockContainerRuntime(t)2188 runtime := mocks.NewMockContainerRuntime(t)
2189+ allowRestartPolicyEnsure(t, runtime)
2082 envLoader := mocks.NewMockEnvLoader(t)2190 envLoader := mocks.NewMockEnvLoader(t)
2083 eventBus := mocks.NewMockEventPublisher(t)2191 eventBus := mocks.NewMockEventPublisher(t)
2084 2192
@@ -2128,6 +2236,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
2128 2236
2129 func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {2237 func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
2130 runtime := mocks.NewMockContainerRuntime(t)2238 runtime := mocks.NewMockContainerRuntime(t)
2239+ allowRestartPolicyEnsure(t, runtime)
2131 envLoader := mocks.NewMockEnvLoader(t)2240 envLoader := mocks.NewMockEnvLoader(t)
2132 eventBus := mocks.NewMockEventPublisher(t)2241 eventBus := mocks.NewMockEventPublisher(t)
2133 2242
@@ -2156,6 +2265,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
2156 2265
2157 func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {2266 func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
2158 runtime := mocks.NewMockContainerRuntime(t)2267 runtime := mocks.NewMockContainerRuntime(t)
2268+ allowRestartPolicyEnsure(t, runtime)
2159 envLoader := mocks.NewMockEnvLoader(t)2269 envLoader := mocks.NewMockEnvLoader(t)
2160 eventBus := mocks.NewMockEventPublisher(t)2270 eventBus := mocks.NewMockEventPublisher(t)
2161 2271
@@ -2208,6 +2318,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
2208 // until the new container is ready and traffic is switched.2318 // until the new container is ready and traffic is switched.
2209 func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {2319 func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2210 runtime := mocks.NewMockContainerRuntime(t)2320 runtime := mocks.NewMockContainerRuntime(t)
2321+ allowRestartPolicyEnsure(t, runtime)
2211 envLoader := mocks.NewMockEnvLoader(t)2322 envLoader := mocks.NewMockEnvLoader(t)
2212 eventBus := mocks.NewMockEventPublisher(t)2323 eventBus := mocks.NewMockEventPublisher(t)
2213 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2324 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2304,6 +2415,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2304 // Running canonical containers are preserved (see NeverKillsRunningCanonical test).2415 // Running canonical containers are preserved (see NeverKillsRunningCanonical test).
2305 func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {2416 func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
2306 runtime := mocks.NewMockContainerRuntime(t)2417 runtime := mocks.NewMockContainerRuntime(t)
2418+ allowRestartPolicyEnsure(t, runtime)
2307 envLoader := mocks.NewMockEnvLoader(t)2419 envLoader := mocks.NewMockEnvLoader(t)
2308 eventBus := mocks.NewMockEventPublisher(t)2420 eventBus := mocks.NewMockEventPublisher(t)
2309 2421
@@ -2384,6 +2496,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
2384 2496
2385 func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {2497 func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
2386 runtime := mocks.NewMockContainerRuntime(t)2498 runtime := mocks.NewMockContainerRuntime(t)
2499+ allowRestartPolicyEnsure(t, runtime)
2387 envLoader := mocks.NewMockEnvLoader(t)2500 envLoader := mocks.NewMockEnvLoader(t)
2388 eventBus := mocks.NewMockEventPublisher(t)2501 eventBus := mocks.NewMockEventPublisher(t)
2389 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2502 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2456,6 +2569,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
2456 2569
2457 func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {2570 func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) {
2458 runtime := mocks.NewMockContainerRuntime(t)2571 runtime := mocks.NewMockContainerRuntime(t)
2572+ allowRestartPolicyEnsure(t, runtime)
2459 envLoader := mocks.NewMockEnvLoader(t)2573 envLoader := mocks.NewMockEnvLoader(t)
2460 eventBus := mocks.NewMockEventPublisher(t)2574 eventBus := mocks.NewMockEventPublisher(t)
2461 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2575 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2645,6 +2759,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2645 // Verify that concurrent Deploy calls for the same domain are serialized2759 // Verify that concurrent Deploy calls for the same domain are serialized
2646 // and both succeed without container name conflicts.2760 // and both succeed without container name conflicts.
2647 runtime := mocks.NewMockContainerRuntime(t)2761 runtime := mocks.NewMockContainerRuntime(t)
2762+ allowRestartPolicyEnsure(t, runtime)
2648 envLoader := mocks.NewMockEnvLoader(t)2763 envLoader := mocks.NewMockEnvLoader(t)
2649 eventBus := mocks.NewMockEventPublisher(t)2764 eventBus := mocks.NewMockEventPublisher(t)
2650 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2765 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2755,6 +2870,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2755 func TestService_Deploy_ContextCancellation(t *testing.T) {2870 func TestService_Deploy_ContextCancellation(t *testing.T) {
2756 // Verify that deploy lock acquisition respects context cancellation.2871 // Verify that deploy lock acquisition respects context cancellation.
2757 runtime := mocks.NewMockContainerRuntime(t)2872 runtime := mocks.NewMockContainerRuntime(t)
2873+ allowRestartPolicyEnsure(t, runtime)
2758 envLoader := mocks.NewMockEnvLoader(t)2874 envLoader := mocks.NewMockEnvLoader(t)
2759 eventBus := mocks.NewMockEventPublisher(t)2875 eventBus := mocks.NewMockEventPublisher(t)
2760 2876
@@ -2785,6 +2901,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
2785 // BEFORE the old container is stopped, preventing 503 errors during deployment.2901 // BEFORE the old container is stopped, preventing 503 errors during deployment.
2786 func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {2902 func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2787 runtime := mocks.NewMockContainerRuntime(t)2903 runtime := mocks.NewMockContainerRuntime(t)
2904+ allowRestartPolicyEnsure(t, runtime)
2788 envLoader := mocks.NewMockEnvLoader(t)2905 envLoader := mocks.NewMockEnvLoader(t)
2789 eventBus := mocks.NewMockEventPublisher(t)2906 eventBus := mocks.NewMockEventPublisher(t)
2790 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2907 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -2865,6 +2982,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2865 // when no cache invalidator is set (e.g., in tests or minimal configurations).2982 // when no cache invalidator is set (e.g., in tests or minimal configurations).
2866 func TestService_Deploy_NilCacheInvalidator(t *testing.T) {2983 func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2867 runtime := mocks.NewMockContainerRuntime(t)2984 runtime := mocks.NewMockContainerRuntime(t)
2985+ allowRestartPolicyEnsure(t, runtime)
2868 envLoader := mocks.NewMockEnvLoader(t)2986 envLoader := mocks.NewMockEnvLoader(t)
2869 eventBus := mocks.NewMockEventPublisher(t)2987 eventBus := mocks.NewMockEventPublisher(t)
2870 2988
@@ -2914,6 +3032,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2914 3032
2915 func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {3033 func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2916 runtime := mocks.NewMockContainerRuntime(t)3034 runtime := mocks.NewMockContainerRuntime(t)
3035+ allowRestartPolicyEnsure(t, runtime)
2917 envLoader := mocks.NewMockEnvLoader(t)3036 envLoader := mocks.NewMockEnvLoader(t)
2918 eventBus := mocks.NewMockEventPublisher(t)3037 eventBus := mocks.NewMockEventPublisher(t)
2919 3038
@@ -2974,6 +3093,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2974 3093
2975 func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {3094 func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) {
2976 runtime := mocks.NewMockContainerRuntime(t)3095 runtime := mocks.NewMockContainerRuntime(t)
3096+ allowRestartPolicyEnsure(t, runtime)
2977 envLoader := mocks.NewMockEnvLoader(t)3097 envLoader := mocks.NewMockEnvLoader(t)
2978 eventBus := mocks.NewMockEventPublisher(t)3098 eventBus := mocks.NewMockEventPublisher(t)
2979 3099
@@ -3035,6 +3155,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
3035 3155
3036 func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {3156 func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
3037 runtime := mocks.NewMockContainerRuntime(t)3157 runtime := mocks.NewMockContainerRuntime(t)
3158+ allowRestartPolicyEnsure(t, runtime)
3038 envLoader := mocks.NewMockEnvLoader(t)3159 envLoader := mocks.NewMockEnvLoader(t)
3039 eventBus := mocks.NewMockEventPublisher(t)3160 eventBus := mocks.NewMockEventPublisher(t)
3040 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3161 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3101,7 +3222,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
3101 ctx := testContext()3222 ctx := testContext()
3102 3223
3103 // ListContainers returns one main container and one attachment container3224 // ListContainers returns one main container and one attachment container
3104- runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{3225+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
3105 {3226 {
3106 ID: "main-container-1",3227 ID: "main-container-1",
3107 Labels: map[string]string{3228 Labels: map[string]string{
@@ -3137,6 +3258,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
3137 3258
3138 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {3259 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
3139 runtime := mocks.NewMockContainerRuntime(t)3260 runtime := mocks.NewMockContainerRuntime(t)
3261+ allowRestartPolicyEnsure(t, runtime)
3140 envLoader := mocks.NewMockEnvLoader(t)3262 envLoader := mocks.NewMockEnvLoader(t)
3141 eventBus := mocks.NewMockEventPublisher(t)3263 eventBus := mocks.NewMockEventPublisher(t)
3142 3264
@@ -3200,6 +3322,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
3200 3322
3201 func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {3323 func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
3202 runtime := mocks.NewMockContainerRuntime(t)3324 runtime := mocks.NewMockContainerRuntime(t)
3325+ allowRestartPolicyEnsure(t, runtime)
3203 envLoader := mocks.NewMockEnvLoader(t)3326 envLoader := mocks.NewMockEnvLoader(t)
3204 eventBus := mocks.NewMockEventPublisher(t)3327 eventBus := mocks.NewMockEventPublisher(t)
3205 3328
@@ -3258,6 +3381,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
3258 3381
3259 func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {3382 func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
3260 runtime := mocks.NewMockContainerRuntime(t)3383 runtime := mocks.NewMockContainerRuntime(t)
3384+ allowRestartPolicyEnsure(t, runtime)
3261 envLoader := mocks.NewMockEnvLoader(t)3385 envLoader := mocks.NewMockEnvLoader(t)
3262 eventBus := mocks.NewMockEventPublisher(t)3386 eventBus := mocks.NewMockEventPublisher(t)
3263 3387
@@ -3384,6 +3508,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
3384 // disallows creating a container with the same name as an existing running container.3508 // disallows creating a container with the same name as an existing running container.
3385 func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {3509 func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3386 runtime := mocks.NewMockContainerRuntime(t)3510 runtime := mocks.NewMockContainerRuntime(t)
3511+ allowRestartPolicyEnsure(t, runtime)
3387 envLoader := mocks.NewMockEnvLoader(t)3512 envLoader := mocks.NewMockEnvLoader(t)
3388 eventBus := mocks.NewMockEventPublisher(t)3513 eventBus := mocks.NewMockEventPublisher(t)
3389 3514
@@ -3480,6 +3605,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3480 // and the failed new container is cleaned up.3605 // and the failed new container is cleaned up.
3481 func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {3606 func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
3482 runtime := mocks.NewMockContainerRuntime(t)3607 runtime := mocks.NewMockContainerRuntime(t)
3608+ allowRestartPolicyEnsure(t, runtime)
3483 envLoader := mocks.NewMockEnvLoader(t)3609 envLoader := mocks.NewMockEnvLoader(t)
3484 eventBus := mocks.NewMockEventPublisher(t)3610 eventBus := mocks.NewMockEventPublisher(t)
3485 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3611 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3574,6 +3700,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
3574 // container is finalized (stopped and removed).3700 // container is finalized (stopped and removed).
3575 func TestService_Deploy_StabilizationSuccess(t *testing.T) {3701 func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3576 runtime := mocks.NewMockContainerRuntime(t)3702 runtime := mocks.NewMockContainerRuntime(t)
3703+ allowRestartPolicyEnsure(t, runtime)
3577 envLoader := mocks.NewMockEnvLoader(t)3704 envLoader := mocks.NewMockEnvLoader(t)
3578 eventBus := mocks.NewMockEventPublisher(t)3705 eventBus := mocks.NewMockEventPublisher(t)
3579 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3706 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3662,6 +3789,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3662 // Uses testify's NotBefore() to enforce mock call ordering.3789 // Uses testify's NotBefore() to enforce mock call ordering.
3663 func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {3790 func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) {
3664 runtime := mocks.NewMockContainerRuntime(t)3791 runtime := mocks.NewMockContainerRuntime(t)
3792+ allowRestartPolicyEnsure(t, runtime)
3665 envLoader := mocks.NewMockEnvLoader(t)3793 envLoader := mocks.NewMockEnvLoader(t)
3666 eventBus := mocks.NewMockEventPublisher(t)3794 eventBus := mocks.NewMockEventPublisher(t)
3667 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)3795 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
@@ -3758,6 +3886,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
3758 // the old container remains as the tracked container.3886 // the old container remains as the tracked container.
3759 func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {3887 func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) {
3760 runtime := mocks.NewMockContainerRuntime(t)3888 runtime := mocks.NewMockContainerRuntime(t)
3889+ allowRestartPolicyEnsure(t, runtime)
3761 envLoader := mocks.NewMockEnvLoader(t)3890 envLoader := mocks.NewMockEnvLoader(t)
3762 eventBus := mocks.NewMockEventPublisher(t)3891 eventBus := mocks.NewMockEventPublisher(t)
3763 3892
@@ -3848,6 +3977,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3848 // finalize/drain logic being triggered.3977 // finalize/drain logic being triggered.
3849 func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {3978 func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3850 runtime := mocks.NewMockContainerRuntime(t)3979 runtime := mocks.NewMockContainerRuntime(t)
3980+ allowRestartPolicyEnsure(t, runtime)
3851 envLoader := mocks.NewMockEnvLoader(t)3981 envLoader := mocks.NewMockEnvLoader(t)
3852 eventBus := mocks.NewMockEventPublisher(t)3982 eventBus := mocks.NewMockEventPublisher(t)
3853 3983
@@ -3924,6 +4054,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3924 4054
3925 func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {4055 func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3926 runtime := mocks.NewMockContainerRuntime(t)4056 runtime := mocks.NewMockContainerRuntime(t)
4057+ allowRestartPolicyEnsure(t, runtime)
3927 envLoader := mocks.NewMockEnvLoader(t)4058 envLoader := mocks.NewMockEnvLoader(t)
3928 eventBus := mocks.NewMockEventPublisher(t)4059 eventBus := mocks.NewMockEventPublisher(t)
3929 4060
@@ -4181,6 +4312,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4181 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")4312 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")
4182 assert.Equal(t, "postgres", cfg.Hostname)4313 assert.Equal(t, "postgres", cfg.Hostname)
4183 assert.Equal(t, networkName, cfg.NetworkMode)4314 assert.Equal(t, networkName, cfg.NetworkMode)
4315+ assert.Empty(t, cfg.RestartPolicy)
4184 }).4316 }).
4185 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)4317 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)
4186 4318
@@ -4193,6 +4325,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4193 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil)4325 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil)
4194 // TCP probe: resolve endpoint fails -> falls back to delay4326 // TCP probe: resolve endpoint fails -> falls back to delay
4195 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network"))4327 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, "pg-container-1").Return("", 0, errors.New("no network"))
4328+ runtime.EXPECT().EnsureContainerRestartPolicy(mock.Anything, "pg-container-1", domain.RestartPolicyAlways).Return(nil).Once()
4196 4329
4197 err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName)4330 err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName)
4198 require.NoError(t, err)4331 require.NoError(t, err)
@@ -4317,3 +4450,11 @@ func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T)
4317 assert.Nil(t, cfg.CapDrop)4450 assert.Nil(t, cfg.CapDrop)
4318 assert.Nil(t, cfg.CapAdd)4451 assert.Nil(t, cfg.CapAdd)
4319 }4452 }
4453+
4454+func TestService_BuildContainerConfig_OmitsRestartPolicyBeforeReadiness(t *testing.T) {
4455+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4456+
4457+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4458+
4459+ assert.Empty(t, cfg.RestartPolicy)
4460+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close