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/docker"
15 "github.com/bnema/gordon/internal/adapters/out/filesystem"
16 "github.com/bnema/gordon/internal/domain"
17 "github.com/bnema/gordon/internal/usecase/backup"
18 "github.com/bnema/zerowrap"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21)
22
23func TestBackupService_Integration_Postgres17And18(t *testing.T) {
24 if testing.Short() {
25 t.Skip("skipping integration test in short mode")
26 }
27
28 runtime := requireDockerRuntime(t)
29 ctx := context.Background()
30
31 versions := []string{"17", "18"}
32 for _, version := range versions {
33 t.Run("postgres_"+version, func(t *testing.T) {
34 runPostgresBackupFlow(t, ctx, runtime, version)
35 })
36 }
37}
38
39func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Runtime, version string) {
40 image := fmt.Sprintf("postgres:%s", version)
41 domainName := fmt.Sprintf("backup-it-%s.example.com", version)
42 networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
43 containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
44
45 pullCtx, cancelPull := context.WithTimeout(ctx, 5*time.Minute)
46 defer cancelPull()
47 require.NoError(t, runtime.PullImage(pullCtx, image))
48 netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
49 defer cancelNet()
50 require.NoError(t, runtime.CreateNetwork(netCtx, networkName, map[string]string{"driver": "bridge"}))
51 t.Cleanup(func() {
52 cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
53 defer cancelCleanup()
54 _ = runtime.RemoveNetwork(cleanupCtx, networkName)
55 })
56
57 containerCfg := &domain.ContainerConfig{
58 Image: image,
59 Name: containerName,
60 Hostname: "postgres",
61 NetworkMode: networkName,
62 Env: []string{
63 "POSTGRES_USER=postgres",
64 "POSTGRES_PASSWORD=postgres",
65 "POSTGRES_DB=appdb",
66 },
67 Labels: map[string]string{
68 domain.LabelManaged: "true",
69 domain.LabelAttachment: "true",
70 domain.LabelAttachedTo: domainName,
71 domain.LabelImage: image,
72 },
73 }
74
75 contCtx, cancelCont := context.WithTimeout(ctx, 2*time.Minute)
76 defer cancelCont()
77 container, err := runtime.CreateContainer(contCtx, containerCfg)
78 require.NoError(t, err)
79 t.Cleanup(func() {
80 stopCtx, cancelStop := context.WithTimeout(context.Background(), 20*time.Second)
81 _ = runtime.StopContainer(stopCtx, container.ID)
82 cancelStop()
83
84 removeCtx, cancelRemove := context.WithTimeout(context.Background(), 20*time.Second)
85 _ = runtime.RemoveContainer(removeCtx, container.ID, true)
86 cancelRemove()
87 })
88 startCtx, cancelStart := context.WithTimeout(ctx, 2*time.Minute)
89 defer cancelStart()
90 require.NoError(t, runtime.StartContainer(startCtx, container.ID))
91
92 require.NoError(t, waitForPostgresReady(ctx, runtime, container.ID, 60*time.Second))
93 require.NoError(t, seedPostgresData(ctx, runtime, container.ID))
94
95 storage, err := filesystem.NewBackupStorage(t.TempDir(), zerowrap.Default())
96 require.NoError(t, err)
97
98 containerSvc := &integrationContainerService{
99 routes: map[string]*domain.Container{
100 domainName: container,
101 },
102 attachments: map[string][]domain.Attachment{
103 domainName: {
104 {
105 Name: "postgres",
106 Image: image,
107 ContainerID: container.ID,
108 Status: container.Status,
109 Network: networkName,
110 Ports: []int{5432},
111 },
112 },
113 },
114 }
115
116 svc := backup.NewService(runtime, storage, containerSvc, domain.BackupConfig{Enabled: true}, zerowrap.Default())
117
118 detected, err := svc.DetectDatabases(ctx, domainName)
119 require.NoError(t, err)
120 require.Len(t, detected, 1)
121 assert.Equal(t, domain.DBTypePostgreSQL, detected[0].Type)
122
123 result, err := svc.RunBackup(ctx, domainName, "postgres")
124 require.NoError(t, err)
125 require.NotNil(t, result)
126 assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
127
128 backupBytes, err := os.ReadFile(result.Job.FilePath)
129 require.NoError(t, err)
130 assert.Greater(t, len(backupBytes), 32)
131 assert.True(t, bytes.HasPrefix(backupBytes, []byte("PGDMP")), "expected pg_dump custom format header")
132
133 jobs, err := svc.ListBackups(ctx, domainName)
134 require.NoError(t, err)
135 assert.Len(t, jobs, 1)
136}
137
138func requireDockerRuntime(t *testing.T) *docker.Runtime {
139 t.Helper()
140 runtime, err := docker.NewRuntime()
141 if err != nil {
142 t.Skipf("docker runtime unavailable: %v", err)
143 }
144 if err := runtime.Ping(context.Background()); err != nil {
145 t.Skipf("docker daemon unreachable: %v", err)
146 }
147 return runtime
148}
149
150func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error {
151 deadline := time.Now().Add(timeout)
152 for time.Now().Before(deadline) {
153 select {
154 case <-ctx.Done():
155 return ctx.Err()
156 default:
157 }
158
159 execCtx, cancelExec := context.WithTimeout(ctx, 10*time.Second)
160 res, err := runtime.ExecInContainer(execCtx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"})
161 cancelExec()
162 if err == nil && res.ExitCode == 0 {
163 return nil
164 }
165 time.Sleep(1 * time.Second)
166 }
167 return fmt.Errorf("postgres did not become ready within %s", timeout)
168}
169
170func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error {
171 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');"`
172 execCtx, cancelExec := context.WithTimeout(ctx, 30*time.Second)
173 defer cancelExec()
174
175 res, err := runtime.ExecInContainer(execCtx, containerID, []string{"sh", "-c", cmd})
176 if err != nil {
177 return err
178 }
179 if res.ExitCode != 0 {
180 return fmt.Errorf("seed command failed: %s", strings.TrimSpace(string(res.Stderr)))
181 }
182 return nil
183}
184
185type integrationContainerService struct {
186 routes map[string]*domain.Container
187 attachments map[string][]domain.Attachment
188}
189
190func (s *integrationContainerService) Deploy(context.Context, domain.Route) (*domain.Container, error) {
191 return nil, fmt.Errorf("not implemented")
192}
193
194func (s *integrationContainerService) Stop(context.Context, string) error {
195 return fmt.Errorf("not implemented")
196}
197
198func (s *integrationContainerService) Remove(context.Context, string, bool) error {
199 return fmt.Errorf("not implemented")
200}
201
202func (s *integrationContainerService) Get(_ context.Context, domainName string) (*domain.Container, bool) {
203 c, ok := s.routes[domainName]
204 return c, ok
205}
206
207func (s *integrationContainerService) Restart(context.Context, string, bool) error {
208 return fmt.Errorf("not implemented")
209}
210
211func (s *integrationContainerService) List(context.Context) map[string]*domain.Container {
212 out := make(map[string]*domain.Container, len(s.routes))
213 for k, v := range s.routes {
214 out[k] = v
215 }
216 return out
217}
218
219func (s *integrationContainerService) ListRoutesWithDetails(context.Context) []domain.RouteInfo {
220 return nil
221}
222
223func (s *integrationContainerService) ListAttachments(_ context.Context, domainName string) []domain.Attachment {
224 attachments := s.attachments[domainName]
225 out := make([]domain.Attachment, len(attachments))
226 copy(out, attachments)
227 return out
228}
229
230func (s *integrationContainerService) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) {
231 return nil, fmt.Errorf("not implemented")
232}
233
234func (s *integrationContainerService) HealthCheck(context.Context) map[string]bool {
235 return map[string]bool{}
236}
237
238func (s *integrationContainerService) SyncContainers(context.Context) error {
239 return nil
240}
241
242func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {
243 return nil
244}
245
246func (s *integrationContainerService) Shutdown(context.Context) error {
247 return nil
248}