Merge pull request #191 from bnema/fix/managed-container-restart-policy
Set restart policy for managed containers
9 files · +565 -55
M internal/adapters/out/docker/runtime.go +41 -0
| @@ -43,6 +43,8 @@ type Runtime struct { | |||
| 43 | 43 | client *client.Client | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | + | var _ out.ContainerRuntime = (*Runtime)(nil) | |
| 47 | + | ||
| 46 | 48 | type pipeReadCloser struct { | |
| 47 | 49 | pr *io.PipeReader | |
| 48 | 50 | pw *io.PipeWriter | |
| @@ -208,6 +210,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC | |||
| 208 | 210 | CapAdd: capAdd, | |
| 209 | 211 | ReadonlyRootfs: config.ReadOnlyRootFS, | |
| 210 | 212 | } | |
| 213 | + | if config.RestartPolicy != "" { | |
| 214 | + | hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)} | |
| 215 | + | } | |
| 211 | 216 | ||
| 212 | 217 | // Create network configuration for container | |
| 213 | 218 | var networkConfig *network.NetworkingConfig | |
| @@ -358,6 +363,42 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri | |||
| 358 | 363 | 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 | 402 | // ListContainers lists containers. | |
| 362 | 403 | func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) { | |
| 363 | 404 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -43,6 +43,8 @@ type Runtime struct { | |||||
| 43 | client *client.Client | 43 | 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.PipeReader | 49 | pr *io.PipeReader | ||
| 48 | pw *io.PipeWriter | 50 | 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 container | 217 | // Create network configuration for container | ||
| 213 | var networkConfig *network.NetworkingConfig | 218 | var networkConfig *network.NetworkingConfig | ||
| @@ -358,6 +363,42 @@ func (r *Runtime) RenameContainer(ctx context.Context, containerID, newName stri | |||||
| 358 | return nil | 363 | 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 | |||
| 2015 | 2015 | ||
| 2016 | 2016 | // syncAndAutoStart syncs existing containers and auto-starts if configured. | |
| 2017 | 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 | 2022 | if err := svc.containerSvc.SyncContainers(ctx); err != nil { | |
| 2019 | 2023 | log.Warn().Err(err).Msg("failed to sync existing containers") | |
| 2020 | 2024 | } | |
| @@ -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 | |||
| 428 | 428 | 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 | 494 | // ExecInContainer provides a mock function for the type MockContainerRuntime | |
| 432 | 495 | func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { | |
| 433 | 496 | ret := _mock.Called(ctx, containerID, cmd) | |
| @@ -428,6 +428,69 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn | |||||
| 428 | return _c | 428 | 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 MockContainerRuntime | 494 | // 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 { | |||
| 20 | 20 | RestartContainer(ctx context.Context, containerID string) error | |
| 21 | 21 | RemoveContainer(ctx context.Context, containerID string, force bool) error | |
| 22 | 22 | RenameContainer(ctx context.Context, containerID, newName string) error | |
| 23 | + | EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error | |
| 23 | 24 | ||
| 24 | 25 | // Container inspection | |
| 25 | 26 | ListContainers(ctx context.Context, all bool) ([]*domain.Container, error) | |
| @@ -20,6 +20,7 @@ type ContainerRuntime interface { | |||||
| 20 | RestartContainer(ctx context.Context, containerID string) error | 20 | RestartContainer(ctx context.Context, containerID string) error | ||
| 21 | RemoveContainer(ctx context.Context, containerID string, force bool) error | 21 | RemoveContainer(ctx context.Context, containerID string, force bool) error | ||
| 22 | RenameContainer(ctx context.Context, containerID, newName string) error | 22 | RenameContainer(ctx context.Context, containerID, newName string) error | ||
| 23 | + | EnsureContainerRestartPolicy(ctx context.Context, containerID, policy string) error | |||
| 23 | 24 | ||||
| 24 | // Container inspection | 25 | // 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 { | |||
| 63 | 63 | WorkingDir string | |
| 64 | 64 | Cmd []string | |
| 65 | 65 | AutoRemove bool | |
| 66 | + | RestartPolicy string | |
| 66 | 67 | Volumes map[string]string // map[containerPath]volumeName | |
| 67 | 68 | ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only) | |
| 68 | 69 | NetworkMode string // Network to join | |
| @@ -88,3 +89,7 @@ const ( | |||
| 88 | 89 | ContainerStatusPaused ContainerStatus = "paused" | |
| 89 | 90 | ContainerStatusUnknown ContainerStatus = "unknown" | |
| 90 | 91 | ) | |
| 92 | + | ||
| 93 | + | const ( | |
| 94 | + | RestartPolicyAlways = "always" | |
| 95 | + | ) | |
| @@ -63,6 +63,7 @@ type ContainerConfig struct { | |||||
| 63 | WorkingDir string | 63 | WorkingDir string | ||
| 64 | Cmd []string | 64 | Cmd []string | ||
| 65 | AutoRemove bool | 65 | AutoRemove bool | ||
| 66 | + | RestartPolicy string | |||
| 66 | Volumes map[string]string // map[containerPath]volumeName | 67 | 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 join | 69 | 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 | |||
| 654 | 654 | 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 | 662 | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) | |
| 659 | 663 | if err != nil { | |
| @@ -1404,6 +1408,41 @@ func (s *Service) HealthCheck(ctx context.Context) map[string]bool { | |||
| 1404 | 1408 | 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 | 1446 | // SyncContainers synchronizes containers with runtime state. | |
| 1408 | 1447 | func (s *Service) SyncContainers(ctx context.Context) error { | |
| 1409 | 1448 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -1412,8 +1451,10 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||
| 1412 | 1451 | }) | |
| 1413 | 1452 | log := zerowrap.FromCtx(ctx) | |
| 1414 | 1453 | ||
| 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) | |
| 1417 | 1458 | if err != nil { | |
| 1418 | 1459 | return log.WrapErr(err, "failed to list containers") | |
| 1419 | 1460 | } | |
| @@ -1421,6 +1462,9 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||
| 1421 | 1462 | managed := make(map[string]*domain.Container) | |
| 1422 | 1463 | attachments := make(map[string][]string) | |
| 1423 | 1464 | for _, c := range allContainers { | |
| 1465 | + | if c.Status != "" && c.Status != string(domain.ContainerStatusRunning) { | |
| 1466 | + | continue | |
| 1467 | + | } | |
| 1424 | 1468 | if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" { | |
| 1425 | 1469 | continue | |
| 1426 | 1470 | } | |
| @@ -2577,46 +2621,85 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2577 | 2621 | ||
| 2578 | 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). | |
| 2581 | - | imageRef, err := s.buildValidatedImageRef(ctx, serviceImage) | |
| 2624 | + | config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName) | |
| 2582 | 2625 | if err != nil { | |
| 2583 | 2626 | return err | |
| 2584 | 2627 | } | |
| 2585 | - | actualImageRef, err := s.ensureImage(ctx, imageRef) | |
| 2628 | + | ||
| 2629 | + | container, err := s.runtime.CreateContainer(ctx, config) | |
| 2586 | 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 | 2650 | 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 | 2675 | exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef) | |
| 2591 | 2676 | volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef) | |
| 2592 | 2677 | if err != nil { | |
| 2593 | - | return err | |
| 2678 | + | return nil, err | |
| 2594 | 2679 | } | |
| 2595 | 2680 | envVars, err := s.attachmentEnv(ctx, containerName, actualImageRef) | |
| 2596 | 2681 | if err != nil { | |
| 2597 | - | return err | |
| 2682 | + | return nil, err | |
| 2598 | 2683 | } | |
| 2599 | - | envHash := hashEnvironment(envVars) | |
| 2600 | 2684 | ||
| 2601 | 2685 | s.mu.RLock() | |
| 2602 | 2686 | cfg := s.config | |
| 2603 | 2687 | s.mu.RUnlock() | |
| 2604 | 2688 | ||
| 2605 | - | // Create container with attachment labels (use actual ref, track original in labels) | |
| 2606 | 2689 | config := &domain.ContainerConfig{ | |
| 2607 | 2690 | Image: actualImageRef, | |
| 2608 | 2691 | Name: containerName, | |
| 2609 | - | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | |
| 2692 | + | Hostname: serviceName, | |
| 2610 | 2693 | Aliases: []string{serviceName}, | |
| 2611 | 2694 | Ports: exposedPorts, | |
| 2612 | 2695 | Env: envVars, | |
| 2613 | 2696 | Volumes: volumes, | |
| 2614 | - | NetworkMode: networkName, // Same network as main app | |
| 2697 | + | NetworkMode: networkName, | |
| 2615 | 2698 | Labels: map[string]string{ | |
| 2616 | 2699 | domain.LabelManaged: "true", | |
| 2617 | 2700 | domain.LabelAttachment: "true", | |
| 2618 | 2701 | domain.LabelAttachedTo: ownerDomain, | |
| 2619 | - | domain.LabelEnvHash: envHash, | |
| 2702 | + | domain.LabelEnvHash: hashEnvironment(envVars), | |
| 2620 | 2703 | domain.LabelImage: serviceImage, | |
| 2621 | 2704 | }, | |
| 2622 | 2705 | MemoryLimit: cfg.DefaultMemoryLimit, | |
| @@ -2624,37 +2707,18 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2624 | 2707 | PidsLimit: cfg.DefaultPidsLimit, | |
| 2625 | 2708 | } | |
| 2626 | 2709 | applySecurityProfile(config, cfg) | |
| 2710 | + | return config, nil | |
| 2711 | + | } | |
| 2627 | 2712 | ||
| 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") | |
| 2644 | 2718 | } | |
| 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") | |
| 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 | 2722 | return nil | |
| 2659 | 2723 | } | |
| 2660 | 2724 | ||
| @@ -654,6 +654,10 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 654 | return nil, deployErr | 654 | 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 health | 1408 | 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 call | 1454 | + | // 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 | continue | 1469 | 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 err | 2626 | 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 err | 2650 | 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 err | 2678 | + | 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 err | 2682 | + | return nil, err |
| 2598 | } | 2683 | } | ||
| 2599 | - | envHash := hashEnvironment(envVars) | |||
| 2600 | 2684 | ||||
| 2601 | s.mu.RLock() | 2685 | s.mu.RLock() | ||
| 2602 | cfg := s.config | 2686 | 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 app | 2697 | + | 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 nil | 2722 | return nil | ||
| 2659 | } | 2723 | } | ||
| 2660 | 2724 | ||||
M internal/usecase/container/service_test.go +156 -15
| @@ -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 | 65 | func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) { | |
| 61 | 66 | t.Helper() | |
| 62 | 67 | ||
| @@ -255,8 +260,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) | |||
| 255 | 260 | ctx := testContext() | |
| 256 | 261 | metrics, reader := setupMetricsTest(t) | |
| 257 | 262 | svc.SetMetrics(metrics) | |
| 258 | - | ||
| 259 | - | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 263 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 260 | 264 | { | |
| 261 | 265 | ID: "container-1", | |
| 262 | 266 | Labels: map[string]string{ | |
| @@ -280,7 +284,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) | |||
| 280 | 284 | assert.Equal(t, 0, points[0].Attributes.Len()) | |
| 281 | 285 | ||
| 282 | 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 | 289 | ID: "container-1", | |
| 286 | 290 | Labels: map[string]string{ | |
| @@ -304,7 +308,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) | |||
| 304 | 308 | assert.Equal(t, 0, points[0].Attributes.Len()) | |
| 305 | 309 | ||
| 306 | 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 | 313 | ID: "container-1", | |
| 310 | 314 | Labels: map[string]string{ | |
| @@ -378,6 +382,8 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 378 | 382 | // Wait for ready: IsContainerRunning (first check returns true) + verify after delay | |
| 379 | 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 | 387 | // Re-inspect after start | |
| 382 | 388 | runningContainer := &domain.Container{ | |
| 383 | 389 | ID: "container-123", | |
| @@ -406,6 +412,7 @@ func TestService_Deploy_Success(t *testing.T) { | |||
| 406 | 412 | ||
| 407 | 413 | func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T) { | |
| 408 | 414 | runtime := mocks.NewMockContainerRuntime(t) | |
| 415 | + | allowRestartPolicyEnsure(t, runtime) | |
| 409 | 416 | envLoader := mocks.NewMockEnvLoader(t) | |
| 410 | 417 | eventBus := mocks.NewMockEventPublisher(t) | |
| 411 | 418 | ||
| @@ -472,6 +479,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T | |||
| 472 | 479 | ||
| 473 | 480 | func TestService_Deploy_ImagePullFailure(t *testing.T) { | |
| 474 | 481 | runtime := mocks.NewMockContainerRuntime(t) | |
| 482 | + | allowRestartPolicyEnsure(t, runtime) | |
| 475 | 483 | envLoader := mocks.NewMockEnvLoader(t) | |
| 476 | 484 | eventBus := mocks.NewMockEventPublisher(t) | |
| 477 | 485 | ||
| @@ -514,6 +522,7 @@ func TestIsPullFailure_ErrImagePullFailed(t *testing.T) { | |||
| 514 | 522 | ||
| 515 | 523 | func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) { | |
| 516 | 524 | runtime := mocks.NewMockContainerRuntime(t) | |
| 525 | + | allowRestartPolicyEnsure(t, runtime) | |
| 517 | 526 | envLoader := mocks.NewMockEnvLoader(t) | |
| 518 | 527 | eventBus := mocks.NewMockEventPublisher(t) | |
| 519 | 528 | ||
| @@ -554,6 +563,7 @@ func TestService_CaptureRecentContainerLogs_SkipsCanceledContext(t *testing.T) { | |||
| 554 | 563 | ||
| 555 | 564 | func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing.T) { | |
| 556 | 565 | runtime := mocks.NewMockContainerRuntime(t) | |
| 566 | + | allowRestartPolicyEnsure(t, runtime) | |
| 557 | 567 | envLoader := mocks.NewMockEnvLoader(t) | |
| 558 | 568 | eventBus := mocks.NewMockEventPublisher(t) | |
| 559 | 569 | ||
| @@ -613,6 +623,7 @@ func TestService_Deploy_ReadinessFailureReturnsDeployFailureWithLogs(t *testing. | |||
| 613 | 623 | ||
| 614 | 624 | func TestService_Deploy_StrictHealthModeWithoutHealthcheckReturnsHint(t *testing.T) { | |
| 615 | 625 | runtime := mocks.NewMockContainerRuntime(t) | |
| 626 | + | allowRestartPolicyEnsure(t, runtime) | |
| 616 | 627 | envLoader := mocks.NewMockEnvLoader(t) | |
| 617 | 628 | eventBus := mocks.NewMockEventPublisher(t) | |
| 618 | 629 | ||
| @@ -724,6 +735,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) { | |||
| 724 | 735 | ||
| 725 | 736 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |
| 726 | 737 | runtime := mocks.NewMockContainerRuntime(t) | |
| 738 | + | allowRestartPolicyEnsure(t, runtime) | |
| 727 | 739 | envLoader := mocks.NewMockEnvLoader(t) | |
| 728 | 740 | eventBus := mocks.NewMockEventPublisher(t) | |
| 729 | 741 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -807,6 +819,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |||
| 807 | 819 | // from killing the real active container after a Gordon restart. | |
| 808 | 820 | func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T) { | |
| 809 | 821 | runtime := mocks.NewMockContainerRuntime(t) | |
| 822 | + | allowRestartPolicyEnsure(t, runtime) | |
| 810 | 823 | envLoader := mocks.NewMockEnvLoader(t) | |
| 811 | 824 | eventBus := mocks.NewMockEventPublisher(t) | |
| 812 | 825 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -906,6 +919,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T | |||
| 906 | 919 | ||
| 907 | 920 | func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |
| 908 | 921 | runtime := mocks.NewMockContainerRuntime(t) | |
| 922 | + | allowRestartPolicyEnsure(t, runtime) | |
| 909 | 923 | envLoader := mocks.NewMockEnvLoader(t) | |
| 910 | 924 | eventBus := mocks.NewMockEventPublisher(t) | |
| 911 | 925 | ||
| @@ -968,6 +982,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 968 | 982 | ||
| 969 | 983 | func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |
| 970 | 984 | runtime := mocks.NewMockContainerRuntime(t) | |
| 985 | + | allowRestartPolicyEnsure(t, runtime) | |
| 971 | 986 | envLoader := mocks.NewMockEnvLoader(t) | |
| 972 | 987 | eventBus := mocks.NewMockEventPublisher(t) | |
| 973 | 988 | ||
| @@ -1026,6 +1041,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) { | |||
| 1026 | 1041 | ||
| 1027 | 1042 | func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) { | |
| 1028 | 1043 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1044 | + | allowRestartPolicyEnsure(t, runtime) | |
| 1029 | 1045 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1030 | 1046 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1031 | 1047 | ||
| @@ -1238,6 +1254,97 @@ func TestService_HealthCheck(t *testing.T) { | |||
| 1238 | 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 | 1348 | func TestService_SyncContainers(t *testing.T) { | |
| 1242 | 1349 | runtime := mocks.NewMockContainerRuntime(t) | |
| 1243 | 1350 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -1246,7 +1353,7 @@ func TestService_SyncContainers(t *testing.T) { | |||
| 1246 | 1353 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil) | |
| 1247 | 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 | 1358 | ID: "container-1", | |
| 1252 | 1359 | Name: "gordon-app1.example.com", | |
| @@ -1372,7 +1479,7 @@ func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) { | |||
| 1372 | 1479 | runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id"). | |
| 1373 | 1480 | Return(errors.New("no container with name or ID \"stale-container-id\" found")). | |
| 1374 | 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 | 1484 | ID: "fresh-container-id", | |
| 1378 | 1485 | Name: "gordon-test.example.com", | |
| @@ -1406,7 +1513,7 @@ func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing | |||
| 1406 | 1513 | runtime.EXPECT().RestartContainer(mock.Anything, "stale-container-id"). | |
| 1407 | 1514 | Return(errors.New("no such container")). | |
| 1408 | 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 | 1518 | err := svc.Restart(ctx, "test.example.com", false) | |
| 1412 | 1519 | ||
| @@ -1428,7 +1535,7 @@ func TestService_Restart_WithAttachments(t *testing.T) { | |||
| 1428 | 1535 | Status: "running", | |
| 1429 | 1536 | } | |
| 1430 | 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 | 1540 | ID: "container-123", | |
| 1434 | 1541 | Labels: map[string]string{ | |
| @@ -1453,7 +1560,6 @@ func TestService_Restart_WithAttachments(t *testing.T) { | |||
| 1453 | 1560 | }, | |
| 1454 | 1561 | }, | |
| 1455 | 1562 | }, nil).Maybe() | |
| 1456 | - | ||
| 1457 | 1563 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | |
| 1458 | 1564 | runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil) | |
| 1459 | 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 | 1588 | Status: "running", | |
| 1483 | 1589 | } | |
| 1484 | 1590 | // 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 | 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 | 1619 | // Only 1 of 2 attachments deployed | |
| 1514 | 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 | 1623 | ID: "container-123", | |
| 1518 | 1624 | Labels: map[string]string{ | |
| @@ -1529,7 +1635,6 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing. | |||
| 1529 | 1635 | }, | |
| 1530 | 1636 | }, | |
| 1531 | 1637 | }, nil).Maybe() | |
| 1532 | - | ||
| 1533 | 1638 | err := svc.Restart(testContext(), "test.example.com", true) | |
| 1534 | 1639 | ||
| 1535 | 1640 | assert.Error(t, err) | |
| @@ -1558,7 +1663,7 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) { | |||
| 1558 | 1663 | Status: "running", | |
| 1559 | 1664 | } | |
| 1560 | 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 | 1668 | ID: "container-123", | |
| 1564 | 1669 | Labels: map[string]string{ | |
| @@ -1575,7 +1680,6 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) { | |||
| 1575 | 1680 | }, | |
| 1576 | 1681 | }, | |
| 1577 | 1682 | }, nil).Maybe() | |
| 1578 | - | ||
| 1579 | 1683 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | |
| 1580 | 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 | 1730 | Name: "gordon-test.example.com", | |
| 1627 | 1731 | Status: "running", | |
| 1628 | 1732 | } | |
| 1733 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Maybe() | |
| 1629 | 1734 | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe() | |
| 1630 | 1735 | ||
| 1631 | 1736 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | |
| @@ -1950,6 +2055,7 @@ func TestRewriteToLocalRegistry(t *testing.T) { | |||
| 1950 | 2055 | ||
| 1951 | 2056 | func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |
| 1952 | 2057 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2058 | + | allowRestartPolicyEnsure(t, runtime) | |
| 1953 | 2059 | envLoader := mocks.NewMockEnvLoader(t) | |
| 1954 | 2060 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1955 | 2061 | ||
| @@ -2030,6 +2136,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) { | |||
| 2030 | 2136 | ||
| 2031 | 2137 | func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |
| 2032 | 2138 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2139 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2033 | 2140 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2034 | 2141 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2035 | 2142 | ||
| @@ -2079,6 +2186,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 2079 | 2186 | ||
| 2080 | 2187 | func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |
| 2081 | 2188 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2189 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2082 | 2190 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2083 | 2191 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2084 | 2192 | ||
| @@ -2128,6 +2236,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 2128 | 2236 | ||
| 2129 | 2237 | func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |
| 2130 | 2238 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2239 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2131 | 2240 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2132 | 2241 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2133 | 2242 | ||
| @@ -2156,6 +2265,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) { | |||
| 2156 | 2265 | ||
| 2157 | 2266 | func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |
| 2158 | 2267 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2268 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2159 | 2269 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2160 | 2270 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2161 | 2271 | ||
| @@ -2208,6 +2318,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 2208 | 2318 | // until the new container is ready and traffic is switched. | |
| 2209 | 2319 | func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |
| 2210 | 2320 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2321 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2211 | 2322 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2212 | 2323 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2213 | 2324 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -2304,6 +2415,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 2304 | 2415 | // Running canonical containers are preserved (see NeverKillsRunningCanonical test). | |
| 2305 | 2416 | func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |
| 2306 | 2417 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2418 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2307 | 2419 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2308 | 2420 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2309 | 2421 | ||
| @@ -2384,6 +2496,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) { | |||
| 2384 | 2496 | ||
| 2385 | 2497 | func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |
| 2386 | 2498 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2499 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2387 | 2500 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2388 | 2501 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2389 | 2502 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -2456,6 +2569,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 2456 | 2569 | ||
| 2457 | 2570 | func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) { | |
| 2458 | 2571 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2572 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2459 | 2573 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2460 | 2574 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2461 | 2575 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -2645,6 +2759,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 2645 | 2759 | // Verify that concurrent Deploy calls for the same domain are serialized | |
| 2646 | 2760 | // and both succeed without container name conflicts. | |
| 2647 | 2761 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2762 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2648 | 2763 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2649 | 2764 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2650 | 2765 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -2755,6 +2870,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 2755 | 2870 | func TestService_Deploy_ContextCancellation(t *testing.T) { | |
| 2756 | 2871 | // Verify that deploy lock acquisition respects context cancellation. | |
| 2757 | 2872 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2873 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2758 | 2874 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2759 | 2875 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2760 | 2876 | ||
| @@ -2785,6 +2901,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) { | |||
| 2785 | 2901 | // BEFORE the old container is stopped, preventing 503 errors during deployment. | |
| 2786 | 2902 | func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |
| 2787 | 2903 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2904 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2788 | 2905 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2789 | 2906 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2790 | 2907 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -2865,6 +2982,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||
| 2865 | 2982 | // when no cache invalidator is set (e.g., in tests or minimal configurations). | |
| 2866 | 2983 | func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |
| 2867 | 2984 | runtime := mocks.NewMockContainerRuntime(t) | |
| 2985 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2868 | 2986 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2869 | 2987 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2870 | 2988 | ||
| @@ -2914,6 +3032,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||
| 2914 | 3032 | ||
| 2915 | 3033 | func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |
| 2916 | 3034 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3035 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2917 | 3036 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2918 | 3037 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2919 | 3038 | ||
| @@ -2974,6 +3093,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2974 | 3093 | ||
| 2975 | 3094 | func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testing.T) { | |
| 2976 | 3095 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3096 | + | allowRestartPolicyEnsure(t, runtime) | |
| 2977 | 3097 | envLoader := mocks.NewMockEnvLoader(t) | |
| 2978 | 3098 | eventBus := mocks.NewMockEventPublisher(t) | |
| 2979 | 3099 | ||
| @@ -3035,6 +3155,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||
| 3035 | 3155 | ||
| 3036 | 3156 | func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |
| 3037 | 3157 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3158 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3038 | 3159 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3039 | 3160 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3040 | 3161 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -3101,7 +3222,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) { | |||
| 3101 | 3222 | ctx := testContext() | |
| 3102 | 3223 | ||
| 3103 | 3224 | // 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 | 3227 | ID: "main-container-1", | |
| 3107 | 3228 | Labels: map[string]string{ | |
| @@ -3137,6 +3258,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) { | |||
| 3137 | 3258 | ||
| 3138 | 3259 | func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |
| 3139 | 3260 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3261 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3140 | 3262 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3141 | 3263 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3142 | 3264 | ||
| @@ -3200,6 +3322,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 3200 | 3322 | ||
| 3201 | 3323 | func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) { | |
| 3202 | 3324 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3325 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3203 | 3326 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3204 | 3327 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3205 | 3328 | ||
| @@ -3258,6 +3381,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) { | |||
| 3258 | 3381 | ||
| 3259 | 3382 | func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) { | |
| 3260 | 3383 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3384 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3261 | 3385 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3262 | 3386 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3263 | 3387 | ||
| @@ -3384,6 +3508,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) { | |||
| 3384 | 3508 | // disallows creating a container with the same name as an existing running container. | |
| 3385 | 3509 | func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |
| 3386 | 3510 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3511 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3387 | 3512 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3388 | 3513 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3389 | 3514 | ||
| @@ -3480,6 +3605,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) { | |||
| 3480 | 3605 | // and the failed new container is cleaned up. | |
| 3481 | 3606 | func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) { | |
| 3482 | 3607 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3608 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3483 | 3609 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3484 | 3610 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3485 | 3611 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -3574,6 +3700,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) { | |||
| 3574 | 3700 | // container is finalized (stopped and removed). | |
| 3575 | 3701 | func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |
| 3576 | 3702 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3703 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3577 | 3704 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3578 | 3705 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3579 | 3706 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -3662,6 +3789,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |||
| 3662 | 3789 | // Uses testify's NotBefore() to enforce mock call ordering. | |
| 3663 | 3790 | func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) { | |
| 3664 | 3791 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3792 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3665 | 3793 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3666 | 3794 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3667 | 3795 | cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t) | |
| @@ -3758,6 +3886,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T) | |||
| 3758 | 3886 | // the old container remains as the tracked container. | |
| 3759 | 3887 | func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) { | |
| 3760 | 3888 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3889 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3761 | 3890 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3762 | 3891 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3763 | 3892 | ||
| @@ -3848,6 +3977,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T) | |||
| 3848 | 3977 | // finalize/drain logic being triggered. | |
| 3849 | 3978 | func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |
| 3850 | 3979 | runtime := mocks.NewMockContainerRuntime(t) | |
| 3980 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3851 | 3981 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3852 | 3982 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3853 | 3983 | ||
| @@ -3924,6 +4054,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) { | |||
| 3924 | 4054 | ||
| 3925 | 4055 | func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) { | |
| 3926 | 4056 | runtime := mocks.NewMockContainerRuntime(t) | |
| 4057 | + | allowRestartPolicyEnsure(t, runtime) | |
| 3927 | 4058 | envLoader := mocks.NewMockEnvLoader(t) | |
| 3928 | 4059 | eventBus := mocks.NewMockEventPublisher(t) | |
| 3929 | 4060 | ||
| @@ -4181,6 +4312,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |||
| 4181 | 4312 | assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution") | |
| 4182 | 4313 | assert.Equal(t, "postgres", cfg.Hostname) | |
| 4183 | 4314 | assert.Equal(t, networkName, cfg.NetworkMode) | |
| 4315 | + | assert.Empty(t, cfg.RestartPolicy) | |
| 4184 | 4316 | }). | |
| 4185 | 4317 | Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil) | |
| 4186 | 4318 | ||
| @@ -4193,6 +4325,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |||
| 4193 | 4325 | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "pg-container-1").Return("", false, nil) | |
| 4194 | 4326 | // TCP probe: resolve endpoint fails -> falls back to delay | |
| 4195 | 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 | 4330 | err := svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName) | |
| 4198 | 4331 | require.NoError(t, err) | |
| @@ -4317,3 +4450,11 @@ func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T) | |||
| 4317 | 4450 | assert.Nil(t, cfg.CapDrop) | |
| 4318 | 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 | + | } | |
| @@ -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 delay | 382 | // 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 start | 387 | // 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 empty | 1590 | // 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 deployed | 1619 | // 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 serialized | 2759 | // 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 container | 3224 | // 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 delay | 4326 | // 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 | + | } | |||