detached cbddb67

Merge pull request 'fix(backup): stream volume archives through attach instead of logs' (#24) from fix/volume-backup-binary-stream into main

commit
cbddb67b36e609d5551916146616d56133a9cd75
author
bnema · · 2026-10-17 04:52 UTC
merge of
b164fe9 + 8155d4c · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
2 files · +185 -8
M internal/adapters/out/docker/runtime.go +36 -8
@@ -57,6 +57,20 @@ type pipeReadCloser struct {
5757 once sync.Once
5858 }
5959
60+// hijackedReadCloser exposes a hijacked attach connection as an io.ReadCloser.
61+type hijackedReadCloser struct {
62+ resp client.HijackedResponse
63+}
64+
65+func (h hijackedReadCloser) Read(b []byte) (int, error) {
66+ return h.resp.Reader.Read(b)
67+}
68+
69+func (h hijackedReadCloser) Close() error {
70+ h.resp.Close()
71+ return nil
72+}
73+
6074 type removeOnCloseFile struct {
6175 *os.File
6276 path string
@@ -1630,11 +1644,6 @@ func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArch
16301644 if err != nil {
16311645 return nil, err
16321646 }
1633- if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
1634- _ = cleanup()
1635- return nil, log.WrapErr(err, "failed to start volume archive helper container")
1636- }
1637-
16381647 stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
16391648 if err != nil {
16401649 _ = cleanup()
@@ -1715,16 +1724,35 @@ func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
17151724 }
17161725 }
17171726
1727+// attachVolumeArchiveStream attaches to the helper's stdout/stderr, then
1728+// starts it. The archive is read from the attach stream, never from
1729+// container logs: log drivers store output as text and corrupt binary
1730+// data (json-file replaces invalid UTF-8), and some drivers cannot be
1731+// read back at all. Attaching before start guarantees no output byte is
1732+// missed; waiting for not-running after start reports the exit status
1733+// even when the helper has already exited.
17181734 func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1719- logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1735+ attached, err := r.client.ContainerAttach(ctx, containerID, client.ContainerAttachOptions{Stream: true, Stdout: true, Stderr: true})
17201736 if err != nil {
17211737 return nil, log.WrapErr(err, "failed to attach volume archive stream")
17221738 }
1739+ if _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil {
1740+ attached.Close()
1741+ return nil, log.WrapErr(err, "failed to start volume archive helper container")
1742+ }
17231743 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1744+ logs := hijackedReadCloser{resp: attached.HijackedResponse}
17241745 statusCh, errCh := waitResult.Result, waitResult.Error
17251746 pr, pw := io.Pipe()
1726- go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1727- return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
1747+ stream := &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}
1748+ // A hijacked connection ignores ctx, so cancellation must close the
1749+ // stream explicitly to unblock the copy and remove the helper.
1750+ stopCancelWatch := context.AfterFunc(ctx, func() { _ = stream.Close() })
1751+ go func() {
1752+ defer stopCancelWatch()
1753+ streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1754+ }()
1755+ return stream, nil
17281756 }
17291757
17301758 func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
@@ -57,6 +57,20 @@ type pipeReadCloser struct {
57 once sync.Once57 once sync.Once
58 }58 }
59 59
60+// hijackedReadCloser exposes a hijacked attach connection as an io.ReadCloser.
61+type hijackedReadCloser struct {
62+ resp client.HijackedResponse
63+}
64+
65+func (h hijackedReadCloser) Read(b []byte) (int, error) {
66+ return h.resp.Reader.Read(b)
67+}
68+
69+func (h hijackedReadCloser) Close() error {
70+ h.resp.Close()
71+ return nil
72+}
73+
60 type removeOnCloseFile struct {74 type removeOnCloseFile struct {
61 *os.File75 *os.File
62 path string76 path string
@@ -1630,11 +1644,6 @@ func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArch
1630 if err != nil {1644 if err != nil {
1631 return nil, err1645 return nil, err
1632 }1646 }
1633- if _, err := r.client.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil {
1634- _ = cleanup()
1635- return nil, log.WrapErr(err, "failed to start volume archive helper container")
1636- }
1637-
1638 stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)1647 stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
1639 if err != nil {1648 if err != nil {
1640 _ = cleanup()1649 _ = cleanup()
@@ -1715,16 +1724,35 @@ func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1715 }1724 }
1716 }1725 }
1717 1726
1727+// attachVolumeArchiveStream attaches to the helper's stdout/stderr, then
1728+// starts it. The archive is read from the attach stream, never from
1729+// container logs: log drivers store output as text and corrupt binary
1730+// data (json-file replaces invalid UTF-8), and some drivers cannot be
1731+// read back at all. Attaching before start guarantees no output byte is
1732+// missed; waiting for not-running after start reports the exit status
1733+// even when the helper has already exited.
1718 func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {1734 func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1719- logs, err := r.client.ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})1735+ attached, err := r.client.ContainerAttach(ctx, containerID, client.ContainerAttachOptions{Stream: true, Stdout: true, Stderr: true})
1720 if err != nil {1736 if err != nil {
1721 return nil, log.WrapErr(err, "failed to attach volume archive stream")1737 return nil, log.WrapErr(err, "failed to attach volume archive stream")
1722 }1738 }
1739+ if _, err := r.client.ContainerStart(ctx, containerID, client.ContainerStartOptions{}); err != nil {
1740+ attached.Close()
1741+ return nil, log.WrapErr(err, "failed to start volume archive helper container")
1742+ }
1723 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})1743 waitResult := r.client.ContainerWait(ctx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning})
1744+ logs := hijackedReadCloser{resp: attached.HijackedResponse}
1724 statusCh, errCh := waitResult.Result, waitResult.Error1745 statusCh, errCh := waitResult.Result, waitResult.Error
1725 pr, pw := io.Pipe()1746 pr, pw := io.Pipe()
1726- go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)1747+ stream := &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}
1727- return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil1748+ // A hijacked connection ignores ctx, so cancellation must close the
1749+ // stream explicitly to unblock the copy and remove the helper.
1750+ stopCancelWatch := context.AfterFunc(ctx, func() { _ = stream.Close() })
1751+ go func() {
1752+ defer stopCancelWatch()
1753+ streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1754+ }()
1755+ return stream, nil
1728 }1756 }
1729 1757
1730 func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {1758 func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
A internal/adapters/out/docker/volume_archive_integration_test.go +149 -0
@@ -0,0 +1,149 @@
1+//go:build integration
2+
3+package docker
4+
5+import (
6+ "archive/tar"
7+ "compress/gzip"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "fmt"
12+ "io"
13+ "os/exec"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const volumeArchiveTestImage = "alpine:3.20"
25+
26+// TestRuntime_ExportVolumeArchive_Integration_BinaryRoundTrip proves the
27+// exported stream is a byte-exact archive: binary content (random data
28+// that is not valid UTF-8) must survive export unchanged.
29+func TestRuntime_ExportVolumeArchive_Integration_BinaryRoundTrip(t *testing.T) {
30+ if testing.Short() {
31+ t.Skip("skipping integration test in short mode")
32+ }
33+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
34+ defer cancel()
35+
36+ volume := fmt.Sprintf("gordon-archive-it-%d", time.Now().UnixNano())
37+ t.Cleanup(func() { _ = exec.Command("docker", "volume", "rm", "-f", volume).Run() })
38+
39+ out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-v", volume+":/v", volumeArchiveTestImage, "sh", "-c",
40+ "head -c 5000000 /dev/urandom > /v/blob && sha256sum /v/blob").CombinedOutput()
41+ require.NoError(t, err, string(out))
42+ wantSum := strings.Fields(string(out))[0]
43+
44+ runtime, err := NewRuntime()
45+ require.NoError(t, err)
46+
47+ res, err := runtime.ExportVolumeArchive(ctx, domain.VolumeArchiveRequest{
48+ VolumeName: volume,
49+ Compression: domain.VolumeBackupCompressionGzip,
50+ HelperImage: volumeArchiveTestImage,
51+ })
52+ require.NoError(t, err)
53+ defer res.Stream.Close()
54+
55+ gz, err := gzip.NewReader(res.Stream)
56+ require.NoError(t, err)
57+ tr := tar.NewReader(gz)
58+
59+ found := false
60+ for {
61+ header, err := tr.Next()
62+ if err == io.EOF {
63+ break
64+ }
65+ require.NoError(t, err)
66+ if strings.TrimPrefix(header.Name, "./") != "blob" {
67+ continue
68+ }
69+ hash := sha256.New()
70+ _, err = io.Copy(hash, tr)
71+ require.NoError(t, err)
72+ assert.Equal(t, wantSum, hex.EncodeToString(hash.Sum(nil)))
73+ found = true
74+ }
75+ assert.True(t, found, "archive must contain the blob file")
76+}
77+
78+// TestRuntime_ExportVolumeArchive_Integration_CancelUnblocksStream proves a
79+// stalled export cannot outlive its context: once the reader stops, the
80+// helper blocks on its output; cancellation must then fail the stream and
81+// remove the helper container.
82+func TestRuntime_ExportVolumeArchive_Integration_CancelUnblocksStream(t *testing.T) {
83+ if testing.Short() {
84+ t.Skip("skipping integration test in short mode")
85+ }
86+ volume := fmt.Sprintf("gordon-archive-it-cancel-%d", time.Now().UnixNano())
87+ t.Cleanup(func() { _ = exec.Command("docker", "volume", "rm", "-f", volume).Run() })
88+ // Incompressible data far larger than the attach socket buffers.
89+ out, err := exec.Command("docker", "run", "--rm", "-v", volume+":/v", volumeArchiveTestImage, "sh", "-c",
90+ "head -c 30000000 /dev/urandom > /v/blob").CombinedOutput()
91+ require.NoError(t, err, string(out))
92+
93+ runtime, err := NewRuntime()
94+ require.NoError(t, err)
95+
96+ ctx, cancel := context.WithCancel(context.Background())
97+ defer cancel()
98+ res, err := runtime.ExportVolumeArchive(ctx, domain.VolumeArchiveRequest{
99+ VolumeName: volume,
100+ Compression: domain.VolumeBackupCompressionGzip,
101+ HelperImage: volumeArchiveTestImage,
102+ })
103+ require.NoError(t, err)
104+ defer res.Stream.Close()
105+
106+ // Read a little, then stop consuming so the helper blocks on output.
107+ _, err = io.ReadFull(res.Stream, make([]byte, 1024))
108+ require.NoError(t, err)
109+ cancel()
110+
111+ // Without reading further, cancellation alone must tear the export down.
112+ require.Eventually(t, func() bool {
113+ ps, psErr := exec.Command("docker", "ps", "-aq", "--filter", "volume="+volume).Output()
114+ return psErr == nil && strings.TrimSpace(string(ps)) == ""
115+ }, 30*time.Second, 200*time.Millisecond, "helper container must be removed after cancellation")
116+
117+ _, err = io.ReadAll(res.Stream)
118+ require.Error(t, err, "a canceled export must not end as a complete archive")
119+}
120+
121+// TestRuntime_ExportVolumeArchive_Integration_HelperFailure proves a helper
122+// that exits non-zero fails the stream instead of producing an artifact.
123+func TestRuntime_ExportVolumeArchive_Integration_HelperFailure(t *testing.T) {
124+ if testing.Short() {
125+ t.Skip("skipping integration test in short mode")
126+ }
127+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
128+ defer cancel()
129+
130+ runtime, err := NewRuntime()
131+ require.NoError(t, err)
132+
133+ // Docker creates the missing volume on mount; remove it afterwards.
134+ volume := fmt.Sprintf("gordon-archive-it-fail-%d", time.Now().UnixNano())
135+ t.Cleanup(func() { _ = exec.Command("docker", "volume", "rm", "-f", volume).Run() })
136+
137+ // zstd is absent from plain alpine, so the helper exits non-zero.
138+ res, err := runtime.ExportVolumeArchive(ctx, domain.VolumeArchiveRequest{
139+ VolumeName: volume,
140+ Compression: domain.VolumeBackupCompressionZstd,
141+ HelperImage: volumeArchiveTestImage,
142+ })
143+ require.NoError(t, err)
144+ defer res.Stream.Close()
145+
146+ _, err = io.ReadAll(res.Stream)
147+ require.Error(t, err)
148+ assert.Contains(t, err.Error(), "volume archive helper exited with code")
149+}
@@ -0,0 +1,149 @@
1+//go:build integration
2+
3+package docker
4+
5+import (
6+ "archive/tar"
7+ "compress/gzip"
8+ "context"
9+ "crypto/sha256"
10+ "encoding/hex"
11+ "fmt"
12+ "io"
13+ "os/exec"
14+ "strings"
15+ "testing"
16+ "time"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/require"
20+
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const volumeArchiveTestImage = "alpine:3.20"
25+
26+// TestRuntime_ExportVolumeArchive_Integration_BinaryRoundTrip proves the
27+// exported stream is a byte-exact archive: binary content (random data
28+// that is not valid UTF-8) must survive export unchanged.
29+func TestRuntime_ExportVolumeArchive_Integration_BinaryRoundTrip(t *testing.T) {
30+ if testing.Short() {
31+ t.Skip("skipping integration test in short mode")
32+ }
33+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
34+ defer cancel()
35+
36+ volume := fmt.Sprintf("gordon-archive-it-%d", time.Now().UnixNano())
37+ t.Cleanup(func() { _ = exec.Command("docker", "volume", "rm", "-f", volume).Run() })
38+
39+ out, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-v", volume+":/v", volumeArchiveTestImage, "sh", "-c",
40+ "head -c 5000000 /dev/urandom > /v/blob && sha256sum /v/blob").CombinedOutput()
41+ require.NoError(t, err, string(out))
42+ wantSum := strings.Fields(string(out))[0]
43+
44+ runtime, err := NewRuntime()
45+ require.NoError(t, err)
46+
47+ res, err := runtime.ExportVolumeArchive(ctx, domain.VolumeArchiveRequest{
48+ VolumeName: volume,
49+ Compression: domain.VolumeBackupCompressionGzip,
50+ HelperImage: volumeArchiveTestImage,
51+ })
52+ require.NoError(t, err)
53+ defer res.Stream.Close()
54+
55+ gz, err := gzip.NewReader(res.Stream)
56+ require.NoError(t, err)
57+ tr := tar.NewReader(gz)
58+
59+ found := false
60+ for {
61+ header, err := tr.Next()
62+ if err == io.EOF {
63+ break
64+ }
65+ require.NoError(t, err)
66+ if strings.TrimPrefix(header.Name, "./") != "blob" {
67+ continue
68+ }
69+ hash := sha256.New()
70+ _, err = io.Copy(hash, tr)
71+ require.NoError(t, err)
72+ assert.Equal(t, wantSum, hex.EncodeToString(hash.Sum(nil)))
73+ found = true
74+ }
75+ assert.True(t, found, "archive must contain the blob file")
76+}
77+
78+// TestRuntime_ExportVolumeArchive_Integration_CancelUnblocksStream proves a
79+// stalled export cannot outlive its context: once the reader stops, the
80+// helper blocks on its output; cancellation must then fail the stream and
81+// remove the helper container.
82+func TestRuntime_ExportVolumeArchive_Integration_CancelUnblocksStream(t *testing.T) {
83+ if testing.Short() {
84+ t.Skip("skipping integration test in short mode")
85+ }
86+ volume := fmt.Sprintf("gordon-archive-it-cancel-%d", time.Now().UnixNano())
87+ t.Cleanup(func() { _ = exec.Command("docker", "volume", "rm", "-f", volume).Run() })
88+ // Incompressible data far larger than the attach socket buffers.
89+ out, err := exec.Command("docker", "run", "--rm", "-v", volume+":/v", volumeArchiveTestImage, "sh", "-c",
90+ "head -c 30000000 /dev/urandom > /v/blob").CombinedOutput()
91+ require.NoError(t, err, string(out))
92+
93+ runtime, err := NewRuntime()
94+ require.NoError(t, err)
95+
96+ ctx, cancel := context.WithCancel(context.Background())
97+ defer cancel()
98+ res, err := runtime.ExportVolumeArchive(ctx, domain.VolumeArchiveRequest{
99+ VolumeName: volume,
100+ Compression: domain.VolumeBackupCompressionGzip,
101+ HelperImage: volumeArchiveTestImage,
102+ })
103+ require.NoError(t, err)
104+ defer res.Stream.Close()
105+
106+ // Read a little, then stop consuming so the helper blocks on output.
107+ _, err = io.ReadFull(res.Stream, make([]byte, 1024))
108+ require.NoError(t, err)
109+ cancel()
110+
111+ // Without reading further, cancellation alone must tear the export down.
112+ require.Eventually(t, func() bool {
113+ ps, psErr := exec.Command("docker", "ps", "-aq", "--filter", "volume="+volume).Output()
114+ return psErr == nil && strings.TrimSpace(string(ps)) == ""
115+ }, 30*time.Second, 200*time.Millisecond, "helper container must be removed after cancellation")
116+
117+ _, err = io.ReadAll(res.Stream)
118+ require.Error(t, err, "a canceled export must not end as a complete archive")
119+}
120+
121+// TestRuntime_ExportVolumeArchive_Integration_HelperFailure proves a helper
122+// that exits non-zero fails the stream instead of producing an artifact.
123+func TestRuntime_ExportVolumeArchive_Integration_HelperFailure(t *testing.T) {
124+ if testing.Short() {
125+ t.Skip("skipping integration test in short mode")
126+ }
127+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
128+ defer cancel()
129+
130+ runtime, err := NewRuntime()
131+ require.NoError(t, err)
132+
133+ // Docker creates the missing volume on mount; remove it afterwards.
134+ volume := fmt.Sprintf("gordon-archive-it-fail-%d", time.Now().UnixNano())
135+ t.Cleanup(func() { _ = exec.Command("docker", "volume", "rm", "-f", volume).Run() })
136+
137+ // zstd is absent from plain alpine, so the helper exits non-zero.
138+ res, err := runtime.ExportVolumeArchive(ctx, domain.VolumeArchiveRequest{
139+ VolumeName: volume,
140+ Compression: domain.VolumeBackupCompressionZstd,
141+ HelperImage: volumeArchiveTestImage,
142+ })
143+ require.NoError(t, err)
144+ defer res.Stream.Close()
145+
146+ _, err = io.ReadAll(res.Stream)
147+ require.Error(t, err)
148+ assert.Contains(t, err.Error(), "volume archive helper exited with code")
149+}

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