detached ecdfc27

Set restart policy for managed containers

commit
ecdfc27e2d68b712d5fcac726b66f3b49cee08c2
author
brice · · 2026-05-05 06:48 UTC
parent
521b9c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +96 -15
M internal/adapters/out/docker/runtime.go +3 -0
@@ -193,6 +193,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
193193 CapAdd: capAdd,
194194 ReadonlyRootfs: config.ReadOnlyRootFS,
195195 }
196+ if config.RestartPolicy != "" {
197+ hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
198+ }
196199
197200 // Create network configuration for container
198201 var networkConfig *network.NetworkingConfig
@@ -193,6 +193,9 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
193 CapAdd: capAdd,193 CapAdd: capAdd,
194 ReadonlyRootfs: config.ReadOnlyRootFS,194 ReadonlyRootfs: config.ReadOnlyRootFS,
195 }195 }
196+ if config.RestartPolicy != "" {
197+ hostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyMode(config.RestartPolicy)}
198+ }
196 199
197 // Create network configuration for container200 // Create network configuration for container
198 var networkConfig *network.NetworkingConfig201 var networkConfig *network.NetworkingConfig
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+}
M internal/domain/container.go +5 -0
@@ -63,6 +63,7 @@ type ContainerConfig struct {
6363 WorkingDir string
6464 Cmd []string
6565 AutoRemove bool
66+ RestartPolicy string
6667 Volumes map[string]string // map[containerPath]volumeName
6768 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
6869 NetworkMode string // Network to join
@@ -88,3 +89,7 @@ const (
8889 ContainerStatusPaused ContainerStatus = "paused"
8990 ContainerStatusUnknown ContainerStatus = "unknown"
9091 )
92+
93+const (
94+ RestartPolicyAlways = "always"
95+)
@@ -63,6 +63,7 @@ type ContainerConfig struct {
63 WorkingDir string63 WorkingDir string
64 Cmd []string64 Cmd []string
65 AutoRemove bool65 AutoRemove bool
66+ RestartPolicy string
66 Volumes map[string]string // map[containerPath]volumeName67 Volumes map[string]string // map[containerPath]volumeName
67 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)68 ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
68 NetworkMode string // Network to join69 NetworkMode string // Network to join
@@ -88,3 +89,7 @@ const (
88 ContainerStatusPaused ContainerStatus = "paused"89 ContainerStatusPaused ContainerStatus = "paused"
89 ContainerStatusUnknown ContainerStatus = "unknown"90 ContainerStatusUnknown ContainerStatus = "unknown"
90 )91 )
92+
93+const (
94+ RestartPolicyAlways = "always"
95+)
M internal/usecase/container/service.go +17 -15
@@ -275,18 +275,19 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
275275 }
276276
277277 containerConfig := &domain.ContainerConfig{
278- Image: in.ImageRef,
279- Name: containerName,
280- Ports: ports,
281- Env: in.EnvVars,
282- Volumes: in.Volumes,
283- NetworkMode: in.NetworkName,
284- Hostname: in.Domain,
285- Labels: labels,
286- AutoRemove: false,
287- MemoryLimit: cfg.DefaultMemoryLimit,
288- NanoCPUs: cfg.DefaultNanoCPUs,
289- PidsLimit: cfg.DefaultPidsLimit,
278+ Image: in.ImageRef,
279+ Name: containerName,
280+ Ports: ports,
281+ Env: in.EnvVars,
282+ Volumes: in.Volumes,
283+ NetworkMode: in.NetworkName,
284+ Hostname: in.Domain,
285+ Labels: labels,
286+ AutoRemove: false,
287+ RestartPolicy: domain.RestartPolicyAlways,
288+ MemoryLimit: cfg.DefaultMemoryLimit,
289+ NanoCPUs: cfg.DefaultNanoCPUs,
290+ PidsLimit: cfg.DefaultPidsLimit,
290291 }
291292 applySecurityProfile(containerConfig, cfg)
292293 return containerConfig
@@ -2619,9 +2620,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
26192620 domain.LabelEnvHash: envHash,
26202621 domain.LabelImage: serviceImage,
26212622 },
2622- MemoryLimit: cfg.DefaultMemoryLimit,
2623- NanoCPUs: cfg.DefaultNanoCPUs,
2624- PidsLimit: cfg.DefaultPidsLimit,
2623+ MemoryLimit: cfg.DefaultMemoryLimit,
2624+ NanoCPUs: cfg.DefaultNanoCPUs,
2625+ PidsLimit: cfg.DefaultPidsLimit,
2626+ RestartPolicy: domain.RestartPolicyAlways,
26252627 }
26262628 applySecurityProfile(config, cfg)
26272629
@@ -275,18 +275,19 @@ func (s *Service) buildContainerConfig(in containerConfigInput) *domain.Containe
275 }275 }
276 276
277 containerConfig := &domain.ContainerConfig{277 containerConfig := &domain.ContainerConfig{
278- Image: in.ImageRef,278+ Image: in.ImageRef,
279- Name: containerName,279+ Name: containerName,
280- Ports: ports,280+ Ports: ports,
281- Env: in.EnvVars,281+ Env: in.EnvVars,
282- Volumes: in.Volumes,282+ Volumes: in.Volumes,
283- NetworkMode: in.NetworkName,283+ NetworkMode: in.NetworkName,
284- Hostname: in.Domain,284+ Hostname: in.Domain,
285- Labels: labels,285+ Labels: labels,
286- AutoRemove: false,286+ AutoRemove: false,
287- MemoryLimit: cfg.DefaultMemoryLimit,287+ RestartPolicy: domain.RestartPolicyAlways,
288- NanoCPUs: cfg.DefaultNanoCPUs,288+ MemoryLimit: cfg.DefaultMemoryLimit,
289- PidsLimit: cfg.DefaultPidsLimit,289+ NanoCPUs: cfg.DefaultNanoCPUs,
290+ PidsLimit: cfg.DefaultPidsLimit,
290 }291 }
291 applySecurityProfile(containerConfig, cfg)292 applySecurityProfile(containerConfig, cfg)
292 return containerConfig293 return containerConfig
@@ -2619,9 +2620,10 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2619 domain.LabelEnvHash: envHash,2620 domain.LabelEnvHash: envHash,
2620 domain.LabelImage: serviceImage,2621 domain.LabelImage: serviceImage,
2621 },2622 },
2622- MemoryLimit: cfg.DefaultMemoryLimit,2623+ MemoryLimit: cfg.DefaultMemoryLimit,
2623- NanoCPUs: cfg.DefaultNanoCPUs,2624+ NanoCPUs: cfg.DefaultNanoCPUs,
2624- PidsLimit: cfg.DefaultPidsLimit,2625+ PidsLimit: cfg.DefaultPidsLimit,
2626+ RestartPolicy: domain.RestartPolicyAlways,
2625 }2627 }
2626 applySecurityProfile(config, cfg)2628 applySecurityProfile(config, cfg)
2627 2629
M internal/usecase/container/service_test.go +9 -0
@@ -4181,6 +4181,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
41814181 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")
41824182 assert.Equal(t, "postgres", cfg.Hostname)
41834183 assert.Equal(t, networkName, cfg.NetworkMode)
4184+ assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
41844185 }).
41854186 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)
41864187
@@ -4317,3 +4318,11 @@ func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T)
43174318 assert.Nil(t, cfg.CapDrop)
43184319 assert.Nil(t, cfg.CapAdd)
43194320 }
4321+
4322+func TestService_BuildContainerConfig_SetsRestartPolicyAlways(t *testing.T) {
4323+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4324+
4325+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4326+
4327+ assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4328+}
@@ -4181,6 +4181,7 @@ func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4181 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")4181 assert.Equal(t, []string{"postgres"}, cfg.Aliases, "attachment container must have network aliases for DNS resolution")
4182 assert.Equal(t, "postgres", cfg.Hostname)4182 assert.Equal(t, "postgres", cfg.Hostname)
4183 assert.Equal(t, networkName, cfg.NetworkMode)4183 assert.Equal(t, networkName, cfg.NetworkMode)
4184+ assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4184 }).4185 }).
4185 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)4186 Return(&domain.Container{ID: "pg-container-1", Name: containerName}, nil)
4186 4187
@@ -4317,3 +4318,11 @@ func TestService_BuildContainerConfig_CompatSecurityProfileDefault(t *testing.T)
4317 assert.Nil(t, cfg.CapDrop)4318 assert.Nil(t, cfg.CapDrop)
4318 assert.Nil(t, cfg.CapAdd)4319 assert.Nil(t, cfg.CapAdd)
4319 }4320 }
4321+
4322+func TestService_BuildContainerConfig_SetsRestartPolicyAlways(t *testing.T) {
4323+ svc := NewService(nil, nil, nil, nil, Config{}, nil)
4324+
4325+ cfg := svc.buildContainerConfig(containerConfigInput{Domain: "app.example.com", Image: "app:latest", ImageRef: "app:latest"})
4326+
4327+ assert.Equal(t, domain.RestartPolicyAlways, cfg.RestartPolicy)
4328+}

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close