detached 2a244b6

feat(preview): volume cloning with read-only source and cleanup on failure

commit
2a244b6c0032e754443a74026f19bb50361adadc
author
brice · · 2026-03-19 04:59 UTC
committer
brice · · 2026-03-19 05:13 UTC
parent
febaaee
signature
unsigned
4 files · +144 -12
M internal/adapters/out/docker/runtime.go +20 -0
@@ -142,6 +142,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
142142 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
143143 }
144144 }
145+ if config.ReadOnlyVolumes != nil {
146+ for containerPath, volumeName := range config.ReadOnlyVolumes {
147+ bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
148+ binds = append(binds, bind)
149+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
150+ }
151+ }
145152
146153 // Create container configuration
147154 containerConfig := &container.Config{
@@ -207,6 +214,19 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
207214 return nil
208215 }
209216
217+// WaitForContainer waits for a container to stop running.
218+func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
219+ statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
220+ select {
221+ case err := <-errCh:
222+ if err != nil {
223+ return err
224+ }
225+ case <-statusCh:
226+ }
227+ return nil
228+}
229+
210230 // StopContainer stops a container.
211231 func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
212232 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -142,6 +142,13 @@ func (r *Runtime) CreateContainer(ctx context.Context, config *domain.ContainerC
142 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")142 log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding volume mount")
143 }143 }
144 }144 }
145+ if config.ReadOnlyVolumes != nil {
146+ for containerPath, volumeName := range config.ReadOnlyVolumes {
147+ bind := fmt.Sprintf("%s:%s:ro", volumeName, containerPath)
148+ binds = append(binds, bind)
149+ log.Debug().Str("volume", volumeName).Str("mount_path", containerPath).Msg("adding read-only volume mount")
150+ }
151+ }
145 152
146 // Create container configuration153 // Create container configuration
147 containerConfig := &container.Config{154 containerConfig := &container.Config{
@@ -207,6 +214,19 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
207 return nil214 return nil
208 }215 }
209 216
217+// WaitForContainer waits for a container to stop running.
218+func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
219+ statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
220+ select {
221+ case err := <-errCh:
222+ if err != nil {
223+ return err
224+ }
225+ case <-statusCh:
226+ }
227+ return nil
228+}
229+
210 // StopContainer stops a container.230 // StopContainer stops a container.
211 func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {231 func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
212 ctx = zerowrap.CtxWithFields(ctx, map[string]any{232 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/domain/container.go +13 -12
@@ -45,18 +45,19 @@ type RouteInfo struct {
4545
4646 // ContainerConfig holds configuration for creating a container.
4747 type ContainerConfig struct {
48- Image string
49- Name string
50- Env []string
51- Ports []int
52- Labels map[string]string
53- WorkingDir string
54- Cmd []string
55- AutoRemove bool
56- Volumes map[string]string // map[containerPath]volumeName
57- NetworkMode string // Network to join
58- Hostname string // Container hostname for DNS
59- Aliases []string // Additional network aliases
48+ Image string
49+ Name string
50+ Env []string
51+ Ports []int
52+ Labels map[string]string
53+ WorkingDir string
54+ Cmd []string
55+ AutoRemove bool
56+ Volumes map[string]string // map[containerPath]volumeName
57+ ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
58+ NetworkMode string // Network to join
59+ Hostname string // Container hostname for DNS
60+ Aliases []string // Additional network aliases
6061 }
6162
6263 // ContainerStatus represents the current state of a container.
@@ -45,18 +45,19 @@ type RouteInfo struct {
45 45
46 // ContainerConfig holds configuration for creating a container.46 // ContainerConfig holds configuration for creating a container.
47 type ContainerConfig struct {47 type ContainerConfig struct {
48- Image string48+ Image string
49- Name string49+ Name string
50- Env []string50+ Env []string
51- Ports []int51+ Ports []int
52- Labels map[string]string52+ Labels map[string]string
53- WorkingDir string53+ WorkingDir string
54- Cmd []string54+ Cmd []string
55- AutoRemove bool55+ AutoRemove bool
56- Volumes map[string]string // map[containerPath]volumeName56+ Volumes map[string]string // map[containerPath]volumeName
57- NetworkMode string // Network to join57+ ReadOnlyVolumes map[string]string // containerPath -> volumeName (mounted read-only)
58- Hostname string // Container hostname for DNS58+ NetworkMode string // Network to join
59- Aliases []string // Additional network aliases59+ Hostname string // Container hostname for DNS
60+ Aliases []string // Additional network aliases
60 }61 }
61 62
62 // ContainerStatus represents the current state of a container.63 // ContainerStatus represents the current state of a container.
A internal/usecase/auto/preview/volumes.go +93 -0
@@ -0,0 +1,93 @@
1+package preview
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// VolumeCloner abstracts container runtime operations needed for volume cloning.
12+type VolumeCloner interface {
13+ CreateVolume(ctx context.Context, name string) error
14+ RemoveVolume(ctx context.Context, name string, force bool) error
15+ CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error)
16+ StartContainer(ctx context.Context, containerID string) error
17+ WaitForContainer(ctx context.Context, containerID string) error
18+ RemoveContainer(ctx context.Context, containerID string, force bool) error
19+}
20+
21+// BuildCloneVolumeName generates a preview volume name.
22+func BuildCloneVolumeName(previewName, volumeName string) string {
23+ return "preview-" + previewName + "-" + volumeName
24+}
25+
26+// BuildCloneContainerName generates a preview attachment container name from an image reference.
27+func BuildCloneContainerName(previewName, image string) string {
28+ name := image
29+ if i := strings.LastIndex(name, "/"); i >= 0 {
30+ name = name[i+1:]
31+ }
32+ if i := strings.Index(name, ":"); i >= 0 {
33+ name = name[:i]
34+ }
35+ return "preview-" + previewName + "-" + name
36+}
37+
38+// CloneVolumes copies volumes from source using a read-only helper container.
39+// sourceVolumes maps volume display names to actual Docker volume names.
40+func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) {
41+ var clonedNames []string
42+ for volName, sourceVolName := range sourceVolumes {
43+ destName := BuildCloneVolumeName(previewName, volName)
44+
45+ if err := cloner.CreateVolume(ctx, destName); err != nil {
46+ cleanupVolumes(ctx, cloner, clonedNames)
47+ return nil, fmt.Errorf("create volume %s: %w", destName, err)
48+ }
49+
50+ helperConfig := &domain.ContainerConfig{
51+ Image: "busybox:latest",
52+ Name: "gordon-vol-copy-" + destName,
53+ Cmd: []string{"cp", "-a", "/src/.", "/dst/"},
54+ Volumes: map[string]string{
55+ "/dst": destName,
56+ },
57+ ReadOnlyVolumes: map[string]string{
58+ "/src": sourceVolName,
59+ },
60+ }
61+
62+ created, err := cloner.CreateContainer(ctx, helperConfig)
63+ if err != nil {
64+ _ = cloner.RemoveVolume(ctx, destName, true)
65+ cleanupVolumes(ctx, cloner, clonedNames)
66+ return nil, fmt.Errorf("create copy helper for %s: %w", destName, err)
67+ }
68+
69+ if err := cloner.StartContainer(ctx, created.ID); err != nil {
70+ _ = cloner.RemoveContainer(ctx, created.ID, true)
71+ _ = cloner.RemoveVolume(ctx, destName, true)
72+ cleanupVolumes(ctx, cloner, clonedNames)
73+ return nil, fmt.Errorf("start copy helper for %s: %w", destName, err)
74+ }
75+
76+ if err := cloner.WaitForContainer(ctx, created.ID); err != nil {
77+ _ = cloner.RemoveContainer(ctx, created.ID, true)
78+ _ = cloner.RemoveVolume(ctx, destName, true)
79+ cleanupVolumes(ctx, cloner, clonedNames)
80+ return nil, fmt.Errorf("wait for copy helper %s: %w", destName, err)
81+ }
82+
83+ _ = cloner.RemoveContainer(ctx, created.ID, true)
84+ clonedNames = append(clonedNames, destName)
85+ }
86+ return clonedNames, nil
87+}
88+
89+func cleanupVolumes(ctx context.Context, cloner VolumeCloner, names []string) {
90+ for _, name := range names {
91+ _ = cloner.RemoveVolume(ctx, name, true)
92+ }
93+}
@@ -0,0 +1,93 @@
1+package preview
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/gordon/internal/domain"
9+)
10+
11+// VolumeCloner abstracts container runtime operations needed for volume cloning.
12+type VolumeCloner interface {
13+ CreateVolume(ctx context.Context, name string) error
14+ RemoveVolume(ctx context.Context, name string, force bool) error
15+ CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error)
16+ StartContainer(ctx context.Context, containerID string) error
17+ WaitForContainer(ctx context.Context, containerID string) error
18+ RemoveContainer(ctx context.Context, containerID string, force bool) error
19+}
20+
21+// BuildCloneVolumeName generates a preview volume name.
22+func BuildCloneVolumeName(previewName, volumeName string) string {
23+ return "preview-" + previewName + "-" + volumeName
24+}
25+
26+// BuildCloneContainerName generates a preview attachment container name from an image reference.
27+func BuildCloneContainerName(previewName, image string) string {
28+ name := image
29+ if i := strings.LastIndex(name, "/"); i >= 0 {
30+ name = name[i+1:]
31+ }
32+ if i := strings.Index(name, ":"); i >= 0 {
33+ name = name[:i]
34+ }
35+ return "preview-" + previewName + "-" + name
36+}
37+
38+// CloneVolumes copies volumes from source using a read-only helper container.
39+// sourceVolumes maps volume display names to actual Docker volume names.
40+func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string, sourceVolumes map[string]string) ([]string, error) {
41+ var clonedNames []string
42+ for volName, sourceVolName := range sourceVolumes {
43+ destName := BuildCloneVolumeName(previewName, volName)
44+
45+ if err := cloner.CreateVolume(ctx, destName); err != nil {
46+ cleanupVolumes(ctx, cloner, clonedNames)
47+ return nil, fmt.Errorf("create volume %s: %w", destName, err)
48+ }
49+
50+ helperConfig := &domain.ContainerConfig{
51+ Image: "busybox:latest",
52+ Name: "gordon-vol-copy-" + destName,
53+ Cmd: []string{"cp", "-a", "/src/.", "/dst/"},
54+ Volumes: map[string]string{
55+ "/dst": destName,
56+ },
57+ ReadOnlyVolumes: map[string]string{
58+ "/src": sourceVolName,
59+ },
60+ }
61+
62+ created, err := cloner.CreateContainer(ctx, helperConfig)
63+ if err != nil {
64+ _ = cloner.RemoveVolume(ctx, destName, true)
65+ cleanupVolumes(ctx, cloner, clonedNames)
66+ return nil, fmt.Errorf("create copy helper for %s: %w", destName, err)
67+ }
68+
69+ if err := cloner.StartContainer(ctx, created.ID); err != nil {
70+ _ = cloner.RemoveContainer(ctx, created.ID, true)
71+ _ = cloner.RemoveVolume(ctx, destName, true)
72+ cleanupVolumes(ctx, cloner, clonedNames)
73+ return nil, fmt.Errorf("start copy helper for %s: %w", destName, err)
74+ }
75+
76+ if err := cloner.WaitForContainer(ctx, created.ID); err != nil {
77+ _ = cloner.RemoveContainer(ctx, created.ID, true)
78+ _ = cloner.RemoveVolume(ctx, destName, true)
79+ cleanupVolumes(ctx, cloner, clonedNames)
80+ return nil, fmt.Errorf("wait for copy helper %s: %w", destName, err)
81+ }
82+
83+ _ = cloner.RemoveContainer(ctx, created.ID, true)
84+ clonedNames = append(clonedNames, destName)
85+ }
86+ return clonedNames, nil
87+}
88+
89+func cleanupVolumes(ctx context.Context, cloner VolumeCloner, names []string) {
90+ for _, name := range names {
91+ _ = cloner.RemoveVolume(ctx, name, true)
92+ }
93+}
A internal/usecase/auto/preview/volumes_test.go +18 -0
@@ -0,0 +1,18 @@
1+package preview
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestBuildCloneVolumeName(t *testing.T) {
10+ assert.Equal(t, "preview-feat-pgdata", BuildCloneVolumeName("feat", "pgdata"))
11+ assert.Equal(t, "preview-pr-42-redis-data", BuildCloneVolumeName("pr-42", "redis-data"))
12+}
13+
14+func TestBuildCloneContainerName(t *testing.T) {
15+ assert.Equal(t, "preview-feat-postgres", BuildCloneContainerName("feat", "postgres:17"))
16+ assert.Equal(t, "preview-feat-redis", BuildCloneContainerName("feat", "redis:7"))
17+ assert.Equal(t, "preview-feat-myapp", BuildCloneContainerName("feat", "org/myapp:latest"))
18+}
@@ -0,0 +1,18 @@
1+package preview
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestBuildCloneVolumeName(t *testing.T) {
10+ assert.Equal(t, "preview-feat-pgdata", BuildCloneVolumeName("feat", "pgdata"))
11+ assert.Equal(t, "preview-pr-42-redis-data", BuildCloneVolumeName("pr-42", "redis-data"))
12+}
13+
14+func TestBuildCloneContainerName(t *testing.T) {
15+ assert.Equal(t, "preview-feat-postgres", BuildCloneContainerName("feat", "postgres:17"))
16+ assert.Equal(t, "preview-feat-redis", BuildCloneContainerName("feat", "redis:7"))
17+ assert.Equal(t, "preview-feat-myapp", BuildCloneContainerName("feat", "org/myapp:latest"))
18+}

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