v3.3.1 31f1eec

service_test.go RawHistoryFolder

1package backup
2
3import (
4	"context"
5	"io"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/zerowrap"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"github.com/bnema/gordon/internal/boundaries/out"
16	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20// appSpecWithDatabase builds one service that declares a database and
21// references it from its backup declaration.
22func appSpecWithDatabase(service, database, schedule string) domain.AppService {
23	return domain.AppService{
24		Name:      service,
25		Image:     "postgres:17",
26		Databases: []domain.AppDatabase{{Name: database, Type: domain.AppDBPostgres, Schedule: schedule}},
27		Backup:    domain.AppBackup{Postgres: []string{database}},
28	}
29}
30
31// activeWith builds an ACTIVE record holding one effective service.
32func activeWith(app string, services map[string]domain.AppService, container string) domain.AppActive {
33	effective := make(map[string]domain.AppEffectiveService, len(services))
34	for name, spec := range services {
35		effective[name] = domain.AppEffectiveService{
36			EffectiveRevision: "rev-1",
37			Container:         container,
38			Spec:              spec,
39		}
40	}
41	return domain.AppActive{App: app, Converged: true, ConvergedRevision: "rev-1", Services: effective}
42}
43
44func backupTestService(t *testing.T, state *outmocks.MockAppStateReader) (*outmocks.MockContainerRuntime, *outmocks.MockBackupStorage, *Service) {
45	t.Helper()
46	runtime := outmocks.NewMockContainerRuntime(t)
47	storage := outmocks.NewMockBackupStorage(t)
48	svc := &Service{runtime: runtime, storage: storage, log: zerowrap.Default(), state: state}
49	return runtime, storage, svc
50}
51
52// TestService_TargetsResolveFromDeclarations proves a target exists only
53// because the ACTIVE record declares it: no image, port, or label is
54// inspected.
55func TestService_TargetsResolveFromDeclarations(t *testing.T) {
56	ctx := context.Background()
57	state := outmocks.NewMockAppStateReader(t)
58	state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
59	state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
60		"api": appSpecWithDatabase("api", "orders", "daily"),
61		"web": appSpecWithDatabase("web", "users", "hourly"),
62	}, "ctr-api"), true, nil).Once()
63
64	_, _, svc := backupTestService(t, state)
65	targets, err := svc.Targets(ctx, "shop")
66	require.NoError(t, err)
67	require.Len(t, targets, 2)
68	assert.Equal(t, domain.DatabaseTarget{
69		App: "shop", Service: "api", Database: "orders",
70		Schedule: domain.ScheduleDaily, ContainerID: "ctr-api",
71	}, targets[0])
72	assert.Equal(t, "users", targets[1].Database)
73	assert.Equal(t, domain.ScheduleHourly, targets[1].Schedule)
74}
75
76// TestService_TargetsIgnoreUnreferencedDatabases proves a declared
77// database the backup declaration does not reference is not a target.
78func TestService_TargetsIgnoreUnreferencedDatabases(t *testing.T) {
79	ctx := context.Background()
80	state := outmocks.NewMockAppStateReader(t)
81	spec := appSpecWithDatabase("api", "orders", "daily")
82	spec.Databases = append(spec.Databases, domain.AppDatabase{Name: "metrics", Type: domain.AppDBPostgres, Schedule: "daily"})
83	state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
84	state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{"api": spec}, "ctr-api"), true, nil).Once()
85
86	_, _, svc := backupTestService(t, state)
87	targets, err := svc.Targets(ctx, "shop")
88	require.NoError(t, err)
89	require.Len(t, targets, 1)
90	assert.Equal(t, "orders", targets[0].Database)
91}
92
93// TestService_TargetsSkipStoppedApps proves a stopped app has no target:
94// its workloads are intentionally down.
95func TestService_TargetsSkipStoppedApps(t *testing.T) {
96	ctx := context.Background()
97	state := outmocks.NewMockAppStateReader(t)
98	state.EXPECT().LoadIntent(mock.Anything, "shop").
99		Return(domain.AppStopIntent{App: "shop", Stopped: true}, nil).Once()
100
101	_, _, svc := backupTestService(t, state)
102	targets, err := svc.Targets(ctx, "shop")
103	require.NoError(t, err)
104	assert.Empty(t, targets)
105}
106
107// TestService_RunBackup_StoresUnderAppIdentity proves a run dumps from the
108// declared service container and stores the artifact under the app name.
109func TestService_RunBackup_StoresUnderAppIdentity(t *testing.T) {
110	ctx := context.Background()
111	state := outmocks.NewMockAppStateReader(t)
112	state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
113	state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
114		"api": appSpecWithDatabase("api", "orders", "daily"),
115	}, "ctr-api"), true, nil).Once()
116
117	runtime, storage, svc := backupTestService(t, state)
118	runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
119		return len(cmd) == 3 && strings.Contains(cmd[2], "'orders'") && !strings.Contains(cmd[2], "POSTGRES_DB")
120	})).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
121	runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
122		Return(io.NopCloser(strings.NewReader("dump-bytes")), nil).Once()
123	runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
124		return len(cmd) == 3 && strings.HasPrefix(cmd[2], "rm -f ")
125	})).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
126	storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
127		RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
128			if _, err := io.Copy(io.Discard, data); err != nil {
129				return "", err
130			}
131			return "/backups/shop/orders/manual/file.bak", nil
132		}).Once()
133
134	result, err := svc.RunBackup(ctx, "shop", "api", "orders")
135	require.NoError(t, err)
136	assert.Equal(t, "shop", result.Job.App)
137	assert.Equal(t, "api", result.Job.Service)
138	assert.Equal(t, "orders", result.Job.DBName)
139	assert.Equal(t, int64(len("dump-bytes")), result.Job.SizeBytes)
140	assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
141}
142
143// TestService_RunBackup_SelectorRules proves the explicit-selector rules:
144// a missing selector with several candidates lists them, and an unknown
145// target is not found.
146func TestService_RunBackup_SelectorRules(t *testing.T) {
147	ctx := context.Background()
148	state := outmocks.NewMockAppStateReader(t)
149	active := activeWith("shop", map[string]domain.AppService{
150		"api": appSpecWithDatabase("api", "orders", "daily"),
151		"web": appSpecWithDatabase("web", "users", "daily"),
152	}, "ctr-x")
153	state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil)
154	state.EXPECT().LoadActive(mock.Anything, "shop").Return(active, true, nil)
155
156	_, _, svc := backupTestService(t, state)
157
158	_, err := svc.RunBackup(ctx, "shop", "", "")
159	require.Error(t, err)
160	assert.Contains(t, err.Error(), "ambiguous")
161	assert.Contains(t, err.Error(), "api/orders")
162	assert.Contains(t, err.Error(), "web/users")
163
164	_, err = svc.RunBackup(ctx, "shop", "api", "missing")
165	require.Error(t, err)
166	assert.Contains(t, err.Error(), "not found")
167
168	_, err = svc.RunBackup(ctx, "shop", "missing", "orders")
169	require.Error(t, err)
170	assert.Contains(t, err.Error(), "not found")
171}
172
173// TestService_RunForSchedule_RunsOnlyTheDeclaredSchedule proves the
174// scheduler filters by each database's own declaration.
175func TestService_RunForSchedule_RunsOnlyTheDeclaredSchedule(t *testing.T) {
176	ctx := context.Background()
177	state := outmocks.NewMockAppStateReader(t)
178	state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
179	spec := appSpecWithDatabase("api", "orders", "daily")
180	spec.Databases = append(spec.Databases, domain.AppDatabase{Name: "users", Type: domain.AppDBPostgres, Schedule: "hourly"})
181	spec.Backup.Postgres = append(spec.Backup.Postgres, "users")
182	state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
183	state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{"api": spec}, "ctr-api"), true, nil).Once()
184
185	runtime, storage, svc := backupTestService(t, state)
186	runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
187		return strings.Contains(cmd[2], "'users'")
188	})).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
189	runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
190		Return(io.NopCloser(strings.NewReader("x")), nil).Once()
191	runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
192		return strings.HasPrefix(cmd[2], "rm -f ")
193	})).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
194	storage.EXPECT().Store(mock.Anything, "shop", "api", "users", domain.ScheduleHourly, mock.Anything, mock.Anything).
195		RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
196			if _, err := io.Copy(io.Discard, data); err != nil {
197				return "", err
198			}
199			return "/backups/shop/users/hourly/file.bak", nil
200		}).Once()
201	storage.EXPECT().ApplyRetention(mock.Anything, "shop", mock.Anything).Return(0, nil).Once()
202
203	require.NoError(t, svc.RunForSchedule(ctx, domain.ScheduleHourly))
204}
205
206// TestService_RunForSchedule_RejectsUnknownSchedule proves a caller cannot
207// invent a schedule tier.
208func TestService_RunForSchedule_RejectsUnknownSchedule(t *testing.T) {
209	state := outmocks.NewMockAppStateReader(t)
210	_, _, svc := backupTestService(t, state)
211	require.Error(t, svc.RunForSchedule(context.Background(), domain.BackupSchedule("sometimes")))
212}
213
214// TestService_ListBackupsUsesAppIdentity proves listing is app-keyed and
215// never domain-keyed.
216func TestService_ListBackupsUsesAppIdentity(t *testing.T) {
217	ctx := context.Background()
218	state := outmocks.NewMockAppStateReader(t)
219	state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
220
221	_, storage, svc := backupTestService(t, state)
222	storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{{
223		ID: "job-1", App: "shop", Service: "api", DBName: "orders",
224		Status: domain.BackupStatusCompleted, StartedAt: time.Now().UTC(),
225	}}, nil).Once()
226
227	jobs, err := svc.ListBackups(ctx, "")
228	require.NoError(t, err)
229	require.Len(t, jobs, 1)
230	assert.Equal(t, "shop", jobs[0].App)
231}

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