detached 77c8aa8

Merge pull request #210 from bnema/feat/volume-backups-s3

feat(backups): add volume backups to s3
commit
77c8aa834575a57a15eaadff51519f65a905637a
author
bnema · · 2026-06-20 08:22 UTC
committer
GitHub · · 2026-06-20 08:22 UTC
merge of
b59533b + cf5815b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
42 files · +3622 -338
M .mockery.yaml +1 -0
@@ -34,6 +34,7 @@ packages:
3434 interfaces:
3535 ContainerService:
3636 BackupService:
37+ VolumeBackupService:
3738 RegistryService:
3839 ProxyService:
3940 ConfigService:
M docs/cli/backup.md +34 -67
@@ -1,96 +1,63 @@
11 # Backup Command
22
3-Manage backups for database attachments.
3+Manage database backups and volume backups.
44
5-## gordon backups
5+## gordon backups databases
6+
7+Database backups are logical PostgreSQL backups made with `pg_dump`.
68
79 ```bash
8-gordon backups <subcommand>
10+gordon backups databases <subcommand>
911 ```
1012
1113 Subcommands:
1214
13-- `list [domain]` - List backups globally or for one domain
14-- `run <domain> [--db <name>]` - Trigger an immediate backup
15+- `list [domain]` - List database backups
16+- `run <domain> [--db <name>]` - Trigger an immediate database backup
1517 - `detect <domain>` - Detect supported databases for a domain
16-- `status` - Show backup status across domains
18+- `status` - Show database backup status
1719
18-> Backup commands work both locally and remotely.
19->
20-> - Local mode (no `--remote`) executes backups through in-process services.
21-> - Remote mode uses the admin API on the target instance.
22->
23-> `gordon backups run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.
20+Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
2421
25-In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.
22+## gordon backups volumes
2623
27-## Examples
24+Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
2825
2926 ```bash
30-# List all backups
31-gordon backups list
32-gordon backups list --json
27+gordon backups volumes <subcommand>
28+```
3329
34-# List backups for one domain
35-gordon backups list app.example.com
30+Subcommands:
3631
37-# Trigger backup (auto-detect when exactly one DB attachment exists)
38-gordon backups run app.example.com
32+- `list [domain]` - List completed volume backup archives
33+- `run [domain] [--volume <name>]` - Trigger volume backups now
34+- `status` - Show completed archives plus current/recent in-memory job state
3935
40-# Trigger backup for specific attachment name
41-gordon backups run app.example.com --db postgres
36+Volume backups exclude bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes. Live archives are not application-consistent unless the application is quiesced or stopped.
4237
43-# Detect DB attachments
44-gordon backups detect app.example.com
38+## Examples
4539
46-# Status summary
47-gordon backups status
40+```bash
41+# Database backups
42+gordon backups databases list
43+gordon backups databases run app.example.com --db postgres
44+gordon backups databases detect app.example.com
45+gordon backups databases status
46+
47+# Volume backups
48+gordon backups volumes list
49+gordon backups volumes list app.example.com
50+gordon backups volumes run
51+gordon backups volumes run app.example.com --volume gordon-app-example-com-data
52+gordon backups volumes status
4853 ```
4954
5055 ## JSON Output
5156
52-`gordon backups list` supports `--json` for machine-readable output.
57+Database and volume list commands support `--json`.
5358
5459 ```bash
55-gordon backups list --json
56-```
57-
58-```json
59-[
60- {
61- "domain": "app.example.com",
62- "db": "postgres",
63- "status": "completed",
64- "started_at": "2026-03-13T09:45:00Z",
65- "backup_id": "backup_20260313_094500"
66- }
67-]
68-```
69-
70-## Output Columns
71-
72-### list
73-
74-```text
75-<domain>\t<db>\t<status>\t<started_at>\t<backup_id>
76-```
77-
78-### detect
79-
80-```text
81-<name>\t<type>\t<host>\t<port>\t<image>
82-```
83-
84-### status
85-
86-```text
87-<domain>\t<db>\t<status>\t<started_at>
88-```
89-
90-### run
91-
92-```text
93-<domain>\t<db>\t<status>\t<started_at>\t<backup_id>\t<size_bytes>
60+gordon backups volumes list --json
9461 ```
9562
9663 ## Required Permissions
M docs/config/backups.md +57 -40
@@ -1,69 +1,86 @@
11 # Backups Configuration
22
3-Configure automatic PostgreSQL logical backups for attachment containers.
3+Gordon has two backup flows:
44
5-## Overview
5+- **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.
6+- **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
67
7-When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:
8-
9-- Run on-demand backups via `gordon backups run`
10-- Store backup artifacts on the Gordon host filesystem
11-- Apply retention policies per schedule tier
12-
13-Current scope:
14-
15-- PostgreSQL 17 and 18
16-- Logical dumps (`pg_dump -Fc`)
17-- Local filesystem storage
18-
19-## Configuration
8+## Database backups
209
2110 ```toml
22-[backups]
11+[backups.databases]
2312 enabled = true
2413 schedule = "daily"
2514 storage_dir = "~/.gordon/backups"
2615
27-[backups.retention]
16+[backups.databases.retention]
2817 hourly = 24
2918 daily = 7
3019 weekly = 4
3120 monthly = 12
3221 ```
3322
34-## Settings
35-
36-| Key | Type | Default | Description |
37-|-----|------|---------|-------------|
38-| `backups.enabled` | bool | `false` | Enables backup service wiring |
39-| `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
40-| `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` |
41-| `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB |
42-| `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB |
43-| `backups.retention.weekly` | int | `0` | Number of weekly backups to keep per DB |
44-| `backups.retention.monthly` | int | `0` | Number of monthly backups to keep per DB |
23+| Key | Default | Description |
24+|---|---:|---|
25+| `backups.databases.enabled` | `false` | Enables PostgreSQL backup service wiring |
26+| `backups.databases.schedule` | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
27+| `backups.databases.storage_dir` | `""` | Root backup directory; defaults to `{server.data_dir}/backups` |
28+| `backups.databases.retention.*` | `0` | Number of backups to keep per DB and schedule tier |
4529
46-## Storage Layout
30+## Volume backups to S3
4731
48-Backups are stored under:
49-
50-```text
51-<storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak
32+```toml
33+[backups.volumes]
34+enabled = true
35+interval = "24h"
36+compression = "gzip"
37+timeout = "2h"
38+max_concurrency = 2
39+helper_image = "alpine:3.20"
40+
41+[backups.volumes.s3]
42+bucket = "gordon-backups"
43+region = "eu-west-3"
44+prefix = "prod/gordon"
45+# endpoint = "https://s3.example.com"
46+# path_style = false
47+# sse_algorithm = "AES256"
48+# sse_kms_key_id = ""
49+
50+[backups.volumes.retention]
51+keep = 14
5252 ```
5353
54-Example:
54+| Key | Default | Description |
55+|---|---:|---|
56+| `backups.volumes.enabled` | `false` | Enables automatic volume archives |
57+| `backups.volumes.interval` | `"24h"` | Interval between scheduled runs |
58+| `backups.volumes.compression` | `"gzip"` | `gzip` or `zstd`; helper image must provide the tool |
59+| `backups.volumes.timeout` | `"2h"` | Per-volume archive/upload timeout |
60+| `backups.volumes.max_concurrency` | `2` | Maximum concurrent volume backups |
61+| `backups.volumes.helper_image` | `"alpine:3.20"` | Helper container image used for tar/compression |
62+| `backups.volumes.s3.bucket` | `""` | Required when volume backups are enabled |
63+| `backups.volumes.s3.region` | `""` | Required when volume backups are enabled |
64+| `backups.volumes.s3.prefix` | `""` | Object key prefix |
65+| `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
66+| `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
67+| `backups.volumes.retention.keep` | `14` | Completed archives to keep per domain + volume |
68+
69+Volume backup objects are stored under:
5570
5671 ```text
57-~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak
72+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
5874 ```
5975
6076 ## Notes
6177
62-- Backups require PostgreSQL attachment containers to be running.
63-- Scheduled backups run only when `backups.enabled = true`.
64-- The backup command executes inside the database container using Gordon runtime APIs.
65-- Retention applies per database and per schedule.
66-- `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.
78+- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.
79+- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.
80+- Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
81+- Automated volume restore is not part of the MVP.
82+- Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.
83+- S3 credentials should come from the environment, shared config, instance role, or Gordon secret conventions. Use least-privilege IAM for the configured prefix.
6784
6885 ## Related
6986
M go.mod +19 -0
@@ -3,6 +3,10 @@ module github.com/bnema/gordon
33 go 1.26.1
44
55 require (
6+ github.com/aws/aws-sdk-go-v2 v1.42.0
7+ github.com/aws/aws-sdk-go-v2/config v1.32.25
8+ github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11
9+ github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0
610 github.com/bnema/zerowrap v1.4.0
711 github.com/charmbracelet/bubbles v1.0.0
812 github.com/charmbracelet/bubbletea v1.3.10
@@ -33,6 +37,21 @@ require (
3337
3438 require (
3539 github.com/atotto/clipboard v0.1.4 // indirect
40+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
41+ github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
42+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
43+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
44+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
45+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
46+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
47+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
48+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
49+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
50+ github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
51+ github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
52+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
53+ github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
54+ github.com/aws/smithy-go v1.27.1 // indirect
3655 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
3756 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
3857 github.com/cespare/xxhash/v2 v2.3.0 // indirect
M go.sum +38 -0 generated file: collapsed
M gordon.toml.example +23 -2
@@ -128,17 +128,38 @@ trace_sample_rate = 1.0
128128 [env]
129129 # dir = "~/.gordon/env"
130130
131-[backups]
131+[backups.databases]
132132 enabled = false
133133 schedule = "daily"
134134 storage_dir = ""
135135
136-[backups.retention]
136+[backups.databases.retention]
137137 hourly = 0
138138 daily = 0
139139 weekly = 0
140140 monthly = 0
141141
142+[backups.volumes]
143+enabled = false
144+interval = "24h"
145+compression = "gzip"
146+timeout = "2h"
147+max_concurrency = 2
148+# Helper image must include tar and the selected compressor.
149+helper_image = "alpine:3.20"
150+
151+[backups.volumes.s3]
152+bucket = ""
153+region = ""
154+prefix = ""
155+# endpoint = "https://s3.example.com"
156+# path_style = false
157+# sse_algorithm = "AES256"
158+# sse_kms_key_id = ""
159+
160+[backups.volumes.retention]
161+keep = 14
162+
142163 [routes]
143164 # "app.example.com" = { image = "myapp:latest" }
144165
M internal/adapters/dto/admin_backups.go +35 -0
@@ -47,3 +47,38 @@ type DatabaseInfo struct {
4747 type BackupDetectResponse struct {
4848 Databases []DatabaseInfo `json:"databases"`
4949 }
50+
51+// VolumeBackupJob represents volume backup metadata in admin API responses.
52+type VolumeBackupJob struct {
53+ ID string `json:"id"`
54+ Domain string `json:"domain"`
55+ ContainerName string `json:"container_name,omitempty"`
56+ ContainerID string `json:"container_id,omitempty"`
57+ VolumeName string `json:"volume_name"`
58+ MountPath string `json:"mount_path,omitempty"`
59+ Compression string `json:"compression,omitempty"`
60+ Type string `json:"type"`
61+ Status string `json:"status"`
62+ StartedAt *time.Time `json:"started_at,omitempty"`
63+ CompletedAt *time.Time `json:"completed_at,omitempty"`
64+ SizeBytes int64 `json:"size_bytes"`
65+ ArtifactRef string `json:"artifact_ref,omitempty"`
66+ Error string `json:"error,omitempty"`
67+}
68+
69+// VolumeBackupsResponse is returned by volume backup listing endpoints.
70+type VolumeBackupsResponse struct {
71+ Backups []VolumeBackupJob `json:"backups"`
72+}
73+
74+// VolumeBackupRunRequest triggers volume backups.
75+type VolumeBackupRunRequest struct {
76+ Volume string `json:"volume,omitempty"`
77+}
78+
79+// VolumeBackupRunResponse is returned after triggering volume backups.
80+type VolumeBackupRunResponse struct {
81+ Status string `json:"status"`
82+ Backups []VolumeBackupJob `json:"backups,omitempty"`
83+ Error string `json:"error,omitempty"`
84+}
M internal/adapters/in/cli/backup.go +151 -2
@@ -1,6 +1,7 @@
11 package cli
22
33 import (
4+ "context"
45 "fmt"
56 "io"
67 "os"
@@ -13,23 +14,58 @@ import (
1314 "github.com/bnema/gordon/pkg/bytesize"
1415 )
1516
17+var backupResolveControlPlane = func(ctx context.Context, configPath, domainName string) (*controlPlaneHandle, error) {
18+ if domainName != "" {
19+ return resolveControlPlaneForRouteDomain(ctx, domainName)
20+ }
21+ return resolveControlPlane(configPath)
22+}
23+
1624 // newBackupCmd creates the backup command group.
1725 func newBackupCmd() *cobra.Command {
1826 cmd := &cobra.Command{
1927 Use: "backups",
2028 Aliases: []string{"backup"},
21- Short: "Manage database backups",
22- Long: `Manage database backups.
29+ Short: "Manage backups",
30+ Long: `Manage backups.
2331
2432 Runs locally via in-process services by default, or against a remote Gordon
2533 instance when --remote targeting is configured.`,
2634 }
2735
36+ cmd.AddCommand(newBackupDatabasesCmd())
37+ cmd.AddCommand(newBackupVolumesCmd())
38+
39+ // Compatibility aliases for the original database backup command shape.
40+ cmd.AddCommand(newBackupListCmd())
41+ cmd.AddCommand(newBackupRunCmd())
42+ cmd.AddCommand(newBackupDetectCmd())
43+ cmd.AddCommand(newBackupStatusCmd())
44+
45+ return cmd
46+}
47+
48+func newBackupDatabasesCmd() *cobra.Command {
49+ cmd := &cobra.Command{
50+ Use: "databases",
51+ Aliases: []string{"database", "db"},
52+ Short: "Manage database backups",
53+ }
2854 cmd.AddCommand(newBackupListCmd())
2955 cmd.AddCommand(newBackupRunCmd())
3056 cmd.AddCommand(newBackupDetectCmd())
3157 cmd.AddCommand(newBackupStatusCmd())
58+ return cmd
59+}
3260
61+func newBackupVolumesCmd() *cobra.Command {
62+ cmd := &cobra.Command{
63+ Use: "volumes",
64+ Short: "Manage volume backups",
65+ }
66+ cmd.AddCommand(newVolumeBackupListCmd())
67+ cmd.AddCommand(newVolumeBackupRunCmd())
68+ cmd.AddCommand(newVolumeBackupStatusCmd())
3369 return cmd
3470 }
3571
@@ -103,6 +139,119 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
103139 return w.Flush()
104140 }
105141
142+func newVolumeBackupListCmd() *cobra.Command {
143+ var jsonOut bool
144+
145+ cmd := &cobra.Command{
146+ Use: "list [domain]",
147+ Short: "List volume backups",
148+ Args: cobra.MaximumNArgs(1),
149+ RunE: func(cmd *cobra.Command, args []string) error {
150+ domainName := ""
151+ if len(args) == 1 {
152+ domainName = args[0]
153+ }
154+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
155+ if err != nil {
156+ return err
157+ }
158+ defer handle.close()
159+
160+ jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)
161+ if err != nil {
162+ return fmt.Errorf("failed to list volume backups: %w", err)
163+ }
164+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
165+ },
166+ }
167+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
168+ return cmd
169+}
170+
171+func newVolumeBackupRunCmd() *cobra.Command {
172+ var volumeName string
173+ var jsonOut bool
174+
175+ cmd := &cobra.Command{
176+ Use: "run [domain]",
177+ Short: "Run volume backups now",
178+ Args: cobra.MaximumNArgs(1),
179+ RunE: func(cmd *cobra.Command, args []string) error {
180+ domainName := ""
181+ if len(args) == 1 {
182+ domainName = args[0]
183+ }
184+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, domainName)
185+ if err != nil {
186+ return err
187+ }
188+ defer handle.close()
189+
190+ result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
191+ if err != nil {
192+ if result != nil && len(result.Backups) > 0 {
193+ if printErr := printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut); printErr != nil {
194+ return printErr
195+ }
196+ }
197+ return fmt.Errorf("failed to run volume backups: %w", err)
198+ }
199+ return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, jsonOut)
200+ },
201+ }
202+ cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
203+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
204+ return cmd
205+}
206+
207+func newVolumeBackupStatusCmd() *cobra.Command {
208+ var jsonOut bool
209+ cmd := &cobra.Command{
210+ Use: "status",
211+ Short: "Show volume backup status",
212+ RunE: func(cmd *cobra.Command, args []string) error {
213+ handle, err := backupResolveControlPlane(cmd.Context(), configPath, "")
214+ if err != nil {
215+ return err
216+ }
217+ defer handle.close()
218+
219+ jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
220+ if err != nil {
221+ return fmt.Errorf("failed to get volume backup status: %w", err)
222+ }
223+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
224+ },
225+ }
226+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
227+ return cmd
228+}
229+
230+func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
231+ if len(jobs) == 0 {
232+ if jsonOut {
233+ return writeJSON(out, []dto.VolumeBackupJob{})
234+ }
235+ return cliWriteLine(out, cliRenderMuted("No volume backups found"))
236+ }
237+ if jsonOut {
238+ return writeJSON(out, jobs)
239+ }
240+ if err := cliWriteLine(out, cliRenderTitle("Volume Backups")); err != nil {
241+ return err
242+ }
243+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
244+ if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
245+ return err
246+ }
247+ for _, job := range jobs {
248+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", job.Domain, job.VolumeName, job.ContainerName, job.Compression, job.Status, formatBackupTime(job.StartedAt), bytesize.Format(job.SizeBytes), job.ArtifactRef); err != nil {
249+ return err
250+ }
251+ }
252+ return w.Flush()
253+}
254+
106255 func newBackupRunCmd() *cobra.Command {
107256 var dbName string
108257
A internal/adapters/in/cli/backup_volume_test.go +98 -0
@@ -0,0 +1,98 @@
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(context.Context, string, string) (*controlPlaneHandle, error) {
20+ return &controlPlaneHandle{plane: plane}, nil
21+ }
22+ t.Cleanup(func() { backupResolveControlPlane = old })
23+}
24+
25+func TestVolumeBackupRun_UsesDomainAwareResolver(t *testing.T) {
26+ plane := climocks.NewMockControlPlane(t)
27+ old := backupResolveControlPlane
28+ var gotDomain string
29+ backupResolveControlPlane = func(_ context.Context, _ string, domainName string) (*controlPlaneHandle, error) {
30+ gotDomain = domainName
31+ return &controlPlaneHandle{plane: plane}, nil
32+ }
33+ t.Cleanup(func() { backupResolveControlPlane = old })
34+
35+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{Status: "ok"}, nil)
36+
37+ cmd := newVolumeBackupRunCmd()
38+ cmd.SetArgs([]string{"app.example.com"})
39+
40+ require.NoError(t, cmd.ExecuteContext(context.Background()))
41+ require.Equal(t, "app.example.com", gotDomain)
42+}
43+
44+func TestVolumeBackupRun_JSONFlag(t *testing.T) {
45+ plane := climocks.NewMockControlPlane(t)
46+ withBackupControlPlane(t, plane)
47+
48+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(&dto.VolumeBackupRunResponse{
49+ Status: "ok",
50+ Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
51+ }, nil)
52+
53+ cmd := newVolumeBackupRunCmd()
54+ var out bytes.Buffer
55+ cmd.SetOut(&out)
56+ cmd.SetArgs([]string{"app.example.com", "--volume", "gordon-app-data", "--json"})
57+
58+ require.NoError(t, cmd.ExecuteContext(context.Background()))
59+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
60+}
61+
62+func TestVolumeBackupStatus_JSONFlag(t *testing.T) {
63+ plane := climocks.NewMockControlPlane(t)
64+ withBackupControlPlane(t, plane)
65+
66+ plane.EXPECT().VolumeBackupStatus(mock.Anything).Return([]dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "running"}}, nil)
67+
68+ cmd := newVolumeBackupStatusCmd()
69+ var out bytes.Buffer
70+ cmd.SetOut(&out)
71+ cmd.SetArgs([]string{"--json"})
72+
73+ require.NoError(t, cmd.ExecuteContext(context.Background()))
74+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"running","size_bytes":0}]`, out.String())
75+}
76+
77+func TestVolumeBackupRun_PrintsPartialJobsBeforeReturningError(t *testing.T) {
78+ plane := climocks.NewMockControlPlane(t)
79+ withBackupControlPlane(t, plane)
80+
81+ runErr := errors.New("one backup failed")
82+ plane.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(&dto.VolumeBackupRunResponse{
83+ Status: "partial",
84+ Backups: []dto.VolumeBackupJob{{Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
85+ Error: runErr.Error(),
86+ }, runErr)
87+
88+ cmd := newVolumeBackupRunCmd()
89+ cmd.SilenceUsage = true
90+ var out bytes.Buffer
91+ cmd.SetOut(&out)
92+ cmd.SetArgs([]string{"app.example.com", "--json"})
93+
94+ err := cmd.ExecuteContext(context.Background())
95+ require.Error(t, err)
96+ require.ErrorIs(t, err, runErr)
97+ require.JSONEq(t, `[{"id":"","domain":"app.example.com","volume_name":"gordon-app-data","type":"","status":"completed","size_bytes":0}]`, out.String())
98+}
M internal/adapters/in/cli/controlplane.go +3 -0
@@ -53,6 +53,9 @@ type ControlPlane interface {
5353 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
5454 RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
5555 DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)
56+ ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)
57+ VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error)
58+ RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error)
5659
5760 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
5861 GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
M internal/adapters/in/cli/controlplane_local.go +101 -20
@@ -16,16 +16,17 @@ import (
1616 )
1717
1818 type localControlPlane struct {
19- configSvc in.ConfigService
20- secretSvc in.SecretService
21- containerSvc in.ContainerService
22- backupSvc in.BackupService
23- registrySvc in.RegistryService
24- deployCoord in.DeployCoordinator
25- healthSvc in.HealthService
26- logSvc in.LogService
27- volumeSvc in.VolumeService
28- publicTLSSvc in.PublicTLSService
19+ configSvc in.ConfigService
20+ secretSvc in.SecretService
21+ containerSvc in.ContainerService
22+ backupSvc in.BackupService
23+ volumeBackupSvc in.VolumeBackupService
24+ registrySvc in.RegistryService
25+ deployCoord in.DeployCoordinator
26+ healthSvc in.HealthService
27+ logSvc in.LogService
28+ volumeSvc in.VolumeService
29+ publicTLSSvc in.PublicTLSService
2930 }
3031
3132 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
@@ -42,16 +43,17 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
4243 }
4344
4445 return &localControlPlane{
45- configSvc: kernel.Config(),
46- secretSvc: kernel.Secrets(),
47- containerSvc: kernel.Container(),
48- backupSvc: kernel.Backup(),
49- registrySvc: registrySvc,
50- deployCoord: deployCoord,
51- healthSvc: kernel.Health(),
52- logSvc: kernel.Logs(),
53- volumeSvc: kernel.Volumes(),
54- publicTLSSvc: kernel.PublicTLS(),
46+ configSvc: kernel.Config(),
47+ secretSvc: kernel.Secrets(),
48+ containerSvc: kernel.Container(),
49+ backupSvc: kernel.Backup(),
50+ volumeBackupSvc: kernel.VolumeBackup(),
51+ registrySvc: registrySvc,
52+ deployCoord: deployCoord,
53+ healthSvc: kernel.Health(),
54+ logSvc: kernel.Logs(),
55+ volumeSvc: kernel.Volumes(),
56+ publicTLSSvc: kernel.PublicTLS(),
5557 }
5658 }
5759
@@ -629,6 +631,47 @@ func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain st
629631 return out, nil
630632 }
631633
634+func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
635+ if l.volumeBackupSvc == nil {
636+ return nil, localVolumeBackupServiceUnavailable()
637+ }
638+ jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
639+ if err != nil {
640+ return nil, fmt.Errorf("list volume backups: %w", err)
641+ }
642+ return toDTOVolumeBackupJobs(jobs), nil
643+}
644+
645+func (l *localControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
646+ if l.volumeBackupSvc == nil {
647+ return nil, localVolumeBackupServiceUnavailable()
648+ }
649+ jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)
650+ if err != nil {
651+ return nil, fmt.Errorf("get volume backup status: %w", err)
652+ }
653+ return toDTOVolumeBackupJobs(jobs), nil
654+}
655+
656+func (l *localControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
657+ if l.volumeBackupSvc == nil {
658+ return nil, localVolumeBackupServiceUnavailable()
659+ }
660+ jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)
661+ if err != nil {
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)
667+ }
668+ return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil
669+}
670+
671+func localVolumeBackupServiceUnavailable() error {
672+ return fmt.Errorf("local volume backup service unavailable: %w", domain.ErrVolumeBackupUnavailable)
673+}
674+
632675 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
633676 if l.logSvc == nil {
634677 return nil, fmt.Errorf("local log service unavailable")
@@ -785,3 +828,41 @@ func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
785828 Error: job.Error,
786829 }
787830 }
831+
832+func toDTOVolumeBackupJobs(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
833+ out := make([]dto.VolumeBackupJob, 0, len(jobs))
834+ for _, job := range jobs {
835+ out = append(out, toDTOVolumeBackupJob(job))
836+ }
837+ return out
838+}
839+
840+func toDTOVolumeBackupJob(job domain.VolumeBackupJob) dto.VolumeBackupJob {
841+ var startedAt *time.Time
842+ if !job.StartedAt.IsZero() {
843+ t := job.StartedAt
844+ startedAt = &t
845+ }
846+ var completedAt *time.Time
847+ if !job.CompletedAt.IsZero() {
848+ t := job.CompletedAt
849+ completedAt = &t
850+ }
851+
852+ return dto.VolumeBackupJob{
853+ ID: job.ID,
854+ Domain: job.Domain,
855+ ContainerName: job.ContainerName,
856+ ContainerID: job.ContainerID,
857+ VolumeName: job.VolumeName,
858+ MountPath: job.MountPath,
859+ Compression: job.Metadata["compression"],
860+ Type: string(job.Type),
861+ Status: string(job.Status),
862+ StartedAt: startedAt,
863+ CompletedAt: completedAt,
864+ SizeBytes: job.SizeBytes,
865+ ArtifactRef: job.ArtifactRef,
866+ Error: job.Error,
867+ }
868+}
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
M internal/adapters/in/cli/controlplane_remote.go +25 -0
@@ -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"
@@ -169,6 +170,30 @@ func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain s
169170 return r.client.DetectDatabases(ctx, backupDomain)
170171 }
171172
173+func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
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
179+}
180+
181+func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
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
187+}
188+
189+func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
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
195+}
196+
172197 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
173198 return r.client.GetProcessLogs(ctx, lines)
174199 }
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+}
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},
M internal/adapters/in/cli/mocks/mock_control_plane.go +204 -0
@@ -1922,6 +1922,74 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont
19221922 return _c
19231923 }
19241924
1925+// ListVolumeBackups provides a mock function for the type MockControlPlane
1926+func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
1927+ ret := _mock.Called(ctx, backupDomain)
1928+
1929+ if len(ret) == 0 {
1930+ panic("no return value specified for ListVolumeBackups")
1931+ }
1932+
1933+ var r0 []dto.VolumeBackupJob
1934+ var r1 error
1935+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.VolumeBackupJob, error)); ok {
1936+ return returnFunc(ctx, backupDomain)
1937+ }
1938+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.VolumeBackupJob); ok {
1939+ r0 = returnFunc(ctx, backupDomain)
1940+ } else {
1941+ if ret.Get(0) != nil {
1942+ r0 = ret.Get(0).([]dto.VolumeBackupJob)
1943+ }
1944+ }
1945+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1946+ r1 = returnFunc(ctx, backupDomain)
1947+ } else {
1948+ r1 = ret.Error(1)
1949+ }
1950+ return r0, r1
1951+}
1952+
1953+// MockControlPlane_ListVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumeBackups'
1954+type MockControlPlane_ListVolumeBackups_Call struct {
1955+ *mock.Call
1956+}
1957+
1958+// ListVolumeBackups is a helper method to define mock.On call
1959+// - ctx context.Context
1960+// - backupDomain string
1961+func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx interface{}, backupDomain interface{}) *MockControlPlane_ListVolumeBackups_Call {
1962+ return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}
1963+}
1964+
1965+func (_c *MockControlPlane_ListVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_ListVolumeBackups_Call {
1966+ _c.Call.Run(func(args mock.Arguments) {
1967+ var arg0 context.Context
1968+ if args[0] != nil {
1969+ arg0 = args[0].(context.Context)
1970+ }
1971+ var arg1 string
1972+ if args[1] != nil {
1973+ arg1 = args[1].(string)
1974+ }
1975+ run(
1976+ arg0,
1977+ arg1,
1978+ )
1979+ })
1980+ return _c
1981+}
1982+
1983+func (_c *MockControlPlane_ListVolumeBackups_Call) Return(volumeBackupJobs []dto.VolumeBackupJob, err error) *MockControlPlane_ListVolumeBackups_Call {
1984+ _c.Call.Return(volumeBackupJobs, err)
1985+ return _c
1986+}
1987+
1988+func (_c *MockControlPlane_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)) *MockControlPlane_ListVolumeBackups_Call {
1989+ _c.Call.Return(run)
1990+ return _c
1991+}
1992+
19251993 // ListVolumes provides a mock function for the type MockControlPlane
19261994 func (_mock *MockControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
19271995 ret := _mock.Called(ctx)
@@ -2428,6 +2496,80 @@ func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Con
24282496 return _c
24292497 }
24302498
2499+// RunVolumeBackups provides a mock function for the type MockControlPlane
2500+func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error) {
2501+ ret := _mock.Called(ctx, backupDomain, volumeName)
2502+
2503+ if len(ret) == 0 {
2504+ panic("no return value specified for RunVolumeBackups")
2505+ }
2506+
2507+ var r0 *dto.VolumeBackupRunResponse
2508+ var r1 error
2509+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.VolumeBackupRunResponse, error)); ok {
2510+ return returnFunc(ctx, backupDomain, volumeName)
2511+ }
2512+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.VolumeBackupRunResponse); ok {
2513+ r0 = returnFunc(ctx, backupDomain, volumeName)
2514+ } else {
2515+ if ret.Get(0) != nil {
2516+ r0 = ret.Get(0).(*dto.VolumeBackupRunResponse)
2517+ }
2518+ }
2519+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
2520+ r1 = returnFunc(ctx, backupDomain, volumeName)
2521+ } else {
2522+ r1 = ret.Error(1)
2523+ }
2524+ return r0, r1
2525+}
2526+
2527+// MockControlPlane_RunVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunVolumeBackups'
2528+type MockControlPlane_RunVolumeBackups_Call struct {
2529+ *mock.Call
2530+}
2531+
2532+// RunVolumeBackups is a helper method to define mock.On call
2533+// - ctx context.Context
2534+// - backupDomain string
2535+// - volumeName string
2536+func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx interface{}, backupDomain interface{}, volumeName interface{}) *MockControlPlane_RunVolumeBackups_Call {
2537+ return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}
2538+}
2539+
2540+func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string, volumeName string)) *MockControlPlane_RunVolumeBackups_Call {
2541+ _c.Call.Run(func(args mock.Arguments) {
2542+ var arg0 context.Context
2543+ if args[0] != nil {
2544+ arg0 = args[0].(context.Context)
2545+ }
2546+ var arg1 string
2547+ if args[1] != nil {
2548+ arg1 = args[1].(string)
2549+ }
2550+ var arg2 string
2551+ if args[2] != nil {
2552+ arg2 = args[2].(string)
2553+ }
2554+ run(
2555+ arg0,
2556+ arg1,
2557+ arg2,
2558+ )
2559+ })
2560+ return _c
2561+}
2562+
2563+func (_c *MockControlPlane_RunVolumeBackups_Call) Return(volumeBackupRunResponse *dto.VolumeBackupRunResponse, err error) *MockControlPlane_RunVolumeBackups_Call {
2564+ _c.Call.Return(volumeBackupRunResponse, err)
2565+ return _c
2566+}
2567+
2568+func (_c *MockControlPlane_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error)) *MockControlPlane_RunVolumeBackups_Call {
2569+ _c.Call.Return(run)
2570+ return _c
2571+}
2572+
24312573 // SetAttachmentSecrets provides a mock function for the type MockControlPlane
24322574 func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error {
24332575 ret := _mock.Called(ctx, domain1, service, secrets)
@@ -2758,3 +2900,65 @@ func (_c *MockControlPlane_UpdateRoute_Call) RunAndReturn(run func(ctx context.C
27582900 _c.Call.Return(run)
27592901 return _c
27602902 }
2903+
2904+// VolumeBackupStatus provides a mock function for the type MockControlPlane
2905+func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
2906+ ret := _mock.Called(ctx)
2907+
2908+ if len(ret) == 0 {
2909+ panic("no return value specified for VolumeBackupStatus")
2910+ }
2911+
2912+ var r0 []dto.VolumeBackupJob
2913+ var r1 error
2914+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]dto.VolumeBackupJob, error)); ok {
2915+ return returnFunc(ctx)
2916+ }
2917+ if returnFunc, ok := ret.Get(0).(func(context.Context) []dto.VolumeBackupJob); ok {
2918+ r0 = returnFunc(ctx)
2919+ } else {
2920+ if ret.Get(0) != nil {
2921+ r0 = ret.Get(0).([]dto.VolumeBackupJob)
2922+ }
2923+ }
2924+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2925+ r1 = returnFunc(ctx)
2926+ } else {
2927+ r1 = ret.Error(1)
2928+ }
2929+ return r0, r1
2930+}
2931+
2932+// MockControlPlane_VolumeBackupStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VolumeBackupStatus'
2933+type MockControlPlane_VolumeBackupStatus_Call struct {
2934+ *mock.Call
2935+}
2936+
2937+// VolumeBackupStatus is a helper method to define mock.On call
2938+// - ctx context.Context
2939+func (_e *MockControlPlane_Expecter) VolumeBackupStatus(ctx interface{}) *MockControlPlane_VolumeBackupStatus_Call {
2940+ return &MockControlPlane_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
2941+}
2942+
2943+func (_c *MockControlPlane_VolumeBackupStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_VolumeBackupStatus_Call {
2944+ _c.Call.Run(func(args mock.Arguments) {
2945+ var arg0 context.Context
2946+ if args[0] != nil {
2947+ arg0 = args[0].(context.Context)
2948+ }
2949+ run(
2950+ arg0,
2951+ )
2952+ })
2953+ return _c
2954+}
2955+
2956+func (_c *MockControlPlane_VolumeBackupStatus_Call) Return(volumeBackupJobs []dto.VolumeBackupJob, err error) *MockControlPlane_VolumeBackupStatus_Call {
2957+ _c.Call.Return(volumeBackupJobs, err)
2958+ return _c
2959+}
2960+
2961+func (_c *MockControlPlane_VolumeBackupStatus_Call) RunAndReturn(run func(ctx context.Context) ([]dto.VolumeBackupJob, error)) *MockControlPlane_VolumeBackupStatus_Call {
2962+ _c.Call.Return(run)
2963+ return _c
2964+}
M internal/adapters/in/cli/remote/client.go +58 -0
@@ -836,6 +836,64 @@ func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dt
836836 return result.Databases, nil
837837 }
838838
839+// ListVolumeBackups returns volume backups globally or for a domain.
840+func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
841+ path := "/backups/volumes"
842+ if backupDomain != "" {
843+ path += "/" + url.PathEscape(backupDomain)
844+ }
845+
846+ resp, err := c.request(ctx, http.MethodGet, path, nil)
847+ if err != nil {
848+ return nil, err
849+ }
850+
851+ var result dto.VolumeBackupsResponse
852+ if err := parseResponse(resp, &result); err != nil {
853+ return nil, err
854+ }
855+ return result.Backups, nil
856+}
857+
858+// VolumeBackupStatus returns aggregate volume backup status.
859+func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
860+ resp, err := c.request(ctx, http.MethodGet, "/backups/volumes/status", nil)
861+ if err != nil {
862+ return nil, err
863+ }
864+
865+ var result dto.VolumeBackupsResponse
866+ if err := parseResponse(resp, &result); err != nil {
867+ return nil, err
868+ }
869+ return result.Backups, nil
870+}
871+
872+// RunVolumeBackups triggers volume backups.
873+func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
874+ path := "/backups/volumes"
875+ if backupDomain != "" {
876+ path += "/" + url.PathEscape(backupDomain)
877+ }
878+
879+ resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName})
880+ if err != nil {
881+ return nil, err
882+ }
883+
884+ var result dto.VolumeBackupRunResponse
885+ if err := parseResponse(resp, &result); err != nil {
886+ return nil, err
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+ }
894+ return &result, nil
895+}
896+
839897 // GetHealth returns health status for all routes with HTTP probing.
840898 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
841899 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
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")
M internal/adapters/in/http/admin/handler.go +170 -44
@@ -40,20 +40,21 @@ type reloadTrigger interface {
4040
4141 // Handler implements the HTTP handler for the admin API.
4242 type Handler struct {
43- configSvc in.ConfigService
44- authSvc in.AuthService
45- containerSvc in.ContainerService
46- backupSvc in.BackupService
47- imageSvc in.ImageService
48- healthSvc in.HealthService
49- secretSvc in.SecretService
50- logSvc in.LogService
51- volumeSvc in.VolumeService
52- registrySvc registryDeployService
53- previewSvc previewService
54- reloadTrigger reloadTrigger
55- publicTLSSvc in.PublicTLSService
56- log zerowrap.Logger
43+ configSvc in.ConfigService
44+ authSvc in.AuthService
45+ containerSvc in.ContainerService
46+ backupSvc in.BackupService
47+ volumeBackupSvc in.VolumeBackupService
48+ imageSvc in.ImageService
49+ healthSvc in.HealthService
50+ secretSvc in.SecretService
51+ logSvc in.LogService
52+ volumeSvc in.VolumeService
53+ registrySvc registryDeployService
54+ previewSvc previewService
55+ reloadTrigger reloadTrigger
56+ publicTLSSvc in.PublicTLSService
57+ log zerowrap.Logger
5758 }
5859
5960 // Type aliases for API responses using shared DTO types.
@@ -142,6 +143,36 @@ func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
142143 }
143144 }
144145
146+func toVolumeBackupJobResponse(job domain.VolumeBackupJob) dto.VolumeBackupJob {
147+ var startedAt *time.Time
148+ if !job.StartedAt.IsZero() {
149+ t := job.StartedAt
150+ startedAt = &t
151+ }
152+ var completedAt *time.Time
153+ if !job.CompletedAt.IsZero() {
154+ t := job.CompletedAt
155+ completedAt = &t
156+ }
157+
158+ return dto.VolumeBackupJob{
159+ ID: job.ID,
160+ Domain: job.Domain,
161+ ContainerName: job.ContainerName,
162+ ContainerID: job.ContainerID,
163+ VolumeName: job.VolumeName,
164+ MountPath: job.MountPath,
165+ Compression: job.Metadata["compression"],
166+ Type: string(job.Type),
167+ Status: string(job.Status),
168+ StartedAt: startedAt,
169+ CompletedAt: completedAt,
170+ SizeBytes: job.SizeBytes,
171+ ArtifactRef: job.ArtifactRef,
172+ Error: job.Error,
173+ }
174+}
175+
145176 func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
146177 return dto.DatabaseInfo{
147178 Type: string(db.Type),
@@ -156,39 +187,41 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
156187
157188 // HandlerDeps contains all dependencies for the admin HTTP handler.
158189 type HandlerDeps struct {
159- ConfigSvc in.ConfigService
160- AuthSvc in.AuthService
161- ContainerSvc in.ContainerService
162- HealthSvc in.HealthService
163- SecretSvc in.SecretService
164- LogSvc in.LogService
165- RegistrySvc registryDeployService
166- Log zerowrap.Logger
167- BackupSvc in.BackupService
168- PreviewSvc previewService
169- ImageSvc in.ImageService
170- VolumeSvc in.VolumeService
171- ReloadTrigger reloadTrigger
172- PublicTLSSvc in.PublicTLSService
190+ ConfigSvc in.ConfigService
191+ AuthSvc in.AuthService
192+ ContainerSvc in.ContainerService
193+ HealthSvc in.HealthService
194+ SecretSvc in.SecretService
195+ LogSvc in.LogService
196+ RegistrySvc registryDeployService
197+ Log zerowrap.Logger
198+ BackupSvc in.BackupService
199+ VolumeBackupSvc in.VolumeBackupService
200+ PreviewSvc previewService
201+ ImageSvc in.ImageService
202+ VolumeSvc in.VolumeService
203+ ReloadTrigger reloadTrigger
204+ PublicTLSSvc in.PublicTLSService
173205 }
174206
175207 // NewHandler creates a new admin HTTP handler.
176208 func NewHandler(deps HandlerDeps) *Handler {
177209 return &Handler{
178- configSvc: deps.ConfigSvc,
179- authSvc: deps.AuthSvc,
180- containerSvc: deps.ContainerSvc,
181- backupSvc: deps.BackupSvc,
182- imageSvc: deps.ImageSvc,
183- healthSvc: deps.HealthSvc,
184- secretSvc: deps.SecretSvc,
185- logSvc: deps.LogSvc,
186- volumeSvc: deps.VolumeSvc,
187- registrySvc: deps.RegistrySvc,
188- previewSvc: deps.PreviewSvc,
189- reloadTrigger: deps.ReloadTrigger,
190- publicTLSSvc: deps.PublicTLSSvc,
191- log: deps.Log,
210+ configSvc: deps.ConfigSvc,
211+ authSvc: deps.AuthSvc,
212+ containerSvc: deps.ContainerSvc,
213+ backupSvc: deps.BackupSvc,
214+ volumeBackupSvc: deps.VolumeBackupSvc,
215+ imageSvc: deps.ImageSvc,
216+ healthSvc: deps.HealthSvc,
217+ secretSvc: deps.SecretSvc,
218+ logSvc: deps.LogSvc,
219+ volumeSvc: deps.VolumeSvc,
220+ registrySvc: deps.RegistrySvc,
221+ previewSvc: deps.PreviewSvc,
222+ reloadTrigger: deps.ReloadTrigger,
223+ publicTLSSvc: deps.PublicTLSSvc,
224+ log: deps.Log,
192225 }
193226 }
194227
@@ -1313,13 +1346,17 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
13131346
13141347 // handleBackups handles /admin/backups endpoints.
13151348 func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
1349+ path = strings.TrimSuffix(path, "/")
1350+ if path == "/backups/volumes" || strings.HasPrefix(path, "/backups/volumes/") {
1351+ h.handleVolumeBackups(w, r, path)
1352+ return
1353+ }
1354+
13161355 if h.backupSvc == nil {
13171356 h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
13181357 return
13191358 }
13201359
1321- path = strings.TrimSuffix(path, "/")
1322-
13231360 if path == "/backups" || path == "/backups/status" {
13241361 h.handleBackupsStatus(w, r)
13251362 return
@@ -1346,6 +1383,87 @@ func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path str
13461383 h.sendError(w, http.StatusNotFound, "route not found")
13471384 }
13481385
1386+func (h *Handler) handleVolumeBackups(w http.ResponseWriter, r *http.Request, path string) {
1387+ if h.volumeBackupSvc == nil {
1388+ h.sendError(w, http.StatusServiceUnavailable, "volume backup service not available")
1389+ return
1390+ }
1391+ if path == "/backups/volumes/status" {
1392+ h.handleVolumeBackupsStatus(w, r)
1393+ return
1394+ }
1395+ if path == "/backups/volumes" {
1396+ h.handleVolumeBackupsDomain(w, r, "")
1397+ return
1398+ }
1399+ suffix := strings.TrimPrefix(path, "/backups/volumes/")
1400+ parts := strings.Split(suffix, "/")
1401+ if len(parts) != 1 || parts[0] == "" {
1402+ h.sendError(w, http.StatusNotFound, "route not found")
1403+ return
1404+ }
1405+ h.handleVolumeBackupsDomain(w, r, parts[0])
1406+}
1407+
1408+func (h *Handler) handleVolumeBackupsStatus(w http.ResponseWriter, r *http.Request) {
1409+ ctx := r.Context()
1410+ if r.Method != http.MethodGet {
1411+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1412+ return
1413+ }
1414+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1415+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1416+ return
1417+ }
1418+ jobs, err := h.volumeBackupSvc.VolumeBackupStatus(ctx)
1419+ if err != nil {
1420+ h.sendError(w, http.StatusInternalServerError, "failed to get volume backup status")
1421+ return
1422+ }
1423+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupsResponse{Backups: mapVolumeBackupJobsResponse(jobs)})
1424+}
1425+
1426+func (h *Handler) handleVolumeBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
1427+ ctx := r.Context()
1428+ switch r.Method {
1429+ case http.MethodGet:
1430+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1431+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1432+ return
1433+ }
1434+ jobs, err := h.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
1435+ if err != nil {
1436+ h.sendError(w, http.StatusInternalServerError, "failed to list volume backups")
1437+ return
1438+ }
1439+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupsResponse{Backups: mapVolumeBackupJobsResponse(jobs)})
1440+ case http.MethodPost:
1441+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1442+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1443+ return
1444+ }
1445+ var req dto.VolumeBackupRunRequest
1446+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1447+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
1448+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
1449+ return
1450+ }
1451+ jobs, err := h.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, req.Volume)
1452+ backups := mapVolumeBackupJobsResponse(jobs)
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+ }
1458+ h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")
1459+ return
1460+ }
1461+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: backups})
1462+ default:
1463+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1464+ }
1465+}
1466+
13491467 func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
13501468 ctx := r.Context()
13511469 if r.Method != http.MethodGet {
@@ -1456,6 +1574,14 @@ func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
14561574 return response
14571575 }
14581576
1577+func mapVolumeBackupJobsResponse(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
1578+ response := make([]dto.VolumeBackupJob, 0, len(jobs))
1579+ for _, job := range jobs {
1580+ response = append(response, toVolumeBackupJobResponse(job))
1581+ }
1582+ return response
1583+}
1584+
14591585 // handleReload handles /admin/reload endpoint.
14601586 // This reloads configuration from file into memory and triggers container sync.
14611587 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
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)
M internal/adapters/out/docker/runtime.go +219 -17
@@ -24,6 +24,7 @@ import (
2424 "github.com/docker/docker/api/types/container"
2525 "github.com/docker/docker/api/types/filters"
2626 "github.com/docker/docker/api/types/image"
27+ "github.com/docker/docker/api/types/mount"
2728 "github.com/docker/docker/api/types/network"
2829 "github.com/docker/docker/api/types/registry"
2930 "github.com/docker/docker/api/types/strslice"
@@ -44,11 +45,13 @@ type Runtime struct {
4445 }
4546
4647 var _ out.ContainerRuntime = (*Runtime)(nil)
48+var _ out.VolumeArchiveExporter = (*Runtime)(nil)
4749
4850 type pipeReadCloser struct {
4951 pr *io.PipeReader
5052 pw *io.PipeWriter
5153 original io.Closer
54+ cleanup func() error
5255 once sync.Once
5356 }
5457
@@ -75,6 +78,11 @@ func (p *pipeReadCloser) Close() error {
7578 closeErr = err
7679 }
7780 }
81+ if p.cleanup != nil {
82+ if err := p.cleanup(); err != nil && closeErr == nil {
83+ closeErr = err
84+ }
85+ }
7886 })
7987 return closeErr
8088 }
@@ -394,15 +402,27 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
394402 name = strings.TrimPrefix(c.Names[0], "/")
395403 }
396404
405+ volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts))
406+ for _, m := range c.Mounts {
407+ volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
408+ Name: m.Name,
409+ Type: string(m.Type),
410+ Source: m.Source,
411+ Destination: m.Destination,
412+ ReadOnly: !m.RW,
413+ })
414+ }
415+
397416 result = append(result, &domain.Container{
398- ID: c.ID,
399- Image: c.Image,
400- ImageID: c.ImageID,
401- Name: name,
402- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
403- Ports: ports,
404- Labels: c.Labels,
405- Created: time.Unix(c.Created, 0),
417+ ID: c.ID,
418+ Image: c.Image,
419+ ImageID: c.ImageID,
420+ Name: name,
421+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
422+ Ports: ports,
423+ Labels: c.Labels,
424+ VolumeMounts: volumeMounts,
425+ Created: time.Unix(c.Created, 0),
406426 })
407427 }
408428
@@ -442,17 +462,28 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
442462 name := strings.TrimPrefix(resp.Name, "/")
443463
444464 created, _ := time.Parse(time.RFC3339Nano, resp.Created)
465+ volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts))
466+ for _, m := range resp.Mounts {
467+ volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
468+ Name: m.Name,
469+ Type: string(m.Type),
470+ Source: m.Source,
471+ Destination: m.Destination,
472+ ReadOnly: !m.RW,
473+ })
474+ }
445475
446476 return &domain.Container{
447- ID: resp.ID,
448- Image: resp.Config.Image,
449- ImageID: resp.Image,
450- Name: name,
451- Status: resp.State.Status,
452- ExitCode: resp.State.ExitCode,
453- Ports: ports,
454- Labels: resp.Config.Labels,
455- Created: created,
477+ ID: resp.ID,
478+ Image: resp.Config.Image,
479+ ImageID: resp.Image,
480+ Name: name,
481+ Status: resp.State.Status,
482+ ExitCode: resp.State.ExitCode,
483+ Ports: ports,
484+ Labels: resp.Config.Labels,
485+ VolumeMounts: volumeMounts,
486+ Created: created,
456487 }, nil
457488 }
458489
@@ -1204,6 +1235,177 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
12041235 return result, nil
12051236 }
12061237
1238+// ExportVolumeArchive exports a named volume as a compressed tar archive stream.
1239+func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
1240+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1241+ zerowrap.FieldLayer: "adapter",
1242+ zerowrap.FieldAdapter: "docker",
1243+ zerowrap.FieldAction: "ExportVolumeArchive",
1244+ "volume": req.VolumeName,
1245+ })
1246+ log := zerowrap.FromCtx(ctx)
1247+
1248+ if err := validateVolumeArchiveRequest(req); err != nil {
1249+ return nil, err
1250+ }
1251+ if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil {
1252+ return nil, err
1253+ }
1254+ created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log)
1255+ if err != nil {
1256+ return nil, err
1257+ }
1258+ if err := r.client.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
1259+ _ = cleanup()
1260+ return nil, log.WrapErr(err, "failed to start volume archive helper container")
1261+ }
1262+
1263+ stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
1264+ if err != nil {
1265+ _ = cleanup()
1266+ return nil, err
1267+ }
1268+ return &domain.VolumeArchiveResult{Stream: stream}, nil
1269+}
1270+
1271+func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error {
1272+ if strings.TrimSpace(req.VolumeName) == "" {
1273+ return fmt.Errorf("volume name is required")
1274+ }
1275+ if strings.TrimSpace(req.HelperImage) == "" {
1276+ return fmt.Errorf("helper image is required")
1277+ }
1278+ return nil
1279+}
1280+
1281+func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error {
1282+ if _, err := r.client.ImageInspect(ctx, imageRef); err != nil {
1283+ if !cerrdefs.IsNotFound(err) {
1284+ return log.WrapErr(err, "failed to inspect volume archive helper image")
1285+ }
1286+ return r.PullImage(ctx, imageRef)
1287+ }
1288+ return nil
1289+}
1290+
1291+func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (container.CreateResponse, func() error, error) {
1292+ cmd, err := volumeArchiveCommand(req.Compression)
1293+ if err != nil {
1294+ return container.CreateResponse{}, nil, err
1295+ }
1296+ created, err := r.client.ContainerCreate(ctx, volumeArchiveContainerConfig(req.HelperImage, cmd), volumeArchiveHostConfig(req.VolumeName), nil, nil, fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()))
1297+ if err != nil {
1298+ return container.CreateResponse{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1299+ }
1300+ var cleanupOnce sync.Once
1301+ cleanup := func() error {
1302+ var cleanupErr error
1303+ cleanupOnce.Do(func() {
1304+ removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1305+ defer cancel()
1306+ cleanupErr = r.RemoveContainer(removeCtx, created.ID, true)
1307+ })
1308+ return cleanupErr
1309+ }
1310+ return created, cleanup, nil
1311+}
1312+
1313+func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config {
1314+ return &container.Config{
1315+ Image: imageRef,
1316+ Cmd: []string{"sh", "-c", cmd},
1317+ AttachStdout: true,
1318+ AttachStderr: true,
1319+ NetworkDisabled: true,
1320+ Labels: map[string]string{
1321+ domain.LabelManaged: "true",
1322+ "gordon.purpose": "volume-backup",
1323+ },
1324+ }
1325+}
1326+
1327+func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1328+ return &container.HostConfig{
1329+ AutoRemove: false,
1330+ Mounts: []mount.Mount{{
1331+ Type: mount.TypeVolume,
1332+ Source: volumeName,
1333+ Target: "/volume",
1334+ ReadOnly: true,
1335+ }},
1336+ }
1337+}
1338+
1339+func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1340+ logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1341+ if err != nil {
1342+ return nil, log.WrapErr(err, "failed to attach volume archive stream")
1343+ }
1344+ statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
1345+ pr, pw := io.Pipe()
1346+ go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1347+ return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
1348+}
1349+
1350+func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
1351+ defer logs.Close()
1352+ defer func() {
1353+ if err := cleanup(); err != nil {
1354+ log.Warn().Err(err).Msg("failed to clean up volume archive helper container")
1355+ }
1356+ }()
1357+
1358+ var stderr bytes.Buffer
1359+ _, copyErr := stdcopy.StdCopy(pw, &stderr, logs)
1360+ statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh)
1361+ closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String())
1362+}
1363+
1364+func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
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+ }
1381+ }
1382+ return 0, fmt.Errorf("volume archive helper container wait channels closed")
1383+}
1384+
1385+func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
1386+ switch {
1387+ case copyErr != nil:
1388+ _ = pw.CloseWithError(copyErr)
1389+ case waitErr != nil:
1390+ _ = pw.CloseWithError(waitErr)
1391+ case statusCode != 0:
1392+ _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr)))
1393+ default:
1394+ _ = pw.Close()
1395+ }
1396+}
1397+
1398+func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) {
1399+ switch compression {
1400+ case domain.VolumeBackupCompressionGzip:
1401+ return "cd /volume && tar -czf - .", nil
1402+ case domain.VolumeBackupCompressionZstd:
1403+ return "cd /volume && fifo=/tmp/gordon-volume-backup.tar && mkfifo $fifo && zstd -T0 -c $fifo & zstd_pid=$! && tar -cf $fifo .; tar_status=$?; wait $zstd_pid; zstd_status=$?; if [ $tar_status -ne 0 ]; then exit $tar_status; fi; exit $zstd_status", nil
1404+ default:
1405+ return "", fmt.Errorf("unsupported volume backup compression: %s", compression)
1406+ }
1407+}
1408+
12071409 // InspectImageEnv gets the environment variables declared in the image.
12081410 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
12091411 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
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+}
A internal/adapters/out/s3/volume_backup_storage.go +398 -0
@@ -0,0 +1,398 @@
1+package s3
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "encoding/hex"
7+ "fmt"
8+ "io"
9+ "net/url"
10+ "path"
11+ "sort"
12+ "strings"
13+ "time"
14+
15+ "github.com/aws/aws-sdk-go-v2/aws"
16+ "github.com/aws/aws-sdk-go-v2/config"
17+ "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
18+ tmtypes "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager/types"
19+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
20+
21+ "github.com/bnema/gordon/internal/domain"
22+)
23+
24+const volumeBackupTimestampLayout = "20060102T150405Z"
25+
26+type volumeS3Client interface {
27+ GetObject(ctx context.Context, params *awss3.GetObjectInput, optFns ...func(*awss3.Options)) (*awss3.GetObjectOutput, error)
28+ HeadObject(ctx context.Context, params *awss3.HeadObjectInput, optFns ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error)
29+ ListObjectsV2(ctx context.Context, params *awss3.ListObjectsV2Input, optFns ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error)
30+ DeleteObject(ctx context.Context, params *awss3.DeleteObjectInput, optFns ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error)
31+}
32+
33+type volumeUploader interface {
34+ UploadObject(ctx context.Context, input *transfermanager.UploadObjectInput, opts ...func(*transfermanager.Options)) (*transfermanager.UploadObjectOutput, error)
35+}
36+
37+// VolumeBackupStorage stores volume archive backups in S3.
38+type VolumeBackupStorage struct {
39+ bucket string
40+ prefix string
41+ sseAlgorithm string
42+ sseKMSKeyID string
43+ client volumeS3Client
44+ uploader volumeUploader
45+}
46+
47+// NewVolumeBackupStorage creates an S3-backed volume backup storage adapter.
48+func NewVolumeBackupStorage(ctx context.Context, cfg domain.VolumeBackupConfig) (*VolumeBackupStorage, error) {
49+ awsCfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(cfg.S3Region))
50+ if err != nil {
51+ return nil, fmt.Errorf("failed to load AWS config: %w", err)
52+ }
53+
54+ client := awss3.NewFromConfig(awsCfg, func(o *awss3.Options) {
55+ if cfg.S3Endpoint != "" {
56+ o.BaseEndpoint = aws.String(cfg.S3Endpoint)
57+ }
58+ o.UsePathStyle = cfg.S3PathStyle
59+ })
60+ uploader := transfermanager.New(client, func(o *transfermanager.Options) {
61+ o.Concurrency = 2
62+ o.PartSizeBytes = 16 * 1024 * 1024
63+ o.MultipartUploadThreshold = 16 * 1024 * 1024
64+ o.FailTimeout = time.Minute
65+ })
66+
67+ return NewVolumeBackupStorageWithClients(cfg, client, uploader), nil
68+}
69+
70+// NewVolumeBackupStorageWithClients creates storage with injected clients for tests.
71+func NewVolumeBackupStorageWithClients(cfg domain.VolumeBackupConfig, client volumeS3Client, uploader volumeUploader) *VolumeBackupStorage {
72+ return &VolumeBackupStorage{
73+ bucket: strings.TrimSpace(cfg.S3Bucket),
74+ prefix: normalizeS3Prefix(cfg.S3Prefix),
75+ sseAlgorithm: strings.TrimSpace(cfg.S3SSEAlgorithm),
76+ sseKMSKeyID: strings.TrimSpace(cfg.S3SSEKMSKeyID),
77+ client: client,
78+ uploader: uploader,
79+ }
80+}
81+
82+// StoreVolumeArchive uploads a volume archive stream to S3 and returns its artifact reference.
83+func (s *VolumeBackupStorage) StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
84+ if s.bucket == "" {
85+ return "", fmt.Errorf("s3 bucket is required")
86+ }
87+ if job.Domain == "" {
88+ return "", fmt.Errorf("backup domain is required")
89+ }
90+ if job.VolumeName == "" {
91+ return "", fmt.Errorf("backup volume name is required")
92+ }
93+ if data == nil {
94+ return "", fmt.Errorf("backup data is required")
95+ }
96+
97+ key := s.objectKey(job)
98+ compression := ""
99+ if job.Metadata != nil {
100+ compression = job.Metadata["compression"]
101+ }
102+ input := &transfermanager.UploadObjectInput{
103+ Bucket: aws.String(s.bucket),
104+ Key: aws.String(key),
105+ Body: data,
106+ ContentType: aws.String(contentTypeForCompression(compression)),
107+ Metadata: map[string]string{
108+ "gordon-domain": job.Domain,
109+ "gordon-volume": job.VolumeName,
110+ "gordon-container": job.ContainerName,
111+ "gordon-mount-path": job.MountPath,
112+ "gordon-compression": compression,
113+ },
114+ }
115+ if s.sseAlgorithm != "" {
116+ input.ServerSideEncryption = tmtypes.ServerSideEncryption(s.sseAlgorithm)
117+ }
118+ if s.sseKMSKeyID != "" {
119+ input.SSEKMSKeyID = aws.String(s.sseKMSKeyID)
120+ }
121+
122+ if _, err := s.uploader.UploadObject(ctx, input); err != nil {
123+ return "", fmt.Errorf("failed to upload volume archive: %w", err)
124+ }
125+
126+ return s.artifactRef(key), nil
127+}
128+
129+// GetVolumeArchive retrieves a volume archive by artifact reference.
130+func (s *VolumeBackupStorage) GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error) {
131+ key, err := s.keyFromArtifactRef(artifactRef)
132+ if err != nil {
133+ return nil, err
134+ }
135+ out, err := s.client.GetObject(ctx, &awss3.GetObjectInput{
136+ Bucket: aws.String(s.bucket),
137+ Key: aws.String(key),
138+ })
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to get volume archive: %w", err)
141+ }
142+ return out.Body, nil
143+}
144+
145+// ListVolumeArchives lists completed volume archive backups for a domain.
146+func (s *VolumeBackupStorage) ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) {
147+ listPrefix := s.domainPrefix(domainName)
148+ jobs := make([]domain.VolumeBackupJob, 0)
149+ var token *string
150+ for {
151+ out, err := s.client.ListObjectsV2(ctx, &awss3.ListObjectsV2Input{
152+ Bucket: aws.String(s.bucket),
153+ Prefix: aws.String(listPrefix),
154+ ContinuationToken: token,
155+ })
156+ if err != nil {
157+ return nil, fmt.Errorf("failed to list volume archives: %w", err)
158+ }
159+ for _, obj := range out.Contents {
160+ if obj.Key == nil {
161+ continue
162+ }
163+ job, ok := s.jobFromKey(domainName, *obj.Key)
164+ if !ok {
165+ continue
166+ }
167+ if obj.Size != nil {
168+ job.SizeBytes = *obj.Size
169+ }
170+ s.hydrateVolumeBackupJobMetadata(ctx, &job, *obj.Key)
171+ jobs = append(jobs, job)
172+ }
173+ if out.IsTruncated == nil || !*out.IsTruncated {
174+ break
175+ }
176+ token = out.NextContinuationToken
177+ }
178+
179+ sortVolumeBackupJobs(jobs)
180+ return jobs, nil
181+}
182+
183+// DeleteVolumeArchive deletes a volume archive by artifact reference.
184+func (s *VolumeBackupStorage) hydrateVolumeBackupJobMetadata(ctx context.Context, job *domain.VolumeBackupJob, key string) {
185+ out, err := s.client.HeadObject(ctx, &awss3.HeadObjectInput{Bucket: aws.String(s.bucket), Key: aws.String(key)})
186+ if err != nil || out == nil || out.Metadata == nil {
187+ return
188+ }
189+ job.ContainerName = out.Metadata["gordon-container"]
190+ job.MountPath = out.Metadata["gordon-mount-path"]
191+ if compression := out.Metadata["gordon-compression"]; compression != "" {
192+ if job.Metadata == nil {
193+ job.Metadata = make(map[string]string)
194+ }
195+ job.Metadata["compression"] = compression
196+ }
197+}
198+
199+func (s *VolumeBackupStorage) DeleteVolumeArchive(ctx context.Context, artifactRef string) error {
200+ key, err := s.keyFromArtifactRef(artifactRef)
201+ if err != nil {
202+ return err
203+ }
204+ _, err = s.client.DeleteObject(ctx, &awss3.DeleteObjectInput{
205+ Bucket: aws.String(s.bucket),
206+ Key: aws.String(key),
207+ })
208+ if err != nil {
209+ return fmt.Errorf("failed to delete volume archive: %w", err)
210+ }
211+ return nil
212+}
213+
214+// ApplyVolumeRetention deletes old completed volume archives according to the keep count.
215+func (s *VolumeBackupStorage) ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error) {
216+ if policy.Keep < 0 {
217+ return 0, fmt.Errorf("volume backup retention keep cannot be negative")
218+ }
219+ if policy.Keep == 0 {
220+ return 0, nil
221+ }
222+
223+ jobs, err := s.ListVolumeArchives(ctx, domainName)
224+ if err != nil {
225+ return 0, err
226+ }
227+
228+ byVolume := make(map[string][]domain.VolumeBackupJob)
229+ for _, job := range jobs {
230+ byVolume[job.VolumeName] = append(byVolume[job.VolumeName], job)
231+ }
232+
233+ deleted := 0
234+ for _, group := range byVolume {
235+ sortVolumeBackupJobs(group)
236+ for i := policy.Keep; i < len(group); i++ {
237+ if err := s.DeleteVolumeArchive(ctx, group[i].ArtifactRef); err != nil {
238+ return deleted, err
239+ }
240+ deleted++
241+ }
242+ }
243+ return deleted, nil
244+}
245+
246+func (s *VolumeBackupStorage) objectKey(job domain.VolumeBackupJob) string {
247+ started := job.StartedAt.UTC()
248+ if started.IsZero() {
249+ started = time.Now().UTC()
250+ }
251+ id := job.ID
252+ if id == "" {
253+ id = randomObjectSuffix()
254+ }
255+
256+ ext := "tar.zst"
257+ if job.Metadata != nil && job.Metadata["compression"] == string(domain.VolumeBackupCompressionGzip) {
258+ ext = "tar.gz"
259+ }
260+ fileName := fmt.Sprintf("%s-%s.%s", started.Format(volumeBackupTimestampLayout), sanitizeS3KeyComponent(id), ext)
261+ parts := []string{s.prefix, "domains", sanitizeS3KeyComponent(job.Domain), "volumes", sanitizeS3KeyComponent(job.VolumeName), fileName}
262+ return joinS3Key(parts...)
263+}
264+
265+func (s *VolumeBackupStorage) domainPrefix(domainName string) string {
266+ if strings.TrimSpace(domainName) == "" {
267+ return joinS3Key(s.prefix, "domains") + "/"
268+ }
269+ return joinS3Key(s.prefix, "domains", sanitizeS3KeyComponent(domainName), "volumes") + "/"
270+}
271+
272+func (s *VolumeBackupStorage) artifactRef(key string) string {
273+ return "s3://" + s.bucket + "/" + key
274+}
275+
276+func (s *VolumeBackupStorage) keyFromArtifactRef(artifactRef string) (string, error) {
277+ if strings.HasPrefix(artifactRef, "s3://") {
278+ u, err := url.Parse(artifactRef)
279+ if err != nil {
280+ return "", fmt.Errorf("invalid s3 artifact ref: %w", err)
281+ }
282+ if u.Host != s.bucket {
283+ return "", fmt.Errorf("artifact bucket %q does not match configured bucket", u.Host)
284+ }
285+ return strings.TrimPrefix(u.Path, "/"), nil
286+ }
287+ if strings.TrimSpace(artifactRef) == "" {
288+ return "", fmt.Errorf("artifact ref is required")
289+ }
290+ return strings.TrimPrefix(artifactRef, "/"), nil
291+}
292+
293+func (s *VolumeBackupStorage) jobFromKey(domainName, key string) (domain.VolumeBackupJob, bool) {
294+ var rel string
295+ if strings.TrimSpace(domainName) == "" {
296+ rel = strings.TrimPrefix(key, joinS3Key(s.prefix, "domains")+"/")
297+ parts := strings.Split(rel, "/")
298+ if len(parts) != 4 || parts[1] != "volumes" {
299+ return domain.VolumeBackupJob{}, false
300+ }
301+ domainName = parts[0]
302+ rel = strings.Join(parts[2:], "/")
303+ } else {
304+ rel = strings.TrimPrefix(key, joinS3Key(s.prefix, "domains", sanitizeS3KeyComponent(domainName), "volumes")+"/")
305+ }
306+ parts := strings.Split(rel, "/")
307+ if len(parts) != 2 {
308+ return domain.VolumeBackupJob{}, false
309+ }
310+ volumeName := parts[0]
311+ fileName := parts[1]
312+ if volumeName == "" || fileName == "" {
313+ return domain.VolumeBackupJob{}, false
314+ }
315+ started, id, ok := parseVolumeBackupFileName(fileName)
316+ if !ok {
317+ return domain.VolumeBackupJob{}, false
318+ }
319+ return domain.VolumeBackupJob{
320+ ID: id,
321+ Domain: domainName,
322+ VolumeName: volumeName,
323+ Type: domain.BackupTypeVolumeArchive,
324+ Status: domain.BackupStatusCompleted,
325+ StartedAt: started,
326+ CompletedAt: started,
327+ ArtifactRef: s.artifactRef(key),
328+ }, true
329+}
330+
331+func parseVolumeBackupFileName(fileName string) (time.Time, string, bool) {
332+ trimmed := strings.TrimSuffix(strings.TrimSuffix(fileName, ".tar.zst"), ".tar.gz")
333+ if trimmed == fileName {
334+ return time.Time{}, "", false
335+ }
336+ timestampPart, id, ok := strings.Cut(trimmed, "-")
337+ if !ok {
338+ return time.Time{}, "", false
339+ }
340+ started, err := time.Parse(volumeBackupTimestampLayout, timestampPart)
341+ if err != nil {
342+ return time.Time{}, "", false
343+ }
344+ return started.UTC(), id, true
345+}
346+
347+func sortVolumeBackupJobs(jobs []domain.VolumeBackupJob) {
348+ sort.Slice(jobs, func(i, j int) bool {
349+ if !jobs[i].StartedAt.Equal(jobs[j].StartedAt) {
350+ return jobs[i].StartedAt.After(jobs[j].StartedAt)
351+ }
352+ return jobs[i].ArtifactRef < jobs[j].ArtifactRef
353+ })
354+}
355+
356+func normalizeS3Prefix(prefix string) string {
357+ return strings.Trim(strings.TrimSpace(prefix), "/")
358+}
359+
360+func joinS3Key(parts ...string) string {
361+ clean := make([]string, 0, len(parts))
362+ for _, part := range parts {
363+ part = strings.Trim(part, "/")
364+ if part != "" {
365+ clean = append(clean, part)
366+ }
367+ }
368+ return path.Join(clean...)
369+}
370+
371+func sanitizeS3KeyComponent(input string) string {
372+ clean := strings.TrimSpace(input)
373+ if clean == "" || strings.Trim(clean, ".") == "" {
374+ return "unknown"
375+ }
376+ clean = strings.Trim(clean, ".")
377+ replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
378+ return replacer.Replace(clean)
379+}
380+
381+func contentTypeForCompression(compression string) string {
382+ switch compression {
383+ case string(domain.VolumeBackupCompressionGzip):
384+ return "application/gzip"
385+ case string(domain.VolumeBackupCompressionZstd):
386+ return "application/zstd"
387+ default:
388+ return "application/octet-stream"
389+ }
390+}
391+
392+func randomObjectSuffix() string {
393+ var b [8]byte
394+ if _, err := rand.Read(b[:]); err != nil {
395+ return fmt.Sprintf("%d", time.Now().UTC().UnixNano())
396+ }
397+ return hex.EncodeToString(b[:])
398+}
A internal/adapters/out/s3/volume_backup_storage_test.go +150 -0
@@ -0,0 +1,150 @@
1+package s3
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "sort"
8+ "testing"
9+ "time"
10+
11+ "github.com/aws/aws-sdk-go-v2/aws"
12+ "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
13+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
14+ awss3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+type fakeVolumeS3Client struct {
22+ objects map[string][]byte
23+ metadata map[string]map[string]string
24+ deleted []string
25+}
26+
27+func newFakeVolumeS3Client() *fakeVolumeS3Client {
28+ return &fakeVolumeS3Client{objects: make(map[string][]byte), metadata: make(map[string]map[string]string)}
29+}
30+
31+func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
32+ data := f.objects[aws.ToString(params.Key)]
33+ return &awss3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil
34+}
35+
36+func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
37+ return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
38+}
39+
40+func (f *fakeVolumeS3Client) ListObjectsV2(_ context.Context, params *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
41+ prefix := aws.ToString(params.Prefix)
42+ keys := make([]string, 0)
43+ for key := range f.objects {
44+ if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
45+ keys = append(keys, key)
46+ }
47+ }
48+ sort.Strings(keys)
49+ contents := make([]awss3types.Object, 0, len(keys))
50+ for _, key := range keys {
51+ size := int64(len(f.objects[key]))
52+ contents = append(contents, awss3types.Object{Key: aws.String(key), Size: aws.Int64(size)})
53+ }
54+ return &awss3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(false)}, nil
55+}
56+
57+func (f *fakeVolumeS3Client) DeleteObject(_ context.Context, params *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
58+ key := aws.ToString(params.Key)
59+ delete(f.objects, key)
60+ f.deleted = append(f.deleted, key)
61+ return &awss3.DeleteObjectOutput{}, nil
62+}
63+
64+type fakeVolumeUploader struct {
65+ client *fakeVolumeS3Client
66+ last *transfermanager.UploadObjectInput
67+}
68+
69+func (f *fakeVolumeUploader) UploadObject(_ context.Context, input *transfermanager.UploadObjectInput, _ ...func(*transfermanager.Options)) (*transfermanager.UploadObjectOutput, error) {
70+ data, err := io.ReadAll(input.Body)
71+ if err != nil {
72+ return nil, err
73+ }
74+ key := aws.ToString(input.Key)
75+ f.client.objects[key] = data
76+ f.client.metadata[key] = input.Metadata
77+ f.last = input
78+ return &transfermanager.UploadObjectOutput{Key: input.Key}, nil
79+}
80+
81+func TestVolumeBackupStorageStoreListGet(t *testing.T) {
82+ client := newFakeVolumeS3Client()
83+ uploader := &fakeVolumeUploader{client: client}
84+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
85+ S3Bucket: "gordon-backups",
86+ S3Prefix: "/prod/gordon/",
87+ }, client, uploader)
88+ started := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC)
89+
90+ artifact, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
91+ ID: "job/1",
92+ Domain: "app.example.com",
93+ ContainerName: "app",
94+ VolumeName: "gordon-app-data",
95+ MountPath: "/data",
96+ StartedAt: started,
97+ Metadata: map[string]string{"compression": string(domain.VolumeBackupCompressionZstd)},
98+ }, bytes.NewReader([]byte("archive")))
99+ require.NoError(t, err)
100+
101+ assert.Equal(t, "s3://gordon-backups/prod/gordon/domains/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)
102+ assert.Equal(t, "application/zstd", aws.ToString(uploader.last.ContentType))
103+
104+ jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
105+ require.NoError(t, err)
106+ require.Len(t, jobs, 1)
107+ assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
108+ assert.Equal(t, "app", jobs[0].ContainerName)
109+ assert.Equal(t, "/data", jobs[0].MountPath)
110+ assert.Equal(t, string(domain.VolumeBackupCompressionZstd), jobs[0].Metadata["compression"])
111+ assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
112+ assert.Equal(t, artifact, jobs[0].ArtifactRef)
113+
114+ rc, err := storage.GetVolumeArchive(context.Background(), artifact)
115+ require.NoError(t, err)
116+ defer rc.Close()
117+ data, err := io.ReadAll(rc)
118+ require.NoError(t, err)
119+ assert.Equal(t, []byte("archive"), data)
120+}
121+
122+func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
123+ client := newFakeVolumeS3Client()
124+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
125+ keys := []string{
126+ "domains/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst",
127+ "domains/app.example.com/volumes/gordon-data/20260619T020000Z-b.tar.zst",
128+ "domains/app.example.com/volumes/gordon-data/20260619T030000Z-c.tar.zst",
129+ "domains/app.example.com/volumes/gordon-data/not-a-backup.tar.zst",
130+ }
131+ for _, key := range keys {
132+ client.objects[key] = []byte("x")
133+ }
134+
135+ deleted, err := storage.ApplyVolumeRetention(context.Background(), "app.example.com", domain.VolumeBackupRetentionPolicy{Keep: 2})
136+ require.NoError(t, err)
137+
138+ assert.Equal(t, 1, deleted)
139+ assert.Equal(t, []string{"domains/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst"}, client.deleted)
140+ assert.Contains(t, client.objects, "domains/app.example.com/volumes/gordon-data/not-a-backup.tar.zst")
141+}
142+
143+func TestVolumeBackupStorageRejectsWrongBucketArtifact(t *testing.T) {
144+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "expected"}, newFakeVolumeS3Client(), nil)
145+
146+ _, err := storage.GetVolumeArchive(context.Background(), "s3://other/key")
147+
148+ require.Error(t, err)
149+ assert.Contains(t, err.Error(), "does not match")
150+}
M internal/app/kernel.go +26 -22
@@ -17,17 +17,18 @@ import (
1717 //
1818 // It intentionally does not start HTTP servers or register signal handlers.
1919 type Kernel struct {
20- authEnabled bool
21- configSvc in.ConfigService
22- secretSvc in.SecretService
23- containerSvc in.ContainerService
24- backupSvc in.BackupService
25- registrySvc in.RegistryService
26- healthSvc in.HealthService
27- logSvc in.LogService
28- volumeSvc in.VolumeService
29- publicTLSSvc in.PublicTLSService
30- cleanup func()
20+ authEnabled bool
21+ configSvc in.ConfigService
22+ secretSvc in.SecretService
23+ containerSvc in.ContainerService
24+ backupSvc in.BackupService
25+ volumeBackupSvc in.VolumeBackupService
26+ registrySvc in.RegistryService
27+ healthSvc in.HealthService
28+ logSvc in.LogService
29+ volumeSvc in.VolumeService
30+ publicTLSSvc in.PublicTLSService
31+ cleanup func()
3132 }
3233
3334 // NewKernel initializes local services without starting server listeners.
@@ -78,17 +79,18 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
7879 }
7980
8081 return &Kernel{
81- authEnabled: cfg.Auth.Enabled,
82- configSvc: svc.configSvc,
83- secretSvc: svc.secretSvc,
84- containerSvc: svc.containerSvc,
85- backupSvc: svc.backupSvc,
86- registrySvc: svc.registrySvc,
87- healthSvc: svc.healthSvc,
88- logSvc: svc.logSvc,
89- volumeSvc: svc.volumeSvc,
90- publicTLSSvc: svc.publicTLSSvc,
91- cleanup: wrappedCleanup,
82+ authEnabled: cfg.Auth.Enabled,
83+ configSvc: svc.configSvc,
84+ secretSvc: svc.secretSvc,
85+ containerSvc: svc.containerSvc,
86+ backupSvc: svc.backupSvc,
87+ volumeBackupSvc: svc.volumeBackupSvc,
88+ registrySvc: svc.registrySvc,
89+ healthSvc: svc.healthSvc,
90+ logSvc: svc.logSvc,
91+ volumeSvc: svc.volumeSvc,
92+ publicTLSSvc: svc.publicTLSSvc,
93+ cleanup: wrappedCleanup,
9294 }, nil
9395 } else {
9496 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
@@ -136,6 +138,8 @@ func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
136138
137139 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
138140
141+func (k *Kernel) VolumeBackup() in.VolumeBackupService { return k.volumeBackupSvc }
142+
139143 func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
140144
141145 func (k *Kernel) Health() in.HealthService { return k.healthSvc }
M internal/app/run.go +321 -40
@@ -39,6 +39,7 @@ import (
3939 "github.com/bnema/gordon/internal/adapters/out/logwriter"
4040 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
4141 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
42+ s3storage "github.com/bnema/gordon/internal/adapters/out/s3"
4243 "github.com/bnema/gordon/internal/adapters/out/secrets"
4344 "github.com/bnema/gordon/internal/adapters/out/telemetry"
4445 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
@@ -147,6 +148,12 @@ type Config struct {
147148 Dir string `mapstructure:"dir"`
148149 } `mapstructure:"env"`
149150
151+ Volumes struct {
152+ AutoCreate bool `mapstructure:"auto_create"`
153+ Prefix string `mapstructure:"prefix"`
154+ Preserve bool `mapstructure:"preserve"`
155+ } `mapstructure:"volumes"`
156+
150157 Auth struct {
151158 Enabled bool `mapstructure:"enabled"`
152159 Type string `mapstructure:"type"` // only "token" is supported
@@ -168,6 +175,7 @@ type Config struct {
168175 } `mapstructure:"api"`
169176
170177 Backups struct {
178+ // Legacy database backup keys. Prefer backups.databases.* for new configs.
171179 Enabled bool `mapstructure:"enabled"`
172180 Schedule string `mapstructure:"schedule"`
173181 StorageDir string `mapstructure:"storage_dir"`
@@ -177,6 +185,37 @@ type Config struct {
177185 Weekly int `mapstructure:"weekly"`
178186 Monthly int `mapstructure:"monthly"`
179187 } `mapstructure:"retention"`
188+ Databases struct {
189+ Enabled bool `mapstructure:"enabled"`
190+ Schedule string `mapstructure:"schedule"`
191+ StorageDir string `mapstructure:"storage_dir"`
192+ Retention struct {
193+ Hourly int `mapstructure:"hourly"`
194+ Daily int `mapstructure:"daily"`
195+ Weekly int `mapstructure:"weekly"`
196+ Monthly int `mapstructure:"monthly"`
197+ } `mapstructure:"retention"`
198+ } `mapstructure:"databases"`
199+ Volumes struct {
200+ Enabled bool `mapstructure:"enabled"`
201+ Interval string `mapstructure:"interval"`
202+ Compression string `mapstructure:"compression"`
203+ Timeout string `mapstructure:"timeout"`
204+ MaxConcurrency int `mapstructure:"max_concurrency"`
205+ HelperImage string `mapstructure:"helper_image"`
206+ S3 struct {
207+ Bucket string `mapstructure:"bucket"`
208+ Region string `mapstructure:"region"`
209+ Prefix string `mapstructure:"prefix"`
210+ Endpoint string `mapstructure:"endpoint"`
211+ PathStyle bool `mapstructure:"path_style"`
212+ SSEAlgorithm string `mapstructure:"sse_algorithm"`
213+ SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
214+ } `mapstructure:"s3"`
215+ Retention struct {
216+ Keep int `mapstructure:"keep"`
217+ } `mapstructure:"retention"`
218+ } `mapstructure:"volumes"`
180219 } `mapstructure:"backups"`
181220
182221 Images struct {
@@ -221,6 +260,8 @@ type services struct {
221260 blobStorage *filesystem.BlobStorage
222261 manifestStorage *filesystem.ManifestStorage
223262 backupStorage *filesystem.BackupStorage
263+ volumeBackupStore out.VolumeBackupStorage
264+ volumeBackupCfg domain.VolumeBackupConfig
224265 envLoader out.EnvLoader
225266 logWriter *logwriter.LogWriter
226267 tokenStore out.TokenStore
@@ -228,6 +269,7 @@ type services struct {
228269 secretSvc *secretsSvc.Service
229270 containerSvc *container.Service
230271 backupSvc *backup.Service
272+ volumeBackupSvc *backup.VolumeService
231273 registrySvc *registrySvc.Service
232274 healthSvc *health.Service
233275 logSvc *logs.Service
@@ -673,6 +715,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
673715 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
674716 return err
675717 }
718+ if si.svc.volumeBackupStore, si.svc.volumeBackupSvc, si.svc.volumeBackupCfg, err = createVolumeBackupService(si.ctx, si.cfg, si.svc, si.log); err != nil {
719+ return err
720+ }
676721
677722 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
678723 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
@@ -728,20 +773,21 @@ func (si *serviceInit) initHandlers() {
728773 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
729774
730775 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
731- ConfigSvc: si.svc.configSvc,
732- AuthSvc: si.svc.authSvc,
733- ContainerSvc: si.svc.containerSvc,
734- HealthSvc: si.svc.healthSvc,
735- SecretSvc: si.svc.secretSvc,
736- LogSvc: si.svc.logSvc,
737- RegistrySvc: si.svc.registrySvc,
738- ReloadTrigger: si.svc.reloadCoordinator,
739- Log: si.log,
740- BackupSvc: si.svc.backupSvc,
741- PreviewSvc: si.svc.previewService,
742- ImageSvc: si.svc.imageSvc,
743- VolumeSvc: si.svc.volumeSvc,
744- PublicTLSSvc: si.svc.publicTLSSvc,
776+ ConfigSvc: si.svc.configSvc,
777+ AuthSvc: si.svc.authSvc,
778+ ContainerSvc: si.svc.containerSvc,
779+ HealthSvc: si.svc.healthSvc,
780+ SecretSvc: si.svc.secretSvc,
781+ LogSvc: si.svc.logSvc,
782+ RegistrySvc: si.svc.registrySvc,
783+ ReloadTrigger: si.svc.reloadCoordinator,
784+ Log: si.log,
785+ BackupSvc: si.svc.backupSvc,
786+ VolumeBackupSvc: si.svc.volumeBackupSvc,
787+ PreviewSvc: si.svc.previewService,
788+ ImageSvc: si.svc.imageSvc,
789+ VolumeSvc: si.svc.volumeSvc,
790+ PublicTLSSvc: si.svc.publicTLSSvc,
745791 })
746792 }
747793
@@ -1940,12 +1986,69 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
19401986 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
19411987 }
19421988
1989+type databaseBackupSettingsConfig struct {
1990+ Enabled bool
1991+ Schedule string
1992+ StorageDir string
1993+ Retention struct {
1994+ Hourly int
1995+ Daily int
1996+ Weekly int
1997+ Monthly int
1998+ }
1999+}
2000+
2001+func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
2002+ out := databaseBackupSettingsConfig{
2003+ Enabled: cfg.Backups.Databases.Enabled,
2004+ Schedule: cfg.Backups.Databases.Schedule,
2005+ StorageDir: cfg.Backups.Databases.StorageDir,
2006+ }
2007+ out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
2008+ out.Retention.Daily = cfg.Backups.Databases.Retention.Daily
2009+ out.Retention.Weekly = cfg.Backups.Databases.Retention.Weekly
2010+ out.Retention.Monthly = cfg.Backups.Databases.Retention.Monthly
2011+ // Legacy [backups] keys intentionally override new database defaults when
2012+ // backups.enabled is true, preserving existing working pg_dump schedules.
2013+ // Otherwise, prefer the already-populated backups.databases.* values.
2014+ if cfg.Backups.Enabled {
2015+ out.Enabled = true
2016+ if cfg.Backups.Schedule != "" {
2017+ out.Schedule = cfg.Backups.Schedule
2018+ }
2019+ if cfg.Backups.StorageDir != "" {
2020+ out.StorageDir = cfg.Backups.StorageDir
2021+ }
2022+ } else {
2023+ if out.Schedule == "" {
2024+ out.Schedule = cfg.Backups.Schedule
2025+ }
2026+ if out.StorageDir == "" {
2027+ out.StorageDir = cfg.Backups.StorageDir
2028+ }
2029+ }
2030+ if out.Retention.Hourly == 0 {
2031+ out.Retention.Hourly = cfg.Backups.Retention.Hourly
2032+ }
2033+ if out.Retention.Daily == 0 {
2034+ out.Retention.Daily = cfg.Backups.Retention.Daily
2035+ }
2036+ if out.Retention.Weekly == 0 {
2037+ out.Retention.Weekly = cfg.Backups.Retention.Weekly
2038+ }
2039+ if out.Retention.Monthly == 0 {
2040+ out.Retention.Monthly = cfg.Backups.Retention.Monthly
2041+ }
2042+ return out
2043+}
2044+
19432045 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1944- if !cfg.Backups.Enabled {
2046+ dbCfg := databaseBackupSettings(cfg)
2047+ if !dbCfg.Enabled {
19452048 return nil, nil, nil
19462049 }
19472050
1948- storageDir := cfg.Backups.StorageDir
2051+ storageDir := dbCfg.StorageDir
19492052 if storageDir == "" {
19502053 dataDir := resolveDataDir(cfg.Server.DataDir)
19512054 storageDir = filepath.Join(dataDir, "backups")
@@ -1962,7 +2065,7 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
19622065 }
19632066
19642067 backupCfg := domain.BackupConfig{
1965- Enabled: cfg.Backups.Enabled,
2068+ Enabled: dbCfg.Enabled,
19662069 StorageDir: storageDir,
19672070 Retention: retention,
19682071 }
@@ -1976,28 +2079,146 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
19762079 return backupStorage, backupSvc, nil
19772080 }
19782081
2082+func createVolumeBackupService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (out.VolumeBackupStorage, *backup.VolumeService, domain.VolumeBackupConfig, error) {
2083+ if !cfg.Backups.Volumes.Enabled {
2084+ return nil, nil, domain.VolumeBackupConfig{}, nil
2085+ }
2086+ volumeCfg, err := validateVolumeBackupConfig(cfg)
2087+ if err != nil {
2088+ return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "invalid volume backup configuration")
2089+ }
2090+
2091+ storage, err := s3storage.NewVolumeBackupStorage(ctx, volumeCfg)
2092+ if err != nil {
2093+ return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "failed to create volume backup storage")
2094+ }
2095+
2096+ volumeSvc := backup.NewVolumeService(svc.runtime, svc.runtime, storage, volumeCfg, log)
2097+ log.Info().
2098+ Str("bucket", volumeCfg.S3Bucket).
2099+ Str("prefix", volumeCfg.S3Prefix).
2100+ Msg("volume backup service initialized")
2101+
2102+ return storage, volumeSvc, volumeCfg, nil
2103+}
2104+
19792105 func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1980- if cfg.Backups.Retention.Hourly < 0 {
1981- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
2106+ dbCfg := databaseBackupSettings(cfg)
2107+ if dbCfg.Retention.Hourly < 0 {
2108+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
19822109 }
1983- if cfg.Backups.Retention.Daily < 0 {
1984- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
2110+ if dbCfg.Retention.Daily < 0 {
2111+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
19852112 }
1986- if cfg.Backups.Retention.Weekly < 0 {
1987- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
2113+ if dbCfg.Retention.Weekly < 0 {
2114+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
19882115 }
1989- if cfg.Backups.Retention.Monthly < 0 {
1990- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
2116+ if dbCfg.Retention.Monthly < 0 {
2117+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
19912118 }
19922119
19932120 return domain.RetentionPolicy{
1994- Hourly: cfg.Backups.Retention.Hourly,
1995- Daily: cfg.Backups.Retention.Daily,
1996- Weekly: cfg.Backups.Retention.Weekly,
1997- Monthly: cfg.Backups.Retention.Monthly,
2121+ Hourly: dbCfg.Retention.Hourly,
2122+ Daily: dbCfg.Retention.Daily,
2123+ Weekly: dbCfg.Retention.Weekly,
2124+ Monthly: dbCfg.Retention.Monthly,
19982125 }, nil
19992126 }
20002127
2128+func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
2129+ volumeCfg := cfg.Backups.Volumes
2130+ interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")
2131+ if err != nil {
2132+ return domain.VolumeBackupConfig{}, err
2133+ }
2134+ timeout, err := parsePositiveDurationDefault(volumeCfg.Timeout, "2h", "backups.volumes.timeout")
2135+ if err != nil {
2136+ return domain.VolumeBackupConfig{}, err
2137+ }
2138+ compression, err := parseVolumeBackupCompression(volumeCfg.Compression)
2139+ if err != nil {
2140+ return domain.VolumeBackupConfig{}, err
2141+ }
2142+ maxConcurrency := volumeCfg.MaxConcurrency
2143+ if maxConcurrency == 0 {
2144+ maxConcurrency = 2
2145+ }
2146+ helperImage := strings.TrimSpace(volumeCfg.HelperImage)
2147+ if helperImage == "" {
2148+ helperImage = "alpine:3.20"
2149+ }
2150+ volumePrefix := strings.TrimSpace(cfg.Volumes.Prefix)
2151+ if volumePrefix == "" {
2152+ volumePrefix = "gordon"
2153+ }
2154+ if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
2155+ return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
2156+ }
2157+ if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
2158+ return domain.VolumeBackupConfig{}, err
2159+ }
2160+ return domain.VolumeBackupConfig{
2161+ Enabled: volumeCfg.Enabled,
2162+ Interval: interval,
2163+ Compression: compression,
2164+ Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
2165+ Timeout: timeout,
2166+ MaxConcurrency: maxConcurrency,
2167+ HelperImage: helperImage,
2168+ VolumePrefix: volumePrefix,
2169+ S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
2170+ S3Region: strings.TrimSpace(volumeCfg.S3.Region),
2171+ S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
2172+ S3Endpoint: strings.TrimSpace(volumeCfg.S3.Endpoint),
2173+ S3PathStyle: volumeCfg.S3.PathStyle,
2174+ S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
2175+ S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
2176+ }, nil
2177+}
2178+
2179+func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
2180+ if strings.TrimSpace(raw) == "" {
2181+ raw = defaultValue
2182+ }
2183+ d, err := time.ParseDuration(strings.TrimSpace(raw))
2184+ if err != nil || d <= 0 {
2185+ return 0, fmt.Errorf("%s must be a positive duration", field)
2186+ }
2187+ return d, nil
2188+}
2189+
2190+func parseVolumeBackupCompression(raw string) (domain.VolumeBackupCompression, error) {
2191+ if strings.TrimSpace(raw) == "" {
2192+ raw = string(domain.VolumeBackupCompressionGzip)
2193+ }
2194+ compression := domain.VolumeBackupCompression(strings.ToLower(strings.TrimSpace(raw)))
2195+ switch compression {
2196+ case domain.VolumeBackupCompressionGzip, domain.VolumeBackupCompressionZstd:
2197+ return compression, nil
2198+ default:
2199+ return "", fmt.Errorf("backups.volumes.compression must be one of: gzip, zstd")
2200+ }
2201+}
2202+
2203+func validateVolumeBackupS3Settings(enabled bool, keep, maxConcurrency int, bucket, region string) error {
2204+ if keep < 0 {
2205+ return fmt.Errorf("backups.volumes.retention.keep cannot be negative")
2206+ }
2207+ if enabled && keep == 0 {
2208+ return fmt.Errorf("backups.volumes.retention.keep must be positive when volume backups are enabled")
2209+ }
2210+ if maxConcurrency < 1 {
2211+ return fmt.Errorf("backups.volumes.max_concurrency must be at least 1")
2212+ }
2213+ if enabled && strings.TrimSpace(bucket) == "" {
2214+ return fmt.Errorf("backups.volumes.s3.bucket is required when volume backups are enabled")
2215+ }
2216+ if enabled && strings.TrimSpace(region) == "" {
2217+ return fmt.Errorf("backups.volumes.s3.region is required when volume backups are enabled")
2218+ }
2219+ return nil
2220+}
2221+
20012222 // registerEventHandlers registers all event handlers.
20022223 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {
20032224 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
@@ -2643,7 +2864,7 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
26432864 }
26442865
26452866 func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
2646- schedulers := make([]*cronSvc.Scheduler, 0, 2)
2867+ schedulers := make([]*cronSvc.Scheduler, 0, 3)
26472868
26482869 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
26492870 if err != nil {
@@ -2653,6 +2874,14 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
26532874 schedulers = append(schedulers, backupScheduler)
26542875 }
26552876
2877+ volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
2878+ if err != nil {
2879+ return nil, err
2880+ }
2881+ if volumeBackupScheduler != nil {
2882+ schedulers = append(schedulers, volumeBackupScheduler)
2883+ }
2884+
26562885 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
26572886 return v.GetInt("images.prune.keep_last")
26582887 })
@@ -2675,11 +2904,12 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
26752904 }
26762905
26772906 func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
2678- if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
2907+ dbCfg := databaseBackupSettings(cfg)
2908+ if !dbCfg.Enabled || svc == nil || svc.backupSvc == nil {
26792909 return nil, nil
26802910 }
26812911
2682- preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
2912+ preset, err := resolveBackupSchedule(dbCfg.Schedule)
26832913 if err != nil {
26842914 return nil, err
26852915 }
@@ -2712,7 +2942,44 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
27122942 }
27132943
27142944 func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
2715- return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
2945+ return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)
2946+}
2947+
2948+func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
2949+ if !cfg.Backups.Volumes.Enabled || svc == nil || svc.volumeBackupSvc == nil {
2950+ return nil, nil
2951+ }
2952+
2953+ volumeCfg, err := validateVolumeBackupConfig(cfg)
2954+ if err != nil {
2955+ return nil, err
2956+ }
2957+
2958+ scheduler := cronSvc.NewScheduler(log)
2959+ err = scheduler.Add(
2960+ "volume-backup-scheduler",
2961+ "Volume Backups",
2962+ domain.CronSchedule{Interval: volumeCfg.Interval},
2963+ func(jobCtx context.Context) error {
2964+ if _, err := svc.volumeBackupSvc.RunVolumeBackups(jobCtx, "", ""); err != nil {
2965+ return err
2966+ }
2967+ log.Info().
2968+ Dur("interval", volumeCfg.Interval).
2969+ Msg("scheduled volume backup run complete")
2970+ return nil
2971+ },
2972+ )
2973+ if err != nil {
2974+ return nil, log.WrapErr(err, "failed to register volume backup schedule")
2975+ }
2976+
2977+ scheduler.Start(ctx)
2978+ log.Info().
2979+ Dur("interval", volumeCfg.Interval).
2980+ Msg("volume backup scheduler enabled")
2981+
2982+ return scheduler, nil
27162983 }
27172984
27182985 func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
@@ -3399,13 +3666,27 @@ func loadConfig(v *viper.Viper, configPath string) error {
33993666 v.SetDefault("volumes.prefix", "gordon")
34003667 v.SetDefault("volumes.preserve", true)
34013668 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
3402- v.SetDefault("backups.enabled", false)
3403- v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
3404- v.SetDefault("backups.storage_dir", "")
3405- v.SetDefault("backups.retention.hourly", 0)
3406- v.SetDefault("backups.retention.daily", 0)
3407- v.SetDefault("backups.retention.weekly", 0)
3408- v.SetDefault("backups.retention.monthly", 0)
3669+ v.SetDefault("backups.databases.enabled", false)
3670+ v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
3671+ v.SetDefault("backups.databases.storage_dir", "")
3672+ v.SetDefault("backups.databases.retention.hourly", 0)
3673+ v.SetDefault("backups.databases.retention.daily", 0)
3674+ v.SetDefault("backups.databases.retention.weekly", 0)
3675+ v.SetDefault("backups.databases.retention.monthly", 0)
3676+ v.SetDefault("backups.volumes.enabled", false)
3677+ v.SetDefault("backups.volumes.interval", "24h")
3678+ v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
3679+ v.SetDefault("backups.volumes.timeout", "2h")
3680+ v.SetDefault("backups.volumes.max_concurrency", 2)
3681+ v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
3682+ v.SetDefault("backups.volumes.s3.bucket", "")
3683+ v.SetDefault("backups.volumes.s3.region", "")
3684+ v.SetDefault("backups.volumes.s3.prefix", "")
3685+ v.SetDefault("backups.volumes.s3.endpoint", "")
3686+ v.SetDefault("backups.volumes.s3.path_style", false)
3687+ v.SetDefault("backups.volumes.s3.sse_algorithm", "")
3688+ v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
3689+ v.SetDefault("backups.volumes.retention.keep", 14)
34093690 v.SetDefault("images.allowed_registries", []string{})
34103691 v.SetDefault("images.require_digest", false)
34113692 v.SetDefault("images.prune.enabled", false)
M internal/app/run_backup_test.go +120 -19
@@ -19,30 +19,30 @@ func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
1919 {
2020 name: "hourly",
2121 set: func(cfg *Config) {
22- cfg.Backups.Retention.Hourly = -1
22+ cfg.Backups.Databases.Retention.Hourly = -1
2323 },
24- err: "backups.retention.hourly",
24+ err: "backups.databases.retention.hourly",
2525 },
2626 {
2727 name: "daily",
2828 set: func(cfg *Config) {
29- cfg.Backups.Retention.Daily = -1
29+ cfg.Backups.Databases.Retention.Daily = -1
3030 },
31- err: "backups.retention.daily",
31+ err: "backups.databases.retention.daily",
3232 },
3333 {
3434 name: "weekly",
3535 set: func(cfg *Config) {
36- cfg.Backups.Retention.Weekly = -1
36+ cfg.Backups.Databases.Retention.Weekly = -1
3737 },
38- err: "backups.retention.weekly",
38+ err: "backups.databases.retention.weekly",
3939 },
4040 {
4141 name: "monthly",
4242 set: func(cfg *Config) {
43- cfg.Backups.Retention.Monthly = -1
43+ cfg.Backups.Databases.Retention.Monthly = -1
4444 },
45- err: "backups.retention.monthly",
45+ err: "backups.databases.retention.monthly",
4646 },
4747 }
4848
@@ -61,10 +61,10 @@ func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
6161 func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
6262 t.Run("positive values", func(t *testing.T) {
6363 var cfg Config
64- cfg.Backups.Retention.Hourly = 1
65- cfg.Backups.Retention.Daily = 7
66- cfg.Backups.Retention.Weekly = 4
67- cfg.Backups.Retention.Monthly = 12
64+ cfg.Backups.Databases.Retention.Hourly = 1
65+ cfg.Backups.Databases.Retention.Daily = 7
66+ cfg.Backups.Databases.Retention.Weekly = 4
67+ cfg.Backups.Databases.Retention.Monthly = 12
6868
6969 retention, err := validateBackupRetention(cfg)
7070 require.NoError(t, err)
@@ -76,10 +76,10 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
7676
7777 t.Run("zero values", func(t *testing.T) {
7878 var cfg Config
79- cfg.Backups.Retention.Hourly = 0
80- cfg.Backups.Retention.Daily = 0
81- cfg.Backups.Retention.Weekly = 0
82- cfg.Backups.Retention.Monthly = 0
79+ cfg.Backups.Databases.Retention.Hourly = 0
80+ cfg.Backups.Databases.Retention.Daily = 0
81+ cfg.Backups.Databases.Retention.Weekly = 0
82+ cfg.Backups.Databases.Retention.Monthly = 0
8383
8484 retention, err := validateBackupRetention(cfg)
8585 require.NoError(t, err)
@@ -90,12 +90,113 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
9090 })
9191 }
9292
93-func TestLoadConfigSetsBackupScheduleDefault(t *testing.T) {
93+func TestValidateBackupRetentionAcceptsLegacyBackupsConfig(t *testing.T) {
94+ var cfg Config
95+ cfg.Backups.Retention.Daily = 3
96+
97+ retention, err := validateBackupRetention(cfg)
98+
99+ require.NoError(t, err)
100+ assert.Equal(t, 3, retention.Daily)
101+}
102+
103+func TestDatabaseBackupSettingsPreferEnabledLegacySchedule(t *testing.T) {
104+ var cfg Config
105+ cfg.Backups.Enabled = true
106+ cfg.Backups.Schedule = string(domain.ScheduleHourly)
107+ cfg.Backups.Databases.Schedule = string(domain.ScheduleDaily)
108+
109+ settings := databaseBackupSettings(cfg)
110+
111+ assert.True(t, settings.Enabled)
112+ assert.Equal(t, string(domain.ScheduleHourly), settings.Schedule)
113+}
114+
115+func TestValidateVolumeBackupConfig(t *testing.T) {
116+ t.Run("disabled accepts defaults", func(t *testing.T) {
117+ var cfg Config
118+
119+ volumeCfg, err := validateVolumeBackupConfig(cfg)
120+ require.NoError(t, err)
121+ assert.False(t, volumeCfg.Enabled)
122+ assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
123+ assert.Equal(t, 0, volumeCfg.Retention.Keep)
124+ assert.Equal(t, 2, volumeCfg.MaxConcurrency)
125+ assert.Equal(t, "alpine:3.20", volumeCfg.HelperImage)
126+ assert.Equal(t, "gordon", volumeCfg.VolumePrefix)
127+ })
128+
129+ t.Run("enabled requires bucket and region", func(t *testing.T) {
130+ var cfg Config
131+ cfg.Backups.Volumes.Enabled = true
132+ cfg.Backups.Volumes.Retention.Keep = 14
133+
134+ _, err := validateVolumeBackupConfig(cfg)
135+ require.Error(t, err)
136+ assert.Contains(t, err.Error(), "backups.volumes.s3.bucket")
137+
138+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
139+ _, err = validateVolumeBackupConfig(cfg)
140+ require.Error(t, err)
141+ assert.Contains(t, err.Error(), "backups.volumes.s3.region")
142+ })
143+
144+ t.Run("enabled accepts s3 config", func(t *testing.T) {
145+ var cfg Config
146+ cfg.Backups.Volumes.Enabled = true
147+ cfg.Backups.Volumes.Interval = "6h"
148+ cfg.Backups.Volumes.Timeout = "30m"
149+ cfg.Backups.Volumes.Compression = "gzip"
150+ cfg.Backups.Volumes.MaxConcurrency = 3
151+ cfg.Backups.Volumes.HelperImage = "example/helper:1"
152+ cfg.Volumes.Prefix = "acme"
153+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
154+ cfg.Backups.Volumes.S3.Region = "eu-west-3"
155+ cfg.Backups.Volumes.S3.Prefix = "prod/gordon"
156+ cfg.Backups.Volumes.Retention.Keep = 7
157+
158+ volumeCfg, err := validateVolumeBackupConfig(cfg)
159+ require.NoError(t, err)
160+ assert.True(t, volumeCfg.Enabled)
161+ assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
162+ assert.Equal(t, 7, volumeCfg.Retention.Keep)
163+ assert.Equal(t, 3, volumeCfg.MaxConcurrency)
164+ assert.Equal(t, "acme", volumeCfg.VolumePrefix)
165+ assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
166+ })
167+
168+ t.Run("zstd requires helper image with zstd", func(t *testing.T) {
169+ var cfg Config
170+ cfg.Backups.Volumes.Compression = "zstd"
171+
172+ _, err := validateVolumeBackupConfig(cfg)
173+
174+ require.Error(t, err)
175+ assert.Contains(t, err.Error(), "helper_image")
176+ })
177+
178+ t.Run("enabled rejects zero retention", func(t *testing.T) {
179+ var cfg Config
180+ cfg.Backups.Volumes.Enabled = true
181+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
182+ cfg.Backups.Volumes.S3.Region = "eu-west-3"
183+ cfg.Backups.Volumes.Retention.Keep = 0
184+
185+ _, err := validateVolumeBackupConfig(cfg)
186+ require.Error(t, err)
187+ assert.Contains(t, err.Error(), "backups.volumes.retention.keep")
188+ })
189+}
190+
191+func TestLoadConfigSetsBackupDefaults(t *testing.T) {
94192 v := viper.New()
95193
96194 require.NoError(t, loadConfig(v, ""))
97195
98- assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule"))
196+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))
197+ assert.Equal(t, "24h", v.GetString("backups.volumes.interval"))
198+ assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))
199+ assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))
99200 }
100201
101202 func TestResolveBackupSchedule(t *testing.T) {
@@ -123,6 +224,6 @@ func TestResolveBackupSchedule(t *testing.T) {
123224 t.Run("invalid value", func(t *testing.T) {
124225 _, err := resolveBackupSchedule("every-minute")
125226 require.Error(t, err)
126- assert.Contains(t, err.Error(), "backups.schedule")
227+ assert.Contains(t, err.Error(), "backups.databases.schedule")
127228 })
128229 }
M internal/boundaries/in/backup.go +15 -5
@@ -7,12 +7,22 @@ import (
77 "github.com/bnema/gordon/internal/domain"
88 )
99
10-// BackupService defines backup orchestration use cases.
11-type BackupService interface {
12- ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error)
13- RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error)
10+// DatabaseBackupService defines database backup orchestration use cases.
11+type DatabaseBackupService interface {
12+ ListBackups(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error)
13+ RunBackup(ctx context.Context, domainName, dbName string) (*domain.DatabaseBackupResult, error)
1414 Restore(ctx context.Context, domainName, backupID string) error
1515 RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error
16- Status(ctx context.Context) ([]domain.BackupJob, error)
16+ Status(ctx context.Context) ([]domain.DatabaseBackupJob, error)
1717 DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error)
1818 }
19+
20+// BackupService is kept as a compatibility alias for the existing database backup feature.
21+type BackupService = DatabaseBackupService
22+
23+// VolumeBackupService defines volume archive backup orchestration use cases.
24+type VolumeBackupService interface {
25+ ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)
26+ RunVolumeBackups(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error)
27+ VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error)
28+}
M internal/boundaries/in/mocks/mock_backup_service.go +24 -24
@@ -108,23 +108,23 @@ func (_c *MockBackupService_DetectDatabases_Call) RunAndReturn(run func(ctx cont
108108 }
109109
110110 // ListBackups provides a mock function for the type MockBackupService
111-func (_mock *MockBackupService) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) {
111+func (_mock *MockBackupService) ListBackups(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error) {
112112 ret := _mock.Called(ctx, domainName)
113113
114114 if len(ret) == 0 {
115115 panic("no return value specified for ListBackups")
116116 }
117117
118- var r0 []domain.BackupJob
118+ var r0 []domain.DatabaseBackupJob
119119 var r1 error
120- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.BackupJob, error)); ok {
120+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.DatabaseBackupJob, error)); ok {
121121 return returnFunc(ctx, domainName)
122122 }
123- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.BackupJob); ok {
123+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.DatabaseBackupJob); ok {
124124 r0 = returnFunc(ctx, domainName)
125125 } else {
126126 if ret.Get(0) != nil {
127- r0 = ret.Get(0).([]domain.BackupJob)
127+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
128128 }
129129 }
130130 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
@@ -165,12 +165,12 @@ func (_c *MockBackupService_ListBackups_Call) Run(run func(ctx context.Context,
165165 return _c
166166 }
167167
168-func (_c *MockBackupService_ListBackups_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_ListBackups_Call {
169- _c.Call.Return(backupJobs, err)
168+func (_c *MockBackupService_ListBackups_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupService_ListBackups_Call {
169+ _c.Call.Return(vs, err)
170170 return _c
171171 }
172172
173-func (_c *MockBackupService_ListBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.BackupJob, error)) *MockBackupService_ListBackups_Call {
173+func (_c *MockBackupService_ListBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error)) *MockBackupService_ListBackups_Call {
174174 _c.Call.Return(run)
175175 return _c
176176 }
@@ -302,23 +302,23 @@ func (_c *MockBackupService_RestorePITR_Call) RunAndReturn(run func(ctx context.
302302 }
303303
304304 // RunBackup provides a mock function for the type MockBackupService
305-func (_mock *MockBackupService) RunBackup(ctx context.Context, domainName string, dbName string) (*domain.BackupResult, error) {
305+func (_mock *MockBackupService) RunBackup(ctx context.Context, domainName string, dbName string) (*domain.DatabaseBackupResult, error) {
306306 ret := _mock.Called(ctx, domainName, dbName)
307307
308308 if len(ret) == 0 {
309309 panic("no return value specified for RunBackup")
310310 }
311311
312- var r0 *domain.BackupResult
312+ var r0 *domain.DatabaseBackupResult
313313 var r1 error
314- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.BackupResult, error)); ok {
314+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.DatabaseBackupResult, error)); ok {
315315 return returnFunc(ctx, domainName, dbName)
316316 }
317- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.BackupResult); ok {
317+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.DatabaseBackupResult); ok {
318318 r0 = returnFunc(ctx, domainName, dbName)
319319 } else {
320320 if ret.Get(0) != nil {
321- r0 = ret.Get(0).(*domain.BackupResult)
321+ r0 = ret.Get(0).(*domain.DatabaseBackupResult)
322322 }
323323 }
324324 if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
@@ -365,34 +365,34 @@ func (_c *MockBackupService_RunBackup_Call) Run(run func(ctx context.Context, do
365365 return _c
366366 }
367367
368-func (_c *MockBackupService_RunBackup_Call) Return(backupResult *domain.BackupResult, err error) *MockBackupService_RunBackup_Call {
369- _c.Call.Return(backupResult, err)
368+func (_c *MockBackupService_RunBackup_Call) Return(v *domain.DatabaseBackupResult, err error) *MockBackupService_RunBackup_Call {
369+ _c.Call.Return(v, err)
370370 return _c
371371 }
372372
373-func (_c *MockBackupService_RunBackup_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string) (*domain.BackupResult, error)) *MockBackupService_RunBackup_Call {
373+func (_c *MockBackupService_RunBackup_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string) (*domain.DatabaseBackupResult, error)) *MockBackupService_RunBackup_Call {
374374 _c.Call.Return(run)
375375 return _c
376376 }
377377
378378 // Status provides a mock function for the type MockBackupService
379-func (_mock *MockBackupService) Status(ctx context.Context) ([]domain.BackupJob, error) {
379+func (_mock *MockBackupService) Status(ctx context.Context) ([]domain.DatabaseBackupJob, error) {
380380 ret := _mock.Called(ctx)
381381
382382 if len(ret) == 0 {
383383 panic("no return value specified for Status")
384384 }
385385
386- var r0 []domain.BackupJob
386+ var r0 []domain.DatabaseBackupJob
387387 var r1 error
388- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.BackupJob, error)); ok {
388+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.DatabaseBackupJob, error)); ok {
389389 return returnFunc(ctx)
390390 }
391- if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.BackupJob); ok {
391+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.DatabaseBackupJob); ok {
392392 r0 = returnFunc(ctx)
393393 } else {
394394 if ret.Get(0) != nil {
395- r0 = ret.Get(0).([]domain.BackupJob)
395+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
396396 }
397397 }
398398 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -427,12 +427,12 @@ func (_c *MockBackupService_Status_Call) Run(run func(ctx context.Context)) *Moc
427427 return _c
428428 }
429429
430-func (_c *MockBackupService_Status_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_Status_Call {
431- _c.Call.Return(backupJobs, err)
430+func (_c *MockBackupService_Status_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupService_Status_Call {
431+ _c.Call.Return(vs, err)
432432 return _c
433433 }
434434
435-func (_c *MockBackupService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.BackupJob, error)) *MockBackupService_Status_Call {
435+func (_c *MockBackupService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.DatabaseBackupJob, error)) *MockBackupService_Status_Call {
436436 _c.Call.Return(run)
437437 return _c
438438 }
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+}
M internal/boundaries/out/backupstore.go +21 -4
@@ -8,11 +8,28 @@ import (
88 "github.com/bnema/gordon/internal/domain"
99 )
1010
11-// BackupStorage defines persistence for backup artifacts and metadata.
12-type BackupStorage interface {
11+// DatabaseBackupStorage defines persistence for database backup artifacts and metadata.
12+type DatabaseBackupStorage interface {
1313 Store(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)
1414 Get(ctx context.Context, path string) (io.ReadCloser, error)
15- List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)
15+ List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error)
1616 Delete(ctx context.Context, path string) error
17- ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error)
17+ ApplyRetention(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error)
18+}
19+
20+// BackupStorage is kept as a compatibility alias for the existing database backup feature.
21+type BackupStorage = DatabaseBackupStorage
22+
23+// VolumeArchiveExporter exports named container volumes as archive streams.
24+type VolumeArchiveExporter interface {
25+ ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error)
26+}
27+
28+// VolumeBackupStorage defines persistence for volume archive backup artifacts.
29+type VolumeBackupStorage interface {
30+ StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error)
31+ GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error)
32+ ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)
33+ DeleteVolumeArchive(ctx context.Context, artifactRef string) error
34+ ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error)
1835 }
M internal/boundaries/out/mocks/mock_backup_storage.go +17 -17
@@ -41,7 +41,7 @@ func (_m *MockBackupStorage) EXPECT() *MockBackupStorage_Expecter {
4141 }
4242
4343 // ApplyRetention provides a mock function for the type MockBackupStorage
44-func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) {
44+func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error) {
4545 ret := _mock.Called(ctx, domainName, policy)
4646
4747 if len(ret) == 0 {
@@ -50,15 +50,15 @@ func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName s
5050
5151 var r0 int
5252 var r1 error
53- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) (int, error)); ok {
53+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.DatabaseBackupRetentionPolicy) (int, error)); ok {
5454 return returnFunc(ctx, domainName, policy)
5555 }
56- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) int); ok {
56+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.DatabaseBackupRetentionPolicy) int); ok {
5757 r0 = returnFunc(ctx, domainName, policy)
5858 } else {
5959 r0 = ret.Get(0).(int)
6060 }
61- if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.RetentionPolicy) error); ok {
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.DatabaseBackupRetentionPolicy) error); ok {
6262 r1 = returnFunc(ctx, domainName, policy)
6363 } else {
6464 r1 = ret.Error(1)
@@ -74,12 +74,12 @@ type MockBackupStorage_ApplyRetention_Call struct {
7474 // ApplyRetention is a helper method to define mock.On call
7575 // - ctx context.Context
7676 // - domainName string
77-// - policy domain.RetentionPolicy
77+// - policy domain.DatabaseBackupRetentionPolicy
7878 func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx interface{}, domainName interface{}, policy interface{}) *MockBackupStorage_ApplyRetention_Call {
7979 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}
8080 }
8181
82-func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy)) *MockBackupStorage_ApplyRetention_Call {
82+func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy)) *MockBackupStorage_ApplyRetention_Call {
8383 _c.Call.Run(func(args mock.Arguments) {
8484 var arg0 context.Context
8585 if args[0] != nil {
@@ -89,9 +89,9 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Contex
8989 if args[1] != nil {
9090 arg1 = args[1].(string)
9191 }
92- var arg2 domain.RetentionPolicy
92+ var arg2 domain.DatabaseBackupRetentionPolicy
9393 if args[2] != nil {
94- arg2 = args[2].(domain.RetentionPolicy)
94+ arg2 = args[2].(domain.DatabaseBackupRetentionPolicy)
9595 }
9696 run(
9797 arg0,
@@ -107,7 +107,7 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Return(n int, err error) *MockB
107107 return _c
108108 }
109109
110-func (_c *MockBackupStorage_ApplyRetention_Call) RunAndReturn(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error)) *MockBackupStorage_ApplyRetention_Call {
110+func (_c *MockBackupStorage_ApplyRetention_Call) RunAndReturn(run func(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error)) *MockBackupStorage_ApplyRetention_Call {
111111 _c.Call.Return(run)
112112 return _c
113113 }
@@ -238,23 +238,23 @@ func (_c *MockBackupStorage_Get_Call) RunAndReturn(run func(ctx context.Context,
238238 }
239239
240240 // List provides a mock function for the type MockBackupStorage
241-func (_mock *MockBackupStorage) List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
241+func (_mock *MockBackupStorage) List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error) {
242242 ret := _mock.Called(ctx, domainName, schedule)
243243
244244 if len(ret) == 0 {
245245 panic("no return value specified for List")
246246 }
247247
248- var r0 []domain.BackupJob
248+ var r0 []domain.DatabaseBackupJob
249249 var r1 error
250- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error)); ok {
250+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error)); ok {
251251 return returnFunc(ctx, domainName, schedule)
252252 }
253- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) []domain.BackupJob); ok {
253+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) []domain.DatabaseBackupJob); ok {
254254 r0 = returnFunc(ctx, domainName, schedule)
255255 } else {
256256 if ret.Get(0) != nil {
257- r0 = ret.Get(0).([]domain.BackupJob)
257+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
258258 }
259259 }
260260 if returnFunc, ok := ret.Get(1).(func(context.Context, string, *domain.BackupSchedule) error); ok {
@@ -301,12 +301,12 @@ func (_c *MockBackupStorage_List_Call) Run(run func(ctx context.Context, domainN
301301 return _c
302302 }
303303
304-func (_c *MockBackupStorage_List_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupStorage_List_Call {
305- _c.Call.Return(backupJobs, err)
304+func (_c *MockBackupStorage_List_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupStorage_List_Call {
305+ _c.Call.Return(vs, err)
306306 return _c
307307 }
308308
309-func (_c *MockBackupStorage_List_Call) RunAndReturn(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)) *MockBackupStorage_List_Call {
309+func (_c *MockBackupStorage_List_Call) RunAndReturn(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error)) *MockBackupStorage_List_Call {
310310 _c.Call.Return(run)
311311 return _c
312312 }
M internal/domain/backup.go +98 -3
@@ -1,6 +1,9 @@
11 package domain
22
3-import "time"
3+import (
4+ "io"
5+ "time"
6+)
47
58 // DBType identifies the database engine.
69 type DBType string
@@ -24,7 +27,16 @@ const (
2427 type BackupType string
2528
2629 const (
27- BackupTypeLogical BackupType = "logical"
30+ BackupTypeLogical BackupType = "logical"
31+ BackupTypeVolumeArchive BackupType = "volume_archive"
32+)
33+
34+// VolumeBackupCompression identifies the archive compression format for volume backups.
35+type VolumeBackupCompression string
36+
37+const (
38+ VolumeBackupCompressionGzip VolumeBackupCompression = "gzip"
39+ VolumeBackupCompressionZstd VolumeBackupCompression = "zstd"
2840 )
2941
3042 // BackupJobStatus tracks backup job lifecycle state.
@@ -86,6 +98,12 @@ type BackupResult struct {
8698 Duration time.Duration
8799 }
88100
101+// DatabaseBackupJob represents a logical database backup operation.
102+type DatabaseBackupJob = BackupJob
103+
104+// DatabaseBackupResult is returned after a database backup operation completes.
105+type DatabaseBackupResult = BackupResult
106+
89107 // RetentionPolicy defines how many backups to keep per schedule tier.
90108 type RetentionPolicy struct {
91109 Hourly int
@@ -94,13 +112,21 @@ type RetentionPolicy struct {
94112 Monthly int
95113 }
96114
115+// DatabaseBackupRetentionPolicy defines how many database backups to keep per schedule tier.
116+type DatabaseBackupRetentionPolicy = RetentionPolicy
117+
118+// VolumeBackupRetentionPolicy defines how many volume backup archives to keep.
119+type VolumeBackupRetentionPolicy struct {
120+ Keep int
121+}
122+
97123 // BackupOverride allows per-domain backup configuration.
98124 type BackupOverride struct {
99125 Schedules []BackupSchedule
100126 Retention *RetentionPolicy
101127 }
102128
103-// BackupConfig is the top-level backup configuration.
129+// BackupConfig is the database backup configuration.
104130 type BackupConfig struct {
105131 Enabled bool
106132 StorageDir string
@@ -108,6 +134,75 @@ type BackupConfig struct {
108134 Overrides map[string]BackupOverride
109135 }
110136
137+// DatabaseBackupConfig is the database backup configuration.
138+type DatabaseBackupConfig = BackupConfig
139+
140+// VolumeBackupConfig is the volume backup configuration.
141+type VolumeBackupConfig struct {
142+ Enabled bool
143+ Interval time.Duration
144+ Compression VolumeBackupCompression
145+ Retention VolumeBackupRetentionPolicy
146+ Timeout time.Duration
147+ MaxConcurrency int
148+ HelperImage string
149+ VolumePrefix string
150+ S3Bucket string
151+ S3Region string
152+ S3Prefix string
153+ S3Endpoint string
154+ S3PathStyle bool
155+ S3SSEAlgorithm string
156+ S3SSEKMSKeyID string
157+}
158+
159+// VolumeBackupJob represents a filesystem archive backup of a named volume.
160+type VolumeBackupJob struct {
161+ ID string
162+ Domain string
163+ ContainerName string
164+ ContainerID string
165+ VolumeName string
166+ MountPath string
167+ Type BackupType
168+ Status BackupJobStatus
169+ StartedAt time.Time
170+ CompletedAt time.Time
171+ SizeBytes int64
172+ ArtifactRef string
173+ Error string
174+ Metadata map[string]string
175+}
176+
177+// VolumeBackupTarget identifies one selected volume backup source.
178+type VolumeBackupTarget struct {
179+ Domain string
180+ ContainerName string
181+ ContainerID string
182+ VolumeName string
183+ MountPath string
184+}
185+
186+// VolumeArchiveRequest describes a volume archive export request.
187+type VolumeArchiveRequest struct {
188+ VolumeName string
189+ MountPath string
190+ Compression VolumeBackupCompression
191+ HelperImage string
192+}
193+
194+// VolumeArchiveMetadata contains metadata observed while exporting a volume archive.
195+type VolumeArchiveMetadata struct {
196+ SizeBytes int64
197+ Checksum string
198+}
199+
200+// VolumeArchiveResult contains a readable archive stream and export metadata.
201+type VolumeArchiveResult struct {
202+ Stream io.ReadCloser
203+ Metadata VolumeArchiveMetadata
204+}
205+
111206 // Backup labels for container metadata.
112207 const (
113208 LabelBackupEnabled = "gordon.backup"
M internal/domain/container.go +19 -9
@@ -6,15 +6,25 @@ import "time"
66
77 // Container represents a running container in the system.
88 type Container struct {
9- ID string
10- Image string
11- ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
12- Name string
13- Status string
14- ExitCode int
15- Ports []int
16- Labels map[string]string
17- Created time.Time
9+ ID string
10+ Image string
11+ ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
12+ Name string
13+ Status string
14+ ExitCode int
15+ Ports []int
16+ Labels map[string]string
17+ VolumeMounts []ContainerVolumeMount
18+ Created time.Time
19+}
20+
21+// ContainerVolumeMount describes a mounted volume-like resource on a container.
22+type ContainerVolumeMount struct {
23+ Name string
24+ Type string
25+ Source string
26+ Destination string
27+ ReadOnly bool
1828 }
1929
2030 // NetworkInfo represents network configuration and state.
M internal/domain/cron.go +2 -1
@@ -8,7 +8,8 @@ import "time"
88 // Timezone-aware scheduling is deferred until a timezone field is added.
99 // When timezone support lands, host system timezone should be the default.
1010 type CronSchedule struct {
11- Preset BackupSchedule
11+ Preset BackupSchedule
12+ Interval time.Duration
1213 }
1314
1415 // CronEntry represents a registered cron job.
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")
A internal/usecase/backup/volume_service.go +286 -0
@@ -0,0 +1,286 @@
1+package backup
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "sort"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/domain"
15+)
16+
17+// VolumeService orchestrates volume archive backups.
18+type VolumeService struct {
19+ runtime out.ContainerRuntime
20+ exporter out.VolumeArchiveExporter
21+ storage out.VolumeBackupStorage
22+ config domain.VolumeBackupConfig
23+ log zerowrap.Logger
24+
25+ mu sync.Mutex
26+ recent map[string]domain.VolumeBackupJob
27+}
28+
29+// NewVolumeService creates a volume backup service.
30+func NewVolumeService(runtime out.ContainerRuntime, exporter out.VolumeArchiveExporter, storage out.VolumeBackupStorage, config domain.VolumeBackupConfig, log zerowrap.Logger) *VolumeService {
31+ return &VolumeService{
32+ runtime: runtime,
33+ exporter: exporter,
34+ storage: storage,
35+ config: config,
36+ log: log,
37+ recent: make(map[string]domain.VolumeBackupJob),
38+ }
39+}
40+
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) {
43+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
44+ zerowrap.FieldLayer: "usecase",
45+ zerowrap.FieldUseCase: "ListVolumeBackups",
46+ "domain": domainName,
47+ })
48+ jobs, err := s.storage.ListVolumeArchives(ctx, domainName)
49+ if err != nil {
50+ return nil, fmt.Errorf("list volume archives: %w", err)
51+ }
52+ return jobs, nil
53+}
54+
55+// VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
56+func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
57+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
58+ zerowrap.FieldLayer: "usecase",
59+ zerowrap.FieldUseCase: "VolumeBackupStatus",
60+ })
61+ jobs, err := s.storage.ListVolumeArchives(ctx, "")
62+ if err != nil {
63+ return nil, fmt.Errorf("list volume backup status: %w", err)
64+ }
65+
66+ s.mu.Lock()
67+ for _, job := range s.recent {
68+ if job.Status == domain.BackupStatusRunning || job.Status == domain.BackupStatusFailed {
69+ jobs = append(jobs, job)
70+ }
71+ }
72+ s.mu.Unlock()
73+
74+ sort.Slice(jobs, func(i, j int) bool {
75+ if !jobs[i].StartedAt.Equal(jobs[j].StartedAt) {
76+ return jobs[i].StartedAt.After(jobs[j].StartedAt)
77+ }
78+ return jobs[i].VolumeName < jobs[j].VolumeName
79+ })
80+ return jobs, nil
81+}
82+
83+// RunVolumeBackups runs volume backups for all eligible targets, optionally scoped to a domain and volume.
84+func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volumeName string) ([]domain.VolumeBackupJob, error) {
85+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
86+ zerowrap.FieldLayer: "usecase",
87+ zerowrap.FieldUseCase: "RunVolumeBackups",
88+ "domain": domainName,
89+ "volume": volumeName,
90+ })
91+ log := zerowrap.FromCtx(ctx)
92+ if !s.config.Enabled {
93+ return []domain.VolumeBackupJob{}, nil
94+ }
95+
96+ containers, err := s.runtime.ListContainers(ctx, true)
97+ if err != nil {
98+ return nil, fmt.Errorf("failed to list containers for volume backups: %w", err)
99+ }
100+ targets := SelectVolumeBackupTargetsForScope(containers, s.config.VolumePrefix, domainName, volumeName)
101+ log.Info().
102+ Str("domain", domainName).
103+ Str("volume", volumeName).
104+ Int("targets", len(targets)).
105+ Msg("selected volume backup targets")
106+ if len(targets) == 0 {
107+ return []domain.VolumeBackupJob{}, nil
108+ }
109+
110+ results, firstErr := s.runVolumeBackupTargets(ctx, targets)
111+ if err := s.applyRetentionForSuccessfulVolumeBackups(ctx, results); err != nil && firstErr == nil {
112+ firstErr = err
113+ }
114+ return results, firstErr
115+}
116+
117+func (s *VolumeService) runVolumeBackupTargets(ctx context.Context, targets []domain.VolumeBackupTarget) ([]domain.VolumeBackupJob, error) {
118+ runCtx, cancel := context.WithCancel(ctx)
119+ defer cancel()
120+
121+ sem := make(chan struct{}, max(s.config.MaxConcurrency, 1))
122+ results := make([]domain.VolumeBackupJob, 0, len(targets))
123+ var wg sync.WaitGroup
124+ var mu sync.Mutex
125+ var firstErr error
126+
127+launchLoop:
128+ for _, target := range targets {
129+ select {
130+ case <-runCtx.Done():
131+ firstErr = runCtx.Err()
132+ break launchLoop
133+ case sem <- struct{}{}:
134+ }
135+ wg.Add(1)
136+ go func(target domain.VolumeBackupTarget) {
137+ defer wg.Done()
138+ defer func() { <-sem }()
139+ job := s.runVolumeBackup(runCtx, target)
140+ mu.Lock()
141+ results = append(results, job)
142+ mu.Unlock()
143+ }(target)
144+ }
145+ if firstErr != nil {
146+ cancel()
147+ }
148+ wg.Wait()
149+ return results, firstVolumeBackupError(results, firstErr)
150+}
151+
152+func (s *VolumeService) applyRetentionForSuccessfulVolumeBackups(ctx context.Context, results []domain.VolumeBackupJob) error {
153+ successDomains, failedDomains := volumeBackupResultDomains(results)
154+ for domainName := range successDomains {
155+ if _, failed := failedDomains[domainName]; failed {
156+ continue
157+ }
158+ deleted, err := s.storage.ApplyVolumeRetention(ctx, domainName, s.config.Retention)
159+ if err != nil {
160+ return fmt.Errorf("apply volume backup retention for %s: %w", domainName, err)
161+ }
162+ log := zerowrap.FromCtx(ctx)
163+ log.Info().
164+ Str("domain", domainName).
165+ Int("deleted", deleted).
166+ Msg("volume backup retention applied")
167+ }
168+ return nil
169+}
170+
171+func firstVolumeBackupError(results []domain.VolumeBackupJob, firstErr error) error {
172+ if firstErr != nil {
173+ return firstErr
174+ }
175+ for _, job := range results {
176+ if job.Status == domain.BackupStatusFailed {
177+ return fmt.Errorf("volume backup failed for %s/%s: %s", job.Domain, job.VolumeName, job.Error)
178+ }
179+ }
180+ return nil
181+}
182+
183+func volumeBackupResultDomains(results []domain.VolumeBackupJob) (map[string]struct{}, map[string]struct{}) {
184+ successDomains := make(map[string]struct{})
185+ failedDomains := make(map[string]struct{})
186+ for _, job := range results {
187+ switch job.Status {
188+ case domain.BackupStatusCompleted:
189+ successDomains[job.Domain] = struct{}{}
190+ case domain.BackupStatusFailed:
191+ failedDomains[job.Domain] = struct{}{}
192+ }
193+ }
194+ return successDomains, failedDomains
195+}
196+
197+func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
198+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
199+ "domain": target.Domain,
200+ "volume": target.VolumeName,
201+ "container_name": target.ContainerName,
202+ "mount_path": target.MountPath,
203+ })
204+ log := zerowrap.FromCtx(ctx)
205+ started := time.Now().UTC()
206+ job := domain.VolumeBackupJob{
207+ ID: newBackupJobID(started),
208+ Domain: target.Domain,
209+ ContainerName: target.ContainerName,
210+ ContainerID: target.ContainerID,
211+ VolumeName: target.VolumeName,
212+ MountPath: target.MountPath,
213+ Type: domain.BackupTypeVolumeArchive,
214+ Status: domain.BackupStatusRunning,
215+ StartedAt: started,
216+ Metadata: map[string]string{
217+ "compression": string(s.config.Compression),
218+ },
219+ }
220+ s.remember(job)
221+
222+ timeout := s.config.Timeout
223+ if timeout <= 0 {
224+ timeout = 2 * time.Hour
225+ }
226+ exportCtx, cancel := context.WithTimeout(ctx, timeout)
227+ defer cancel()
228+
229+ archive, err := s.exporter.ExportVolumeArchive(exportCtx, domain.VolumeArchiveRequest{
230+ VolumeName: target.VolumeName,
231+ MountPath: target.MountPath,
232+ Compression: s.config.Compression,
233+ HelperImage: s.config.HelperImage,
234+ })
235+ if err != nil {
236+ log.Error().Err(err).Msg("volume archive export failed")
237+ return s.failJob(ctx, job, err)
238+ }
239+ defer archive.Stream.Close()
240+
241+ counter := &byteCounter{}
242+ artifactRef, err := s.storage.StoreVolumeArchive(exportCtx, job, io.TeeReader(archive.Stream, counter))
243+ if err != nil {
244+ log.Error().Err(err).Msg("volume archive upload failed")
245+ return s.failJob(ctx, job, err)
246+ }
247+
248+ job.Status = domain.BackupStatusCompleted
249+ job.CompletedAt = time.Now().UTC()
250+ job.SizeBytes = counter.n
251+ job.ArtifactRef = artifactRef
252+ log.Info().
253+ Int64("size_bytes", job.SizeBytes).
254+ Dur("duration", job.CompletedAt.Sub(job.StartedAt)).
255+ Msg("volume backup completed")
256+ s.forget(job)
257+ return job
258+}
259+
260+func (s *VolumeService) failJob(ctx context.Context, job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {
261+ job.Status = domain.BackupStatusFailed
262+ job.CompletedAt = time.Now().UTC()
263+ job.Error = err.Error()
264+ log := zerowrap.FromCtx(ctx)
265+ log.Error().Err(err).Msg("volume backup failed")
266+ s.remember(job)
267+ return job
268+}
269+
270+func (s *VolumeService) remember(job domain.VolumeBackupJob) {
271+ s.mu.Lock()
272+ defer s.mu.Unlock()
273+ s.recent[job.Domain+"/"+job.VolumeName] = job
274+ if len(s.recent) > 100 {
275+ for k := range s.recent {
276+ delete(s.recent, k)
277+ break
278+ }
279+ }
280+}
281+
282+func (s *VolumeService) forget(job domain.VolumeBackupJob) {
283+ s.mu.Lock()
284+ defer s.mu.Unlock()
285+ delete(s.recent, job.Domain+"/"+job.VolumeName)
286+}
A internal/usecase/backup/volume_service_test.go +165 -0
@@ -0,0 +1,165 @@
1+package backup
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "io"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+func testLogger() zerowrap.Logger {
21+ return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard})
22+}
23+
24+type fakeVolumeArchiveExporter struct {
25+ err error
26+}
27+
28+func (f fakeVolumeArchiveExporter) ExportVolumeArchive(context.Context, domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
29+ if f.err != nil {
30+ return nil, f.err
31+ }
32+ return &domain.VolumeArchiveResult{Stream: io.NopCloser(bytes.NewReader([]byte("archive")))}, nil
33+}
34+
35+type fakeVolumeBackupStorage struct {
36+ stored []domain.VolumeBackupJob
37+ retention []string
38+ listErr error
39+}
40+
41+func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
42+ if _, err := io.Copy(io.Discard, data); err != nil {
43+ return "", err
44+ }
45+ f.stored = append(f.stored, job)
46+ return "s3://bucket/" + job.VolumeName, nil
47+}
48+
49+func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.ReadCloser, error) {
50+ return io.NopCloser(bytes.NewReader(nil)), nil
51+}
52+
53+func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
54+ if f.listErr != nil {
55+ return nil, f.listErr
56+ }
57+ return nil, nil
58+}
59+
60+func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
61+
62+func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, domainName string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
63+ f.retention = append(f.retention, domainName)
64+ return 0, nil
65+}
66+
67+func TestVolumeServiceRunVolumeBackups(t *testing.T) {
68+ runtime := outmocks.NewMockContainerRuntime(t)
69+ storage := &fakeVolumeBackupStorage{}
70+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, storage, domain.VolumeBackupConfig{
71+ Enabled: true,
72+ Compression: domain.VolumeBackupCompressionGzip,
73+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
74+ Timeout: time.Minute,
75+ MaxConcurrency: 1,
76+ HelperImage: "helper:latest",
77+ VolumePrefix: "gordon",
78+ }, testLogger())
79+
80+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
81+ {
82+ ID: "app",
83+ Name: "app",
84+ Labels: map[string]string{
85+ domain.LabelManaged: "true",
86+ domain.LabelDomain: "app.example.com",
87+ },
88+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
89+ },
90+ }, nil)
91+
92+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
93+ require.NoError(t, err)
94+ require.Len(t, jobs, 1)
95+ assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
96+ assert.Equal(t, "s3://bucket/gordon-app-data", jobs[0].ArtifactRef)
97+ assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
98+ assert.Len(t, storage.stored, 1)
99+ assert.Equal(t, []string{"app.example.com"}, storage.retention)
100+}
101+
102+func TestVolumeServiceRunVolumeBackupsReturnsPartialFailure(t *testing.T) {
103+ runtime := outmocks.NewMockContainerRuntime(t)
104+ storage := &fakeVolumeBackupStorage{}
105+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{err: fmt.Errorf("boom")}, storage, domain.VolumeBackupConfig{
106+ Enabled: true,
107+ Compression: domain.VolumeBackupCompressionGzip,
108+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
109+ Timeout: time.Minute,
110+ MaxConcurrency: 1,
111+ HelperImage: "helper:latest",
112+ VolumePrefix: "gordon",
113+ }, testLogger())
114+
115+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
116+ {
117+ ID: "app",
118+ Name: "app",
119+ Labels: map[string]string{
120+ domain.LabelManaged: "true",
121+ domain.LabelDomain: "app.example.com",
122+ },
123+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
124+ },
125+ }, nil)
126+
127+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
128+ require.Error(t, err)
129+ require.Len(t, jobs, 1)
130+ assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
131+ assert.Contains(t, jobs[0].Error, "boom")
132+ assert.Empty(t, storage.stored)
133+}
134+
135+func TestVolumeServiceListVolumeBackupsWrapsStorageError(t *testing.T) {
136+ storageErr := fmt.Errorf("s3 unavailable")
137+ svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
138+
139+ jobs, err := svc.ListVolumeBackups(context.Background(), "app.example.com")
140+
141+ require.ErrorIs(t, err, storageErr)
142+ assert.Contains(t, err.Error(), "list volume archives")
143+ assert.Nil(t, jobs)
144+}
145+
146+func TestVolumeServiceStatusWrapsStorageError(t *testing.T) {
147+ storageErr := fmt.Errorf("s3 unavailable")
148+ svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
149+
150+ jobs, err := svc.VolumeBackupStatus(context.Background())
151+
152+ require.ErrorIs(t, err, storageErr)
153+ assert.Contains(t, err.Error(), "list volume backup status")
154+ assert.Nil(t, jobs)
155+}
156+
157+func TestVolumeServiceDisabledDoesNothing(t *testing.T) {
158+ runtime := outmocks.NewMockContainerRuntime(t)
159+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())
160+
161+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
162+
163+ require.NoError(t, err)
164+ assert.Empty(t, jobs)
165+}
A internal/usecase/backup/volume_targets.go +115 -0
@@ -0,0 +1,115 @@
1+package backup
2+
3+import (
4+ "sort"
5+ "strings"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// SelectVolumeBackupTargets returns deterministic volume backup targets from Gordon-managed containers.
11+func SelectVolumeBackupTargets(containers []*domain.Container, volumePrefix string) []domain.VolumeBackupTarget {
12+ return SelectVolumeBackupTargetsForScope(containers, volumePrefix, "", "")
13+}
14+
15+// SelectVolumeBackupTargetsForScope returns deterministic volume backup targets after applying domain/volume filters.
16+func SelectVolumeBackupTargetsForScope(containers []*domain.Container, volumePrefix, domainFilter, volumeFilter string) []domain.VolumeBackupTarget {
17+ targets := make([]domain.VolumeBackupTarget, 0)
18+ seenVolumes := make(map[string]struct{})
19+
20+ for _, c := range sortedVolumeBackupContainers(containers) {
21+ domainName, ok := selectedVolumeBackupContainerDomain(c, domainFilter)
22+ if !ok {
23+ continue
24+ }
25+ for _, mount := range selectedVolumeBackupMounts(c.VolumeMounts, volumePrefix, volumeFilter) {
26+ if _, ok := seenVolumes[mount.Name]; ok {
27+ continue
28+ }
29+ seenVolumes[mount.Name] = struct{}{}
30+ targets = append(targets, domain.VolumeBackupTarget{
31+ Domain: domainName,
32+ ContainerName: c.Name,
33+ ContainerID: c.ID,
34+ VolumeName: mount.Name,
35+ MountPath: mount.Destination,
36+ })
37+ }
38+ }
39+
40+ return targets
41+}
42+
43+func sortedVolumeBackupContainers(containers []*domain.Container) []*domain.Container {
44+ sorted := make([]*domain.Container, 0, len(containers))
45+ for _, c := range containers {
46+ if c != nil {
47+ sorted = append(sorted, c)
48+ }
49+ }
50+ sort.Slice(sorted, func(i, j int) bool {
51+ leftDomain := volumeBackupDomain(sorted[i])
52+ rightDomain := volumeBackupDomain(sorted[j])
53+ if leftDomain != rightDomain {
54+ return leftDomain < rightDomain
55+ }
56+ return sorted[i].Name < sorted[j].Name
57+ })
58+ return sorted
59+}
60+
61+func selectedVolumeBackupContainerDomain(c *domain.Container, domainFilter string) (string, bool) {
62+ if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
63+ return "", false
64+ }
65+ domainName := volumeBackupDomain(c)
66+ if domainName == "" || (domainFilter != "" && domainName != domainFilter) {
67+ return "", false
68+ }
69+ return domainName, true
70+}
71+
72+func selectedVolumeBackupMounts(mounts []domain.ContainerVolumeMount, volumePrefix, volumeFilter string) []domain.ContainerVolumeMount {
73+ selected := append([]domain.ContainerVolumeMount(nil), mounts...)
74+ sort.Slice(selected, func(i, j int) bool {
75+ if selected[i].Name != selected[j].Name {
76+ return selected[i].Name < selected[j].Name
77+ }
78+ return selected[i].Destination < selected[j].Destination
79+ })
80+ out := selected[:0]
81+ for _, mount := range selected {
82+ if volumeFilter != "" && mount.Name != volumeFilter {
83+ continue
84+ }
85+ if !isEligibleVolumeMount(mount, volumePrefix) {
86+ continue
87+ }
88+ out = append(out, mount)
89+ }
90+ return out
91+}
92+
93+func volumeBackupDomain(c *domain.Container) string {
94+ if c == nil || c.Labels == nil {
95+ return ""
96+ }
97+ if c.Labels[domain.LabelAttachment] == "true" {
98+ return c.Labels[domain.LabelAttachedTo]
99+ }
100+ if domainName := c.Labels[domain.LabelDomain]; domainName != "" {
101+ return domainName
102+ }
103+ return c.Labels[domain.LabelRoute]
104+}
105+
106+func isEligibleVolumeMount(mount domain.ContainerVolumeMount, volumePrefix string) bool {
107+ if mount.Type != "volume" || mount.Name == "" || mount.Destination == "" {
108+ return false
109+ }
110+ prefix := strings.TrimSpace(volumePrefix)
111+ if prefix == "" {
112+ return true
113+ }
114+ return strings.HasPrefix(mount.Name, prefix+"-")
115+}
A internal/usecase/backup/volume_targets_test.go +115 -0
@@ -0,0 +1,115 @@
1+package backup
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestSelectVolumeBackupTargets(t *testing.T) {
13+ containers := []*domain.Container{
14+ {
15+ ID: "unmanaged",
16+ Name: "unmanaged",
17+ Labels: map[string]string{
18+ domain.LabelDomain: "app.example.com",
19+ },
20+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
21+ },
22+ {
23+ ID: "app",
24+ Name: "app",
25+ Labels: map[string]string{
26+ domain.LabelManaged: "true",
27+ domain.LabelDomain: "app.example.com",
28+ },
29+ VolumeMounts: []domain.ContainerVolumeMount{
30+ {Name: "gordon-app-example-com-data", Type: "volume", Destination: "/data"},
31+ {Name: "gordon-app-example-com-cache", Type: "bind", Destination: "/cache"},
32+ {Name: "random-anonymous", Type: "volume", Destination: "/anon"},
33+ {Name: "", Type: "volume", Destination: "/empty"},
34+ },
35+ },
36+ {
37+ ID: "postgres",
38+ Name: "postgres",
39+ Labels: map[string]string{
40+ domain.LabelManaged: "true",
41+ domain.LabelAttachment: "true",
42+ domain.LabelAttachedTo: "app.example.com",
43+ },
44+ VolumeMounts: []domain.ContainerVolumeMount{
45+ {Name: "gordon-postgres-data", Type: "volume", Destination: "/var/lib/postgresql/data"},
46+ {Name: "gordon-app-example-com-data", Type: "volume", Destination: "/shared"},
47+ },
48+ },
49+ }
50+
51+ targets := SelectVolumeBackupTargets(containers, "gordon")
52+
53+ assert.Equal(t, []domain.VolumeBackupTarget{
54+ {
55+ Domain: "app.example.com",
56+ ContainerName: "app",
57+ ContainerID: "app",
58+ VolumeName: "gordon-app-example-com-data",
59+ MountPath: "/data",
60+ },
61+ {
62+ Domain: "app.example.com",
63+ ContainerName: "postgres",
64+ ContainerID: "postgres",
65+ VolumeName: "gordon-postgres-data",
66+ MountPath: "/var/lib/postgresql/data",
67+ },
68+ }, targets)
69+}
70+
71+func TestSelectVolumeBackupTargetsForScopeDedupesAfterFiltering(t *testing.T) {
72+ containers := []*domain.Container{
73+ {
74+ ID: "alpha",
75+ Name: "alpha",
76+ Labels: map[string]string{
77+ domain.LabelManaged: "true",
78+ domain.LabelDomain: "alpha.example.com",
79+ },
80+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-shared", Type: "volume", Destination: "/data"}},
81+ },
82+ {
83+ ID: "beta",
84+ Name: "beta",
85+ Labels: map[string]string{
86+ domain.LabelManaged: "true",
87+ domain.LabelDomain: "beta.example.com",
88+ },
89+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-shared", Type: "volume", Destination: "/data"}},
90+ },
91+ }
92+
93+ targets := SelectVolumeBackupTargetsForScope(containers, "gordon", "beta.example.com", "")
94+
95+ require.Len(t, targets, 1)
96+ assert.Equal(t, "beta.example.com", targets[0].Domain)
97+ assert.Equal(t, "gordon-shared", targets[0].VolumeName)
98+}
99+
100+func TestSelectVolumeBackupTargetsRequiresDomain(t *testing.T) {
101+ containers := []*domain.Container{
102+ {
103+ ID: "missing-domain",
104+ Name: "missing-domain",
105+ Labels: map[string]string{
106+ domain.LabelManaged: "true",
107+ },
108+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-data", Type: "volume", Destination: "/data"}},
109+ },
110+ }
111+
112+ targets := SelectVolumeBackupTargets(containers, "gordon")
113+
114+ assert.Empty(t, targets)
115+}
M internal/usecase/cron/scheduler.go +6 -0
@@ -290,6 +290,12 @@ func (s *Scheduler) dueEntries(now time.Time) []*entry {
290290 // when no explicit timezone is configured.
291291 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
292292 now = now.UTC()
293+ if schedule.Interval > 0 && schedule.Preset != "" {
294+ return time.Time{}, fmt.Errorf("cron schedule cannot set both interval and preset")
295+ }
296+ if schedule.Interval > 0 {
297+ return now.Add(schedule.Interval), nil
298+ }
293299
294300 switch schedule.Preset {
295301 case domain.ScheduleHourly:
M internal/usecase/cron/scheduler_test.go +16 -0
@@ -51,6 +51,22 @@ func TestCalculateNextRunPresets(t *testing.T) {
5151 }
5252 }
5353
54+func TestCalculateNextRunInterval(t *testing.T) {
55+ now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
56+
57+ next, err := calculateNextRun(now, domain.CronSchedule{Interval: 6 * time.Hour})
58+
59+ require.NoError(t, err)
60+ assert.Equal(t, now.Add(6*time.Hour), next)
61+}
62+
63+func TestCalculateNextRunRejectsIntervalAndPreset(t *testing.T) {
64+ _, err := calculateNextRun(time.Now(), domain.CronSchedule{Preset: domain.ScheduleDaily, Interval: time.Hour})
65+
66+ require.Error(t, err)
67+ assert.Contains(t, err.Error(), "both interval and preset")
68+}
69+
5470 func TestSchedulerAddListAndRunNow(t *testing.T) {
5571 s := NewScheduler(zerowrap.Default())
5672 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)

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