v2.30.0-pre 7341e0e

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/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}

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