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:
34 interfaces:34 interfaces:
35 ContainerService:35 ContainerService:
36 BackupService:36 BackupService:
37+ VolumeBackupService:
37 RegistryService:38 RegistryService:
38 ProxyService:39 ProxyService:
39 ConfigService:40 ConfigService:
M docs/cli/backup.md +34 -67
@@ -1,96 +1,63 @@
1 # Backup Command1 # Backup Command
2 2
3-Manage backups for database attachments.3+Manage database backups and volume backups.
4 4
5-## gordon backups5+## gordon backups databases
6+
7+Database backups are logical PostgreSQL backups made with `pg_dump`.
6 8
7 ```bash9 ```bash
8-gordon backups <subcommand>10+gordon backups databases <subcommand>
9 ```11 ```
10 12
11 Subcommands:13 Subcommands:
12 14
13-- `list [domain]` - List backups globally or for one domain15+- `list [domain]` - List database backups
14-- `run <domain> [--db <name>]` - Trigger an immediate backup16+- `run <domain> [--db <name>]` - Trigger an immediate database backup
15 - `detect <domain>` - Detect supported databases for a domain17 - `detect <domain>` - Detect supported databases for a domain
16-- `status` - Show backup status across domains18+- `status` - Show database backup status
17 19
18-> Backup commands work both locally and remotely.20+Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
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>`.
24 21
25-In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.22+## gordon backups volumes
26 23
27-## Examples24+Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
28 25
29 ```bash26 ```bash
30-# List all backups27+gordon backups volumes <subcommand>
31-gordon backups list28+```
32-gordon backups list --json
33 29
34-# List backups for one domain30+Subcommands:
35-gordon backups list app.example.com
36 31
37-# Trigger backup (auto-detect when exactly one DB attachment exists)32+- `list [domain]` - List completed volume backup archives
38-gordon backups run app.example.com33+- `run [domain] [--volume <name>]` - Trigger volume backups now
34+- `status` - Show completed archives plus current/recent in-memory job state
39 35
40-# Trigger backup for specific attachment name36+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.
41-gordon backups run app.example.com --db postgres
42 37
43-# Detect DB attachments38+## Examples
44-gordon backups detect app.example.com
45 39
46-# Status summary40+```bash
47-gordon backups status41+# 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
48 ```53 ```
49 54
50 ## JSON Output55 ## JSON Output
51 56
52-`gordon backups list` supports `--json` for machine-readable output.57+Database and volume list commands support `--json`.
53 58
54 ```bash59 ```bash
55-gordon backups list --json60+gordon backups volumes 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>
94 ```61 ```
95 62
96 ## Required Permissions63 ## Required Permissions
M docs/config/backups.md +57 -40
@@ -1,69 +1,86 @@
1 # Backups Configuration1 # Backups Configuration
2 2
3-Configure automatic PostgreSQL logical backups for attachment containers.3+Gordon has two backup flows:
4 4
5-## Overview5+- **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.
6 7
7-When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:8+## Database backups
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
20 9
21 ```toml10 ```toml
22-[backups]11+[backups.databases]
23 enabled = true12 enabled = true
24 schedule = "daily"13 schedule = "daily"
25 storage_dir = "~/.gordon/backups"14 storage_dir = "~/.gordon/backups"
26 15
27-[backups.retention]16+[backups.databases.retention]
28 hourly = 2417 hourly = 24
29 daily = 718 daily = 7
30 weekly = 419 weekly = 4
31 monthly = 1220 monthly = 12
32 ```21 ```
33 22
34-## Settings23+| Key | Default | Description |
35-24+|---|---:|---|
36-| Key | Type | Default | Description |25+| `backups.databases.enabled` | `false` | Enables PostgreSQL backup service wiring |
37-|-----|------|---------|-------------|26+| `backups.databases.schedule` | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
38-| `backups.enabled` | bool | `false` | Enables backup service wiring |27+| `backups.databases.storage_dir` | `""` | Root backup directory; defaults to `{server.data_dir}/backups` |
39-| `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |28+| `backups.databases.retention.*` | `0` | Number of backups to keep per DB and schedule tier |
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 |
45 29
46-## Storage Layout30+## Volume backups to S3
47 31
48-Backups are stored under:32+```toml
49-33+[backups.volumes]
50-```text34+enabled = true
51-<storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak35+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
52 ```52 ```
53 53
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:
55 70
56 ```text71 ```text
57-~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak72+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz # gzip
73+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.zst # zstd
58 ```74 ```
59 75
60 ## Notes76 ## Notes
61 77
62-- Backups require PostgreSQL attachment containers to be running.78+- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.
63-- Scheduled backups run only when `backups.enabled = true`.79+- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.
64-- The backup command executes inside the database container using Gordon runtime APIs.80+- Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
65-- Retention applies per database and per schedule.81+- Automated volume restore is not part of the MVP.
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`.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.
67 84
68 ## Related85 ## Related
69 86
M go.mod +19 -0
@@ -3,6 +3,10 @@ module github.com/bnema/gordon
3 go 1.26.13 go 1.26.1
4 4
5 require (5 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
6 github.com/bnema/zerowrap v1.4.010 github.com/bnema/zerowrap v1.4.0
7 github.com/charmbracelet/bubbles v1.0.011 github.com/charmbracelet/bubbles v1.0.0
8 github.com/charmbracelet/bubbletea v1.3.1012 github.com/charmbracelet/bubbletea v1.3.10
@@ -33,6 +37,21 @@ require (
33 37
34 require (38 require (
35 github.com/atotto/clipboard v0.1.4 // indirect39 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
36 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect55 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
37 github.com/cenkalti/backoff/v5 v5.0.3 // indirect56 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
38 github.com/cespare/xxhash/v2 v2.3.0 // indirect57 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
128 [env]128 [env]
129 # dir = "~/.gordon/env"129 # dir = "~/.gordon/env"
130 130
131-[backups]131+[backups.databases]
132 enabled = false132 enabled = false
133 schedule = "daily"133 schedule = "daily"
134 storage_dir = ""134 storage_dir = ""
135 135
136-[backups.retention]136+[backups.databases.retention]
137 hourly = 0137 hourly = 0
138 daily = 0138 daily = 0
139 weekly = 0139 weekly = 0
140 monthly = 0140 monthly = 0
141 141
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+
142 [routes]163 [routes]
143 # "app.example.com" = { image = "myapp:latest" }164 # "app.example.com" = { image = "myapp:latest" }
144 165
M internal/adapters/dto/admin_backups.go +35 -0
@@ -47,3 +47,38 @@ type DatabaseInfo struct {
47 type BackupDetectResponse struct {47 type BackupDetectResponse struct {
48 Databases []DatabaseInfo `json:"databases"`48 Databases []DatabaseInfo `json:"databases"`
49 }49 }
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 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
4 "fmt"5 "fmt"
5 "io"6 "io"
6 "os"7 "os"
@@ -13,23 +14,58 @@ import (
13 "github.com/bnema/gordon/pkg/bytesize"14 "github.com/bnema/gordon/pkg/bytesize"
14 )15 )
15 16
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+
16 // newBackupCmd creates the backup command group.24 // newBackupCmd creates the backup command group.
17 func newBackupCmd() *cobra.Command {25 func newBackupCmd() *cobra.Command {
18 cmd := &cobra.Command{26 cmd := &cobra.Command{
19 Use: "backups",27 Use: "backups",
20 Aliases: []string{"backup"},28 Aliases: []string{"backup"},
21- Short: "Manage database backups",29+ Short: "Manage backups",
22- Long: `Manage database backups.30+ Long: `Manage backups.
23 31
24 Runs locally via in-process services by default, or against a remote Gordon32 Runs locally via in-process services by default, or against a remote Gordon
25 instance when --remote targeting is configured.`,33 instance when --remote targeting is configured.`,
26 }34 }
27 35
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+ }
28 cmd.AddCommand(newBackupListCmd())54 cmd.AddCommand(newBackupListCmd())
29 cmd.AddCommand(newBackupRunCmd())55 cmd.AddCommand(newBackupRunCmd())
30 cmd.AddCommand(newBackupDetectCmd())56 cmd.AddCommand(newBackupDetectCmd())
31 cmd.AddCommand(newBackupStatusCmd())57 cmd.AddCommand(newBackupStatusCmd())
58+ return cmd
59+}
32 60
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())
33 return cmd69 return cmd
34 }70 }
35 71
@@ -103,6 +139,119 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
103 return w.Flush()139 return w.Flush()
104 }140 }
105 141
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+
106 func newBackupRunCmd() *cobra.Command {255 func newBackupRunCmd() *cobra.Command {
107 var dbName string256 var dbName string
108 257
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 {
53 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)53 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
54 RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)54 RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
55 DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)55 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)
56 59
57 GetProcessLogs(ctx context.Context, lines int) ([]string, error)60 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
58 GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)61 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 (
16 )16 )
17 17
18 type localControlPlane struct {18 type localControlPlane struct {
19- configSvc in.ConfigService19+ configSvc in.ConfigService
20- secretSvc in.SecretService20+ secretSvc in.SecretService
21- containerSvc in.ContainerService21+ containerSvc in.ContainerService
22- backupSvc in.BackupService22+ backupSvc in.BackupService
23- registrySvc in.RegistryService23+ volumeBackupSvc in.VolumeBackupService
24- deployCoord in.DeployCoordinator24+ registrySvc in.RegistryService
25- healthSvc in.HealthService25+ deployCoord in.DeployCoordinator
26- logSvc in.LogService26+ healthSvc in.HealthService
27- volumeSvc in.VolumeService27+ logSvc in.LogService
28- publicTLSSvc in.PublicTLSService28+ volumeSvc in.VolumeService
29+ publicTLSSvc in.PublicTLSService
29 }30 }
30 31
31 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {32 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
@@ -42,16 +43,17 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
42 }43 }
43 44
44 return &localControlPlane{45 return &localControlPlane{
45- configSvc: kernel.Config(),46+ configSvc: kernel.Config(),
46- secretSvc: kernel.Secrets(),47+ secretSvc: kernel.Secrets(),
47- containerSvc: kernel.Container(),48+ containerSvc: kernel.Container(),
48- backupSvc: kernel.Backup(),49+ backupSvc: kernel.Backup(),
49- registrySvc: registrySvc,50+ volumeBackupSvc: kernel.VolumeBackup(),
50- deployCoord: deployCoord,51+ registrySvc: registrySvc,
51- healthSvc: kernel.Health(),52+ deployCoord: deployCoord,
52- logSvc: kernel.Logs(),53+ healthSvc: kernel.Health(),
53- volumeSvc: kernel.Volumes(),54+ logSvc: kernel.Logs(),
54- publicTLSSvc: kernel.PublicTLS(),55+ volumeSvc: kernel.Volumes(),
56+ publicTLSSvc: kernel.PublicTLS(),
55 }57 }
56 }58 }
57 59
@@ -629,6 +631,47 @@ func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain st
629 return out, nil631 return out, nil
630 }632 }
631 633
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+
632 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {675 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
633 if l.logSvc == nil {676 if l.logSvc == nil {
634 return nil, fmt.Errorf("local log service unavailable")677 return nil, fmt.Errorf("local log service unavailable")
@@ -785,3 +828,41 @@ func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
785 Error: job.Error,828 Error: job.Error,
786 }829 }
787 }830 }
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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
@@ -111,6 +112,52 @@ func TestLocalControlPlane_Backups(t *testing.T) {
111 require.Equal(t, "postgres", dbs[0].Name)112 require.Equal(t, "postgres", dbs[0].Name)
112 }113 }
113 114
115+func TestLocalControlPlane_RunVolumeBackupsPreservesPartialJobs(t *testing.T) {
116+ t.Parallel()
117+
118+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
119+ ctx := context.Background()
120+ runErr := errors.New("one volume failed")
121+ jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.local", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
122+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.local", "").Return(jobs, runErr)
123+
124+ cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
125+ result, err := cp.RunVolumeBackups(ctx, "app.local", "")
126+
127+ require.ErrorIs(t, err, runErr)
128+ require.NotNil(t, result)
129+ assert.Equal(t, "partial", result.Status)
130+ assert.Equal(t, runErr.Error(), result.Error)
131+ require.Len(t, result.Backups, 1)
132+ assert.Equal(t, "v1", result.Backups[0].ID)
133+}
134+
135+func TestLocalControlPlane_RunVolumeBackupsMissingServiceUsesSentinel(t *testing.T) {
136+ t.Parallel()
137+
138+ cp := &localControlPlane{}
139+ result, err := cp.RunVolumeBackups(context.Background(), "app.local", "")
140+
141+ require.Error(t, err)
142+ assert.Nil(t, result)
143+ assert.ErrorIs(t, err, domain.ErrVolumeBackupUnavailable)
144+}
145+
146+func TestLocalControlPlane_ListVolumeBackupsWrapsServiceError(t *testing.T) {
147+ t.Parallel()
148+
149+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
150+ wantErr := errors.New("store unavailable")
151+ volumeBackupSvc.EXPECT().ListVolumeBackups(mock.Anything, "app.local").Return(nil, wantErr)
152+
153+ cp := &localControlPlane{volumeBackupSvc: volumeBackupSvc}
154+ jobs, err := cp.ListVolumeBackups(context.Background(), "app.local")
155+
156+ require.ErrorIs(t, err, wantErr)
157+ assert.Contains(t, err.Error(), "list volume backups")
158+ assert.Nil(t, jobs)
159+}
160+
114 func TestLocalControlPlane_ListTags(t *testing.T) {161 func TestLocalControlPlane_ListTags(t *testing.T) {
115 t.Parallel()162 t.Parallel()
116 163
M internal/adapters/in/cli/controlplane_remote.go +25 -0
@@ -2,6 +2,7 @@ package cli
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 6
6 "github.com/bnema/gordon/internal/adapters/dto"7 "github.com/bnema/gordon/internal/adapters/dto"
7 "github.com/bnema/gordon/internal/adapters/in/cli/remote"8 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
@@ -169,6 +170,30 @@ func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain s
169 return r.client.DetectDatabases(ctx, backupDomain)170 return r.client.DetectDatabases(ctx, backupDomain)
170 }171 }
171 172
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+
172 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {197 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
173 return r.client.GetProcessLogs(ctx, lines)198 return r.client.GetProcessLogs(ctx, lines)
174 }199 }
M internal/adapters/in/cli/controlplane_remote_test.go +48 -0
@@ -1,8 +1,17 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "net/http"
8+ "net/http/httptest"
4 "testing"9 "testing"
5 10
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/adapters/dto"
6 "github.com/bnema/gordon/internal/adapters/in/cli/remote"15 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7 )16 )
8 17
@@ -14,3 +23,42 @@ func TestRemoteControlPlane_ImplementsInterface(t *testing.T) {
14 t.Fatal("expected non-nil remote control-plane")23 t.Fatal("expected non-nil remote control-plane")
15 }24 }
16 }25 }
26+
27+func TestRemoteControlPlane_RunVolumeBackupsPreservesPartialResult(t *testing.T) {
28+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
29+ w.Header().Set("Content-Type", "application/json")
30+ w.WriteHeader(http.StatusPartialContent)
31+ require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
32+ Status: "partial",
33+ Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data"}},
34+ Error: "one volume failed",
35+ }))
36+ }))
37+ t.Cleanup(server.Close)
38+
39+ cp := NewRemoteControlPlane(remote.NewClient(server.URL))
40+ result, err := cp.RunVolumeBackups(context.Background(), "app.example.com", "")
41+
42+ require.Error(t, err)
43+ assert.Contains(t, err.Error(), "run volume backups")
44+ require.NotNil(t, result)
45+ assert.Equal(t, "partial", result.Status)
46+ require.Len(t, result.Backups, 1)
47+ assert.Equal(t, "v1", result.Backups[0].ID)
48+}
49+
50+func TestRemoteControlPlane_VolumeBackupErrorsAreWrapped(t *testing.T) {
51+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+ http.Error(w, "boom", http.StatusBadGateway)
53+ }))
54+ t.Cleanup(server.Close)
55+
56+ cp := NewRemoteControlPlane(remote.NewClient(server.URL))
57+ _, err := cp.VolumeBackupStatus(context.Background())
58+
59+ require.Error(t, err)
60+ assert.Contains(t, err.Error(), "get volume backup status")
61+ var httpErr *remote.HTTPError
62+ require.True(t, errors.As(err, &httpErr))
63+ assert.Equal(t, http.StatusBadGateway, httpErr.StatusCode)
64+}
M internal/adapters/in/cli/json_parity_test.go +3 -0
@@ -18,6 +18,9 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
18 {"secrets list", newSecretsListCmd},18 {"secrets list", newSecretsListCmd},
19 {"images list", newImagesListCmd},19 {"images list", newImagesListCmd},
20 {"backup list", newBackupListCmd},20 {"backup list", newBackupListCmd},
21+ {"backup volumes list", newVolumeBackupListCmd},
22+ {"backup volumes run", newVolumeBackupRunCmd},
23+ {"backup volumes status", newVolumeBackupStatusCmd},
21 {"auth token list", newTokenListCmd},24 {"auth token list", newTokenListCmd},
22 {"remotes list", newRemotesListCmd},25 {"remotes list", newRemotesListCmd},
23 {"pin list", newPinListCmd},26 {"pin list", newPinListCmd},
M internal/adapters/in/cli/mocks/mock_control_plane.go +204 -0
@@ -1922,6 +1922,74 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont
1922 return _c1922 return _c
1923 }1923 }
1924 1924
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+
1925 // ListVolumes provides a mock function for the type MockControlPlane1993 // ListVolumes provides a mock function for the type MockControlPlane
1926 func (_mock *MockControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {1994 func (_mock *MockControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1927 ret := _mock.Called(ctx)1995 ret := _mock.Called(ctx)
@@ -2428,6 +2496,80 @@ func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Con
2428 return _c2496 return _c
2429 }2497 }
2430 2498
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+
2431 // SetAttachmentSecrets provides a mock function for the type MockControlPlane2573 // SetAttachmentSecrets provides a mock function for the type MockControlPlane
2432 func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error {2574 func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error {
2433 ret := _mock.Called(ctx, domain1, service, secrets)2575 ret := _mock.Called(ctx, domain1, service, secrets)
@@ -2758,3 +2900,65 @@ func (_c *MockControlPlane_UpdateRoute_Call) RunAndReturn(run func(ctx context.C
2758 _c.Call.Return(run)2900 _c.Call.Return(run)
2759 return _c2901 return _c
2760 }2902 }
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
836 return result.Databases, nil836 return result.Databases, nil
837 }837 }
838 838
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+
839 // GetHealth returns health status for all routes with HTTP probing.897 // GetHealth returns health status for all routes with HTTP probing.
840 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {898 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
841 resp, err := c.request(ctx, http.MethodGet, "/health", nil)899 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) {
417 assert.Equal(t, "plain text error", httpErr.Body)417 assert.Equal(t, "plain text error", httpErr.Body)
418 }418 }
419 419
420+func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
421+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422+ require.Equal(t, "/admin/backups/volumes/app.example.com", r.URL.Path)
423+ require.Equal(t, http.MethodPost, r.Method)
424+ w.Header().Set("Content-Type", "application/json")
425+ w.WriteHeader(http.StatusPartialContent)
426+ require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
427+ Status: "partial",
428+ Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
429+ Error: "one volume failed",
430+ }))
431+ }))
432+ defer srv.Close()
433+
434+ client := NewClient(srv.URL)
435+ result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "")
436+
437+ require.Error(t, err)
438+ assert.Contains(t, err.Error(), "one volume failed")
439+ require.NotNil(t, result)
440+ assert.Equal(t, "partial", result.Status)
441+ require.Len(t, result.Backups, 1)
442+ assert.Equal(t, "v1", result.Backups[0].ID)
443+}
444+
420 func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {445 func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
421 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {446 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422 w.Header().Set("Content-Type", "application/json")447 w.Header().Set("Content-Type", "application/json")
M internal/adapters/in/http/admin/handler.go +170 -44
@@ -40,20 +40,21 @@ type reloadTrigger interface {
40 40
41 // Handler implements the HTTP handler for the admin API.41 // Handler implements the HTTP handler for the admin API.
42 type Handler struct {42 type Handler struct {
43- configSvc in.ConfigService43+ configSvc in.ConfigService
44- authSvc in.AuthService44+ authSvc in.AuthService
45- containerSvc in.ContainerService45+ containerSvc in.ContainerService
46- backupSvc in.BackupService46+ backupSvc in.BackupService
47- imageSvc in.ImageService47+ volumeBackupSvc in.VolumeBackupService
48- healthSvc in.HealthService48+ imageSvc in.ImageService
49- secretSvc in.SecretService49+ healthSvc in.HealthService
50- logSvc in.LogService50+ secretSvc in.SecretService
51- volumeSvc in.VolumeService51+ logSvc in.LogService
52- registrySvc registryDeployService52+ volumeSvc in.VolumeService
53- previewSvc previewService53+ registrySvc registryDeployService
54- reloadTrigger reloadTrigger54+ previewSvc previewService
55- publicTLSSvc in.PublicTLSService55+ reloadTrigger reloadTrigger
56- log zerowrap.Logger56+ publicTLSSvc in.PublicTLSService
57+ log zerowrap.Logger
57 }58 }
58 59
59 // Type aliases for API responses using shared DTO types.60 // Type aliases for API responses using shared DTO types.
@@ -142,6 +143,36 @@ func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
142 }143 }
143 }144 }
144 145
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+
145 func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {176 func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
146 return dto.DatabaseInfo{177 return dto.DatabaseInfo{
147 Type: string(db.Type),178 Type: string(db.Type),
@@ -156,39 +187,41 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
156 187
157 // HandlerDeps contains all dependencies for the admin HTTP handler.188 // HandlerDeps contains all dependencies for the admin HTTP handler.
158 type HandlerDeps struct {189 type HandlerDeps struct {
159- ConfigSvc in.ConfigService190+ ConfigSvc in.ConfigService
160- AuthSvc in.AuthService191+ AuthSvc in.AuthService
161- ContainerSvc in.ContainerService192+ ContainerSvc in.ContainerService
162- HealthSvc in.HealthService193+ HealthSvc in.HealthService
163- SecretSvc in.SecretService194+ SecretSvc in.SecretService
164- LogSvc in.LogService195+ LogSvc in.LogService
165- RegistrySvc registryDeployService196+ RegistrySvc registryDeployService
166- Log zerowrap.Logger197+ Log zerowrap.Logger
167- BackupSvc in.BackupService198+ BackupSvc in.BackupService
168- PreviewSvc previewService199+ VolumeBackupSvc in.VolumeBackupService
169- ImageSvc in.ImageService200+ PreviewSvc previewService
170- VolumeSvc in.VolumeService201+ ImageSvc in.ImageService
171- ReloadTrigger reloadTrigger202+ VolumeSvc in.VolumeService
172- PublicTLSSvc in.PublicTLSService203+ ReloadTrigger reloadTrigger
204+ PublicTLSSvc in.PublicTLSService
173 }205 }
174 206
175 // NewHandler creates a new admin HTTP handler.207 // NewHandler creates a new admin HTTP handler.
176 func NewHandler(deps HandlerDeps) *Handler {208 func NewHandler(deps HandlerDeps) *Handler {
177 return &Handler{209 return &Handler{
178- configSvc: deps.ConfigSvc,210+ configSvc: deps.ConfigSvc,
179- authSvc: deps.AuthSvc,211+ authSvc: deps.AuthSvc,
180- containerSvc: deps.ContainerSvc,212+ containerSvc: deps.ContainerSvc,
181- backupSvc: deps.BackupSvc,213+ backupSvc: deps.BackupSvc,
182- imageSvc: deps.ImageSvc,214+ volumeBackupSvc: deps.VolumeBackupSvc,
183- healthSvc: deps.HealthSvc,215+ imageSvc: deps.ImageSvc,
184- secretSvc: deps.SecretSvc,216+ healthSvc: deps.HealthSvc,
185- logSvc: deps.LogSvc,217+ secretSvc: deps.SecretSvc,
186- volumeSvc: deps.VolumeSvc,218+ logSvc: deps.LogSvc,
187- registrySvc: deps.RegistrySvc,219+ volumeSvc: deps.VolumeSvc,
188- previewSvc: deps.PreviewSvc,220+ registrySvc: deps.RegistrySvc,
189- reloadTrigger: deps.ReloadTrigger,221+ previewSvc: deps.PreviewSvc,
190- publicTLSSvc: deps.PublicTLSSvc,222+ reloadTrigger: deps.ReloadTrigger,
191- log: deps.Log,223+ publicTLSSvc: deps.PublicTLSSvc,
224+ log: deps.Log,
192 }225 }
193 }226 }
194 227
@@ -1313,13 +1346,17 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
1313 1346
1314 // handleBackups handles /admin/backups endpoints.1347 // handleBackups handles /admin/backups endpoints.
1315 func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {1348 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+
1316 if h.backupSvc == nil {1355 if h.backupSvc == nil {
1317 h.sendError(w, http.StatusServiceUnavailable, "backup service not available")1356 h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
1318 return1357 return
1319 }1358 }
1320 1359
1321- path = strings.TrimSuffix(path, "/")
1322-
1323 if path == "/backups" || path == "/backups/status" {1360 if path == "/backups" || path == "/backups/status" {
1324 h.handleBackupsStatus(w, r)1361 h.handleBackupsStatus(w, r)
1325 return1362 return
@@ -1346,6 +1383,87 @@ func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path str
1346 h.sendError(w, http.StatusNotFound, "route not found")1383 h.sendError(w, http.StatusNotFound, "route not found")
1347 }1384 }
1348 1385
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+
1349 func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {1467 func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
1350 ctx := r.Context()1468 ctx := r.Context()
1351 if r.Method != http.MethodGet {1469 if r.Method != http.MethodGet {
@@ -1456,6 +1574,14 @@ func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
1456 return response1574 return response
1457 }1575 }
1458 1576
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+
1459 // handleReload handles /admin/reload endpoint.1585 // handleReload handles /admin/reload endpoint.
1460 // This reloads configuration from file into memory and triggers container sync.1586 // This reloads configuration from file into memory and triggers container sync.
1461 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {1587 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) {
2409 assert.Contains(t, rec.Body.String(), "completed")2409 assert.Contains(t, rec.Body.String(), "completed")
2410 }2410 }
2411 2411
2412+func TestHandler_VolumeBackupsRunDomain_ReturnsPartialResults(t *testing.T) {
2413+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2414+ runErr := errors.New("one volume failed")
2415+ jobs := []domain.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: domain.BackupStatusCompleted}}
2416+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "gordon-app-data").Return(jobs, runErr)
2417+
2418+ handler := newTestHandler(t, func(d *HandlerDeps) {
2419+ d.VolumeBackupSvc = volumeBackupSvc
2420+ })
2421+
2422+ body := bytes.NewBufferString(`{"volume":"gordon-app-data"}`)
2423+ req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", body)
2424+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2425+ rec := httptest.NewRecorder()
2426+
2427+ handler.ServeHTTP(rec, req)
2428+
2429+ require.Equal(t, http.StatusPartialContent, rec.Code)
2430+ var result dto.VolumeBackupRunResponse
2431+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result))
2432+ assert.Equal(t, "partial", result.Status)
2433+ assert.Equal(t, runErr.Error(), result.Error)
2434+ require.Len(t, result.Backups, 1)
2435+ assert.Equal(t, "v1", result.Backups[0].ID)
2436+}
2437+
2438+func TestHandler_VolumeBackupsRunDomain_ReturnsServerErrorWithoutResults(t *testing.T) {
2439+ volumeBackupSvc := inmocks.NewMockVolumeBackupService(t)
2440+ volumeBackupSvc.EXPECT().RunVolumeBackups(mock.Anything, "app.example.com", "").Return(nil, errors.New("runtime unavailable"))
2441+
2442+ handler := newTestHandler(t, func(d *HandlerDeps) {
2443+ d.VolumeBackupSvc = volumeBackupSvc
2444+ })
2445+
2446+ req := httptest.NewRequest("POST", "/admin/backups/volumes/app.example.com", bytes.NewBufferString(`{}`))
2447+ req = req.WithContext(ctxWithScopes("admin:config:write"))
2448+ rec := httptest.NewRecorder()
2449+
2450+ handler.ServeHTTP(rec, req)
2451+
2452+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
2453+ assert.Contains(t, rec.Body.String(), "failed to run volume backups")
2454+}
2455+
2412 func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {2456 func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
2413 configSvc := inmocks.NewMockConfigService(t)2457 configSvc := inmocks.NewMockConfigService(t)
2414 authSvc := inmocks.NewMockAuthService(t)2458 authSvc := inmocks.NewMockAuthService(t)
M internal/adapters/out/docker/runtime.go +219 -17
@@ -24,6 +24,7 @@ import (
24 "github.com/docker/docker/api/types/container"24 "github.com/docker/docker/api/types/container"
25 "github.com/docker/docker/api/types/filters"25 "github.com/docker/docker/api/types/filters"
26 "github.com/docker/docker/api/types/image"26 "github.com/docker/docker/api/types/image"
27+ "github.com/docker/docker/api/types/mount"
27 "github.com/docker/docker/api/types/network"28 "github.com/docker/docker/api/types/network"
28 "github.com/docker/docker/api/types/registry"29 "github.com/docker/docker/api/types/registry"
29 "github.com/docker/docker/api/types/strslice"30 "github.com/docker/docker/api/types/strslice"
@@ -44,11 +45,13 @@ type Runtime struct {
44 }45 }
45 46
46 var _ out.ContainerRuntime = (*Runtime)(nil)47 var _ out.ContainerRuntime = (*Runtime)(nil)
48+var _ out.VolumeArchiveExporter = (*Runtime)(nil)
47 49
48 type pipeReadCloser struct {50 type pipeReadCloser struct {
49 pr *io.PipeReader51 pr *io.PipeReader
50 pw *io.PipeWriter52 pw *io.PipeWriter
51 original io.Closer53 original io.Closer
54+ cleanup func() error
52 once sync.Once55 once sync.Once
53 }56 }
54 57
@@ -75,6 +78,11 @@ func (p *pipeReadCloser) Close() error {
75 closeErr = err78 closeErr = err
76 }79 }
77 }80 }
81+ if p.cleanup != nil {
82+ if err := p.cleanup(); err != nil && closeErr == nil {
83+ closeErr = err
84+ }
85+ }
78 })86 })
79 return closeErr87 return closeErr
80 }88 }
@@ -394,15 +402,27 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
394 name = strings.TrimPrefix(c.Names[0], "/")402 name = strings.TrimPrefix(c.Names[0], "/")
395 }403 }
396 404
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+
397 result = append(result, &domain.Container{416 result = append(result, &domain.Container{
398- ID: c.ID,417+ ID: c.ID,
399- Image: c.Image,418+ Image: c.Image,
400- ImageID: c.ImageID,419+ ImageID: c.ImageID,
401- Name: name,420+ Name: name,
402- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")421+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
403- Ports: ports,422+ Ports: ports,
404- Labels: c.Labels,423+ Labels: c.Labels,
405- Created: time.Unix(c.Created, 0),424+ VolumeMounts: volumeMounts,
425+ Created: time.Unix(c.Created, 0),
406 })426 })
407 }427 }
408 428
@@ -442,17 +462,28 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
442 name := strings.TrimPrefix(resp.Name, "/")462 name := strings.TrimPrefix(resp.Name, "/")
443 463
444 created, _ := time.Parse(time.RFC3339Nano, resp.Created)464 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+ }
445 475
446 return &domain.Container{476 return &domain.Container{
447- ID: resp.ID,477+ ID: resp.ID,
448- Image: resp.Config.Image,478+ Image: resp.Config.Image,
449- ImageID: resp.Image,479+ ImageID: resp.Image,
450- Name: name,480+ Name: name,
451- Status: resp.State.Status,481+ Status: resp.State.Status,
452- ExitCode: resp.State.ExitCode,482+ ExitCode: resp.State.ExitCode,
453- Ports: ports,483+ Ports: ports,
454- Labels: resp.Config.Labels,484+ Labels: resp.Config.Labels,
455- Created: created,485+ VolumeMounts: volumeMounts,
486+ Created: created,
456 }, nil487 }, nil
457 }488 }
458 489
@@ -1204,6 +1235,177 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
1204 return result, nil1235 return result, nil
1205 }1236 }
1206 1237
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+
1207 // InspectImageEnv gets the environment variables declared in the image.1409 // InspectImageEnv gets the environment variables declared in the image.
1208 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {1410 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
1209 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1411 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 (
17 //17 //
18 // It intentionally does not start HTTP servers or register signal handlers.18 // It intentionally does not start HTTP servers or register signal handlers.
19 type Kernel struct {19 type Kernel struct {
20- authEnabled bool20+ authEnabled bool
21- configSvc in.ConfigService21+ configSvc in.ConfigService
22- secretSvc in.SecretService22+ secretSvc in.SecretService
23- containerSvc in.ContainerService23+ containerSvc in.ContainerService
24- backupSvc in.BackupService24+ backupSvc in.BackupService
25- registrySvc in.RegistryService25+ volumeBackupSvc in.VolumeBackupService
26- healthSvc in.HealthService26+ registrySvc in.RegistryService
27- logSvc in.LogService27+ healthSvc in.HealthService
28- volumeSvc in.VolumeService28+ logSvc in.LogService
29- publicTLSSvc in.PublicTLSService29+ volumeSvc in.VolumeService
30- cleanup func()30+ publicTLSSvc in.PublicTLSService
31+ cleanup func()
31 }32 }
32 33
33 // NewKernel initializes local services without starting server listeners.34 // NewKernel initializes local services without starting server listeners.
@@ -78,17 +79,18 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
78 }79 }
79 80
80 return &Kernel{81 return &Kernel{
81- authEnabled: cfg.Auth.Enabled,82+ authEnabled: cfg.Auth.Enabled,
82- configSvc: svc.configSvc,83+ configSvc: svc.configSvc,
83- secretSvc: svc.secretSvc,84+ secretSvc: svc.secretSvc,
84- containerSvc: svc.containerSvc,85+ containerSvc: svc.containerSvc,
85- backupSvc: svc.backupSvc,86+ backupSvc: svc.backupSvc,
86- registrySvc: svc.registrySvc,87+ volumeBackupSvc: svc.volumeBackupSvc,
87- healthSvc: svc.healthSvc,88+ registrySvc: svc.registrySvc,
88- logSvc: svc.logSvc,89+ healthSvc: svc.healthSvc,
89- volumeSvc: svc.volumeSvc,90+ logSvc: svc.logSvc,
90- publicTLSSvc: svc.publicTLSSvc,91+ volumeSvc: svc.volumeSvc,
91- cleanup: wrappedCleanup,92+ publicTLSSvc: svc.publicTLSSvc,
93+ cleanup: wrappedCleanup,
92 }, nil94 }, nil
93 } else {95 } else {
94 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")96 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
@@ -136,6 +138,8 @@ func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
136 138
137 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }139 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
138 140
141+func (k *Kernel) VolumeBackup() in.VolumeBackupService { return k.volumeBackupSvc }
142+
139 func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }143 func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
140 144
141 func (k *Kernel) Health() in.HealthService { return k.healthSvc }145 func (k *Kernel) Health() in.HealthService { return k.healthSvc }
M internal/app/run.go +321 -40
@@ -39,6 +39,7 @@ import (
39 "github.com/bnema/gordon/internal/adapters/out/logwriter"39 "github.com/bnema/gordon/internal/adapters/out/logwriter"
40 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"40 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
41 "github.com/bnema/gordon/internal/adapters/out/ratelimit"41 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
42+ s3storage "github.com/bnema/gordon/internal/adapters/out/s3"
42 "github.com/bnema/gordon/internal/adapters/out/secrets"43 "github.com/bnema/gordon/internal/adapters/out/secrets"
43 "github.com/bnema/gordon/internal/adapters/out/telemetry"44 "github.com/bnema/gordon/internal/adapters/out/telemetry"
44 "github.com/bnema/gordon/internal/adapters/out/tokenstore"45 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
@@ -147,6 +148,12 @@ type Config struct {
147 Dir string `mapstructure:"dir"`148 Dir string `mapstructure:"dir"`
148 } `mapstructure:"env"`149 } `mapstructure:"env"`
149 150
151+ Volumes struct {
152+ AutoCreate bool `mapstructure:"auto_create"`
153+ Prefix string `mapstructure:"prefix"`
154+ Preserve bool `mapstructure:"preserve"`
155+ } `mapstructure:"volumes"`
156+
150 Auth struct {157 Auth struct {
151 Enabled bool `mapstructure:"enabled"`158 Enabled bool `mapstructure:"enabled"`
152 Type string `mapstructure:"type"` // only "token" is supported159 Type string `mapstructure:"type"` // only "token" is supported
@@ -168,6 +175,7 @@ type Config struct {
168 } `mapstructure:"api"`175 } `mapstructure:"api"`
169 176
170 Backups struct {177 Backups struct {
178+ // Legacy database backup keys. Prefer backups.databases.* for new configs.
171 Enabled bool `mapstructure:"enabled"`179 Enabled bool `mapstructure:"enabled"`
172 Schedule string `mapstructure:"schedule"`180 Schedule string `mapstructure:"schedule"`
173 StorageDir string `mapstructure:"storage_dir"`181 StorageDir string `mapstructure:"storage_dir"`
@@ -177,6 +185,37 @@ type Config struct {
177 Weekly int `mapstructure:"weekly"`185 Weekly int `mapstructure:"weekly"`
178 Monthly int `mapstructure:"monthly"`186 Monthly int `mapstructure:"monthly"`
179 } `mapstructure:"retention"`187 } `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"`
180 } `mapstructure:"backups"`219 } `mapstructure:"backups"`
181 220
182 Images struct {221 Images struct {
@@ -221,6 +260,8 @@ type services struct {
221 blobStorage *filesystem.BlobStorage260 blobStorage *filesystem.BlobStorage
222 manifestStorage *filesystem.ManifestStorage261 manifestStorage *filesystem.ManifestStorage
223 backupStorage *filesystem.BackupStorage262 backupStorage *filesystem.BackupStorage
263+ volumeBackupStore out.VolumeBackupStorage
264+ volumeBackupCfg domain.VolumeBackupConfig
224 envLoader out.EnvLoader265 envLoader out.EnvLoader
225 logWriter *logwriter.LogWriter266 logWriter *logwriter.LogWriter
226 tokenStore out.TokenStore267 tokenStore out.TokenStore
@@ -228,6 +269,7 @@ type services struct {
228 secretSvc *secretsSvc.Service269 secretSvc *secretsSvc.Service
229 containerSvc *container.Service270 containerSvc *container.Service
230 backupSvc *backup.Service271 backupSvc *backup.Service
272+ volumeBackupSvc *backup.VolumeService
231 registrySvc *registrySvc.Service273 registrySvc *registrySvc.Service
232 healthSvc *health.Service274 healthSvc *health.Service
233 logSvc *logs.Service275 logSvc *logs.Service
@@ -673,6 +715,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
673 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {715 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
674 return err716 return err
675 }717 }
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+ }
676 721
677 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)722 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
678 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)723 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
@@ -728,20 +773,21 @@ func (si *serviceInit) initHandlers() {
728 initPreviewService(si.ctx, si.cfg, si.svc, si.log)773 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
729 774
730 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{775 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
731- ConfigSvc: si.svc.configSvc,776+ ConfigSvc: si.svc.configSvc,
732- AuthSvc: si.svc.authSvc,777+ AuthSvc: si.svc.authSvc,
733- ContainerSvc: si.svc.containerSvc,778+ ContainerSvc: si.svc.containerSvc,
734- HealthSvc: si.svc.healthSvc,779+ HealthSvc: si.svc.healthSvc,
735- SecretSvc: si.svc.secretSvc,780+ SecretSvc: si.svc.secretSvc,
736- LogSvc: si.svc.logSvc,781+ LogSvc: si.svc.logSvc,
737- RegistrySvc: si.svc.registrySvc,782+ RegistrySvc: si.svc.registrySvc,
738- ReloadTrigger: si.svc.reloadCoordinator,783+ ReloadTrigger: si.svc.reloadCoordinator,
739- Log: si.log,784+ Log: si.log,
740- BackupSvc: si.svc.backupSvc,785+ BackupSvc: si.svc.backupSvc,
741- PreviewSvc: si.svc.previewService,786+ VolumeBackupSvc: si.svc.volumeBackupSvc,
742- ImageSvc: si.svc.imageSvc,787+ PreviewSvc: si.svc.previewService,
743- VolumeSvc: si.svc.volumeSvc,788+ ImageSvc: si.svc.imageSvc,
744- PublicTLSSvc: si.svc.publicTLSSvc,789+ VolumeSvc: si.svc.volumeSvc,
790+ PublicTLSSvc: si.svc.publicTLSSvc,
745 })791 })
746 }792 }
747 793
@@ -1940,12 +1986,69 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1940 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil1986 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
1941 }1987 }
1942 1988
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+
1943 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {2045 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 {
1945 return nil, nil, nil2048 return nil, nil, nil
1946 }2049 }
1947 2050
1948- storageDir := cfg.Backups.StorageDir2051+ storageDir := dbCfg.StorageDir
1949 if storageDir == "" {2052 if storageDir == "" {
1950 dataDir := resolveDataDir(cfg.Server.DataDir)2053 dataDir := resolveDataDir(cfg.Server.DataDir)
1951 storageDir = filepath.Join(dataDir, "backups")2054 storageDir = filepath.Join(dataDir, "backups")
@@ -1962,7 +2065,7 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
1962 }2065 }
1963 2066
1964 backupCfg := domain.BackupConfig{2067 backupCfg := domain.BackupConfig{
1965- Enabled: cfg.Backups.Enabled,2068+ Enabled: dbCfg.Enabled,
1966 StorageDir: storageDir,2069 StorageDir: storageDir,
1967 Retention: retention,2070 Retention: retention,
1968 }2071 }
@@ -1976,28 +2079,146 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
1976 return backupStorage, backupSvc, nil2079 return backupStorage, backupSvc, nil
1977 }2080 }
1978 2081
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+
1979 func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {2105 func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1980- if cfg.Backups.Retention.Hourly < 0 {2106+ dbCfg := databaseBackupSettings(cfg)
1981- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")2107+ if dbCfg.Retention.Hourly < 0 {
2108+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
1982 }2109 }
1983- if cfg.Backups.Retention.Daily < 0 {2110+ if dbCfg.Retention.Daily < 0 {
1984- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")2111+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
1985 }2112 }
1986- if cfg.Backups.Retention.Weekly < 0 {2113+ if dbCfg.Retention.Weekly < 0 {
1987- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")2114+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
1988 }2115 }
1989- if cfg.Backups.Retention.Monthly < 0 {2116+ if dbCfg.Retention.Monthly < 0 {
1990- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")2117+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
1991 }2118 }
1992 2119
1993 return domain.RetentionPolicy{2120 return domain.RetentionPolicy{
1994- Hourly: cfg.Backups.Retention.Hourly,2121+ Hourly: dbCfg.Retention.Hourly,
1995- Daily: cfg.Backups.Retention.Daily,2122+ Daily: dbCfg.Retention.Daily,
1996- Weekly: cfg.Backups.Retention.Weekly,2123+ Weekly: dbCfg.Retention.Weekly,
1997- Monthly: cfg.Backups.Retention.Monthly,2124+ Monthly: dbCfg.Retention.Monthly,
1998 }, nil2125 }, nil
1999 }2126 }
2000 2127
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+
2001 // registerEventHandlers registers all event handlers.2222 // registerEventHandlers registers all event handlers.
2002 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {2223 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {
2003 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)2224 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
@@ -2643,7 +2864,7 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2643 }2864 }
2644 2865
2645 func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {2866 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)
2647 2868
2648 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)2869 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
2649 if err != nil {2870 if err != nil {
@@ -2653,6 +2874,14 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
2653 schedulers = append(schedulers, backupScheduler)2874 schedulers = append(schedulers, backupScheduler)
2654 }2875 }
2655 2876
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+
2656 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {2885 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
2657 return v.GetInt("images.prune.keep_last")2886 return v.GetInt("images.prune.keep_last")
2658 })2887 })
@@ -2675,11 +2904,12 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
2675 }2904 }
2676 2905
2677 func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {2906 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 {
2679 return nil, nil2909 return nil, nil
2680 }2910 }
2681 2911
2682- preset, err := resolveBackupSchedule(cfg.Backups.Schedule)2912+ preset, err := resolveBackupSchedule(dbCfg.Schedule)
2683 if err != nil {2913 if err != nil {
2684 return nil, err2914 return nil, err
2685 }2915 }
@@ -2712,7 +2942,44 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
2712 }2942 }
2713 2943
2714 func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {2944 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
2716 }2983 }
2717 2984
2718 func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {2985 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 {
3399 v.SetDefault("volumes.prefix", "gordon")3666 v.SetDefault("volumes.prefix", "gordon")
3400 v.SetDefault("volumes.preserve", true)3667 v.SetDefault("volumes.preserve", true)
3401 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)3668 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
3402- v.SetDefault("backups.enabled", false)3669+ v.SetDefault("backups.databases.enabled", false)
3403- v.SetDefault("backups.schedule", string(domain.ScheduleDaily))3670+ v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
3404- v.SetDefault("backups.storage_dir", "")3671+ v.SetDefault("backups.databases.storage_dir", "")
3405- v.SetDefault("backups.retention.hourly", 0)3672+ v.SetDefault("backups.databases.retention.hourly", 0)
3406- v.SetDefault("backups.retention.daily", 0)3673+ v.SetDefault("backups.databases.retention.daily", 0)
3407- v.SetDefault("backups.retention.weekly", 0)3674+ v.SetDefault("backups.databases.retention.weekly", 0)
3408- v.SetDefault("backups.retention.monthly", 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)
3409 v.SetDefault("images.allowed_registries", []string{})3690 v.SetDefault("images.allowed_registries", []string{})
3410 v.SetDefault("images.require_digest", false)3691 v.SetDefault("images.require_digest", false)
3411 v.SetDefault("images.prune.enabled", false)3692 v.SetDefault("images.prune.enabled", false)
M internal/app/run_backup_test.go +120 -19
@@ -19,30 +19,30 @@ func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
19 {19 {
20 name: "hourly",20 name: "hourly",
21 set: func(cfg *Config) {21 set: func(cfg *Config) {
22- cfg.Backups.Retention.Hourly = -122+ cfg.Backups.Databases.Retention.Hourly = -1
23 },23 },
24- err: "backups.retention.hourly",24+ err: "backups.databases.retention.hourly",
25 },25 },
26 {26 {
27 name: "daily",27 name: "daily",
28 set: func(cfg *Config) {28 set: func(cfg *Config) {
29- cfg.Backups.Retention.Daily = -129+ cfg.Backups.Databases.Retention.Daily = -1
30 },30 },
31- err: "backups.retention.daily",31+ err: "backups.databases.retention.daily",
32 },32 },
33 {33 {
34 name: "weekly",34 name: "weekly",
35 set: func(cfg *Config) {35 set: func(cfg *Config) {
36- cfg.Backups.Retention.Weekly = -136+ cfg.Backups.Databases.Retention.Weekly = -1
37 },37 },
38- err: "backups.retention.weekly",38+ err: "backups.databases.retention.weekly",
39 },39 },
40 {40 {
41 name: "monthly",41 name: "monthly",
42 set: func(cfg *Config) {42 set: func(cfg *Config) {
43- cfg.Backups.Retention.Monthly = -143+ cfg.Backups.Databases.Retention.Monthly = -1
44 },44 },
45- err: "backups.retention.monthly",45+ err: "backups.databases.retention.monthly",
46 },46 },
47 }47 }
48 48
@@ -61,10 +61,10 @@ func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
61 func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {61 func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
62 t.Run("positive values", func(t *testing.T) {62 t.Run("positive values", func(t *testing.T) {
63 var cfg Config63 var cfg Config
64- cfg.Backups.Retention.Hourly = 164+ cfg.Backups.Databases.Retention.Hourly = 1
65- cfg.Backups.Retention.Daily = 765+ cfg.Backups.Databases.Retention.Daily = 7
66- cfg.Backups.Retention.Weekly = 466+ cfg.Backups.Databases.Retention.Weekly = 4
67- cfg.Backups.Retention.Monthly = 1267+ cfg.Backups.Databases.Retention.Monthly = 12
68 68
69 retention, err := validateBackupRetention(cfg)69 retention, err := validateBackupRetention(cfg)
70 require.NoError(t, err)70 require.NoError(t, err)
@@ -76,10 +76,10 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
76 76
77 t.Run("zero values", func(t *testing.T) {77 t.Run("zero values", func(t *testing.T) {
78 var cfg Config78 var cfg Config
79- cfg.Backups.Retention.Hourly = 079+ cfg.Backups.Databases.Retention.Hourly = 0
80- cfg.Backups.Retention.Daily = 080+ cfg.Backups.Databases.Retention.Daily = 0
81- cfg.Backups.Retention.Weekly = 081+ cfg.Backups.Databases.Retention.Weekly = 0
82- cfg.Backups.Retention.Monthly = 082+ cfg.Backups.Databases.Retention.Monthly = 0
83 83
84 retention, err := validateBackupRetention(cfg)84 retention, err := validateBackupRetention(cfg)
85 require.NoError(t, err)85 require.NoError(t, err)
@@ -90,12 +90,113 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
90 })90 })
91 }91 }
92 92
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) {
94 v := viper.New()192 v := viper.New()
95 193
96 require.NoError(t, loadConfig(v, ""))194 require.NoError(t, loadConfig(v, ""))
97 195
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"))
99 }200 }
100 201
101 func TestResolveBackupSchedule(t *testing.T) {202 func TestResolveBackupSchedule(t *testing.T) {
@@ -123,6 +224,6 @@ func TestResolveBackupSchedule(t *testing.T) {
123 t.Run("invalid value", func(t *testing.T) {224 t.Run("invalid value", func(t *testing.T) {
124 _, err := resolveBackupSchedule("every-minute")225 _, err := resolveBackupSchedule("every-minute")
125 require.Error(t, err)226 require.Error(t, err)
126- assert.Contains(t, err.Error(), "backups.schedule")227+ assert.Contains(t, err.Error(), "backups.databases.schedule")
127 })228 })
128 }229 }
M internal/boundaries/in/backup.go +15 -5
@@ -7,12 +7,22 @@ import (
7 "github.com/bnema/gordon/internal/domain"7 "github.com/bnema/gordon/internal/domain"
8 )8 )
9 9
10-// BackupService defines backup orchestration use cases.10+// DatabaseBackupService defines database backup orchestration use cases.
11-type BackupService interface {11+type DatabaseBackupService interface {
12- ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error)12+ ListBackups(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error)
13- RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error)13+ RunBackup(ctx context.Context, domainName, dbName string) (*domain.DatabaseBackupResult, error)
14 Restore(ctx context.Context, domainName, backupID string) error14 Restore(ctx context.Context, domainName, backupID string) error
15 RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error15 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)
17 DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error)17 DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error)
18 }18 }
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
108 }108 }
109 109
110 // ListBackups provides a mock function for the type MockBackupService110 // 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) {
112 ret := _mock.Called(ctx, domainName)112 ret := _mock.Called(ctx, domainName)
113 113
114 if len(ret) == 0 {114 if len(ret) == 0 {
115 panic("no return value specified for ListBackups")115 panic("no return value specified for ListBackups")
116 }116 }
117 117
118- var r0 []domain.BackupJob118+ var r0 []domain.DatabaseBackupJob
119 var r1 error119 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 {
121 return returnFunc(ctx, domainName)121 return returnFunc(ctx, domainName)
122 }122 }
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 {
124 r0 = returnFunc(ctx, domainName)124 r0 = returnFunc(ctx, domainName)
125 } else {125 } else {
126 if ret.Get(0) != nil {126 if ret.Get(0) != nil {
127- r0 = ret.Get(0).([]domain.BackupJob)127+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
128 }128 }
129 }129 }
130 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {130 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,
165 return _c165 return _c
166 }166 }
167 167
168-func (_c *MockBackupService_ListBackups_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_ListBackups_Call {168+func (_c *MockBackupService_ListBackups_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupService_ListBackups_Call {
169- _c.Call.Return(backupJobs, err)169+ _c.Call.Return(vs, err)
170 return _c170 return _c
171 }171 }
172 172
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 {
174 _c.Call.Return(run)174 _c.Call.Return(run)
175 return _c175 return _c
176 }176 }
@@ -302,23 +302,23 @@ func (_c *MockBackupService_RestorePITR_Call) RunAndReturn(run func(ctx context.
302 }302 }
303 303
304 // RunBackup provides a mock function for the type MockBackupService304 // 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) {
306 ret := _mock.Called(ctx, domainName, dbName)306 ret := _mock.Called(ctx, domainName, dbName)
307 307
308 if len(ret) == 0 {308 if len(ret) == 0 {
309 panic("no return value specified for RunBackup")309 panic("no return value specified for RunBackup")
310 }310 }
311 311
312- var r0 *domain.BackupResult312+ var r0 *domain.DatabaseBackupResult
313 var r1 error313 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 {
315 return returnFunc(ctx, domainName, dbName)315 return returnFunc(ctx, domainName, dbName)
316 }316 }
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 {
318 r0 = returnFunc(ctx, domainName, dbName)318 r0 = returnFunc(ctx, domainName, dbName)
319 } else {319 } else {
320 if ret.Get(0) != nil {320 if ret.Get(0) != nil {
321- r0 = ret.Get(0).(*domain.BackupResult)321+ r0 = ret.Get(0).(*domain.DatabaseBackupResult)
322 }322 }
323 }323 }
324 if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {324 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
365 return _c365 return _c
366 }366 }
367 367
368-func (_c *MockBackupService_RunBackup_Call) Return(backupResult *domain.BackupResult, err error) *MockBackupService_RunBackup_Call {368+func (_c *MockBackupService_RunBackup_Call) Return(v *domain.DatabaseBackupResult, err error) *MockBackupService_RunBackup_Call {
369- _c.Call.Return(backupResult, err)369+ _c.Call.Return(v, err)
370 return _c370 return _c
371 }371 }
372 372
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 {
374 _c.Call.Return(run)374 _c.Call.Return(run)
375 return _c375 return _c
376 }376 }
377 377
378 // Status provides a mock function for the type MockBackupService378 // 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) {
380 ret := _mock.Called(ctx)380 ret := _mock.Called(ctx)
381 381
382 if len(ret) == 0 {382 if len(ret) == 0 {
383 panic("no return value specified for Status")383 panic("no return value specified for Status")
384 }384 }
385 385
386- var r0 []domain.BackupJob386+ var r0 []domain.DatabaseBackupJob
387 var r1 error387 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 {
389 return returnFunc(ctx)389 return returnFunc(ctx)
390 }390 }
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 {
392 r0 = returnFunc(ctx)392 r0 = returnFunc(ctx)
393 } else {393 } else {
394 if ret.Get(0) != nil {394 if ret.Get(0) != nil {
395- r0 = ret.Get(0).([]domain.BackupJob)395+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
396 }396 }
397 }397 }
398 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {398 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
427 return _c427 return _c
428 }428 }
429 429
430-func (_c *MockBackupService_Status_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_Status_Call {430+func (_c *MockBackupService_Status_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupService_Status_Call {
431- _c.Call.Return(backupJobs, err)431+ _c.Call.Return(vs, err)
432 return _c432 return _c
433 }433 }
434 434
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 {
436 _c.Call.Return(run)436 _c.Call.Return(run)
437 return _c437 return _c
438 }438 }
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 (
8 "github.com/bnema/gordon/internal/domain"8 "github.com/bnema/gordon/internal/domain"
9 )9 )
10 10
11-// BackupStorage defines persistence for backup artifacts and metadata.11+// DatabaseBackupStorage defines persistence for database backup artifacts and metadata.
12-type BackupStorage interface {12+type DatabaseBackupStorage interface {
13 Store(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)13 Store(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)
14 Get(ctx context.Context, path string) (io.ReadCloser, error)14 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)
16 Delete(ctx context.Context, path string) error16 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)
18 }35 }
M internal/boundaries/out/mocks/mock_backup_storage.go +17 -17
@@ -41,7 +41,7 @@ func (_m *MockBackupStorage) EXPECT() *MockBackupStorage_Expecter {
41 }41 }
42 42
43 // ApplyRetention provides a mock function for the type MockBackupStorage43 // 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) {
45 ret := _mock.Called(ctx, domainName, policy)45 ret := _mock.Called(ctx, domainName, policy)
46 46
47 if len(ret) == 0 {47 if len(ret) == 0 {
@@ -50,15 +50,15 @@ func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName s
50 50
51 var r0 int51 var r0 int
52 var r1 error52 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 {
54 return returnFunc(ctx, domainName, policy)54 return returnFunc(ctx, domainName, policy)
55 }55 }
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 {
57 r0 = returnFunc(ctx, domainName, policy)57 r0 = returnFunc(ctx, domainName, policy)
58 } else {58 } else {
59 r0 = ret.Get(0).(int)59 r0 = ret.Get(0).(int)
60 }60 }
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 {
62 r1 = returnFunc(ctx, domainName, policy)62 r1 = returnFunc(ctx, domainName, policy)
63 } else {63 } else {
64 r1 = ret.Error(1)64 r1 = ret.Error(1)
@@ -74,12 +74,12 @@ type MockBackupStorage_ApplyRetention_Call struct {
74 // ApplyRetention is a helper method to define mock.On call74 // ApplyRetention is a helper method to define mock.On call
75 // - ctx context.Context75 // - ctx context.Context
76 // - domainName string76 // - domainName string
77-// - policy domain.RetentionPolicy77+// - policy domain.DatabaseBackupRetentionPolicy
78 func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx interface{}, domainName interface{}, policy interface{}) *MockBackupStorage_ApplyRetention_Call {78 func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx interface{}, domainName interface{}, policy interface{}) *MockBackupStorage_ApplyRetention_Call {
79 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}79 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}
80 }80 }
81 81
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 {
83 _c.Call.Run(func(args mock.Arguments) {83 _c.Call.Run(func(args mock.Arguments) {
84 var arg0 context.Context84 var arg0 context.Context
85 if args[0] != nil {85 if args[0] != nil {
@@ -89,9 +89,9 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Contex
89 if args[1] != nil {89 if args[1] != nil {
90 arg1 = args[1].(string)90 arg1 = args[1].(string)
91 }91 }
92- var arg2 domain.RetentionPolicy92+ var arg2 domain.DatabaseBackupRetentionPolicy
93 if args[2] != nil {93 if args[2] != nil {
94- arg2 = args[2].(domain.RetentionPolicy)94+ arg2 = args[2].(domain.DatabaseBackupRetentionPolicy)
95 }95 }
96 run(96 run(
97 arg0,97 arg0,
@@ -107,7 +107,7 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Return(n int, err error) *MockB
107 return _c107 return _c
108 }108 }
109 109
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 {
111 _c.Call.Return(run)111 _c.Call.Return(run)
112 return _c112 return _c
113 }113 }
@@ -238,23 +238,23 @@ func (_c *MockBackupStorage_Get_Call) RunAndReturn(run func(ctx context.Context,
238 }238 }
239 239
240 // List provides a mock function for the type MockBackupStorage240 // 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) {
242 ret := _mock.Called(ctx, domainName, schedule)242 ret := _mock.Called(ctx, domainName, schedule)
243 243
244 if len(ret) == 0 {244 if len(ret) == 0 {
245 panic("no return value specified for List")245 panic("no return value specified for List")
246 }246 }
247 247
248- var r0 []domain.BackupJob248+ var r0 []domain.DatabaseBackupJob
249 var r1 error249 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 {
251 return returnFunc(ctx, domainName, schedule)251 return returnFunc(ctx, domainName, schedule)
252 }252 }
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 {
254 r0 = returnFunc(ctx, domainName, schedule)254 r0 = returnFunc(ctx, domainName, schedule)
255 } else {255 } else {
256 if ret.Get(0) != nil {256 if ret.Get(0) != nil {
257- r0 = ret.Get(0).([]domain.BackupJob)257+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
258 }258 }
259 }259 }
260 if returnFunc, ok := ret.Get(1).(func(context.Context, string, *domain.BackupSchedule) error); ok {260 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
301 return _c301 return _c
302 }302 }
303 303
304-func (_c *MockBackupStorage_List_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupStorage_List_Call {304+func (_c *MockBackupStorage_List_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupStorage_List_Call {
305- _c.Call.Return(backupJobs, err)305+ _c.Call.Return(vs, err)
306 return _c306 return _c
307 }307 }
308 308
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 {
310 _c.Call.Return(run)310 _c.Call.Return(run)
311 return _c311 return _c
312 }312 }
M internal/domain/backup.go +98 -3
@@ -1,6 +1,9 @@
1 package domain1 package domain
2 2
3-import "time"3+import (
4+ "io"
5+ "time"
6+)
4 7
5 // DBType identifies the database engine.8 // DBType identifies the database engine.
6 type DBType string9 type DBType string
@@ -24,7 +27,16 @@ const (
24 type BackupType string27 type BackupType string
25 28
26 const (29 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"
28 )40 )
29 41
30 // BackupJobStatus tracks backup job lifecycle state.42 // BackupJobStatus tracks backup job lifecycle state.
@@ -86,6 +98,12 @@ type BackupResult struct {
86 Duration time.Duration98 Duration time.Duration
87 }99 }
88 100
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+
89 // RetentionPolicy defines how many backups to keep per schedule tier.107 // RetentionPolicy defines how many backups to keep per schedule tier.
90 type RetentionPolicy struct {108 type RetentionPolicy struct {
91 Hourly int109 Hourly int
@@ -94,13 +112,21 @@ type RetentionPolicy struct {
94 Monthly int112 Monthly int
95 }113 }
96 114
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+
97 // BackupOverride allows per-domain backup configuration.123 // BackupOverride allows per-domain backup configuration.
98 type BackupOverride struct {124 type BackupOverride struct {
99 Schedules []BackupSchedule125 Schedules []BackupSchedule
100 Retention *RetentionPolicy126 Retention *RetentionPolicy
101 }127 }
102 128
103-// BackupConfig is the top-level backup configuration.129+// BackupConfig is the database backup configuration.
104 type BackupConfig struct {130 type BackupConfig struct {
105 Enabled bool131 Enabled bool
106 StorageDir string132 StorageDir string
@@ -108,6 +134,75 @@ type BackupConfig struct {
108 Overrides map[string]BackupOverride134 Overrides map[string]BackupOverride
109 }135 }
110 136
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+
111 // Backup labels for container metadata.206 // Backup labels for container metadata.
112 const (207 const (
113 LabelBackupEnabled = "gordon.backup"208 LabelBackupEnabled = "gordon.backup"
M internal/domain/container.go +19 -9
@@ -6,15 +6,25 @@ import "time"
6 6
7 // Container represents a running container in the system.7 // Container represents a running container in the system.
8 type Container struct {8 type Container struct {
9- ID string9+ ID string
10- Image string10+ Image string
11- ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys11+ ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
12- Name string12+ Name string
13- Status string13+ Status string
14- ExitCode int14+ ExitCode int
15- Ports []int15+ Ports []int
16- Labels map[string]string16+ Labels map[string]string
17- Created time.Time17+ 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
18 }28 }
19 29
20 // NetworkInfo represents network configuration and state.30 // NetworkInfo represents network configuration and state.
M internal/domain/cron.go +2 -1
@@ -8,7 +8,8 @@ import "time"
8 // Timezone-aware scheduling is deferred until a timezone field is added.8 // Timezone-aware scheduling is deferred until a timezone field is added.
9 // When timezone support lands, host system timezone should be the default.9 // When timezone support lands, host system timezone should be the default.
10 type CronSchedule struct {10 type CronSchedule struct {
11- Preset BackupSchedule11+ Preset BackupSchedule
12+ Interval time.Duration
12 }13 }
13 14
14 // CronEntry represents a registered cron job.15 // CronEntry represents a registered cron job.
M internal/domain/errors.go +3 -2
@@ -41,8 +41,9 @@ var (
41 ErrNetworkExists = errors.New("network already exists")41 ErrNetworkExists = errors.New("network already exists")
42 42
43 // Volume errors43 // Volume errors
44- ErrVolumeNotFound = errors.New("volume not found")44+ ErrVolumeNotFound = errors.New("volume not found")
45- ErrVolumeExists = errors.New("volume already exists")45+ ErrVolumeExists = errors.New("volume already exists")
46+ ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")
46 47
47 // Preview errors48 // Preview errors
48 ErrPreviewNotFound = errors.New("preview not found")49 ErrPreviewNotFound = errors.New("preview not found")
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 {
290 // when no explicit timezone is configured.290 // when no explicit timezone is configured.
291 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {291 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
292 now = now.UTC()292 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+ }
293 299
294 switch schedule.Preset {300 switch schedule.Preset {
295 case domain.ScheduleHourly:301 case domain.ScheduleHourly:
M internal/usecase/cron/scheduler_test.go +16 -0
@@ -51,6 +51,22 @@ func TestCalculateNextRunPresets(t *testing.T) {
51 }51 }
52 }52 }
53 53
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+
54 func TestSchedulerAddListAndRunNow(t *testing.T) {70 func TestSchedulerAddListAndRunNow(t *testing.T) {
55 s := NewScheduler(zerowrap.Default())71 s := NewScheduler(zerowrap.Default())
56 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)72 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