1//go:build integration
2
3package backup_test
4
5import (
6 "bytes"
7 "context"
8 "fmt"
9 "os"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/bnema/gordon/internal/adapters/out/appstate"
15 "github.com/bnema/gordon/internal/adapters/out/docker"
16 "github.com/bnema/gordon/internal/adapters/out/filesystem"
17 "github.com/bnema/gordon/internal/domain"
18 "github.com/bnema/gordon/internal/usecase/backup"
19 "github.com/bnema/zerowrap"
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/require"
22)
23
24func TestBackupService_Integration_Postgres17And18(t *testing.T) {
25 if testing.Short() {
26 t.Skip("skipping integration test in short mode")
27 }
28
29 runtime := requireDockerRuntime(t)
30 ctx := context.Background()
31
32 versions := []string{"17", "18"}
33 for _, version := range versions {
34 t.Run("postgres_"+version, func(t *testing.T) {
35 runPostgresBackupFlow(t, ctx, runtime, version)
36 })
37 }
38}
39
40func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Runtime, version string) {
41 image := fmt.Sprintf("postgres:%s", version)
42 appName := fmt.Sprintf("backup-it-%s", version)
43 networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
44 containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
45
46 pullCtx, cancelPull := context.WithTimeout(ctx, 5*time.Minute)
47 defer cancelPull()
48 require.NoError(t, runtime.PullImage(pullCtx, image))
49 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
50 defer cancelNet()
51 require.NoError(t, runtime.CreateNetwork(netCtx, networkName, domain.NetworkConfig{Driver: "bridge"}))
52 t.Cleanup(func() {
53 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
54 defer cancelCleanup()
55 _ = runtime.RemoveNetwork(cleanupCtx, networkName)
56 })
57
58 containerCfg := &domain.ContainerConfig{
59 Image: image,
60 Name: containerName,
61 Hostname: "postgres",
62 NetworkMode: networkName,
63 Env: []string{
64 "POSTGRES_USER=postgres",
65 "POSTGRES_PASSWORD=postgres",
66 "POSTGRES_DB=appdb",
67 },
68 Labels: map[string]string{
69 domain.LabelManaged: "true",
70 domain.LabelApp: appName,
71 domain.LabelService: "postgres",
72 domain.LabelImage: image,
73 },
74 }
75
76 contCtx, cancelCont := context.WithTimeout(ctx, 2*time.Minute)
77 defer cancelCont()
78 container, err := runtime.CreateContainer(contCtx, containerCfg)
79 require.NoError(t, err)
80 t.Cleanup(func() {
81 stopCtx, cancelStop := context.WithTimeout(context.Background(), 20*time.Second)
82 _ = runtime.StopContainer(stopCtx, container.ID, domain.AppDefaultStopGrace)
83 cancelStop()
84
85 removeCtx, cancelRemove := context.WithTimeout(context.Background(), 20*time.Second)
86 _ = runtime.RemoveContainer(removeCtx, container.ID, true)
87 cancelRemove()
88 })
89 startCtx, cancelStart := context.WithTimeout(ctx, 2*time.Minute)
90 defer cancelStart()
91 require.NoError(t, runtime.StartContainer(startCtx, container.ID))
92
93 require.NoError(t, waitForPostgresReady(ctx, runtime, container.ID, 60*time.Second))
94 require.NoError(t, seedPostgresData(ctx, runtime, container.ID))
95
96 storage, err := filesystem.NewBackupStorage(t.TempDir(), zerowrap.Default())
97 require.NoError(t, err)
98
99 state, err := appstate.NewStore(t.TempDir(), zerowrap.Default())
100 require.NoError(t, err)
101 t.Cleanup(func() { require.NoError(t, state.Close()) })
102 require.NoError(t, state.SaveActive(ctx, domain.AppActive{
103 App: appName,
104 Converged: true,
105 Services: map[string]domain.AppEffectiveService{
106 "postgres": {
107 Image: image,
108 Container: container.ID,
109 Spec: domain.AppService{
110 Name: "postgres",
111 Image: image,
112 Databases: []domain.AppDatabase{{Name: "appdb", Type: domain.AppDBPostgres, Schedule: "daily"}},
113 Backup: domain.AppBackup{Postgres: []string{"appdb"}},
114 },
115 },
116 },
117 }))
118
119 svc := backup.NewService(runtime, storage, domain.BackupConfig{Enabled: true}, zerowrap.Default()).WithAppState(state)
120 targets, err := svc.Targets(ctx, appName)
121 require.NoError(t, err)
122 require.Len(t, targets, 1)
123 assert.Equal(t, "postgres", targets[0].Service)
124
125 result, err := svc.RunBackup(ctx, appName, "postgres", "appdb")
126 require.NoError(t, err)
127 require.NotNil(t, result)
128 assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
129
130 backupBytes, err := os.ReadFile(result.Job.FilePath)
131 require.NoError(t, err)
132 assert.Greater(t, len(backupBytes), 32)
133 assert.True(t, bytes.HasPrefix(backupBytes, []byte("PGDMP")), "expected pg_dump custom format header")
134
135 jobs, err := svc.ListBackups(ctx, appName)
136 require.NoError(t, err)
137 assert.Len(t, jobs, 1)
138}
139
140func requireDockerRuntime(t *testing.T) *docker.Runtime {
141 t.Helper()
142 runtime, err := docker.NewRuntime()
143 if err != nil {
144 t.Skipf("docker runtime unavailable: %v", err)
145 }
146 if err := runtime.Ping(context.Background()); err != nil {
147 t.Skipf("docker daemon unreachable: %v", err)
148 }
149 return runtime
150}
151
152func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error {
153 deadline := time.Now().Add(timeout)
154 for time.Now().Before(deadline) {
155 select {
156 case <-ctx.Done():
157 return ctx.Err()
158 default:
159 }
160
161 execCtx, cancelExec := context.WithTimeout(ctx, 10*time.Second)
162 res, err := runtime.ExecInContainer(execCtx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"})
163 cancelExec()
164 if err == nil && res.ExitCode == 0 {
165 return nil
166 }
167 time.Sleep(1 * time.Second)
168 }
169 return fmt.Errorf("postgres did not become ready within %s", timeout)
170}
171
172func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error {
173 cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -c "SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'" | grep -q 1 || createdb -U "$POSTGRES_USER" "$POSTGRES_DB"; psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"`
174 execCtx, cancelExec := context.WithTimeout(ctx, 30*time.Second)
175 defer cancelExec()
176
177 res, err := runtime.ExecInContainer(execCtx, containerID, []string{"sh", "-c", cmd})
178 if err != nil {
179 return err
180 }
181 if res.ExitCode != 0 {
182 return fmt.Errorf("seed command failed: %s", strings.TrimSpace(string(res.Stderr)))
183 }
184 return nil
185}