detached cf5815b

fix(backups): address follow-up review comments

commit
cf5815b6d666f0b86e5fdababf141b4dc3569bb2
author
brice · · 2026-06-20 08:18 UTC
parent
7b6336f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +62 -7
M internal/adapters/in/cli/backup.go +10 -4
@@ -1,6 +1,7 @@
11 package cli
22
33 import (
4+ "context"
45 "fmt"
56 "io"
67 "os"
@@ -13,7 +14,12 @@ import (
1314 "github.com/bnema/gordon/pkg/bytesize"
1415 )
1516
16-var backupResolveControlPlane = resolveControlPlane
17+var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {
18+ if domainName != "" {
19+ return resolveControlPlaneForRouteDomain(ctx, domainName)
20+ }
21+ return resolveControlPlane(configPath)
22+}
1723
1824 // newBackupCmd creates the backup command group.
1925 func newBackupCmd() *cobra.Command {
@@ -145,7 +151,7 @@ func newVolumeBackupListCmd() *cobra.Command {
145151 if len(args) == 1 {
146152 domainName = args[0]
147153 }
148- handle, err := backupResolveControlPlane(configPath)
154+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
149155 if err != nil {
150156 return err
151157 }
@@ -175,7 +181,7 @@ func newVolumeBackupRunCmd() *cobra.Command {
175181 if len(args) == 1 {
176182 domainName = args[0]
177183 }
178- handle, err := backupResolveControlPlane(configPath)
184+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
179185 if err != nil {
180186 return err
181187 }
@@ -204,7 +210,7 @@ func newVolumeBackupStatusCmd() *cobra.Command {
204210 Use: "status",
205211 Short: "Show volume backup status",
206212 RunE: func(cmd *cobra.Command, args []string) error {
207- handle, err := backupResolveControlPlane(configPath)
213+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")
208214 if err != nil {
209215 return err
210216 }
@@ -1,6 +1,7 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "io"6 "io"
6 "os"7 "os"
@@ -13,7 +14,12 @@ import (
13 "github.com/bnema/gordon/pkg/bytesize"14 "github.com/bnema/gordon/pkg/bytesize"
14 )15 )
15 16
16-var backupResolveControlPlane = resolveControlPlane17+var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {
18+ if domainName != "" {
19+ return resolveControlPlaneForRouteDomain(ctx, domainName)
20+ }
21+ return resolveControlPlane(configPath)
22+}
17 23
18 // newBackupCmd creates the backup command group.24 // newBackupCmd creates the backup command group.
19 func newBackupCmd() *cobra.Command {25 func newBackupCmd() *cobra.Command {
@@ -145,7 +151,7 @@ func newVolumeBackupListCmd() *cobra.Command {
145 if len(args) == 1 {151 if len(args) == 1 {
146 domainName = args[0]152 domainName = args[0]
147 }153 }
148- handle, err := backupResolveControlPlane(configPath)154+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
149 if err != nil {155 if err != nil {
150 return err156 return err
151 }157 }
@@ -175,7 +181,7 @@ func newVolumeBackupRunCmd() *cobra.Command {
175 if len(args) == 1 {181 if len(args) == 1 {
176 domainName = args[0]182 domainName = args[0]
177 }183 }
178- handle, err := backupResolveControlPlane(configPath)184+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
179 if err != nil {185 if err != nil {
180 return err186 return err
181 }187 }
@@ -204,7 +210,7 @@ func newVolumeBackupStatusCmd() *cobra.Command {
204 Use: "status",210 Use: "status",
205 Short: "Show volume backup status",211 Short: "Show volume backup status",
206 RunE: func(cmd *cobra.Command, args []string) error {212 RunE: func(cmd *cobra.Command, args []string) error {
207- handle, err := backupResolveControlPlane(configPath)213+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")
208 if err != nil {214 if err != nil {
209 return err215 return err
210 }216 }
M internal/adapters/in/cli/backup_volume_test.go +20 -1
@@ -16,12 +16,31 @@ import (
1616 func withBackupControlPlane(t *testing.T, plane ControlPlane) {
1717 t.Helper()
1818 old := backupResolveControlPlane
19- backupResolveControlPlane = func(string) (*controlPlaneHandle, error) {
19+ backupResolveControlPlane = func(context.Context, string, string) (*controlPlaneHandle, error) {
2020 return &controlPlaneHandle{plane: plane}, nil
2121 }
2222 t.Cleanup(func() { backupResolveControlPlane = old })
2323 }
2424
25+func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) {
26+ plane := climocks.NewMockControlPlane(t)
27+ old := backupResolveControlPlane
28+ var gotDomain string
29+ backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) {
30+ gotDomain = domainName
31+ return &controlPlaneHandle{plane: plane}, nil
32+ }
33+ t.Cleanup(func() { backupResolveControlPlane = old })
34+
35+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil)
36+
37+ cmd := newVolumeBackupRunCmd()
38+ cmd.SetArgs([]string{"app.example.com"})
39+
40+ require.NoError(t, cmd.ExecuteContext(context.Background()))
41+ require.Equal(t, "app.example.com", gotDomain)
42+}
43+
2544 func TestVolumeBackupRun_JSONFlag(t *testing.T) {
2645 plane := climocks.NewMockControlPlane(t)
2746 withBackupControlPlane(t, plane)
@@ -16,12 +16,31 @@ import (
16 func withBackupControlPlane(t *testing.T, plane ControlPlane) {16 func withBackupControlPlane(t *testing.T, plane ControlPlane) {
17 t.Helper()17 t.Helper()
18 old := backupResolveControlPlane18 old := backupResolveControlPlane
19- backupResolveControlPlane = func(string) (*controlPlaneHandle, error) {19+ backupResolveControlPlane = func(context.Context, string, string) (*controlPlaneHandle, error) {
20 return &controlPlaneHandle{plane: plane}, nil20 return &controlPlaneHandle{plane: plane}, nil
21 }21 }
22 t.Cleanup(func() { backupResolveControlPlane = old })22 t.Cleanup(func() { backupResolveControlPlane = old })
23 }23 }
24 24
25+func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) {
26+ plane := climocks.NewMockControlPlane(t)
27+ old := backupResolveControlPlane
28+ var gotDomain string
29+ backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) {
30+ gotDomain = domainName
31+ return &controlPlaneHandle{plane: plane}, nil
32+ }
33+ t.Cleanup(func() { backupResolveControlPlane = old })
34+
35+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil)
36+
37+ cmd := newVolumeBackupRunCmd()
38+ cmd.SetArgs([]string{"app.example.com"})
39+
40+ require.NoError(t, cmd.ExecuteContext(context.Background()))
41+ require.Equal(t, "app.example.com", gotDomain)
42+}
43+
25 func TestVolumeBackupRun_JSONFlag(t *testing.T) {44 func TestVolumeBackupRun_JSONFlag(t *testing.T) {
26 plane := climocks.NewMockControlPlane(t)45 plane := climocks.NewMockControlPlane(t)
27 withBackupControlPlane(t, plane)46 withBackupControlPlane(t, plane)
M internal/usecase/backup/volume_service.go +6 -2
@@ -45,7 +45,11 @@ func (s *VolumeService) ListVolumeBackups(ctx context.Context, domainName string
4545 zerowrap.FieldUseCase: "ListVolumeBackups",
4646 "domain": domainName,
4747 })
48- return s.storage.ListVolumeArchives(ctx, domainName)
48+ jobs, err := s.storage.ListVolumeArchives(ctx, domainName)
49+ if err != nil {
50+ return nil, fmt.Errorf("list volume archives: %w", err)
51+ }
52+ return jobs, nil
4953 }
5054
5155 // VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
@@ -56,7 +60,7 @@ func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.Volume
5660 })
5761 jobs, err := s.storage.ListVolumeArchives(ctx, "")
5862 if err != nil {
59- return nil, err
63+ return nil, fmt.Errorf("list volume backup status: %w", err)
6064 }
6165
6266 s.mu.Lock()
@@ -45,7 +45,11 @@ func (s *VolumeService) ListVolumeBackups(ctx context.Context, domainName string
45 zerowrap.FieldUseCase: "ListVolumeBackups",45 zerowrap.FieldUseCase: "ListVolumeBackups",
46 "domain": domainName,46 "domain": domainName,
47 })47 })
48- return s.storage.ListVolumeArchives(ctx, domainName)48+ jobs, err := s.storage.ListVolumeArchives(ctx, domainName)
49+ if err != nil {
50+ return nil, fmt.Errorf("list volume archives: %w", err)
51+ }
52+ return jobs, nil
49 }53 }
50 54
51 // VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.55 // VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
@@ -56,7 +60,7 @@ func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.Volume
56 })60 })
57 jobs, err := s.storage.ListVolumeArchives(ctx, "")61 jobs, err := s.storage.ListVolumeArchives(ctx, "")
58 if err != nil {62 if err != nil {
59- return nil, err63+ return nil, fmt.Errorf("list volume backup status: %w", err)
60 }64 }
61 65
62 s.mu.Lock()66 s.mu.Lock()
M internal/usecase/backup/volume_service_test.go +26 -0
@@ -35,6 +35,7 @@ func (f fakeVolumeArchiveExporter) ExportVolumeArchive(context.Context, domain.V
3535 type fakeVolumeBackupStorage struct {
3636 stored []domain.VolumeBackupJob
3737 retention []string
38+ listErr error
3839 }
3940
4041 func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
@@ -50,6 +51,9 @@ func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.
5051 }
5152
5253 func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
54+ if f.listErr != nil {
55+ return nil, f.listErr
56+ }
5357 return nil, nil
5458 }
5559
@@ -128,6 +132,28 @@ func TestVolumeServiceRunVolumeBackupsReturnsPartialFailure(t *testing.T) {
128132 assert.Empty(t, storage.stored)
129133 }
130134
135+func TestVolumeServiceListVolumeBackupsWrapsStorageError(t *testing.T) {
136+ storageErr := fmt.Errorf("s3 unavailable")
137+ svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
138+
139+ jobs, err := svc.ListVolumeBackups(context.Background(), "app.example.com")
140+
141+ require.ErrorIs(t, err, storageErr)
142+ assert.Contains(t, err.Error(), "list volume archives")
143+ assert.Nil(t, jobs)
144+}
145+
146+func TestVolumeServiceStatusWrapsStorageError(t *testing.T) {
147+ storageErr := fmt.Errorf("s3 unavailable")
148+ svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
149+
150+ jobs, err := svc.VolumeBackupStatus(context.Background())
151+
152+ require.ErrorIs(t, err, storageErr)
153+ assert.Contains(t, err.Error(), "list volume backup status")
154+ assert.Nil(t, jobs)
155+}
156+
131157 func TestVolumeServiceDisabledDoesNothing(t *testing.T) {
132158 runtime := outmocks.NewMockContainerRuntime(t)
133159 svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())
@@ -35,6 +35,7 @@ func (f fakeVolumeArchiveExporter) ExportVolumeArchive(context.Context, domain.V
35 type fakeVolumeBackupStorage struct {35 type fakeVolumeBackupStorage struct {
36 stored []domain.VolumeBackupJob36 stored []domain.VolumeBackupJob
37 retention []string37 retention []string
38+ listErr error
38 }39 }
39 40
40 func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {41 func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
@@ -50,6 +51,9 @@ func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.
50 }51 }
51 52
52 func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {53 func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
54+ if f.listErr != nil {
55+ return nil, f.listErr
56+ }
53 return nil, nil57 return nil, nil
54 }58 }
55 59
@@ -128,6 +132,28 @@ func TestVolumeServiceRunVolumeBackupsReturnsPartialFailure(t *testing.T) {
128 assert.Empty(t, storage.stored)132 assert.Empty(t, storage.stored)
129 }133 }
130 134
135+func TestVolumeServiceListVolumeBackupsWrapsStorageError(t *testing.T) {
136+ storageErr := fmt.Errorf("s3 unavailable")
137+ svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
138+
139+ jobs, err := svc.ListVolumeBackups(context.Background(), "app.example.com")
140+
141+ require.ErrorIs(t, err, storageErr)
142+ assert.Contains(t, err.Error(), "list volume archives")
143+ assert.Nil(t, jobs)
144+}
145+
146+func TestVolumeServiceStatusWrapsStorageError(t *testing.T) {
147+ storageErr := fmt.Errorf("s3 unavailable")
148+ svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
149+
150+ jobs, err := svc.VolumeBackupStatus(context.Background())
151+
152+ require.ErrorIs(t, err, storageErr)
153+ assert.Contains(t, err.Error(), "list volume backup status")
154+ assert.Nil(t, jobs)
155+}
156+
131 func TestVolumeServiceDisabledDoesNothing(t *testing.T) {157 func TestVolumeServiceDisabledDoesNothing(t *testing.T) {
132 runtime := outmocks.NewMockContainerRuntime(t)158 runtime := outmocks.NewMockContainerRuntime(t)
133 svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())159 svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())

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