detached 7b6336f

fix(backups): address volume backup review findings

commit
7b6336f44b2b345a47fbefd230a1bda80f2f4154
author
brice · · 2026-06-20 08:07 UTC
parent
e4c37fd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
21 files · +680 -36
M .mockery.yaml +1 -0
@@ -34,6 +34,7 @@ packages:
3434 interfaces:
3535 ContainerService:
3636 BackupService:
37+ VolumeBackupService:
3738 RegistryService:
3839 ProxyService:
3940 ConfigService:
@@ -34,6 +34,7 @@ packages:
34 interfaces:34 interfaces:
35 ContainerService:35 ContainerService:
36 BackupService:36 BackupService:
37+ VolumeBackupService:
37 RegistryService:38 RegistryService:
38 ProxyService:39 ProxyService:
39 ConfigService:40 ConfigService:
M docs/config/backups.md +2 -1
@@ -69,7 +69,8 @@ keep = 14
6969 Volume backup objects are stored under:
7070
7171 ```text
72-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz
72+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
7374 ```
7475
7576 ## Notes
@@ -69,7 +69,8 @@ keep = 14
69 Volume backup objects are stored under:69 Volume backup objects are stored under:
70 70
71 ```text71 ```text
72-<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz72+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
73 ```74 ```
74 75
75 ## Notes76 ## Notes
M internal/adapters/dto/admin_backups.go +1 -0
@@ -80,4 +80,5 @@ type VolumeBackupRunRequest struct {
8080 type VolumeBackupRunResponse struct {
8181 Status string `json:"status"`
8282 Backups []VolumeBackupJob `json:"backups,omitempty"`
83+ Error string `json:"error,omitempty"`
8384 }
@@ -80,4 +80,5 @@ type VolumeBackupRunRequest struct {
80 type VolumeBackupRunResponse struct {80 type VolumeBackupRunResponse struct {
81 Status string `json:"status"`81 Status string `json:"status"`
82 Backups []VolumeBackupJob `json:"backups,omitempty"`82 Backups []VolumeBackupJob `json:"backups,omitempty"`
83+ Error string `json:"error,omitempty"`
83 }84 }
M internal/adapters/in/cli/backup.go +18 -6
@@ -13,6 +13,8 @@ import (
1313 "github.com/bnema/gordon/pkg/bytesize"
1414 )
1515
16+var backupResolveControlPlane = resolveControlPlane
17+
1618 // newBackupCmd creates the backup command group.
1719 func newBackupCmd() *cobra.Command {
1820 cmd := &cobra.Command{
@@ -143,7 +145,7 @@ func newVolumeBackupListCmd() *cobra.Command {
143145 if len(args) == 1 {
144146 domainName = args[0]
145147 }
146- handle, err := resolveControlPlane(configPath)
148+ handle, err := backupResolveControlPlane(configPath)
147149 if err != nil {
148150 return err
149151 }
@@ -162,6 +164,7 @@ func newVolumeBackupListCmd() *cobra.Command {
162164
163165 func newVolumeBackupRunCmd() *cobra.Command {
164166 var volumeName string
167+ var jsonOut bool
165168
166169 cmd := &cobra.Command{
167170 Use: "run [domain]",
@@ -172,7 +175,7 @@ func newVolumeBackupRunCmd() *cobra.Command {
172175 if len(args) == 1 {
173176 domainName = args[0]
174177 }
175- handle, err := resolveControlPlane(configPath)
178+ handle, err := backupResolveControlPlane(configPath)
176179 if err != nil {
177180 return err
178181 }
@@ -180,21 +183,28 @@ func newVolumeBackupRunCmd() *cobra.Command {
180183
181184 result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
182185 if err != nil {
186+ if result != nil && len(result.Backups) > 0 {
187+ if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
188+ return printErr
189+ }
190+ }
183191 return fmt.Errorf("failed to run volume backups: %w", err)
184192 }
185- return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, false)
193+ return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)
186194 },
187195 }
188196 cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
197+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
189198 return cmd
190199 }
191200
192201 func newVolumeBackupStatusCmd() *cobra.Command {
193- return &cobra.Command{
202+ var jsonOut bool
203+ cmd := &cobra.Command{
194204 Use: "status",
195205 Short: "Show volume backup status",
196206 RunE: func(cmd *cobra.Command, args []string) error {
197- handle, err := resolveControlPlane(configPath)
207+ handle, err := backupResolveControlPlane(configPath)
198208 if err != nil {
199209 return err
200210 }
@@ -204,9 +214,11 @@ func newVolumeBackupStatusCmd() *cobra.Command {
204214 if err != nil {
205215 return fmt.Errorf("failed to get volume backup status: %w", err)
206216 }
207- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, false)
217+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
208218 },
209219 }
220+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
221+ return cmd
210222 }
211223
212224 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
@@ -13,6 +13,8 @@ import (
13 "github.com/bnema/gordon/pkg/bytesize"13 "github.com/bnema/gordon/pkg/bytesize"
14 )14 )
15 15
16+var backupResolveControlPlane = resolveControlPlane
17+
16 // newBackupCmd creates the backup command group.18 // newBackupCmd creates the backup command group.
17 func newBackupCmd() *cobra.Command {19 func newBackupCmd() *cobra.Command {
18 cmd := &cobra.Command{20 cmd := &cobra.Command{
@@ -143,7 +145,7 @@ func newVolumeBackupListCmd() *cobra.Command {
143 if len(args) == 1 {145 if len(args) == 1 {
144 domainName = args[0]146 domainName = args[0]
145 }147 }
146- handle, err := resolveControlPlane(configPath)148+ handle, err := backupResolveControlPlane(configPath)
147 if err != nil {149 if err != nil {
148 return err150 return err
149 }151 }
@@ -162,6 +164,7 @@ func newVolumeBackupListCmd() *cobra.Command {
162 164
163 func newVolumeBackupRunCmd() *cobra.Command {165 func newVolumeBackupRunCmd() *cobra.Command {
164 var volumeName string166 var volumeName string
167+ var jsonOut bool
165 168
166 cmd := &cobra.Command{169 cmd := &cobra.Command{
167 Use: "run [domain]",170 Use: "run [domain]",
@@ -172,7 +175,7 @@ func newVolumeBackupRunCmd() *cobra.Command {
172 if len(args) == 1 {175 if len(args) == 1 {
173 domainName = args[0]176 domainName = args[0]
174 }177 }
175- handle, err := resolveControlPlane(configPath)178+ handle, err := backupResolveControlPlane(configPath)
176 if err != nil {179 if err != nil {
177 return err180 return err
178 }181 }
@@ -180,21 +183,28 @@ func newVolumeBackupRunCmd() *cobra.Command {
180 183
181 result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)184 result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
182 if err != nil {185 if err != nil {
186+ if result != nil && len(result.Backups) > 0 {
187+ if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
188+ return printErr
189+ }
190+ }
183 return fmt.Errorf("failed to run volume backups: %w", err)191 return fmt.Errorf("failed to run volume backups: %w", err)
184 }192 }
185- return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, false)193+ return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)
186 },194 },
187 }195 }
188 cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")196 cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
197+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
189 return cmd198 return cmd
190 }199 }
191 200
192 func newVolumeBackupStatusCmd() *cobra.Command {201 func newVolumeBackupStatusCmd() *cobra.Command {
193- return &cobra.Command{202+ var jsonOut bool
203+ cmd := &cobra.Command{
194 Use: "status",204 Use: "status",
195 Short: "Show volume backup status",205 Short: "Show volume backup status",
196 RunE: func(cmd *cobra.Command, args []string) error {206 RunE: func(cmd *cobra.Command, args []string) error {
197- handle, err := resolveControlPlane(configPath)207+ handle, err := backupResolveControlPlane(configPath)
198 if err != nil {208 if err != nil {
199 return err209 return err
200 }210 }
@@ -204,9 +214,11 @@ func newVolumeBackupStatusCmd() *cobra.Command {
204 if err != nil {214 if err != nil {
205 return fmt.Errorf("failed to get volume backup status: %w", err)215 return fmt.Errorf("failed to get volume backup status: %w", err)
206 }216 }
207- return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, false)217+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
208 },218 },
209 }219 }
220+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
221+ return cmd
210 }222 }
211 223
212 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {224 func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
A internal/adapters/in/cli/backup_volume_test.go +79 -0
@@ -0,0 +1,79 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
14+)
15+
16+func withBackupControlPlane(t *testing.T, plane ControlPlane) {
17+ t.Helper()
18+ old := backupResolveControlPlane
19+ backupResolveControlPlane = func(string) (*controlPlaneHandle, error) {
20+ return &controlPlaneHandle{plane: plane}, nil
21+ }
22+ t.Cleanup(func() { backupResolveControlPlane = old })
23+}
24+
25+func TestVolumeBackupRun_JSONFlag(t *testing.T) {
26+ plane := climocks.NewMockControlPlane(t)
27+ withBackupControlPlane(t, plane)
28+
29+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{
30+ Status: "ok",
31+ Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
32+ }, nil)
33+
34+ cmd := newVolumeBackupRunCmd()
35+ var out bytes.Buffer
36+ cmd.SetOut(&out)
37+ cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"})
38+
39+ require.NoError(t, cmd.ExecuteContext(context.Background()))
40+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
41+}
42+
43+func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
44+ plane := climocks.NewMockControlPlane(t)
45+ withBackupControlPlane(t, plane)
46+
47+ plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "running"}}, nil)
48+
49+ cmd := newVolumeBackupStatusCmd()
50+ var out bytes.Buffer
51+ cmd.SetOut(&out)
52+ cmd.SetArgs([]string{"--json"})
53+
54+ require.NoError(t, cmd.ExecuteContext(context.Background()))
55+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String())
56+}
57+
58+func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
59+ plane := climocks.NewMockControlPlane(t)
60+ withBackupControlPlane(t, plane)
61+
62+ runErr := errors.New("one backup failed")
63+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{
64+ Status: "partial",
65+ Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
66+ Error: runErr.Error(),
67+ }, runErr)
68+
69+ cmd := newVolumeBackupRunCmd()
70+ cmd.SilenceUsage = true
71+ var out bytes.Buffer
72+ cmd.SetOut(&out)
73+ cmd.SetArgs([]string{"app.example.com", "--json"})
74+
75+ err := cmd.ExecuteContext(context.Background())
76+ require.Error(t, err)
77+ require.ErrorIs(t, err, runErr)
78+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
79+}
@@ -0,0 +1,79 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+ climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
14+)
15+
16+func withBackupControlPlane(t *testing.T, plane ControlPlane) {
17+ t.Helper()
18+ old := backupResolveControlPlane
19+ backupResolveControlPlane = func(string) (*controlPlaneHandle, error) {
20+ return &controlPlaneHandle{plane: plane}, nil
21+ }
22+ t.Cleanup(func() { backupResolveControlPlane = old })
23+}
24+
25+func TestVolumeBackupRun_JSONFlag(t *testing.T) {
26+ plane := climocks.NewMockControlPlane(t)
27+ withBackupControlPlane(t, plane)
28+
29+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{
30+ Status: "ok",
31+ Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
32+ }, nil)
33+
34+ cmd := newVolumeBackupRunCmd()
35+ var out bytes.Buffer
36+ cmd.SetOut(&out)
37+ cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"})
38+
39+ require.NoError(t, cmd.ExecuteContext(context.Background()))
40+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
41+}
42+
43+func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
44+ plane := climocks.NewMockControlPlane(t)
45+ withBackupControlPlane(t, plane)
46+
47+ plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "running"}}, nil)
48+
49+ cmd := newVolumeBackupStatusCmd()
50+ var out bytes.Buffer
51+ cmd.SetOut(&out)
52+ cmd.SetArgs([]string{"--json"})
53+
54+ require.NoError(t, cmd.ExecuteContext(context.Background()))
55+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String())
56+}
57+
58+func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
59+ plane := climocks.NewMockControlPlane(t)
60+ withBackupControlPlane(t, plane)
61+
62+ runErr := errors.New("one backup failed")
63+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{
64+ Status: "partial",
65+ Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
66+ Error: runErr.Error(),
67+ }, runErr)
68+
69+ cmd := newVolumeBackupRunCmd()
70+ cmd.SilenceUsage = true
71+ var out bytes.Buffer
72+ cmd.SetOut(&out)
73+ cmd.SetArgs([]string{"app.example.com", "--json"})
74+
75+ err := cmd.ExecuteContext(context.Background())
76+ require.Error(t, err)
77+ require.ErrorIs(t, err, runErr)
78+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
79+}
M internal/adapters/in/cli/controlplane_local.go +14 -6
@@ -633,37 +633,45 @@ func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain st
633633
634634 func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
635635 if l.volumeBackupSvc == nil {
636- return nil, fmt.Errorf("local volume backup service unavailable")
636+ return nil, localVolumeBackupServiceUnavailable()
637637 }
638638 jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
639639 if err != nil {
640- return nil, err
640+ return nil, fmt.Errorf("list volume backups: %w", err)
641641 }
642642 return toDTOVolumeBackupJobs(jobs), nil
643643 }
644644
645645 func (l *localControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
646646 if l.volumeBackupSvc == nil {
647- return nil, fmt.Errorf("local volume backup service unavailable")
647+ return nil, localVolumeBackupServiceUnavailable()
648648 }
649649 jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)
650650 if err != nil {
651- return nil, err
651+ return nil, fmt.Errorf("get volume backup status: %w", err)
652652 }
653653 return toDTOVolumeBackupJobs(jobs), nil
654654 }
655655
656656 func (l *localControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
657657 if l.volumeBackupSvc == nil {
658- return nil, fmt.Errorf("local volume backup service unavailable")
658+ return nil, localVolumeBackupServiceUnavailable()
659659 }
660660 jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)
661661 if err != nil {
662- return nil, err
662+ if len(jobs) > 0 {
663+ resp := &dto.VolumeBackupRunResponse{Status: "partial", Backups: toDTOVolumeBackupJobs(jobs), Error: err.Error()}
664+ return resp, fmt.Errorf("run volume backups: %w", err)
665+ }
666+ return nil, fmt.Errorf("run volume backups: %w", err)
663667 }
664668 return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil
665669 }
666670
671+func localVolumeBackupServiceUnavailable() error {
672+ return fmt.Errorf("local volume backup service unavailable: %w", domain.ErrVolumeBackupUnavailable)
673+}
674+
667675 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
668676 if l.logSvc == nil {
669677 return nil, fmt.Errorf("local log service unavailable")
@@ -633,37 +633,45 @@ func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain st
633 633
634 func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {634 func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
635 if l.volumeBackupSvc == nil {635 if l.volumeBackupSvc == nil {
636- return nil, fmt.Errorf("local volume backup service unavailable")636+ return nil, localVolumeBackupServiceUnavailable()
637 }637 }
638 jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)638 jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
639 if err != nil {639 if err != nil {
640- return nil, err640+ return nil, fmt.Errorf("list volume backups: %w", err)
641 }641 }
642 return toDTOVolumeBackupJobs(jobs), nil642 return toDTOVolumeBackupJobs(jobs), nil
643 }643 }
644 644
645 func (l *localControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {645 func (l *localControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
646 if l.volumeBackupSvc == nil {646 if l.volumeBackupSvc == nil {
647- return nil, fmt.Errorf("local volume backup service unavailable")647+ return nil, localVolumeBackupServiceUnavailable()
648 }648 }
649 jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)649 jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)
650 if err != nil {650 if err != nil {
651- return nil, err651+ return nil, fmt.Errorf("get volume backup status: %w", err)
652 }652 }
653 return toDTOVolumeBackupJobs(jobs), nil653 return toDTOVolumeBackupJobs(jobs), nil
654 }654 }
655 655
656 func (l *localControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {656 func (l *localControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
657 if l.volumeBackupSvc == nil {657 if l.volumeBackupSvc == nil {
658- return nil, fmt.Errorf("local volume backup service unavailable")658+ return nil, localVolumeBackupServiceUnavailable()
659 }659 }
660 jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)660 jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)
661 if err != nil {661 if err != nil {
662- return nil, err662+ if len(jobs) > 0 {
663+ resp := &dto.VolumeBackupRunResponse{Status: "partial", Backups: toDTOVolumeBackupJobs(jobs), Error: err.Error()}
664+ return resp, fmt.Errorf("run volume backups: %w", err)
665+ }
666+ return nil, fmt.Errorf("run volume backups: %w", err)
663 }667 }
664 return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil668 return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil
665 }669 }
666 670
671+func localVolumeBackupServiceUnavailable() error {
672+ return fmt.Errorf("local volume backup service unavailable: %w", domain.ErrVolumeBackupUnavailable)
673+}
674+
667 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {675 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
668 if l.logSvc == nil {676 if l.logSvc == nil {
669 return nil, fmt.Errorf("local log service unavailable")677 return nil, fmt.Errorf("local log service unavailable")
M internal/adapters/in/cli/controlplane_local_test.go +47 -0
@@ -2,6 +2,7 @@ package cli
22
33 import (
44 "context"
5+ "errors"
56 "testing"
67 "time"
78
@@ -111,6 +112,52 @@ func TestLocalControlPlane_Backups(t *testing.T) {
111112 require.Equal(t, "postgres", dbs[0].Name)
112113 }
113114
115+func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) {
116+ t.Parallel()
117+
118+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
119+ ctx := context.Background()
120+ runErr := errors.New("one volume failed")
121+ jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
122+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr)
123+
124+ cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
125+ result, err := cp.RunVolumeBackups(ctx, "app.local", "")
126+
127+ require.ErrorIs(t, err, runErr)
128+ require.NotNil(t, result)
129+ assert.Equal(t, "partial", result.Status)
130+ assert.Equal(t, runErr.Error(), result.Error)
131+ require.Len(t, result.Backups, 1)
132+ assert.Equal(t, "v1", result.Backups[0].ID)
133+}
134+
135+func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) {
136+ t.Parallel()
137+
138+ cp := &localControlPlane{}
139+ result, err := cp.RunVolumeBackups(context.Background(), "app.local", "")
140+
141+ require.Error(t, err)
142+ assert.Nil(t, result)
143+ assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable)
144+}
145+
146+func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) {
147+ t.Parallel()
148+
149+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
150+ wantErr := errors.New("store unavailable")
151+ volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr)
152+
153+ cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
154+ jobs, err := cp.ListVolumeBackups(context.Background(), "app.local")
155+
156+ require.ErrorIs(t, err, wantErr)
157+ assert.Contains(t, err.Error(), "list volume backups")
158+ assert.Nil(t, jobs)
159+}
160+
114161 func TestLocalControlPlane_ListTags(t *testing.T) {
115162 t.Parallel()
116163
@@ -2,6 +2,7 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -111,6 +112,52 @@ func TestLocalControlPlane_Backups(t *testing.T) {
111 require.Equal(t, "postgres", dbs[0].Name)112 require.Equal(t, "postgres", dbs[0].Name)
112 }113 }
113 114
115+func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) {
116+ t.Parallel()
117+
118+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
119+ ctx := context.Background()
120+ runErr := errors.New("one volume failed")
121+ jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
122+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr)
123+
124+ cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
125+ result, err := cp.RunVolumeBackups(ctx, "app.local", "")
126+
127+ require.ErrorIs(t, err, runErr)
128+ require.NotNil(t, result)
129+ assert.Equal(t, "partial", result.Status)
130+ assert.Equal(t, runErr.Error(), result.Error)
131+ require.Len(t, result.Backups, 1)
132+ assert.Equal(t, "v1", result.Backups[0].ID)
133+}
134+
135+func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) {
136+ t.Parallel()
137+
138+ cp := &localControlPlane{}
139+ result, err := cp.RunVolumeBackups(context.Background(), "app.local", "")
140+
141+ require.Error(t, err)
142+ assert.Nil(t, result)
143+ assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable)
144+}
145+
146+func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) {
147+ t.Parallel()
148+
149+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
150+ wantErr := errors.New("store unavailable")
151+ volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr)
152+
153+ cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
154+ jobs, err := cp.ListVolumeBackups(context.Background(), "app.local")
155+
156+ require.ErrorIs(t, err, wantErr)
157+ assert.Contains(t, err.Error(), "list volume backups")
158+ assert.Nil(t, jobs)
159+}
160+
114 func TestLocalControlPlane_ListTags(t *testing.T) {161 func TestLocalControlPlane_ListTags(t *testing.T) {
115 t.Parallel()162 t.Parallel()
116 163
M internal/adapters/in/cli/controlplane_remote.go +16 -3
@@ -2,6 +2,7 @@ package cli
22
33 import (
44 "context"
5+ "fmt"
56
67 "github.com/bnema/gordon/internal/adapters/dto"
78 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -170,15 +171,27 @@ func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain s
170171 }
171172
172173 func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
173- return r.client.ListVolumeBackups(ctx, backupDomain)
174+ jobs, err := r.client.ListVolumeBackups(ctx, backupDomain)
175+ if err != nil {
176+ return nil, fmt.Errorf("list volume backups: %w", err)
177+ }
178+ return jobs, nil
174179 }
175180
176181 func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
177- return r.client.VolumeBackupStatus(ctx)
182+ jobs, err := r.client.VolumeBackupStatus(ctx)
183+ if err != nil {
184+ return nil, fmt.Errorf("get volume backup status: %w", err)
185+ }
186+ return jobs, nil
178187 }
179188
180189 func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
181- return r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
190+ result, err := r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
191+ if err != nil {
192+ return result, fmt.Errorf("run volume backups: %w", err)
193+ }
194+ return result, nil
182195 }
183196
184197 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
@@ -2,6 +2,7 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 6
6 "github.com/bnema/gordon/internal/adapters/dto"7 "github.com/bnema/gordon/internal/adapters/dto"
7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -170,15 +171,27 @@ func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain s
170 }171 }
171 172
172 func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {173 func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
173- return r.client.ListVolumeBackups(ctx, backupDomain)174+ jobs, err := r.client.ListVolumeBackups(ctx, backupDomain)
175+ if err != nil {
176+ return nil, fmt.Errorf("list volume backups: %w", err)
177+ }
178+ return jobs, nil
174 }179 }
175 180
176 func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {181 func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
177- return r.client.VolumeBackupStatus(ctx)182+ jobs, err := r.client.VolumeBackupStatus(ctx)
183+ if err != nil {
184+ return nil, fmt.Errorf("get volume backup status: %w", err)
185+ }
186+ return jobs, nil
178 }187 }
179 188
180 func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {189 func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
181- return r.client.RunVolumeBackups(ctx, backupDomain, volumeName)190+ result, err := r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
191+ if err != nil {
192+ return result, fmt.Errorf("run volume backups: %w", err)
193+ }
194+ return result, nil
182 }195 }
183 196
184 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {197 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
M internal/adapters/in/cli/controlplane_remote_test.go +48 -0
@@ -1,8 +1,17 @@
11 package cli
22
33 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
49 "testing"
510
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
615 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
716 )
817
@@ -14,3 +23,42 @@ func TestRemoteControlPlane_ImplementsInterface(t *testing.T) {
1423 t.Fatal("expected non-nil remote control-plane")
1524 }
1625 }
26+
27+func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) {
28+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ w.Header().Set("Content-Type", "application/json")
30+ w.WriteHeader(http.StatusPartialContent)
31+ require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
32+ Status: "partial",
33+ Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data"}},
34+ Error: "one volume failed",
35+ }))
36+ }))
37+ t.Cleanup(server.Close)
38+
39+ cp := NewRemoteControlPlane(remote.NewClient(server.URL))
40+ result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "")
41+
42+ require.Error(t, err)
43+ assert.Contains(t, err.Error(), "run volume backups")
44+ require.NotNil(t, result)
45+ assert.Equal(t, "partial", result.Status)
46+ require.Len(t, result.Backups, 1)
47+ assert.Equal(t, "v1", result.Backups[0].ID)
48+}
49+
50+func TestRemoteControlPlane_VolumeBackupErrorsAreWrapped(t *testing.T) {
51+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+ http.Error(w, "boom", http.StatusBadGateway)
53+ }))
54+ t.Cleanup(server.Close)
55+
56+ cp := NewRemoteControlPlane(remote.NewClient(server.URL))
57+ _, err := cp.VolumeBackupStatus(context.Background())
58+
59+ require.Error(t, err)
60+ assert.Contains(t, err.Error(), "get volume backup status")
61+ var httpErr *remote.HTTPError
62+ require.True(t, errors.As(err, &httpErr))
63+ assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode)
64+}
@@ -1,8 +1,17 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
4 "testing"9 "testing"
5 10
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
6 "github.com/bnema/gordon/internal/adapters/in/cli/remote"15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7 )16 )
8 17
@@ -14,3 +23,42 @@ func TestRemoteControlPlane_ImplementsInterface(t *testing.T) {
14 t.Fatal("expected non-nil remote control-plane")23 t.Fatal("expected non-nil remote control-plane")
15 }24 }
16 }25 }
26+
27+func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) {
28+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ w.Header().Set("Content-Type", "application/json")
30+ w.WriteHeader(http.StatusPartialContent)
31+ require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
32+ Status: "partial",
33+ Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data"}},
34+ Error: "one volume failed",
35+ }))
36+ }))
37+ t.Cleanup(server.Close)
38+
39+ cp := NewRemoteControlPlane(remote.NewClient(server.URL))
40+ result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "")
41+
42+ require.Error(t, err)
43+ assert.Contains(t, err.Error(), "run volume backups")
44+ require.NotNil(t, result)
45+ assert.Equal(t, "partial", result.Status)
46+ require.Len(t, result.Backups, 1)
47+ assert.Equal(t, "v1", result.Backups[0].ID)
48+}
49+
50+func TestRemoteControlPlane_VolumeBackupErrorsAreWrapped(t *testing.T) {
51+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+ http.Error(w, "boom", http.StatusBadGateway)
53+ }))
54+ t.Cleanup(server.Close)
55+
56+ cp := NewRemoteControlPlane(remote.NewClient(server.URL))
57+ _, err := cp.VolumeBackupStatus(context.Background())
58+
59+ require.Error(t, err)
60+ assert.Contains(t, err.Error(), "get volume backup status")
61+ var httpErr *remote.HTTPError
62+ require.True(t, errors.As(err, &httpErr))
63+ assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode)
64+}
M internal/adapters/in/cli/json_parity_test.go +3 -0
@@ -18,6 +18,9 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
1818 {"secrets list", newSecretsListCmd},
1919 {"images list", newImagesListCmd},
2020 {"backup list", newBackupListCmd},
21+ {"backup volumes list", newVolumeBackupListCmd},
22+ {"backup volumes run", newVolumeBackupRunCmd},
23+ {"backup volumes status", newVolumeBackupStatusCmd},
2124 {"auth token list", newTokenListCmd},
2225 {"remotes list", newRemotesListCmd},
2326 {"pin list", newPinListCmd},
@@ -18,6 +18,9 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
18 {"secrets list", newSecretsListCmd},18 {"secrets list", newSecretsListCmd},
19 {"images list", newImagesListCmd},19 {"images list", newImagesListCmd},
20 {"backup list", newBackupListCmd},20 {"backup list", newBackupListCmd},
21+ {"backup volumes list", newVolumeBackupListCmd},
22+ {"backup volumes run", newVolumeBackupRunCmd},
23+ {"backup volumes status", newVolumeBackupStatusCmd},
21 {"auth token list", newTokenListCmd},24 {"auth token list", newTokenListCmd},
22 {"remotes list", newRemotesListCmd},25 {"remotes list", newRemotesListCmd},
23 {"pin list", newPinListCmd},26 {"pin list", newPinListCmd},
M internal/adapters/in/cli/remote/client.go +6 -0
@@ -885,6 +885,12 @@ func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName
885885 if err := parseResponse(resp, &result); err != nil {
886886 return nil, err
887887 }
888+ if resp.StatusCode == http.StatusPartialContent {
889+ if result.Error == "" {
890+ result.Error = "volume backup run partially failed"
891+ }
892+ return &result, fmt.Errorf("volume backup run partially failed: %s", result.Error)
893+ }
888894 return &result, nil
889895 }
890896
@@ -885,6 +885,12 @@ func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName
885 if err := parseResponse(resp, &result); err != nil {885 if err := parseResponse(resp, &result); err != nil {
886 return nil, err886 return nil, err
887 }887 }
888+ if resp.StatusCode == http.StatusPartialContent {
889+ if result.Error == "" {
890+ result.Error = "volume backup run partially failed"
891+ }
892+ return &result, fmt.Errorf("volume backup run partially failed: %s", result.Error)
893+ }
888 return &result, nil894 return &result, nil
889 }895 }
890 896
M internal/adapters/in/cli/remote/client_test.go +25 -0
@@ -417,6 +417,31 @@ func TestParseErrorResponse_NonJSON(t *testing.T) {
417417 assert.Equal(t, "plain text error", httpErr.Body)
418418 }
419419
420+func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
421+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422+ require.Equal(t, "/admin/backups/volumes/app.example.com", r.URL.Path)
423+ require.Equal(t, http.MethodPost, r.Method)
424+ w.Header().Set("Content-Type", "application/json")
425+ w.WriteHeader(http.StatusPartialContent)
426+ require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
427+ Status: "partial",
428+ Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
429+ Error: "one volume failed",
430+ }))
431+ }))
432+ defer srv.Close()
433+
434+ client := NewClient(srv.URL)
435+ result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "")
436+
437+ require.Error(t, err)
438+ assert.Contains(t, err.Error(), "one volume failed")
439+ require.NotNil(t, result)
440+ assert.Equal(t, "partial", result.Status)
441+ require.Len(t, result.Backups, 1)
442+ assert.Equal(t, "v1", result.Backups[0].ID)
443+}
444+
420445 func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
421446 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422447 w.Header().Set("Content-Type", "application/json")
@@ -417,6 +417,31 @@ func TestParseErrorResponse_NonJSON(t *testing.T) {
417 assert.Equal(t, "plain text error", httpErr.Body)417 assert.Equal(t, "plain text error", httpErr.Body)
418 }418 }
419 419
420+func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
421+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422+ require.Equal(t, "/admin/backups/volumes/app.example.com", r.URL.Path)
423+ require.Equal(t, http.MethodPost, r.Method)
424+ w.Header().Set("Content-Type", "application/json")
425+ w.WriteHeader(http.StatusPartialContent)
426+ require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
427+ Status: "partial",
428+ Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
429+ Error: "one volume failed",
430+ }))
431+ }))
432+ defer srv.Close()
433+
434+ client := NewClient(srv.URL)
435+ result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "")
436+
437+ require.Error(t, err)
438+ assert.Contains(t, err.Error(), "one volume failed")
439+ require.NotNil(t, result)
440+ assert.Equal(t, "partial", result.Status)
441+ require.Len(t, result.Backups, 1)
442+ assert.Equal(t, "v1", result.Backups[0].ID)
443+}
444+
420 func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {445 func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
421 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {446 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422 w.Header().Set("Content-Type", "application/json")447 w.Header().Set("Content-Type", "application/json")
M internal/adapters/in/http/admin/handler.go +6 -1
@@ -1449,11 +1449,16 @@ func (h *Handler) handleVolumeBackupsDomain(w http.ResponseWriter, r *http.Reque
14491449 return
14501450 }
14511451 jobs, err := h.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, req.Volume)
1452+ backups := mapVolumeBackupJobsResponse(jobs)
14521453 if err != nil {
1454+ if len(backups) > 0 {
1455+ h.sendJSON(w, http.StatusPartialContent, dto.VolumeBackupRunResponse{Status: "partial", Backups: backups, Error: err.Error()})
1456+ return
1457+ }
14531458 h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")
14541459 return
14551460 }
1456- h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: mapVolumeBackupJobsResponse(jobs)})
1461+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: backups})
14571462 default:
14581463 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
14591464 }
@@ -1449,11 +1449,16 @@ func (h *Handler) handleVolumeBackupsDomain(w http.ResponseWriter, r *http.Reque
1449 return1449 return
1450 }1450 }
1451 jobs, err := h.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, req.Volume)1451 jobs, err := h.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, req.Volume)
1452+ backups := mapVolumeBackupJobsResponse(jobs)
1452 if err != nil {1453 if err != nil {
1454+ if len(backups) > 0 {
1455+ h.sendJSON(w, http.StatusPartialContent, dto.VolumeBackupRunResponse{Status: "partial", Backups: backups, Error: err.Error()})
1456+ return
1457+ }
1453 h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")1458 h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")
1454 return1459 return
1455 }1460 }
1456- h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: mapVolumeBackupJobsResponse(jobs)})1461+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: backups})
1457 default:1462 default:
1458 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")1463 h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1459 }1464 }
M internal/adapters/in/http/admin/handler_test.go +44 -0
@@ -2409,6 +2409,50 @@ func TestHandler_BackupsRunDomain(t *testing.T) {
24092409 assert.Contains(t, rec.Body.String(), "completed")
24102410 }
24112411
2412+func TestHandler_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
2413+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2414+ runErr := errors.New("one volume failed")
2415+ jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
2416+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(jobs, runErr)
2417+
2418+ handler := newTestHandler(t, func(d *HandlerDeps) {
2419+ d.VolumeBackupSvc = volumeBackupSvc
2420+ })
2421+
2422+ body := bytes.NewBufferString(`{"volume":"gordon-app-data"}`)
2423+ req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", body)
2424+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2425+ rec := httptest.NewRecorder()
2426+
2427+ handler.ServeHTTP(rec, req)
2428+
2429+ require.Equal(t, http.StatusPartialContent, rec.Code)
2430+ var result dto.VolumeBackupRunResponse
2431+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
2432+ assert.Equal(t, "partial", result.Status)
2433+ assert.Equal(t, runErr.Error(), result.Error)
2434+ require.Len(t, result.Backups, 1)
2435+ assert.Equal(t, "v1", result.Backups[0].ID)
2436+}
2437+
2438+func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
2439+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2440+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(nil, errors.New("runtime unavailable"))
2441+
2442+ handler := newTestHandler(t, func(d *HandlerDeps) {
2443+ d.VolumeBackupSvc = volumeBackupSvc
2444+ })
2445+
2446+ req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", bytes.NewBufferString(`{}`))
2447+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2448+ rec := httptest.NewRecorder()
2449+
2450+ handler.ServeHTTP(rec, req)
2451+
2452+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2453+ assert.Contains(t, rec.Body.String(), "failed to run volume backups")
2454+}
2455+
24122456 func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
24132457 configSvc := inmocks.NewMockConfigService(t)
24142458 authSvc := inmocks.NewMockAuthService(t)
@@ -2409,6 +2409,50 @@ func TestHandler_BackupsRunDomain(t *testing.T) {
2409 assert.Contains(t, rec.Body.String(), "completed")2409 assert.Contains(t, rec.Body.String(), "completed")
2410 }2410 }
2411 2411
2412+func TestHandler_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
2413+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2414+ runErr := errors.New("one volume failed")
2415+ jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
2416+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(jobs, runErr)
2417+
2418+ handler := newTestHandler(t, func(d *HandlerDeps) {
2419+ d.VolumeBackupSvc = volumeBackupSvc
2420+ })
2421+
2422+ body := bytes.NewBufferString(`{"volume":"gordon-app-data"}`)
2423+ req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", body)
2424+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2425+ rec := httptest.NewRecorder()
2426+
2427+ handler.ServeHTTP(rec, req)
2428+
2429+ require.Equal(t, http.StatusPartialContent, rec.Code)
2430+ var result dto.VolumeBackupRunResponse
2431+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
2432+ assert.Equal(t, "partial", result.Status)
2433+ assert.Equal(t, runErr.Error(), result.Error)
2434+ require.Len(t, result.Backups, 1)
2435+ assert.Equal(t, "v1", result.Backups[0].ID)
2436+}
2437+
2438+func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
2439+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2440+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(nil, errors.New("runtime unavailable"))
2441+
2442+ handler := newTestHandler(t, func(d *HandlerDeps) {
2443+ d.VolumeBackupSvc = volumeBackupSvc
2444+ })
2445+
2446+ req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", bytes.NewBufferString(`{}`))
2447+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2448+ rec := httptest.NewRecorder()
2449+
2450+ handler.ServeHTTP(rec, req)
2451+
2452+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2453+ assert.Contains(t, rec.Body.String(), "failed to run volume backups")
2454+}
2455+
2412 func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {2456 func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2413 configSvc := inmocks.NewMockConfigService(t)2457 configSvc := inmocks.NewMockConfigService(t)
2414 authSvc := inmocks.NewMockAuthService(t)2458 authSvc := inmocks.NewMockAuthService(t)
M internal/adapters/out/docker/runtime.go +17 -5
@@ -1362,12 +1362,24 @@ func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-c
13621362 }
13631363
13641364 func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
1365- select {
1366- case err := <-errCh:
1367- return 0, err
1368- case status := <-statusCh:
1369- return status.StatusCode, nil
1365+ for statusCh != nil || errCh != nil {
1366+ select {
1367+ case err, ok := <-errCh:
1368+ if !ok {
1369+ errCh = nil
1370+ continue
1371+ }
1372+ if err != nil {
1373+ return 0, err
1374+ }
1375+ case status, ok := <-statusCh:
1376+ if !ok {
1377+ return 0, fmt.Errorf("volume archive helper container wait channel closed")
1378+ }
1379+ return status.StatusCode, nil
1380+ }
13701381 }
1382+ return 0, fmt.Errorf("volume archive helper container wait channels closed")
13711383 }
13721384
13731385 func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
@@ -1362,12 +1362,24 @@ func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-c
1362 }1362 }
1363 1363
1364 func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {1364 func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
1365- select {1365+ for statusCh != nil || errCh != nil {
1366- case err := <-errCh:1366+ select {
1367- return 0, err1367+ case err, ok := <-errCh:
1368- case status := <-statusCh:1368+ if !ok {
1369- return status.StatusCode, nil1369+ errCh = nil
1370+ continue
1371+ }
1372+ if err != nil {
1373+ return 0, err
1374+ }
1375+ case status, ok := <-statusCh:
1376+ if !ok {
1377+ return 0, fmt.Errorf("volume archive helper container wait channel closed")
1378+ }
1379+ return status.StatusCode, nil
1380+ }
1370 }1381 }
1382+ return 0, fmt.Errorf("volume archive helper container wait channels closed")
1371 }1383 }
1372 1384
1373 func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {1385 func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
A internal/adapters/out/docker/runtime_test.go +59 -0
@@ -0,0 +1,59 @@
1+package docker
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/docker/docker/api/types/container"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestWaitForVolumeArchiveContainerIgnoresNilErrorBeforeStatus(t *testing.T) {
13+ statusCh := make(chan container.WaitResponse, 1)
14+ errCh := make(chan error, 1)
15+ errCh <- nil
16+ statusCh <- container.WaitResponse{StatusCode: 7}
17+
18+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
19+
20+ require.NoError(t, err)
21+ assert.Equal(t, int64(7), statusCode)
22+}
23+
24+func TestWaitForVolumeArchiveContainerHandlesClosedErrorChannelBeforeStatus(t *testing.T) {
25+ statusCh := make(chan container.WaitResponse, 1)
26+ errCh := make(chan error)
27+ close(errCh)
28+ statusCh <- container.WaitResponse{StatusCode: 3}
29+
30+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
31+
32+ require.NoError(t, err)
33+ assert.Equal(t, int64(3), statusCode)
34+}
35+
36+func TestWaitForVolumeArchiveContainerReturnsErrorChannelError(t *testing.T) {
37+ statusCh := make(chan container.WaitResponse)
38+ errCh := make(chan error, 1)
39+ wantErr := errors.New("wait failed")
40+ errCh <- wantErr
41+
42+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
43+
44+ require.ErrorIs(t, err, wantErr)
45+ assert.Equal(t, int64(0), statusCode)
46+}
47+
48+func TestWaitForVolumeArchiveContainerErrorsWhenStatusChannelCloses(t *testing.T) {
49+ statusCh := make(chan container.WaitResponse)
50+ errCh := make(chan error)
51+ close(statusCh)
52+ close(errCh)
53+
54+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
55+
56+ require.Error(t, err)
57+ assert.Contains(t, err.Error(), "wait channel closed")
58+ assert.Equal(t, int64(0), statusCode)
59+}
@@ -0,0 +1,59 @@
1+package docker
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/docker/docker/api/types/container"
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestWaitForVolumeArchiveContainerIgnoresNilErrorBeforeStatus(t *testing.T) {
13+ statusCh := make(chan container.WaitResponse, 1)
14+ errCh := make(chan error, 1)
15+ errCh <- nil
16+ statusCh <- container.WaitResponse{StatusCode: 7}
17+
18+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
19+
20+ require.NoError(t, err)
21+ assert.Equal(t, int64(7), statusCode)
22+}
23+
24+func TestWaitForVolumeArchiveContainerHandlesClosedErrorChannelBeforeStatus(t *testing.T) {
25+ statusCh := make(chan container.WaitResponse, 1)
26+ errCh := make(chan error)
27+ close(errCh)
28+ statusCh <- container.WaitResponse{StatusCode: 3}
29+
30+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
31+
32+ require.NoError(t, err)
33+ assert.Equal(t, int64(3), statusCode)
34+}
35+
36+func TestWaitForVolumeArchiveContainerReturnsErrorChannelError(t *testing.T) {
37+ statusCh := make(chan container.WaitResponse)
38+ errCh := make(chan error, 1)
39+ wantErr := errors.New("wait failed")
40+ errCh <- wantErr
41+
42+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
43+
44+ require.ErrorIs(t, err, wantErr)
45+ assert.Equal(t, int64(0), statusCode)
46+}
47+
48+func TestWaitForVolumeArchiveContainerErrorsWhenStatusChannelCloses(t *testing.T) {
49+ statusCh := make(chan container.WaitResponse)
50+ errCh := make(chan error)
51+ close(statusCh)
52+ close(errCh)
53+
54+ statusCode, err := waitForVolumeArchiveContainer(statusCh, errCh)
55+
56+ require.Error(t, err)
57+ assert.Contains(t, err.Error(), "wait channel closed")
58+ assert.Equal(t, int64(0), statusCode)
59+}
M internal/app/run.go +11 -1
@@ -148,6 +148,12 @@ type Config struct {
148148 Dir string `mapstructure:"dir"`
149149 } `mapstructure:"env"`
150150
151+ Volumes struct {
152+ AutoCreate bool `mapstructure:"auto_create"`
153+ Prefix string `mapstructure:"prefix"`
154+ Preserve bool `mapstructure:"preserve"`
155+ } `mapstructure:"volumes"`
156+
151157 Auth struct {
152158 Enabled bool `mapstructure:"enabled"`
153159 Type string `mapstructure:"type"` // only "token" is supported
@@ -2141,6 +2147,10 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
21412147 if helperImage == "" {
21422148 helperImage = "alpine:3.20"
21432149 }
2150+ volumePrefix := strings.TrimSpace(cfg.Volumes.Prefix)
2151+ if volumePrefix == "" {
2152+ volumePrefix = "gordon"
2153+ }
21442154 if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
21452155 return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
21462156 }
@@ -2155,7 +2165,7 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
21552165 Timeout: timeout,
21562166 MaxConcurrency: maxConcurrency,
21572167 HelperImage: helperImage,
2158- VolumePrefix: "gordon",
2168+ VolumePrefix: volumePrefix,
21592169 S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
21602170 S3Region: strings.TrimSpace(volumeCfg.S3.Region),
21612171 S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
@@ -148,6 +148,12 @@ type Config struct {
148 Dir string `mapstructure:"dir"`148 Dir string `mapstructure:"dir"`
149 } `mapstructure:"env"`149 } `mapstructure:"env"`
150 150
151+ Volumes struct {
152+ AutoCreate bool `mapstructure:"auto_create"`
153+ Prefix string `mapstructure:"prefix"`
154+ Preserve bool `mapstructure:"preserve"`
155+ } `mapstructure:"volumes"`
156+
151 Auth struct {157 Auth struct {
152 Enabled bool `mapstructure:"enabled"`158 Enabled bool `mapstructure:"enabled"`
153 Type string `mapstructure:"type"` // only "token" is supported159 Type string `mapstructure:"type"` // only "token" is supported
@@ -2141,6 +2147,10 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
2141 if helperImage == "" {2147 if helperImage == "" {
2142 helperImage = "alpine:3.20"2148 helperImage = "alpine:3.20"
2143 }2149 }
2150+ volumePrefix := strings.TrimSpace(cfg.Volumes.Prefix)
2151+ if volumePrefix == "" {
2152+ volumePrefix = "gordon"
2153+ }
2144 if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {2154 if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
2145 return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")2155 return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
2146 }2156 }
@@ -2155,7 +2165,7 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
2155 Timeout: timeout,2165 Timeout: timeout,
2156 MaxConcurrency: maxConcurrency,2166 MaxConcurrency: maxConcurrency,
2157 HelperImage: helperImage,2167 HelperImage: helperImage,
2158- VolumePrefix: "gordon",2168+ VolumePrefix: volumePrefix,
2159 S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),2169 S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
2160 S3Region: strings.TrimSpace(volumeCfg.S3.Region),2170 S3Region: strings.TrimSpace(volumeCfg.S3.Region),
2161 S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),2171 S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
M internal/app/run_backup_test.go +3 -0
@@ -123,6 +123,7 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
123123 assert.Equal(t, 0, volumeCfg.Retention.Keep)
124124 assert.Equal(t, 2, volumeCfg.MaxConcurrency)
125125 assert.Equal(t, "alpine:3.20", volumeCfg.HelperImage)
126+ assert.Equal(t, "gordon", volumeCfg.VolumePrefix)
126127 })
127128
128129 t.Run("enabled requires bucket and region", func(t *testing.T) {
@@ -148,6 +149,7 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
148149 cfg.Backups.Volumes.Compression = "gzip"
149150 cfg.Backups.Volumes.MaxConcurrency = 3
150151 cfg.Backups.Volumes.HelperImage = "example/helper:1"
152+ cfg.Volumes.Prefix = "acme"
151153 cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
152154 cfg.Backups.Volumes.S3.Region = "eu-west-3"
153155 cfg.Backups.Volumes.S3.Prefix = "prod/gordon"
@@ -159,6 +161,7 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
159161 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
160162 assert.Equal(t, 7, volumeCfg.Retention.Keep)
161163 assert.Equal(t, 3, volumeCfg.MaxConcurrency)
164+ assert.Equal(t, "acme", volumeCfg.VolumePrefix)
162165 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
163166 })
164167
@@ -123,6 +123,7 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
123 assert.Equal(t, 0, volumeCfg.Retention.Keep)123 assert.Equal(t, 0, volumeCfg.Retention.Keep)
124 assert.Equal(t, 2, volumeCfg.MaxConcurrency)124 assert.Equal(t, 2, volumeCfg.MaxConcurrency)
125 assert.Equal(t, "alpine:3.20", volumeCfg.HelperImage)125 assert.Equal(t, "alpine:3.20", volumeCfg.HelperImage)
126+ assert.Equal(t, "gordon", volumeCfg.VolumePrefix)
126 })127 })
127 128
128 t.Run("enabled requires bucket and region", func(t *testing.T) {129 t.Run("enabled requires bucket and region", func(t *testing.T) {
@@ -148,6 +149,7 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
148 cfg.Backups.Volumes.Compression = "gzip"149 cfg.Backups.Volumes.Compression = "gzip"
149 cfg.Backups.Volumes.MaxConcurrency = 3150 cfg.Backups.Volumes.MaxConcurrency = 3
150 cfg.Backups.Volumes.HelperImage = "example/helper:1"151 cfg.Backups.Volumes.HelperImage = "example/helper:1"
152+ cfg.Volumes.Prefix = "acme"
151 cfg.Backups.Volumes.S3.Bucket = "gordon-backups"153 cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
152 cfg.Backups.Volumes.S3.Region = "eu-west-3"154 cfg.Backups.Volumes.S3.Region = "eu-west-3"
153 cfg.Backups.Volumes.S3.Prefix = "prod/gordon"155 cfg.Backups.Volumes.S3.Prefix = "prod/gordon"
@@ -159,6 +161,7 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
159 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)161 assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
160 assert.Equal(t, 7, volumeCfg.Retention.Keep)162 assert.Equal(t, 7, volumeCfg.Retention.Keep)
161 assert.Equal(t, 3, volumeCfg.MaxConcurrency)163 assert.Equal(t, 3, volumeCfg.MaxConcurrency)
164+ assert.Equal(t, "acme", volumeCfg.VolumePrefix)
162 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)165 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
163 })166 })
164 167
A internal/boundaries/in/mocks/mock_volume_backup_service.go +243 -0
@@ -0,0 +1,243 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockVolumeBackupService creates a new instance of MockVolumeBackupService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockVolumeBackupService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockVolumeBackupService {
20+ mock := &MockVolumeBackupService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockVolumeBackupService is an autogenerated mock type for the VolumeBackupService type
29+type MockVolumeBackupService struct {
30+ mock.Mock
31+}
32+
33+type MockVolumeBackupService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockVolumeBackupService) EXPECT() *MockVolumeBackupService_Expecter {
38+ return &MockVolumeBackupService_Expecter{mock: &_m.Mock}
39+}
40+
41+// ListVolumeBackups provides a mock function for the type MockVolumeBackupService
42+func (_mock *MockVolumeBackupService) ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) {
43+ ret := _mock.Called(ctx, domainName)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ListVolumeBackups")
47+ }
48+
49+ var r0 []domain.VolumeBackupJob
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.VolumeBackupJob, error)); ok {
52+ return returnFunc(ctx, domainName)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.VolumeBackupJob); ok {
55+ r0 = returnFunc(ctx, domainName)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).([]domain.VolumeBackupJob)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
62+ r1 = returnFunc(ctx, domainName)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockVolumeBackupService_ListVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumeBackups'
70+type MockVolumeBackupService_ListVolumeBackups_Call struct {
71+ *mock.Call
72+}
73+
74+// ListVolumeBackups is a helper method to define mock.On call
75+// - ctx context.Context
76+// - domainName string
77+func (_e *MockVolumeBackupService_Expecter) ListVolumeBackups(ctx interface{}, domainName interface{}) *MockVolumeBackupService_ListVolumeBackups_Call {
78+ return &MockVolumeBackupService_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, domainName)}
79+}
80+
81+func (_c *MockVolumeBackupService_ListVolumeBackups_Call) Run(run func(ctx context.Context, domainName string)) *MockVolumeBackupService_ListVolumeBackups_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockVolumeBackupService_ListVolumeBackups_Call) Return(volumeBackupJobs []domain.VolumeBackupJob, err error) *MockVolumeBackupService_ListVolumeBackups_Call {
100+ _c.Call.Return(volumeBackupJobs, err)
101+ return _c
102+}
103+
104+func (_c *MockVolumeBackupService_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)) *MockVolumeBackupService_ListVolumeBackups_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// RunVolumeBackups provides a mock function for the type MockVolumeBackupService
110+func (_mock *MockVolumeBackupService) RunVolumeBackups(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error) {
111+ ret := _mock.Called(ctx, domainName, volumeName)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for RunVolumeBackups")
115+ }
116+
117+ var r0 []domain.VolumeBackupJob
118+ var r1 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]domain.VolumeBackupJob, error)); ok {
120+ return returnFunc(ctx, domainName, volumeName)
121+ }
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []domain.VolumeBackupJob); ok {
123+ r0 = returnFunc(ctx, domainName, volumeName)
124+ } else {
125+ if ret.Get(0) != nil {
126+ r0 = ret.Get(0).([]domain.VolumeBackupJob)
127+ }
128+ }
129+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
130+ r1 = returnFunc(ctx, domainName, volumeName)
131+ } else {
132+ r1 = ret.Error(1)
133+ }
134+ return r0, r1
135+}
136+
137+// MockVolumeBackupService_RunVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunVolumeBackups'
138+type MockVolumeBackupService_RunVolumeBackups_Call struct {
139+ *mock.Call
140+}
141+
142+// RunVolumeBackups is a helper method to define mock.On call
143+// - ctx context.Context
144+// - domainName string
145+// - volumeName string
146+func (_e *MockVolumeBackupService_Expecter) RunVolumeBackups(ctx interface{}, domainName interface{}, volumeName interface{}) *MockVolumeBackupService_RunVolumeBackups_Call {
147+ return &MockVolumeBackupService_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, domainName, volumeName)}
148+}
149+
150+func (_c *MockVolumeBackupService_RunVolumeBackups_Call) Run(run func(ctx context.Context, domainName string, volumeName string)) *MockVolumeBackupService_RunVolumeBackups_Call {
151+ _c.Call.Run(func(args mock.Arguments) {
152+ var arg0 context.Context
153+ if args[0] != nil {
154+ arg0 = args[0].(context.Context)
155+ }
156+ var arg1 string
157+ if args[1] != nil {
158+ arg1 = args[1].(string)
159+ }
160+ var arg2 string
161+ if args[2] != nil {
162+ arg2 = args[2].(string)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ arg2,
168+ )
169+ })
170+ return _c
171+}
172+
173+func (_c *MockVolumeBackupService_RunVolumeBackups_Call) Return(volumeBackupJobs []domain.VolumeBackupJob, err error) *MockVolumeBackupService_RunVolumeBackups_Call {
174+ _c.Call.Return(volumeBackupJobs, err)
175+ return _c
176+}
177+
178+func (_c *MockVolumeBackupService_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error)) *MockVolumeBackupService_RunVolumeBackups_Call {
179+ _c.Call.Return(run)
180+ return _c
181+}
182+
183+// VolumeBackupStatus provides a mock function for the type MockVolumeBackupService
184+func (_mock *MockVolumeBackupService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
185+ ret := _mock.Called(ctx)
186+
187+ if len(ret) == 0 {
188+ panic("no return value specified for VolumeBackupStatus")
189+ }
190+
191+ var r0 []domain.VolumeBackupJob
192+ var r1 error
193+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.VolumeBackupJob, error)); ok {
194+ return returnFunc(ctx)
195+ }
196+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.VolumeBackupJob); ok {
197+ r0 = returnFunc(ctx)
198+ } else {
199+ if ret.Get(0) != nil {
200+ r0 = ret.Get(0).([]domain.VolumeBackupJob)
201+ }
202+ }
203+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
204+ r1 = returnFunc(ctx)
205+ } else {
206+ r1 = ret.Error(1)
207+ }
208+ return r0, r1
209+}
210+
211+// MockVolumeBackupService_VolumeBackupStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VolumeBackupStatus'
212+type MockVolumeBackupService_VolumeBackupStatus_Call struct {
213+ *mock.Call
214+}
215+
216+// VolumeBackupStatus is a helper method to define mock.On call
217+// - ctx context.Context
218+func (_e *MockVolumeBackupService_Expecter) VolumeBackupStatus(ctx interface{}) *MockVolumeBackupService_VolumeBackupStatus_Call {
219+ return &MockVolumeBackupService_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
220+}
221+
222+func (_c *MockVolumeBackupService_VolumeBackupStatus_Call) Run(run func(ctx context.Context)) *MockVolumeBackupService_VolumeBackupStatus_Call {
223+ _c.Call.Run(func(args mock.Arguments) {
224+ var arg0 context.Context
225+ if args[0] != nil {
226+ arg0 = args[0].(context.Context)
227+ }
228+ run(
229+ arg0,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MockVolumeBackupService_VolumeBackupStatus_Call) Return(volumeBackupJobs []domain.VolumeBackupJob, err error) *MockVolumeBackupService_VolumeBackupStatus_Call {
236+ _c.Call.Return(volumeBackupJobs, err)
237+ return _c
238+}
239+
240+func (_c *MockVolumeBackupService_VolumeBackupStatus_Call) RunAndReturn(run func(ctx context.Context) ([]domain.VolumeBackupJob, error)) *MockVolumeBackupService_VolumeBackupStatus_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
@@ -0,0 +1,243 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockVolumeBackupService creates a new instance of MockVolumeBackupService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockVolumeBackupService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockVolumeBackupService {
20+ mock := &MockVolumeBackupService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockVolumeBackupService is an autogenerated mock type for the VolumeBackupService type
29+type MockVolumeBackupService struct {
30+ mock.Mock
31+}
32+
33+type MockVolumeBackupService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockVolumeBackupService) EXPECT() *MockVolumeBackupService_Expecter {
38+ return &MockVolumeBackupService_Expecter{mock: &_m.Mock}
39+}
40+
41+// ListVolumeBackups provides a mock function for the type MockVolumeBackupService
42+func (_mock *MockVolumeBackupService) ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) {
43+ ret := _mock.Called(ctx, domainName)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ListVolumeBackups")
47+ }
48+
49+ var r0 []domain.VolumeBackupJob
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.VolumeBackupJob, error)); ok {
52+ return returnFunc(ctx, domainName)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.VolumeBackupJob); ok {
55+ r0 = returnFunc(ctx, domainName)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).([]domain.VolumeBackupJob)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
62+ r1 = returnFunc(ctx, domainName)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockVolumeBackupService_ListVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumeBackups'
70+type MockVolumeBackupService_ListVolumeBackups_Call struct {
71+ *mock.Call
72+}
73+
74+// ListVolumeBackups is a helper method to define mock.On call
75+// - ctx context.Context
76+// - domainName string
77+func (_e *MockVolumeBackupService_Expecter) ListVolumeBackups(ctx interface{}, domainName interface{}) *MockVolumeBackupService_ListVolumeBackups_Call {
78+ return &MockVolumeBackupService_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, domainName)}
79+}
80+
81+func (_c *MockVolumeBackupService_ListVolumeBackups_Call) Run(run func(ctx context.Context, domainName string)) *MockVolumeBackupService_ListVolumeBackups_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 string
88+ if args[1] != nil {
89+ arg1 = args[1].(string)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockVolumeBackupService_ListVolumeBackups_Call) Return(volumeBackupJobs []domain.VolumeBackupJob, err error) *MockVolumeBackupService_ListVolumeBackups_Call {
100+ _c.Call.Return(volumeBackupJobs, err)
101+ return _c
102+}
103+
104+func (_c *MockVolumeBackupService_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)) *MockVolumeBackupService_ListVolumeBackups_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// RunVolumeBackups provides a mock function for the type MockVolumeBackupService
110+func (_mock *MockVolumeBackupService) RunVolumeBackups(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error) {
111+ ret := _mock.Called(ctx, domainName, volumeName)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for RunVolumeBackups")
115+ }
116+
117+ var r0 []domain.VolumeBackupJob
118+ var r1 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]domain.VolumeBackupJob, error)); ok {
120+ return returnFunc(ctx, domainName, volumeName)
121+ }
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []domain.VolumeBackupJob); ok {
123+ r0 = returnFunc(ctx, domainName, volumeName)
124+ } else {
125+ if ret.Get(0) != nil {
126+ r0 = ret.Get(0).([]domain.VolumeBackupJob)
127+ }
128+ }
129+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
130+ r1 = returnFunc(ctx, domainName, volumeName)
131+ } else {
132+ r1 = ret.Error(1)
133+ }
134+ return r0, r1
135+}
136+
137+// MockVolumeBackupService_RunVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunVolumeBackups'
138+type MockVolumeBackupService_RunVolumeBackups_Call struct {
139+ *mock.Call
140+}
141+
142+// RunVolumeBackups is a helper method to define mock.On call
143+// - ctx context.Context
144+// - domainName string
145+// - volumeName string
146+func (_e *MockVolumeBackupService_Expecter) RunVolumeBackups(ctx interface{}, domainName interface{}, volumeName interface{}) *MockVolumeBackupService_RunVolumeBackups_Call {
147+ return &MockVolumeBackupService_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, domainName, volumeName)}
148+}
149+
150+func (_c *MockVolumeBackupService_RunVolumeBackups_Call) Run(run func(ctx context.Context, domainName string, volumeName string)) *MockVolumeBackupService_RunVolumeBackups_Call {
151+ _c.Call.Run(func(args mock.Arguments) {
152+ var arg0 context.Context
153+ if args[0] != nil {
154+ arg0 = args[0].(context.Context)
155+ }
156+ var arg1 string
157+ if args[1] != nil {
158+ arg1 = args[1].(string)
159+ }
160+ var arg2 string
161+ if args[2] != nil {
162+ arg2 = args[2].(string)
163+ }
164+ run(
165+ arg0,
166+ arg1,
167+ arg2,
168+ )
169+ })
170+ return _c
171+}
172+
173+func (_c *MockVolumeBackupService_RunVolumeBackups_Call) Return(volumeBackupJobs []domain.VolumeBackupJob, err error) *MockVolumeBackupService_RunVolumeBackups_Call {
174+ _c.Call.Return(volumeBackupJobs, err)
175+ return _c
176+}
177+
178+func (_c *MockVolumeBackupService_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error)) *MockVolumeBackupService_RunVolumeBackups_Call {
179+ _c.Call.Return(run)
180+ return _c
181+}
182+
183+// VolumeBackupStatus provides a mock function for the type MockVolumeBackupService
184+func (_mock *MockVolumeBackupService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
185+ ret := _mock.Called(ctx)
186+
187+ if len(ret) == 0 {
188+ panic("no return value specified for VolumeBackupStatus")
189+ }
190+
191+ var r0 []domain.VolumeBackupJob
192+ var r1 error
193+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.VolumeBackupJob, error)); ok {
194+ return returnFunc(ctx)
195+ }
196+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.VolumeBackupJob); ok {
197+ r0 = returnFunc(ctx)
198+ } else {
199+ if ret.Get(0) != nil {
200+ r0 = ret.Get(0).([]domain.VolumeBackupJob)
201+ }
202+ }
203+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
204+ r1 = returnFunc(ctx)
205+ } else {
206+ r1 = ret.Error(1)
207+ }
208+ return r0, r1
209+}
210+
211+// MockVolumeBackupService_VolumeBackupStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VolumeBackupStatus'
212+type MockVolumeBackupService_VolumeBackupStatus_Call struct {
213+ *mock.Call
214+}
215+
216+// VolumeBackupStatus is a helper method to define mock.On call
217+// - ctx context.Context
218+func (_e *MockVolumeBackupService_Expecter) VolumeBackupStatus(ctx interface{}) *MockVolumeBackupService_VolumeBackupStatus_Call {
219+ return &MockVolumeBackupService_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
220+}
221+
222+func (_c *MockVolumeBackupService_VolumeBackupStatus_Call) Run(run func(ctx context.Context)) *MockVolumeBackupService_VolumeBackupStatus_Call {
223+ _c.Call.Run(func(args mock.Arguments) {
224+ var arg0 context.Context
225+ if args[0] != nil {
226+ arg0 = args[0].(context.Context)
227+ }
228+ run(
229+ arg0,
230+ )
231+ })
232+ return _c
233+}
234+
235+func (_c *MockVolumeBackupService_VolumeBackupStatus_Call) Return(volumeBackupJobs []domain.VolumeBackupJob, err error) *MockVolumeBackupService_VolumeBackupStatus_Call {
236+ _c.Call.Return(volumeBackupJobs, err)
237+ return _c
238+}
239+
240+func (_c *MockVolumeBackupService_VolumeBackupStatus_Call) RunAndReturn(run func(ctx context.Context) ([]domain.VolumeBackupJob, error)) *MockVolumeBackupService_VolumeBackupStatus_Call {
241+ _c.Call.Return(run)
242+ return _c
243+}
M internal/domain/errors.go +3 -2
@@ -41,8 +41,9 @@ var (
4141 ErrNetworkExists = errors.New("network already exists")
4242
4343 // Volume errors
44- ErrVolumeNotFound = errors.New("volume not found")
45- ErrVolumeExists = errors.New("volume already exists")
44+ ErrVolumeNotFound = errors.New("volume not found")
45+ ErrVolumeExists = errors.New("volume already exists")
46+ ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")
4647
4748 // Preview errors
4849 ErrPreviewNotFound = errors.New("preview not found")
@@ -41,8 +41,9 @@ var (
41 ErrNetworkExists = errors.New("network already exists")41 ErrNetworkExists = errors.New("network already exists")
42 42
43 // Volume errors43 // Volume errors
44- ErrVolumeNotFound = errors.New("volume not found")44+ ErrVolumeNotFound = errors.New("volume not found")
45- ErrVolumeExists = errors.New("volume already exists")45+ ErrVolumeExists = errors.New("volume already exists")
46+ ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")
46 47
47 // Preview errors48 // Preview errors
48 ErrPreviewNotFound = errors.New("preview not found")49 ErrPreviewNotFound = errors.New("preview not found")
M internal/usecase/backup/volume_service.go +34 -11
@@ -40,11 +40,20 @@ func NewVolumeService(runtime out.ContainerRuntime, exporter out.VolumeArchiveEx
4040
4141 // ListVolumeBackups lists completed volume backups for a domain, or all domains when empty.
4242 func (s *VolumeService) ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) {
43+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
44+ zerowrap.FieldLayer: "usecase",
45+ zerowrap.FieldUseCase: "ListVolumeBackups",
46+ "domain": domainName,
47+ })
4348 return s.storage.ListVolumeArchives(ctx, domainName)
4449 }
4550
4651 // VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
4752 func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
53+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
54+ zerowrap.FieldLayer: "usecase",
55+ zerowrap.FieldUseCase: "VolumeBackupStatus",
56+ })
4857 jobs, err := s.storage.ListVolumeArchives(ctx, "")
4958 if err != nil {
5059 return nil, err
@@ -69,6 +78,13 @@ func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.Volume
6978
7079 // RunVolumeBackups runs volume backups for all eligible targets, optionally scoped to a domain and volume.
7180 func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volumeName string) ([]domain.VolumeBackupJob, error) {
81+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
82+ zerowrap.FieldLayer: "usecase",
83+ zerowrap.FieldUseCase: "RunVolumeBackups",
84+ "domain": domainName,
85+ "volume": volumeName,
86+ })
87+ log := zerowrap.FromCtx(ctx)
7288 if !s.config.Enabled {
7389 return []domain.VolumeBackupJob{}, nil
7490 }
@@ -78,7 +94,7 @@ func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volume
7894 return nil, fmt.Errorf("failed to list containers for volume backups: %w", err)
7995 }
8096 targets := SelectVolumeBackupTargetsForScope(containers, s.config.VolumePrefix, domainName, volumeName)
81- s.log.Info().
97+ log.Info().
8298 Str("domain", domainName).
8399 Str("volume", volumeName).
84100 Int("targets", len(targets)).
@@ -139,7 +155,8 @@ func (s *VolumeService) applyRetentionForSuccessfulVolumeBackups(ctx context.Con
139155 if err != nil {
140156 return fmt.Errorf("apply volume backup retention for %s: %w", domainName, err)
141157 }
142- s.log.Info().
158+ log := zerowrap.FromCtx(ctx)
159+ log.Info().
143160 Str("domain", domainName).
144161 Int("deleted", deleted).
145162 Msg("volume backup retention applied")
@@ -174,6 +191,13 @@ func volumeBackupResultDomains(results []domain.VolumeBackupJob) (map[string]str
174191 }
175192
176193 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
194+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
195+ "domain": target.Domain,
196+ "volume": target.VolumeName,
197+ "container_name": target.ContainerName,
198+ "mount_path": target.MountPath,
199+ })
200+ log := zerowrap.FromCtx(ctx)
177201 started := time.Now().UTC()
178202 job := domain.VolumeBackupJob{
179203 ID: newBackupJobID(started),
@@ -205,25 +229,23 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
205229 HelperImage: s.config.HelperImage,
206230 })
207231 if err != nil {
208- s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive export failed")
209- return s.failJob(job, err)
232+ log.Error().Err(err).Msg("volume archive export failed")
233+ return s.failJob(ctx, job, err)
210234 }
211235 defer archive.Stream.Close()
212236
213237 counter := &byteCounter{}
214238 artifactRef, err := s.storage.StoreVolumeArchive(exportCtx, job, io.TeeReader(archive.Stream, counter))
215239 if err != nil {
216- s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive upload failed")
217- return s.failJob(job, err)
240+ log.Error().Err(err).Msg("volume archive upload failed")
241+ return s.failJob(ctx, job, err)
218242 }
219243
220244 job.Status = domain.BackupStatusCompleted
221245 job.CompletedAt = time.Now().UTC()
222246 job.SizeBytes = counter.n
223247 job.ArtifactRef = artifactRef
224- s.log.Info().
225- Str("domain", job.Domain).
226- Str("volume", job.VolumeName).
248+ log.Info().
227249 Int64("size_bytes", job.SizeBytes).
228250 Dur("duration", job.CompletedAt.Sub(job.StartedAt)).
229251 Msg("volume backup completed")
@@ -231,11 +253,12 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
231253 return job
232254 }
233255
234-func (s *VolumeService) failJob(job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {
256+func (s *VolumeService) failJob(ctx context.Context, job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {
235257 job.Status = domain.BackupStatusFailed
236258 job.CompletedAt = time.Now().UTC()
237259 job.Error = err.Error()
238- s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume backup failed")
260+ log := zerowrap.FromCtx(ctx)
261+ log.Error().Err(err).Msg("volume backup failed")
239262 s.remember(job)
240263 return job
241264 }
@@ -40,11 +40,20 @@ func NewVolumeService(runtime out.ContainerRuntime, exporter out.VolumeArchiveEx
40 40
41 // ListVolumeBackups lists completed volume backups for a domain, or all domains when empty.41 // ListVolumeBackups lists completed volume backups for a domain, or all domains when empty.
42 func (s *VolumeService) ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) {42 func (s *VolumeService) ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) {
43+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
44+ zerowrap.FieldLayer: "usecase",
45+ zerowrap.FieldUseCase: "ListVolumeBackups",
46+ "domain": domainName,
47+ })
43 return s.storage.ListVolumeArchives(ctx, domainName)48 return s.storage.ListVolumeArchives(ctx, domainName)
44 }49 }
45 50
46 // VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.51 // VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
47 func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {52 func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
53+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
54+ zerowrap.FieldLayer: "usecase",
55+ zerowrap.FieldUseCase: "VolumeBackupStatus",
56+ })
48 jobs, err := s.storage.ListVolumeArchives(ctx, "")57 jobs, err := s.storage.ListVolumeArchives(ctx, "")
49 if err != nil {58 if err != nil {
50 return nil, err59 return nil, err
@@ -69,6 +78,13 @@ func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.Volume
69 78
70 // RunVolumeBackups runs volume backups for all eligible targets, optionally scoped to a domain and volume.79 // RunVolumeBackups runs volume backups for all eligible targets, optionally scoped to a domain and volume.
71 func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volumeName string) ([]domain.VolumeBackupJob, error) {80 func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volumeName string) ([]domain.VolumeBackupJob, error) {
81+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
82+ zerowrap.FieldLayer: "usecase",
83+ zerowrap.FieldUseCase: "RunVolumeBackups",
84+ "domain": domainName,
85+ "volume": volumeName,
86+ })
87+ log := zerowrap.FromCtx(ctx)
72 if !s.config.Enabled {88 if !s.config.Enabled {
73 return []domain.VolumeBackupJob{}, nil89 return []domain.VolumeBackupJob{}, nil
74 }90 }
@@ -78,7 +94,7 @@ func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volume
78 return nil, fmt.Errorf("failed to list containers for volume backups: %w", err)94 return nil, fmt.Errorf("failed to list containers for volume backups: %w", err)
79 }95 }
80 targets := SelectVolumeBackupTargetsForScope(containers, s.config.VolumePrefix, domainName, volumeName)96 targets := SelectVolumeBackupTargetsForScope(containers, s.config.VolumePrefix, domainName, volumeName)
81- s.log.Info().97+ log.Info().
82 Str("domain", domainName).98 Str("domain", domainName).
83 Str("volume", volumeName).99 Str("volume", volumeName).
84 Int("targets", len(targets)).100 Int("targets", len(targets)).
@@ -139,7 +155,8 @@ func (s *VolumeService) applyRetentionForSuccessfulVolumeBackups(ctx context.Con
139 if err != nil {155 if err != nil {
140 return fmt.Errorf("apply volume backup retention for %s: %w", domainName, err)156 return fmt.Errorf("apply volume backup retention for %s: %w", domainName, err)
141 }157 }
142- s.log.Info().158+ log := zerowrap.FromCtx(ctx)
159+ log.Info().
143 Str("domain", domainName).160 Str("domain", domainName).
144 Int("deleted", deleted).161 Int("deleted", deleted).
145 Msg("volume backup retention applied")162 Msg("volume backup retention applied")
@@ -174,6 +191,13 @@ func volumeBackupResultDomains(results []domain.VolumeBackupJob) (map[string]str
174 }191 }
175 192
176 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {193 func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
194+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
195+ "domain": target.Domain,
196+ "volume": target.VolumeName,
197+ "container_name": target.ContainerName,
198+ "mount_path": target.MountPath,
199+ })
200+ log := zerowrap.FromCtx(ctx)
177 started := time.Now().UTC()201 started := time.Now().UTC()
178 job := domain.VolumeBackupJob{202 job := domain.VolumeBackupJob{
179 ID: newBackupJobID(started),203 ID: newBackupJobID(started),
@@ -205,25 +229,23 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
205 HelperImage: s.config.HelperImage,229 HelperImage: s.config.HelperImage,
206 })230 })
207 if err != nil {231 if err != nil {
208- s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive export failed")232+ log.Error().Err(err).Msg("volume archive export failed")
209- return s.failJob(job, err)233+ return s.failJob(ctx, job, err)
210 }234 }
211 defer archive.Stream.Close()235 defer archive.Stream.Close()
212 236
213 counter := &byteCounter{}237 counter := &byteCounter{}
214 artifactRef, err := s.storage.StoreVolumeArchive(exportCtx, job, io.TeeReader(archive.Stream, counter))238 artifactRef, err := s.storage.StoreVolumeArchive(exportCtx, job, io.TeeReader(archive.Stream, counter))
215 if err != nil {239 if err != nil {
216- s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive upload failed")240+ log.Error().Err(err).Msg("volume archive upload failed")
217- return s.failJob(job, err)241+ return s.failJob(ctx, job, err)
218 }242 }
219 243
220 job.Status = domain.BackupStatusCompleted244 job.Status = domain.BackupStatusCompleted
221 job.CompletedAt = time.Now().UTC()245 job.CompletedAt = time.Now().UTC()
222 job.SizeBytes = counter.n246 job.SizeBytes = counter.n
223 job.ArtifactRef = artifactRef247 job.ArtifactRef = artifactRef
224- s.log.Info().248+ log.Info().
225- Str("domain", job.Domain).
226- Str("volume", job.VolumeName).
227 Int64("size_bytes", job.SizeBytes).249 Int64("size_bytes", job.SizeBytes).
228 Dur("duration", job.CompletedAt.Sub(job.StartedAt)).250 Dur("duration", job.CompletedAt.Sub(job.StartedAt)).
229 Msg("volume backup completed")251 Msg("volume backup completed")
@@ -231,11 +253,12 @@ func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.Volum
231 return job253 return job
232 }254 }
233 255
234-func (s *VolumeService) failJob(job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {256+func (s *VolumeService) failJob(ctx context.Context, job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {
235 job.Status = domain.BackupStatusFailed257 job.Status = domain.BackupStatusFailed
236 job.CompletedAt = time.Now().UTC()258 job.CompletedAt = time.Now().UTC()
237 job.Error = err.Error()259 job.Error = err.Error()
238- s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume backup failed")260+ log := zerowrap.FromCtx(ctx)
261+ log.Error().Err(err).Msg("volume backup failed")
239 s.remember(job)262 s.remember(job)
240 return job263 return job
241 }264 }

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