volume_archive_integration_test.go RawHistoryFolder
1//go:build integration
2
3package docker
4
5import (
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
24const 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.
29func 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.
82func 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.
123func 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}