v3.3.1 31f1eec

integration_test.go RawHistoryFolder

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}

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