detached e4c37fd

feat(backups): add volume backups to s3

commit
e4c37fdffd2f0a22496fc4ec602b0c407b9f7770
author
brice · · 2026-06-19 22:18 UTC
committer
brice · · 2026-06-20 05:00 UTC
parent
b59533b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +2921 -336
M docs/cli/backup.md +34 -67
@@ -1,96 +1,63 @@
11 # Backup Command
22
3-Manage backups for database attachments.
3+Manage database backups and volume backups.
44
5-## gordon backups
5+## gordon backups databases
6+
7+Database backups are logical PostgreSQL backups made with `pg_dump`.
68
79 ```bash
8-gordon backups <subcommand>
10+gordon backups databases <subcommand>
911 ```
1012
1113 Subcommands:
1214
13-- `list [domain]` - List backups globally or for one domain
14-- `run <domain> [--db <name>]` - Trigger an immediate backup
15+- `list [domain]` - List database backups
16+- `run <domain> [--db <name>]` - Trigger an immediate database backup
1517 - `detect <domain>` - Detect supported databases for a domain
16-- `status` - Show backup status across domains
18+- `status` - Show database backup status
1719
18-> Backup commands work both locally and remotely.
19->
20-> - Local mode (no `--remote`) executes backups through in-process services.
21-> - Remote mode uses the admin API on the target instance.
22->
23-> `gordon backups run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.
20+Compatibility aliases remain available: `gordon backups list`, `run`, `detect`, and `status` map to database backups.
2421
25-In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.
22+## gordon backups volumes
2623
27-## Examples
24+Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
2825
2926 ```bash
30-# List all backups
31-gordon backups list
32-gordon backups list --json
27+gordon backups volumes <subcommand>
28+```
3329
34-# List backups for one domain
35-gordon backups list app.example.com
30+Subcommands:
3631
37-# Trigger backup (auto-detect when exactly one DB attachment exists)
38-gordon backups run app.example.com
32+- `list [domain]` - List completed volume backup archives
33+- `run [domain] [--volume <name>]` - Trigger volume backups now
34+- `status` - Show completed archives plus current/recent in-memory job state
3935
40-# Trigger backup for specific attachment name
41-gordon backups run app.example.com --db postgres
36+Volume backups exclude bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes. Live archives are not application-consistent unless the application is quiesced or stopped.
4237
43-# Detect DB attachments
44-gordon backups detect app.example.com
38+## Examples
4539
46-# Status summary
47-gordon backups status
40+```bash
41+# Database backups
42+gordon backups databases list
43+gordon backups databases run app.example.com --db postgres
44+gordon backups databases detect app.example.com
45+gordon backups databases status
46+
47+# Volume backups
48+gordon backups volumes list
49+gordon backups volumes list app.example.com
50+gordon backups volumes run
51+gordon backups volumes run app.example.com --volume gordon-app-example-com-data
52+gordon backups volumes status
4853 ```
4954
5055 ## JSON Output
5156
52-`gordon backups list` supports `--json` for machine-readable output.
57+Database and volume list commands support `--json`.
5358
5459 ```bash
55-gordon backups list --json
56-```
57-
58-```json
59-[
60- {
61- "domain": "app.example.com",
62- "db": "postgres",
63- "status": "completed",
64- "started_at": "2026-03-13T09:45:00Z",
65- "backup_id": "backup_20260313_094500"
66- }
67-]
68-```
69-
70-## Output Columns
71-
72-### list
73-
74-```text
75-<domain>\t<db>\t<status>\t<started_at>\t<backup_id>
76-```
77-
78-### detect
79-
80-```text
81-<name>\t<type>\t<host>\t<port>\t<image>
82-```
83-
84-### status
85-
86-```text
87-<domain>\t<db>\t<status>\t<started_at>
88-```
89-
90-### run
91-
92-```text
93-<domain>\t<db>\t<status>\t<started_at>\t<backup_id>\t<size_bytes>
60+gordon backups volumes list --json
9461 ```
9562
9663 ## Required Permissions
@@ -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 +56 -40
@@ -1,69 +1,85 @@
11 # Backups Configuration
22
3-Configure automatic PostgreSQL logical backups for attachment containers.
3+Gordon has two backup flows:
44
5-## Overview
5+- **Database backups**: PostgreSQL logical dumps via `pg_dump`, stored on the local filesystem.
6+- **Volume backups**: best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3.
67
7-When backups are enabled, Gordon detects PostgreSQL attachments for each route and can:
8-
9-- Run on-demand backups via `gordon backups run`
10-- Store backup artifacts on the Gordon host filesystem
11-- Apply retention policies per schedule tier
12-
13-Current scope:
14-
15-- PostgreSQL 17 and 18
16-- Logical dumps (`pg_dump -Fc`)
17-- Local filesystem storage
18-
19-## Configuration
8+## Database backups
209
2110 ```toml
22-[backups]
11+[backups.databases]
2312 enabled = true
2413 schedule = "daily"
2514 storage_dir = "~/.gordon/backups"
2615
27-[backups.retention]
16+[backups.databases.retention]
2817 hourly = 24
2918 daily = 7
3019 weekly = 4
3120 monthly = 12
3221 ```
3322
34-## Settings
35-
36-| Key | Type | Default | Description |
37-|-----|------|---------|-------------|
38-| `backups.enabled` | bool | `false` | Enables backup service wiring |
39-| `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
40-| `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` |
41-| `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB |
42-| `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB |
43-| `backups.retention.weekly` | int | `0` | Number of weekly backups to keep per DB |
44-| `backups.retention.monthly` | int | `0` | Number of monthly backups to keep per DB |
23+| Key | Default | Description |
24+|---|---:|---|
25+| `backups.databases.enabled` | `false` | Enables PostgreSQL backup service wiring |
26+| `backups.databases.schedule` | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
27+| `backups.databases.storage_dir` | `""` | Root backup directory; defaults to `{server.data_dir}/backups` |
28+| `backups.databases.retention.*` | `0` | Number of backups to keep per DB and schedule tier |
4529
46-## Storage Layout
30+## Volume backups to S3
4731
48-Backups are stored under:
49-
50-```text
51-<storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak
32+```toml
33+[backups.volumes]
34+enabled = true
35+interval = "24h"
36+compression = "gzip"
37+timeout = "2h"
38+max_concurrency = 2
39+helper_image = "alpine:3.20"
40+
41+[backups.volumes.s3]
42+bucket = "gordon-backups"
43+region = "eu-west-3"
44+prefix = "prod/gordon"
45+# endpoint = "https://s3.example.com"
46+# path_style = false
47+# sse_algorithm = "AES256"
48+# sse_kms_key_id = ""
49+
50+[backups.volumes.retention]
51+keep = 14
5252 ```
5353
54-Example:
54+| Key | Default | Description |
55+|---|---:|---|
56+| `backups.volumes.enabled` | `false` | Enables automatic volume archives |
57+| `backups.volumes.interval` | `"24h"` | Interval between scheduled runs |
58+| `backups.volumes.compression` | `"gzip"` | `gzip` or `zstd`; helper image must provide the tool |
59+| `backups.volumes.timeout` | `"2h"` | Per-volume archive/upload timeout |
60+| `backups.volumes.max_concurrency` | `2` | Maximum concurrent volume backups |
61+| `backups.volumes.helper_image` | `"alpine:3.20"` | Helper container image used for tar/compression |
62+| `backups.volumes.s3.bucket` | `""` | Required when volume backups are enabled |
63+| `backups.volumes.s3.region` | `""` | Required when volume backups are enabled |
64+| `backups.volumes.s3.prefix` | `""` | Object key prefix |
65+| `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
66+| `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
67+| `backups.volumes.retention.keep` | `14` | Completed archives to keep per domain + volume |
68+
69+Volume backup objects are stored under:
5570
5671 ```text
57-~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak
72+<prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz
5873 ```
5974
6075 ## Notes
6176
62-- Backups require PostgreSQL attachment containers to be running.
63-- Scheduled backups run only when `backups.enabled = true`.
64-- The backup command executes inside the database container using Gordon runtime APIs.
65-- Retention applies per database and per schedule.
66-- `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.
77+- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.
78+- Bind mounts, tmpfs mounts, anonymous volumes, and non-Gordon volumes are excluded.
79+- Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
80+- Automated volume restore is not part of the MVP.
81+- 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.
82+- S3 credentials should come from the environment, shared config, instance role, or Gordon secret conventions. Use least-privilege IAM for the configured prefix.
6783
6884 ## Related
6985
@@ -1,69 +1,85 @@
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
58 ```73 ```
59 74
60 ## Notes75 ## Notes
61 76
62-- Backups require PostgreSQL attachment containers to be running.77+- Volume backups include named volumes mounted by Gordon-managed route or attachment containers.
63-- Scheduled backups run only when `backups.enabled = true`.78+- 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.79+- Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
65-- Retention applies per database and per schedule.80+- 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`.81+- 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.
82+- S3 credentials should come from the environment, shared config, instance role, or Gordon secret conventions. Use least-privilege IAM for the configured prefix.
67 83
68 ## Related84 ## Related
69 85
M go.mod +19 -0
@@ -3,6 +3,10 @@ module github.com/bnema/gordon
33 go 1.26.1
44
55 require (
6+ github.com/aws/aws-sdk-go-v2 v1.42.0
7+ github.com/aws/aws-sdk-go-v2/config v1.32.25
8+ github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11
9+ github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0
610 github.com/bnema/zerowrap v1.4.0
711 github.com/charmbracelet/bubbles v1.0.0
812 github.com/charmbracelet/bubbletea v1.3.10
@@ -33,6 +37,21 @@ require (
3337
3438 require (
3539 github.com/atotto/clipboard v0.1.4 // indirect
40+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
41+ github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
42+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
43+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
44+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
45+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
46+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
47+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
48+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
49+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
50+ github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
51+ github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
52+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
53+ github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
54+ github.com/aws/smithy-go v1.27.1 // indirect
3655 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
3756 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
3857 github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -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
@@ -4,6 +4,44 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
44 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
55 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
66 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7+github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
8+github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
9+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
10+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
11+github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
12+github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
13+github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
14+github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
15+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
16+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
17+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11 h1:ORrr68X2RQ4OBNI4aYJapjcmbaG5mst0Z7/tjUboZEA=
18+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11/go.mod h1:W4kVdkT0M/UDMNncotFG2X/HGX69yMdNn7S9WGMhUjk=
19+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
20+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
21+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
22+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
23+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
24+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
25+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
26+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
27+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
28+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
29+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
30+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
31+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
32+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
33+github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
34+github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
35+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
36+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
37+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
38+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
39+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
40+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
41+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
42+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
43+github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
44+github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
745 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
846 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
947 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
@@ -4,6 +4,44 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
4 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=4 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
5 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=5 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
6 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=6 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7+github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
8+github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
9+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
10+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
11+github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
12+github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
13+github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
14+github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
15+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
16+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
17+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11 h1:ORrr68X2RQ4OBNI4aYJapjcmbaG5mst0Z7/tjUboZEA=
18+github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.11/go.mod h1:W4kVdkT0M/UDMNncotFG2X/HGX69yMdNn7S9WGMhUjk=
19+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
20+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
21+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
22+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
23+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
24+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
25+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
26+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
27+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
28+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
29+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
30+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
31+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
32+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
33+github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
34+github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
35+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
36+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
37+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
38+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
39+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
40+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
41+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
42+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
43+github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
44+github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
7 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=45 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=46 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=47 github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
M gordon.toml.example +23 -2
@@ -128,17 +128,38 @@ trace_sample_rate = 1.0
128128 [env]
129129 # dir = "~/.gordon/env"
130130
131-[backups]
131+[backups.databases]
132132 enabled = false
133133 schedule = "daily"
134134 storage_dir = ""
135135
136-[backups.retention]
136+[backups.databases.retention]
137137 hourly = 0
138138 daily = 0
139139 weekly = 0
140140 monthly = 0
141141
142+[backups.volumes]
143+enabled = false
144+interval = "24h"
145+compression = "gzip"
146+timeout = "2h"
147+max_concurrency = 2
148+# Helper image must include tar and the selected compressor.
149+helper_image = "alpine:3.20"
150+
151+[backups.volumes.s3]
152+bucket = ""
153+region = ""
154+prefix = ""
155+# endpoint = "https://s3.example.com"
156+# path_style = false
157+# sse_algorithm = "AES256"
158+# sse_kms_key_id = ""
159+
160+[backups.volumes.retention]
161+keep = 14
162+
142163 [routes]
143164 # "app.example.com" = { image = "myapp:latest" }
144165
@@ -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 +34 -0
@@ -47,3 +47,37 @@ type DatabaseInfo struct {
4747 type BackupDetectResponse struct {
4848 Databases []DatabaseInfo `json:"databases"`
4949 }
50+
51+// VolumeBackupJob represents volume backup metadata in admin API responses.
52+type VolumeBackupJob struct {
53+ ID string `json:"id"`
54+ Domain string `json:"domain"`
55+ ContainerName string `json:"container_name,omitempty"`
56+ ContainerID string `json:"container_id,omitempty"`
57+ VolumeName string `json:"volume_name"`
58+ MountPath string `json:"mount_path,omitempty"`
59+ Compression string `json:"compression,omitempty"`
60+ Type string `json:"type"`
61+ Status string `json:"status"`
62+ StartedAt *time.Time `json:"started_at,omitempty"`
63+ CompletedAt *time.Time `json:"completed_at,omitempty"`
64+ SizeBytes int64 `json:"size_bytes"`
65+ ArtifactRef string `json:"artifact_ref,omitempty"`
66+ Error string `json:"error,omitempty"`
67+}
68+
69+// VolumeBackupsResponse is returned by volume backup listing endpoints.
70+type VolumeBackupsResponse struct {
71+ Backups []VolumeBackupJob `json:"backups"`
72+}
73+
74+// VolumeBackupRunRequest triggers volume backups.
75+type VolumeBackupRunRequest struct {
76+ Volume string `json:"volume,omitempty"`
77+}
78+
79+// VolumeBackupRunResponse is returned after triggering volume backups.
80+type VolumeBackupRunResponse struct {
81+ Status string `json:"status"`
82+ Backups []VolumeBackupJob `json:"backups,omitempty"`
83+}
@@ -47,3 +47,37 @@ 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+}
M internal/adapters/in/cli/backup.go +133 -2
@@ -18,18 +18,46 @@ func newBackupCmd() *cobra.Command {
1818 cmd := &cobra.Command{
1919 Use: "backups",
2020 Aliases: []string{"backup"},
21- Short: "Manage database backups",
22- Long: `Manage database backups.
21+ Short: "Manage backups",
22+ Long: `Manage backups.
2323
2424 Runs locally via in-process services by default, or against a remote Gordon
2525 instance when --remote targeting is configured.`,
2626 }
2727
28+ cmd.AddCommand(newBackupDatabasesCmd())
29+ cmd.AddCommand(newBackupVolumesCmd())
30+
31+ // Compatibility aliases for the original database backup command shape.
32+ cmd.AddCommand(newBackupListCmd())
33+ cmd.AddCommand(newBackupRunCmd())
34+ cmd.AddCommand(newBackupDetectCmd())
35+ cmd.AddCommand(newBackupStatusCmd())
36+
37+ return cmd
38+}
39+
40+func newBackupDatabasesCmd() *cobra.Command {
41+ cmd := &cobra.Command{
42+ Use: "databases",
43+ Aliases: []string{"database", "db"},
44+ Short: "Manage database backups",
45+ }
2846 cmd.AddCommand(newBackupListCmd())
2947 cmd.AddCommand(newBackupRunCmd())
3048 cmd.AddCommand(newBackupDetectCmd())
3149 cmd.AddCommand(newBackupStatusCmd())
50+ return cmd
51+}
3252
53+func newBackupVolumesCmd() *cobra.Command {
54+ cmd := &cobra.Command{
55+ Use: "volumes",
56+ Short: "Manage volume backups",
57+ }
58+ cmd.AddCommand(newVolumeBackupListCmd())
59+ cmd.AddCommand(newVolumeBackupRunCmd())
60+ cmd.AddCommand(newVolumeBackupStatusCmd())
3361 return cmd
3462 }
3563
@@ -103,6 +131,109 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
103131 return w.Flush()
104132 }
105133
134+func newVolumeBackupListCmd() *cobra.Command {
135+ var jsonOut bool
136+
137+ cmd := &cobra.Command{
138+ Use: "list [domain]",
139+ Short: "List volume backups",
140+ Args: cobra.MaximumNArgs(1),
141+ RunE: func(cmd *cobra.Command, args []string) error {
142+ domainName := ""
143+ if len(args) == 1 {
144+ domainName = args[0]
145+ }
146+ handle, err := resolveControlPlane(configPath)
147+ if err != nil {
148+ return err
149+ }
150+ defer handle.close()
151+
152+ jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)
153+ if err != nil {
154+ return fmt.Errorf("failed to list volume backups: %w", err)
155+ }
156+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
157+ },
158+ }
159+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
160+ return cmd
161+}
162+
163+func newVolumeBackupRunCmd() *cobra.Command {
164+ var volumeName string
165+
166+ cmd := &cobra.Command{
167+ Use: "run [domain]",
168+ Short: "Run volume backups now",
169+ Args: cobra.MaximumNArgs(1),
170+ RunE: func(cmd *cobra.Command, args []string) error {
171+ domainName := ""
172+ if len(args) == 1 {
173+ domainName = args[0]
174+ }
175+ handle, err := resolveControlPlane(configPath)
176+ if err != nil {
177+ return err
178+ }
179+ defer handle.close()
180+
181+ result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
182+ if err != nil {
183+ return fmt.Errorf("failed to run volume backups: %w", err)
184+ }
185+ return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, false)
186+ },
187+ }
188+ cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
189+ return cmd
190+}
191+
192+func newVolumeBackupStatusCmd() *cobra.Command {
193+ return &cobra.Command{
194+ Use: "status",
195+ Short: "Show volume backup status",
196+ RunE: func(cmd *cobra.Command, args []string) error {
197+ handle, err := resolveControlPlane(configPath)
198+ if err != nil {
199+ return err
200+ }
201+ defer handle.close()
202+
203+ jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
204+ if err != nil {
205+ return fmt.Errorf("failed to get volume backup status: %w", err)
206+ }
207+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, false)
208+ },
209+ }
210+}
211+
212+func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
213+ if len(jobs) == 0 {
214+ if jsonOut {
215+ return writeJSON(out, []dto.VolumeBackupJob{})
216+ }
217+ return cliWriteLine(out, cliRenderMuted("No volume backups found"))
218+ }
219+ if jsonOut {
220+ return writeJSON(out, jobs)
221+ }
222+ if err := cliWriteLine(out, cliRenderTitle("Volume Backups")); err != nil {
223+ return err
224+ }
225+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
226+ if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
227+ return err
228+ }
229+ for _, job := range jobs {
230+ 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 {
231+ return err
232+ }
233+ }
234+ return w.Flush()
235+}
236+
106237 func newBackupRunCmd() *cobra.Command {
107238 var dbName string
108239
@@ -18,18 +18,46 @@ func newBackupCmd() *cobra.Command {
18 cmd := &cobra.Command{18 cmd := &cobra.Command{
19 Use: "backups",19 Use: "backups",
20 Aliases: []string{"backup"},20 Aliases: []string{"backup"},
21- Short: "Manage database backups",21+ Short: "Manage backups",
22- Long: `Manage database backups.22+ Long: `Manage backups.
23 23
24 Runs locally via in-process services by default, or against a remote Gordon24 Runs locally via in-process services by default, or against a remote Gordon
25 instance when --remote targeting is configured.`,25 instance when --remote targeting is configured.`,
26 }26 }
27 27
28+ cmd.AddCommand(newBackupDatabasesCmd())
29+ cmd.AddCommand(newBackupVolumesCmd())
30+
31+ // Compatibility aliases for the original database backup command shape.
32+ cmd.AddCommand(newBackupListCmd())
33+ cmd.AddCommand(newBackupRunCmd())
34+ cmd.AddCommand(newBackupDetectCmd())
35+ cmd.AddCommand(newBackupStatusCmd())
36+
37+ return cmd
38+}
39+
40+func newBackupDatabasesCmd() *cobra.Command {
41+ cmd := &cobra.Command{
42+ Use: "databases",
43+ Aliases: []string{"database", "db"},
44+ Short: "Manage database backups",
45+ }
28 cmd.AddCommand(newBackupListCmd())46 cmd.AddCommand(newBackupListCmd())
29 cmd.AddCommand(newBackupRunCmd())47 cmd.AddCommand(newBackupRunCmd())
30 cmd.AddCommand(newBackupDetectCmd())48 cmd.AddCommand(newBackupDetectCmd())
31 cmd.AddCommand(newBackupStatusCmd())49 cmd.AddCommand(newBackupStatusCmd())
50+ return cmd
51+}
32 52
53+func newBackupVolumesCmd() *cobra.Command {
54+ cmd := &cobra.Command{
55+ Use: "volumes",
56+ Short: "Manage volume backups",
57+ }
58+ cmd.AddCommand(newVolumeBackupListCmd())
59+ cmd.AddCommand(newVolumeBackupRunCmd())
60+ cmd.AddCommand(newVolumeBackupStatusCmd())
33 return cmd61 return cmd
34 }62 }
35 63
@@ -103,6 +131,109 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error {
103 return w.Flush()131 return w.Flush()
104 }132 }
105 133
134+func newVolumeBackupListCmd() *cobra.Command {
135+ var jsonOut bool
136+
137+ cmd := &cobra.Command{
138+ Use: "list [domain]",
139+ Short: "List volume backups",
140+ Args: cobra.MaximumNArgs(1),
141+ RunE: func(cmd *cobra.Command, args []string) error {
142+ domainName := ""
143+ if len(args) == 1 {
144+ domainName = args[0]
145+ }
146+ handle, err := resolveControlPlane(configPath)
147+ if err != nil {
148+ return err
149+ }
150+ defer handle.close()
151+
152+ jobs, err := handle.plane.ListVolumeBackups(cmd.Context(), domainName)
153+ if err != nil {
154+ return fmt.Errorf("failed to list volume backups: %w", err)
155+ }
156+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, jsonOut)
157+ },
158+ }
159+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
160+ return cmd
161+}
162+
163+func newVolumeBackupRunCmd() *cobra.Command {
164+ var volumeName string
165+
166+ cmd := &cobra.Command{
167+ Use: "run [domain]",
168+ Short: "Run volume backups now",
169+ Args: cobra.MaximumNArgs(1),
170+ RunE: func(cmd *cobra.Command, args []string) error {
171+ domainName := ""
172+ if len(args) == 1 {
173+ domainName = args[0]
174+ }
175+ handle, err := resolveControlPlane(configPath)
176+ if err != nil {
177+ return err
178+ }
179+ defer handle.close()
180+
181+ result, err := handle.plane.RunVolumeBackups(cmd.Context(), domainName, volumeName)
182+ if err != nil {
183+ return fmt.Errorf("failed to run volume backups: %w", err)
184+ }
185+ return printVolumeBackupJobs(cmd.OutOrStdout(), result.Backups, false)
186+ },
187+ }
188+ cmd.Flags().StringVar(&volumeName, "volume", "", "Volume name (optional)")
189+ return cmd
190+}
191+
192+func newVolumeBackupStatusCmd() *cobra.Command {
193+ return &cobra.Command{
194+ Use: "status",
195+ Short: "Show volume backup status",
196+ RunE: func(cmd *cobra.Command, args []string) error {
197+ handle, err := resolveControlPlane(configPath)
198+ if err != nil {
199+ return err
200+ }
201+ defer handle.close()
202+
203+ jobs, err := handle.plane.VolumeBackupStatus(cmd.Context())
204+ if err != nil {
205+ return fmt.Errorf("failed to get volume backup status: %w", err)
206+ }
207+ return printVolumeBackupJobs(cmd.OutOrStdout(), jobs, false)
208+ },
209+ }
210+}
211+
212+func printVolumeBackupJobs(out io.Writer, jobs []dto.VolumeBackupJob, jsonOut bool) error {
213+ if len(jobs) == 0 {
214+ if jsonOut {
215+ return writeJSON(out, []dto.VolumeBackupJob{})
216+ }
217+ return cliWriteLine(out, cliRenderMuted("No volume backups found"))
218+ }
219+ if jsonOut {
220+ return writeJSON(out, jobs)
221+ }
222+ if err := cliWriteLine(out, cliRenderTitle("Volume Backups")); err != nil {
223+ return err
224+ }
225+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
226+ if _, err := fmt.Fprintln(w, "DOMAIN\tVOLUME\tCONTAINER\tCOMPRESSION\tSTATUS\tSTARTED_AT\tSIZE\tARTIFACT"); err != nil {
227+ return err
228+ }
229+ for _, job := range jobs {
230+ 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 {
231+ return err
232+ }
233+ }
234+ return w.Flush()
235+}
236+
106 func newBackupRunCmd() *cobra.Command {237 func newBackupRunCmd() *cobra.Command {
107 var dbName string238 var dbName string
108 239
M internal/adapters/in/cli/controlplane.go +3 -0
@@ -53,6 +53,9 @@ type ControlPlane interface {
5353 BackupStatus(ctx context.Context) ([]dto.BackupJob, error)
5454 RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error)
5555 DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error)
56+ ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)
57+ VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error)
58+ RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error)
5659
5760 GetProcessLogs(ctx context.Context, lines int) ([]string, error)
5861 GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
@@ -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 +93 -20
@@ -16,16 +16,17 @@ import (
1616 )
1717
1818 type localControlPlane struct {
19- configSvc in.ConfigService
20- secretSvc in.SecretService
21- containerSvc in.ContainerService
22- backupSvc in.BackupService
23- registrySvc in.RegistryService
24- deployCoord in.DeployCoordinator
25- healthSvc in.HealthService
26- logSvc in.LogService
27- volumeSvc in.VolumeService
28- publicTLSSvc in.PublicTLSService
19+ configSvc in.ConfigService
20+ secretSvc in.SecretService
21+ containerSvc in.ContainerService
22+ backupSvc in.BackupService
23+ volumeBackupSvc in.VolumeBackupService
24+ registrySvc in.RegistryService
25+ deployCoord in.DeployCoordinator
26+ healthSvc in.HealthService
27+ logSvc in.LogService
28+ volumeSvc in.VolumeService
29+ publicTLSSvc in.PublicTLSService
2930 }
3031
3132 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
@@ -42,16 +43,17 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
4243 }
4344
4445 return &localControlPlane{
45- configSvc: kernel.Config(),
46- secretSvc: kernel.Secrets(),
47- containerSvc: kernel.Container(),
48- backupSvc: kernel.Backup(),
49- registrySvc: registrySvc,
50- deployCoord: deployCoord,
51- healthSvc: kernel.Health(),
52- logSvc: kernel.Logs(),
53- volumeSvc: kernel.Volumes(),
54- publicTLSSvc: kernel.PublicTLS(),
46+ configSvc: kernel.Config(),
47+ secretSvc: kernel.Secrets(),
48+ containerSvc: kernel.Container(),
49+ backupSvc: kernel.Backup(),
50+ volumeBackupSvc: kernel.VolumeBackup(),
51+ registrySvc: registrySvc,
52+ deployCoord: deployCoord,
53+ healthSvc: kernel.Health(),
54+ logSvc: kernel.Logs(),
55+ volumeSvc: kernel.Volumes(),
56+ publicTLSSvc: kernel.PublicTLS(),
5557 }
5658 }
5759
@@ -629,6 +631,39 @@ func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain st
629631 return out, nil
630632 }
631633
634+func (l *localControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
635+ if l.volumeBackupSvc == nil {
636+ return nil, fmt.Errorf("local volume backup service unavailable")
637+ }
638+ jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
639+ if err != nil {
640+ return nil, 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, fmt.Errorf("local volume backup service unavailable")
648+ }
649+ jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)
650+ if err != nil {
651+ return nil, 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, fmt.Errorf("local volume backup service unavailable")
659+ }
660+ jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)
661+ if err != nil {
662+ return nil, err
663+ }
664+ return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil
665+}
666+
632667 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
633668 if l.logSvc == nil {
634669 return nil, fmt.Errorf("local log service unavailable")
@@ -785,3 +820,41 @@ func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
785820 Error: job.Error,
786821 }
787822 }
823+
824+func toDTOVolumeBackupJobs(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
825+ out := make([]dto.VolumeBackupJob, 0, len(jobs))
826+ for _, job := range jobs {
827+ out = append(out, toDTOVolumeBackupJob(job))
828+ }
829+ return out
830+}
831+
832+func toDTOVolumeBackupJob(job domain.VolumeBackupJob) dto.VolumeBackupJob {
833+ var startedAt *time.Time
834+ if !job.StartedAt.IsZero() {
835+ t := job.StartedAt
836+ startedAt = &t
837+ }
838+ var completedAt *time.Time
839+ if !job.CompletedAt.IsZero() {
840+ t := job.CompletedAt
841+ completedAt = &t
842+ }
843+
844+ return dto.VolumeBackupJob{
845+ ID: job.ID,
846+ Domain: job.Domain,
847+ ContainerName: job.ContainerName,
848+ ContainerID: job.ContainerID,
849+ VolumeName: job.VolumeName,
850+ MountPath: job.MountPath,
851+ Compression: job.Metadata["compression"],
852+ Type: string(job.Type),
853+ Status: string(job.Status),
854+ StartedAt: startedAt,
855+ CompletedAt: completedAt,
856+ SizeBytes: job.SizeBytes,
857+ ArtifactRef: job.ArtifactRef,
858+ Error: job.Error,
859+ }
860+}
@@ -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,39 @@ 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, fmt.Errorf("local volume backup service unavailable")
637+ }
638+ jobs, err := l.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
639+ if err != nil {
640+ return nil, 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, fmt.Errorf("local volume backup service unavailable")
648+ }
649+ jobs, err := l.volumeBackupSvc.VolumeBackupStatus(ctx)
650+ if err != nil {
651+ return nil, 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, fmt.Errorf("local volume backup service unavailable")
659+ }
660+ jobs, err := l.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, volumeName)
661+ if err != nil {
662+ return nil, err
663+ }
664+ return &dto.VolumeBackupRunResponse{Status: "ok", Backups: toDTOVolumeBackupJobs(jobs)}, nil
665+}
666+
632 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {667 func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
633 if l.logSvc == nil {668 if l.logSvc == nil {
634 return nil, fmt.Errorf("local log service unavailable")669 return nil, fmt.Errorf("local log service unavailable")
@@ -785,3 +820,41 @@ func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
785 Error: job.Error,820 Error: job.Error,
786 }821 }
787 }822 }
823+
824+func toDTOVolumeBackupJobs(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
825+ out := make([]dto.VolumeBackupJob, 0, len(jobs))
826+ for _, job := range jobs {
827+ out = append(out, toDTOVolumeBackupJob(job))
828+ }
829+ return out
830+}
831+
832+func toDTOVolumeBackupJob(job domain.VolumeBackupJob) dto.VolumeBackupJob {
833+ var startedAt *time.Time
834+ if !job.StartedAt.IsZero() {
835+ t := job.StartedAt
836+ startedAt = &t
837+ }
838+ var completedAt *time.Time
839+ if !job.CompletedAt.IsZero() {
840+ t := job.CompletedAt
841+ completedAt = &t
842+ }
843+
844+ return dto.VolumeBackupJob{
845+ ID: job.ID,
846+ Domain: job.Domain,
847+ ContainerName: job.ContainerName,
848+ ContainerID: job.ContainerID,
849+ VolumeName: job.VolumeName,
850+ MountPath: job.MountPath,
851+ Compression: job.Metadata["compression"],
852+ Type: string(job.Type),
853+ Status: string(job.Status),
854+ StartedAt: startedAt,
855+ CompletedAt: completedAt,
856+ SizeBytes: job.SizeBytes,
857+ ArtifactRef: job.ArtifactRef,
858+ Error: job.Error,
859+ }
860+}
M internal/adapters/in/cli/controlplane_remote.go +12 -0
@@ -169,6 +169,18 @@ func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain s
169169 return r.client.DetectDatabases(ctx, backupDomain)
170170 }
171171
172+func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
173+ return r.client.ListVolumeBackups(ctx, backupDomain)
174+}
175+
176+func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
177+ return r.client.VolumeBackupStatus(ctx)
178+}
179+
180+func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
181+ return r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
182+}
183+
172184 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
173185 return r.client.GetProcessLogs(ctx, lines)
174186 }
@@ -169,6 +169,18 @@ func (r *remoteControlPlane) DetectDatabases(ctx context.Context, backupDomain s
169 return r.client.DetectDatabases(ctx, backupDomain)169 return r.client.DetectDatabases(ctx, backupDomain)
170 }170 }
171 171
172+func (r *remoteControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
173+ return r.client.ListVolumeBackups(ctx, backupDomain)
174+}
175+
176+func (r *remoteControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
177+ return r.client.VolumeBackupStatus(ctx)
178+}
179+
180+func (r *remoteControlPlane) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
181+ return r.client.RunVolumeBackups(ctx, backupDomain, volumeName)
182+}
183+
172 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {184 func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
173 return r.client.GetProcessLogs(ctx, lines)185 return r.client.GetProcessLogs(ctx, lines)
174 }186 }
M internal/adapters/in/cli/mocks/mock_control_plane.go +204 -0
@@ -1922,6 +1922,74 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont
19221922 return _c
19231923 }
19241924
1925+// ListVolumeBackups provides a mock function for the type MockControlPlane
1926+func (_mock *MockControlPlane) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
1927+ ret := _mock.Called(ctx, backupDomain)
1928+
1929+ if len(ret) == 0 {
1930+ panic("no return value specified for ListVolumeBackups")
1931+ }
1932+
1933+ var r0 []dto.VolumeBackupJob
1934+ var r1 error
1935+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]dto.VolumeBackupJob, error)); ok {
1936+ return returnFunc(ctx, backupDomain)
1937+ }
1938+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []dto.VolumeBackupJob); ok {
1939+ r0 = returnFunc(ctx, backupDomain)
1940+ } else {
1941+ if ret.Get(0) != nil {
1942+ r0 = ret.Get(0).([]dto.VolumeBackupJob)
1943+ }
1944+ }
1945+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1946+ r1 = returnFunc(ctx, backupDomain)
1947+ } else {
1948+ r1 = ret.Error(1)
1949+ }
1950+ return r0, r1
1951+}
1952+
1953+// MockControlPlane_ListVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumeBackups'
1954+type MockControlPlane_ListVolumeBackups_Call struct {
1955+ *mock.Call
1956+}
1957+
1958+// ListVolumeBackups is a helper method to define mock.On call
1959+// - ctx context.Context
1960+// - backupDomain string
1961+func (_e *MockControlPlane_Expecter) ListVolumeBackups(ctx interface{}, backupDomain interface{}) *MockControlPlane_ListVolumeBackups_Call {
1962+ return &MockControlPlane_ListVolumeBackups_Call{Call: _e.mock.On("ListVolumeBackups", ctx, backupDomain)}
1963+}
1964+
1965+func (_c *MockControlPlane_ListVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string)) *MockControlPlane_ListVolumeBackups_Call {
1966+ _c.Call.Run(func(args mock.Arguments) {
1967+ var arg0 context.Context
1968+ if args[0] != nil {
1969+ arg0 = args[0].(context.Context)
1970+ }
1971+ var arg1 string
1972+ if args[1] != nil {
1973+ arg1 = args[1].(string)
1974+ }
1975+ run(
1976+ arg0,
1977+ arg1,
1978+ )
1979+ })
1980+ return _c
1981+}
1982+
1983+func (_c *MockControlPlane_ListVolumeBackups_Call) Return(volumeBackupJobs []dto.VolumeBackupJob, err error) *MockControlPlane_ListVolumeBackups_Call {
1984+ _c.Call.Return(volumeBackupJobs, err)
1985+ return _c
1986+}
1987+
1988+func (_c *MockControlPlane_ListVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error)) *MockControlPlane_ListVolumeBackups_Call {
1989+ _c.Call.Return(run)
1990+ return _c
1991+}
1992+
19251993 // ListVolumes provides a mock function for the type MockControlPlane
19261994 func (_mock *MockControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
19271995 ret := _mock.Called(ctx)
@@ -2428,6 +2496,80 @@ func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Con
24282496 return _c
24292497 }
24302498
2499+// RunVolumeBackups provides a mock function for the type MockControlPlane
2500+func (_mock *MockControlPlane) RunVolumeBackups(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error) {
2501+ ret := _mock.Called(ctx, backupDomain, volumeName)
2502+
2503+ if len(ret) == 0 {
2504+ panic("no return value specified for RunVolumeBackups")
2505+ }
2506+
2507+ var r0 *dto.VolumeBackupRunResponse
2508+ var r1 error
2509+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*dto.VolumeBackupRunResponse, error)); ok {
2510+ return returnFunc(ctx, backupDomain, volumeName)
2511+ }
2512+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *dto.VolumeBackupRunResponse); ok {
2513+ r0 = returnFunc(ctx, backupDomain, volumeName)
2514+ } else {
2515+ if ret.Get(0) != nil {
2516+ r0 = ret.Get(0).(*dto.VolumeBackupRunResponse)
2517+ }
2518+ }
2519+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
2520+ r1 = returnFunc(ctx, backupDomain, volumeName)
2521+ } else {
2522+ r1 = ret.Error(1)
2523+ }
2524+ return r0, r1
2525+}
2526+
2527+// MockControlPlane_RunVolumeBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunVolumeBackups'
2528+type MockControlPlane_RunVolumeBackups_Call struct {
2529+ *mock.Call
2530+}
2531+
2532+// RunVolumeBackups is a helper method to define mock.On call
2533+// - ctx context.Context
2534+// - backupDomain string
2535+// - volumeName string
2536+func (_e *MockControlPlane_Expecter) RunVolumeBackups(ctx interface{}, backupDomain interface{}, volumeName interface{}) *MockControlPlane_RunVolumeBackups_Call {
2537+ return &MockControlPlane_RunVolumeBackups_Call{Call: _e.mock.On("RunVolumeBackups", ctx, backupDomain, volumeName)}
2538+}
2539+
2540+func (_c *MockControlPlane_RunVolumeBackups_Call) Run(run func(ctx context.Context, backupDomain string, volumeName string)) *MockControlPlane_RunVolumeBackups_Call {
2541+ _c.Call.Run(func(args mock.Arguments) {
2542+ var arg0 context.Context
2543+ if args[0] != nil {
2544+ arg0 = args[0].(context.Context)
2545+ }
2546+ var arg1 string
2547+ if args[1] != nil {
2548+ arg1 = args[1].(string)
2549+ }
2550+ var arg2 string
2551+ if args[2] != nil {
2552+ arg2 = args[2].(string)
2553+ }
2554+ run(
2555+ arg0,
2556+ arg1,
2557+ arg2,
2558+ )
2559+ })
2560+ return _c
2561+}
2562+
2563+func (_c *MockControlPlane_RunVolumeBackups_Call) Return(volumeBackupRunResponse *dto.VolumeBackupRunResponse, err error) *MockControlPlane_RunVolumeBackups_Call {
2564+ _c.Call.Return(volumeBackupRunResponse, err)
2565+ return _c
2566+}
2567+
2568+func (_c *MockControlPlane_RunVolumeBackups_Call) RunAndReturn(run func(ctx context.Context, backupDomain string, volumeName string) (*dto.VolumeBackupRunResponse, error)) *MockControlPlane_RunVolumeBackups_Call {
2569+ _c.Call.Return(run)
2570+ return _c
2571+}
2572+
24312573 // SetAttachmentSecrets provides a mock function for the type MockControlPlane
24322574 func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error {
24332575 ret := _mock.Called(ctx, domain1, service, secrets)
@@ -2758,3 +2900,65 @@ func (_c *MockControlPlane_UpdateRoute_Call) RunAndReturn(run func(ctx context.C
27582900 _c.Call.Return(run)
27592901 return _c
27602902 }
2903+
2904+// VolumeBackupStatus provides a mock function for the type MockControlPlane
2905+func (_mock *MockControlPlane) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
2906+ ret := _mock.Called(ctx)
2907+
2908+ if len(ret) == 0 {
2909+ panic("no return value specified for VolumeBackupStatus")
2910+ }
2911+
2912+ var r0 []dto.VolumeBackupJob
2913+ var r1 error
2914+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]dto.VolumeBackupJob, error)); ok {
2915+ return returnFunc(ctx)
2916+ }
2917+ if returnFunc, ok := ret.Get(0).(func(context.Context) []dto.VolumeBackupJob); ok {
2918+ r0 = returnFunc(ctx)
2919+ } else {
2920+ if ret.Get(0) != nil {
2921+ r0 = ret.Get(0).([]dto.VolumeBackupJob)
2922+ }
2923+ }
2924+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
2925+ r1 = returnFunc(ctx)
2926+ } else {
2927+ r1 = ret.Error(1)
2928+ }
2929+ return r0, r1
2930+}
2931+
2932+// MockControlPlane_VolumeBackupStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VolumeBackupStatus'
2933+type MockControlPlane_VolumeBackupStatus_Call struct {
2934+ *mock.Call
2935+}
2936+
2937+// VolumeBackupStatus is a helper method to define mock.On call
2938+// - ctx context.Context
2939+func (_e *MockControlPlane_Expecter) VolumeBackupStatus(ctx interface{}) *MockControlPlane_VolumeBackupStatus_Call {
2940+ return &MockControlPlane_VolumeBackupStatus_Call{Call: _e.mock.On("VolumeBackupStatus", ctx)}
2941+}
2942+
2943+func (_c *MockControlPlane_VolumeBackupStatus_Call) Run(run func(ctx context.Context)) *MockControlPlane_VolumeBackupStatus_Call {
2944+ _c.Call.Run(func(args mock.Arguments) {
2945+ var arg0 context.Context
2946+ if args[0] != nil {
2947+ arg0 = args[0].(context.Context)
2948+ }
2949+ run(
2950+ arg0,
2951+ )
2952+ })
2953+ return _c
2954+}
2955+
2956+func (_c *MockControlPlane_VolumeBackupStatus_Call) Return(volumeBackupJobs []dto.VolumeBackupJob, err error) *MockControlPlane_VolumeBackupStatus_Call {
2957+ _c.Call.Return(volumeBackupJobs, err)
2958+ return _c
2959+}
2960+
2961+func (_c *MockControlPlane_VolumeBackupStatus_Call) RunAndReturn(run func(ctx context.Context) ([]dto.VolumeBackupJob, error)) *MockControlPlane_VolumeBackupStatus_Call {
2962+ _c.Call.Return(run)
2963+ return _c
2964+}
@@ -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 +52 -0
@@ -836,6 +836,58 @@ func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dt
836836 return result.Databases, nil
837837 }
838838
839+// ListVolumeBackups returns volume backups globally or for a domain.
840+func (c *Client) ListVolumeBackups(ctx context.Context, backupDomain string) ([]dto.VolumeBackupJob, error) {
841+ path := "/backups/volumes"
842+ if backupDomain != "" {
843+ path += "/" + url.PathEscape(backupDomain)
844+ }
845+
846+ resp, err := c.request(ctx, http.MethodGet, path, nil)
847+ if err != nil {
848+ return nil, err
849+ }
850+
851+ var result dto.VolumeBackupsResponse
852+ if err := parseResponse(resp, &result); err != nil {
853+ return nil, err
854+ }
855+ return result.Backups, nil
856+}
857+
858+// VolumeBackupStatus returns aggregate volume backup status.
859+func (c *Client) VolumeBackupStatus(ctx context.Context) ([]dto.VolumeBackupJob, error) {
860+ resp, err := c.request(ctx, http.MethodGet, "/backups/volumes/status", nil)
861+ if err != nil {
862+ return nil, err
863+ }
864+
865+ var result dto.VolumeBackupsResponse
866+ if err := parseResponse(resp, &result); err != nil {
867+ return nil, err
868+ }
869+ return result.Backups, nil
870+}
871+
872+// RunVolumeBackups triggers volume backups.
873+func (c *Client) RunVolumeBackups(ctx context.Context, backupDomain, volumeName string) (*dto.VolumeBackupRunResponse, error) {
874+ path := "/backups/volumes"
875+ if backupDomain != "" {
876+ path += "/" + url.PathEscape(backupDomain)
877+ }
878+
879+ resp, err := c.request(ctx, http.MethodPost, path, dto.VolumeBackupRunRequest{Volume: volumeName})
880+ if err != nil {
881+ return nil, err
882+ }
883+
884+ var result dto.VolumeBackupRunResponse
885+ if err := parseResponse(resp, &result); err != nil {
886+ return nil, err
887+ }
888+ return &result, nil
889+}
890+
839891 // GetHealth returns health status for all routes with HTTP probing.
840892 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
841893 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
@@ -836,6 +836,58 @@ 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+ return &result, nil
889+}
890+
839 // GetHealth returns health status for all routes with HTTP probing.891 // GetHealth returns health status for all routes with HTTP probing.
840 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {892 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
841 resp, err := c.request(ctx, http.MethodGet, "/health", nil)893 resp, err := c.request(ctx, http.MethodGet, "/health", nil)
M internal/adapters/in/http/admin/handler.go +165 -44
@@ -40,20 +40,21 @@ type reloadTrigger interface {
4040
4141 // Handler implements the HTTP handler for the admin API.
4242 type Handler struct {
43- configSvc in.ConfigService
44- authSvc in.AuthService
45- containerSvc in.ContainerService
46- backupSvc in.BackupService
47- imageSvc in.ImageService
48- healthSvc in.HealthService
49- secretSvc in.SecretService
50- logSvc in.LogService
51- volumeSvc in.VolumeService
52- registrySvc registryDeployService
53- previewSvc previewService
54- reloadTrigger reloadTrigger
55- publicTLSSvc in.PublicTLSService
56- log zerowrap.Logger
43+ configSvc in.ConfigService
44+ authSvc in.AuthService
45+ containerSvc in.ContainerService
46+ backupSvc in.BackupService
47+ volumeBackupSvc in.VolumeBackupService
48+ imageSvc in.ImageService
49+ healthSvc in.HealthService
50+ secretSvc in.SecretService
51+ logSvc in.LogService
52+ volumeSvc in.VolumeService
53+ registrySvc registryDeployService
54+ previewSvc previewService
55+ reloadTrigger reloadTrigger
56+ publicTLSSvc in.PublicTLSService
57+ log zerowrap.Logger
5758 }
5859
5960 // Type aliases for API responses using shared DTO types.
@@ -142,6 +143,36 @@ func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
142143 }
143144 }
144145
146+func toVolumeBackupJobResponse(job domain.VolumeBackupJob) dto.VolumeBackupJob {
147+ var startedAt *time.Time
148+ if !job.StartedAt.IsZero() {
149+ t := job.StartedAt
150+ startedAt = &t
151+ }
152+ var completedAt *time.Time
153+ if !job.CompletedAt.IsZero() {
154+ t := job.CompletedAt
155+ completedAt = &t
156+ }
157+
158+ return dto.VolumeBackupJob{
159+ ID: job.ID,
160+ Domain: job.Domain,
161+ ContainerName: job.ContainerName,
162+ ContainerID: job.ContainerID,
163+ VolumeName: job.VolumeName,
164+ MountPath: job.MountPath,
165+ Compression: job.Metadata["compression"],
166+ Type: string(job.Type),
167+ Status: string(job.Status),
168+ StartedAt: startedAt,
169+ CompletedAt: completedAt,
170+ SizeBytes: job.SizeBytes,
171+ ArtifactRef: job.ArtifactRef,
172+ Error: job.Error,
173+ }
174+}
175+
145176 func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
146177 return dto.DatabaseInfo{
147178 Type: string(db.Type),
@@ -156,39 +187,41 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
156187
157188 // HandlerDeps contains all dependencies for the admin HTTP handler.
158189 type HandlerDeps struct {
159- ConfigSvc in.ConfigService
160- AuthSvc in.AuthService
161- ContainerSvc in.ContainerService
162- HealthSvc in.HealthService
163- SecretSvc in.SecretService
164- LogSvc in.LogService
165- RegistrySvc registryDeployService
166- Log zerowrap.Logger
167- BackupSvc in.BackupService
168- PreviewSvc previewService
169- ImageSvc in.ImageService
170- VolumeSvc in.VolumeService
171- ReloadTrigger reloadTrigger
172- PublicTLSSvc in.PublicTLSService
190+ ConfigSvc in.ConfigService
191+ AuthSvc in.AuthService
192+ ContainerSvc in.ContainerService
193+ HealthSvc in.HealthService
194+ SecretSvc in.SecretService
195+ LogSvc in.LogService
196+ RegistrySvc registryDeployService
197+ Log zerowrap.Logger
198+ BackupSvc in.BackupService
199+ VolumeBackupSvc in.VolumeBackupService
200+ PreviewSvc previewService
201+ ImageSvc in.ImageService
202+ VolumeSvc in.VolumeService
203+ ReloadTrigger reloadTrigger
204+ PublicTLSSvc in.PublicTLSService
173205 }
174206
175207 // NewHandler creates a new admin HTTP handler.
176208 func NewHandler(deps HandlerDeps) *Handler {
177209 return &Handler{
178- configSvc: deps.ConfigSvc,
179- authSvc: deps.AuthSvc,
180- containerSvc: deps.ContainerSvc,
181- backupSvc: deps.BackupSvc,
182- imageSvc: deps.ImageSvc,
183- healthSvc: deps.HealthSvc,
184- secretSvc: deps.SecretSvc,
185- logSvc: deps.LogSvc,
186- volumeSvc: deps.VolumeSvc,
187- registrySvc: deps.RegistrySvc,
188- previewSvc: deps.PreviewSvc,
189- reloadTrigger: deps.ReloadTrigger,
190- publicTLSSvc: deps.PublicTLSSvc,
191- log: deps.Log,
210+ configSvc: deps.ConfigSvc,
211+ authSvc: deps.AuthSvc,
212+ containerSvc: deps.ContainerSvc,
213+ backupSvc: deps.BackupSvc,
214+ volumeBackupSvc: deps.VolumeBackupSvc,
215+ imageSvc: deps.ImageSvc,
216+ healthSvc: deps.HealthSvc,
217+ secretSvc: deps.SecretSvc,
218+ logSvc: deps.LogSvc,
219+ volumeSvc: deps.VolumeSvc,
220+ registrySvc: deps.RegistrySvc,
221+ previewSvc: deps.PreviewSvc,
222+ reloadTrigger: deps.ReloadTrigger,
223+ publicTLSSvc: deps.PublicTLSSvc,
224+ log: deps.Log,
192225 }
193226 }
194227
@@ -1313,13 +1346,17 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
13131346
13141347 // handleBackups handles /admin/backups endpoints.
13151348 func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
1349+ path = strings.TrimSuffix(path, "/")
1350+ if path == "/backups/volumes" || strings.HasPrefix(path, "/backups/volumes/") {
1351+ h.handleVolumeBackups(w, r, path)
1352+ return
1353+ }
1354+
13161355 if h.backupSvc == nil {
13171356 h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
13181357 return
13191358 }
13201359
1321- path = strings.TrimSuffix(path, "/")
1322-
13231360 if path == "/backups" || path == "/backups/status" {
13241361 h.handleBackupsStatus(w, r)
13251362 return
@@ -1346,6 +1383,82 @@ func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path str
13461383 h.sendError(w, http.StatusNotFound, "route not found")
13471384 }
13481385
1386+func (h *Handler) handleVolumeBackups(w http.ResponseWriter, r *http.Request, path string) {
1387+ if h.volumeBackupSvc == nil {
1388+ h.sendError(w, http.StatusServiceUnavailable, "volume backup service not available")
1389+ return
1390+ }
1391+ if path == "/backups/volumes/status" {
1392+ h.handleVolumeBackupsStatus(w, r)
1393+ return
1394+ }
1395+ if path == "/backups/volumes" {
1396+ h.handleVolumeBackupsDomain(w, r, "")
1397+ return
1398+ }
1399+ suffix := strings.TrimPrefix(path, "/backups/volumes/")
1400+ parts := strings.Split(suffix, "/")
1401+ if len(parts) != 1 || parts[0] == "" {
1402+ h.sendError(w, http.StatusNotFound, "route not found")
1403+ return
1404+ }
1405+ h.handleVolumeBackupsDomain(w, r, parts[0])
1406+}
1407+
1408+func (h *Handler) handleVolumeBackupsStatus(w http.ResponseWriter, r *http.Request) {
1409+ ctx := r.Context()
1410+ if r.Method != http.MethodGet {
1411+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1412+ return
1413+ }
1414+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1415+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1416+ return
1417+ }
1418+ jobs, err := h.volumeBackupSvc.VolumeBackupStatus(ctx)
1419+ if err != nil {
1420+ h.sendError(w, http.StatusInternalServerError, "failed to get volume backup status")
1421+ return
1422+ }
1423+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupsResponse{Backups: mapVolumeBackupJobsResponse(jobs)})
1424+}
1425+
1426+func (h *Handler) handleVolumeBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
1427+ ctx := r.Context()
1428+ switch r.Method {
1429+ case http.MethodGet:
1430+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
1431+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
1432+ return
1433+ }
1434+ jobs, err := h.volumeBackupSvc.ListVolumeBackups(ctx, backupDomain)
1435+ if err != nil {
1436+ h.sendError(w, http.StatusInternalServerError, "failed to list volume backups")
1437+ return
1438+ }
1439+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupsResponse{Backups: mapVolumeBackupJobsResponse(jobs)})
1440+ case http.MethodPost:
1441+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
1442+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
1443+ return
1444+ }
1445+ var req dto.VolumeBackupRunRequest
1446+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
1447+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
1448+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
1449+ return
1450+ }
1451+ jobs, err := h.volumeBackupSvc.RunVolumeBackups(ctx, backupDomain, req.Volume)
1452+ if err != nil {
1453+ h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")
1454+ return
1455+ }
1456+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: mapVolumeBackupJobsResponse(jobs)})
1457+ default:
1458+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1459+ }
1460+}
1461+
13491462 func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
13501463 ctx := r.Context()
13511464 if r.Method != http.MethodGet {
@@ -1456,6 +1569,14 @@ func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
14561569 return response
14571570 }
14581571
1572+func mapVolumeBackupJobsResponse(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
1573+ response := make([]dto.VolumeBackupJob, 0, len(jobs))
1574+ for _, job := range jobs {
1575+ response = append(response, toVolumeBackupJobResponse(job))
1576+ }
1577+ return response
1578+}
1579+
14591580 // handleReload handles /admin/reload endpoint.
14601581 // This reloads configuration from file into memory and triggers container sync.
14611582 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
@@ -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,82 @@ 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+ if err != nil {
1453+ h.sendError(w, http.StatusInternalServerError, "failed to run volume backups")
1454+ return
1455+ }
1456+ h.sendJSON(w, http.StatusOK, dto.VolumeBackupRunResponse{Status: "ok", Backups: mapVolumeBackupJobsResponse(jobs)})
1457+ default:
1458+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
1459+ }
1460+}
1461+
1349 func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {1462 func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
1350 ctx := r.Context()1463 ctx := r.Context()
1351 if r.Method != http.MethodGet {1464 if r.Method != http.MethodGet {
@@ -1456,6 +1569,14 @@ func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
1456 return response1569 return response
1457 }1570 }
1458 1571
1572+func mapVolumeBackupJobsResponse(jobs []domain.VolumeBackupJob) []dto.VolumeBackupJob {
1573+ response := make([]dto.VolumeBackupJob, 0, len(jobs))
1574+ for _, job := range jobs {
1575+ response = append(response, toVolumeBackupJobResponse(job))
1576+ }
1577+ return response
1578+}
1579+
1459 // handleReload handles /admin/reload endpoint.1580 // handleReload handles /admin/reload endpoint.
1460 // This reloads configuration from file into memory and triggers container sync.1581 // This reloads configuration from file into memory and triggers container sync.
1461 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {1582 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
M internal/adapters/out/docker/runtime.go +207 -17
@@ -24,6 +24,7 @@ import (
2424 "github.com/docker/docker/api/types/container"
2525 "github.com/docker/docker/api/types/filters"
2626 "github.com/docker/docker/api/types/image"
27+ "github.com/docker/docker/api/types/mount"
2728 "github.com/docker/docker/api/types/network"
2829 "github.com/docker/docker/api/types/registry"
2930 "github.com/docker/docker/api/types/strslice"
@@ -44,11 +45,13 @@ type Runtime struct {
4445 }
4546
4647 var _ out.ContainerRuntime = (*Runtime)(nil)
48+var _ out.VolumeArchiveExporter = (*Runtime)(nil)
4749
4850 type pipeReadCloser struct {
4951 pr *io.PipeReader
5052 pw *io.PipeWriter
5153 original io.Closer
54+ cleanup func() error
5255 once sync.Once
5356 }
5457
@@ -75,6 +78,11 @@ func (p *pipeReadCloser) Close() error {
7578 closeErr = err
7679 }
7780 }
81+ if p.cleanup != nil {
82+ if err := p.cleanup(); err != nil && closeErr == nil {
83+ closeErr = err
84+ }
85+ }
7886 })
7987 return closeErr
8088 }
@@ -394,15 +402,27 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta
394402 name = strings.TrimPrefix(c.Names[0], "/")
395403 }
396404
405+ volumeMounts := make([]domain.ContainerVolumeMount, 0, len(c.Mounts))
406+ for _, m := range c.Mounts {
407+ volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
408+ Name: m.Name,
409+ Type: string(m.Type),
410+ Source: m.Source,
411+ Destination: m.Destination,
412+ ReadOnly: !m.RW,
413+ })
414+ }
415+
397416 result = append(result, &domain.Container{
398- ID: c.ID,
399- Image: c.Image,
400- ImageID: c.ImageID,
401- Name: name,
402- Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
403- Ports: ports,
404- Labels: c.Labels,
405- Created: time.Unix(c.Created, 0),
417+ ID: c.ID,
418+ Image: c.Image,
419+ ImageID: c.ImageID,
420+ Name: name,
421+ Status: c.State, // Use State (e.g., "running") not Status (e.g., "Up 2 days")
422+ Ports: ports,
423+ Labels: c.Labels,
424+ VolumeMounts: volumeMounts,
425+ Created: time.Unix(c.Created, 0),
406426 })
407427 }
408428
@@ -442,17 +462,28 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
442462 name := strings.TrimPrefix(resp.Name, "/")
443463
444464 created, _ := time.Parse(time.RFC3339Nano, resp.Created)
465+ volumeMounts := make([]domain.ContainerVolumeMount, 0, len(resp.Mounts))
466+ for _, m := range resp.Mounts {
467+ volumeMounts = append(volumeMounts, domain.ContainerVolumeMount{
468+ Name: m.Name,
469+ Type: string(m.Type),
470+ Source: m.Source,
471+ Destination: m.Destination,
472+ ReadOnly: !m.RW,
473+ })
474+ }
445475
446476 return &domain.Container{
447- ID: resp.ID,
448- Image: resp.Config.Image,
449- ImageID: resp.Image,
450- Name: name,
451- Status: resp.State.Status,
452- ExitCode: resp.State.ExitCode,
453- Ports: ports,
454- Labels: resp.Config.Labels,
455- Created: created,
477+ ID: resp.ID,
478+ Image: resp.Config.Image,
479+ ImageID: resp.Image,
480+ Name: name,
481+ Status: resp.State.Status,
482+ ExitCode: resp.State.ExitCode,
483+ Ports: ports,
484+ Labels: resp.Config.Labels,
485+ VolumeMounts: volumeMounts,
486+ Created: created,
456487 }, nil
457488 }
458489
@@ -1204,6 +1235,165 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
12041235 return result, nil
12051236 }
12061237
1238+// ExportVolumeArchive exports a named volume as a compressed tar archive stream.
1239+func (r *Runtime) ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
1240+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1241+ zerowrap.FieldLayer: "adapter",
1242+ zerowrap.FieldAdapter: "docker",
1243+ zerowrap.FieldAction: "ExportVolumeArchive",
1244+ "volume": req.VolumeName,
1245+ })
1246+ log := zerowrap.FromCtx(ctx)
1247+
1248+ if err := validateVolumeArchiveRequest(req); err != nil {
1249+ return nil, err
1250+ }
1251+ if err := r.ensureVolumeArchiveHelperImage(ctx, req.HelperImage, log); err != nil {
1252+ return nil, err
1253+ }
1254+ created, cleanup, err := r.createVolumeArchiveHelper(ctx, req, log)
1255+ if err != nil {
1256+ return nil, err
1257+ }
1258+ if err := r.client.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
1259+ _ = cleanup()
1260+ return nil, log.WrapErr(err, "failed to start volume archive helper container")
1261+ }
1262+
1263+ stream, err := r.attachVolumeArchiveStream(ctx, created.ID, cleanup, log)
1264+ if err != nil {
1265+ _ = cleanup()
1266+ return nil, err
1267+ }
1268+ return &domain.VolumeArchiveResult{Stream: stream}, nil
1269+}
1270+
1271+func validateVolumeArchiveRequest(req domain.VolumeArchiveRequest) error {
1272+ if strings.TrimSpace(req.VolumeName) == "" {
1273+ return fmt.Errorf("volume name is required")
1274+ }
1275+ if strings.TrimSpace(req.HelperImage) == "" {
1276+ return fmt.Errorf("helper image is required")
1277+ }
1278+ return nil
1279+}
1280+
1281+func (r *Runtime) ensureVolumeArchiveHelperImage(ctx context.Context, imageRef string, log zerowrap.Logger) error {
1282+ if _, err := r.client.ImageInspect(ctx, imageRef); err != nil {
1283+ if !cerrdefs.IsNotFound(err) {
1284+ return log.WrapErr(err, "failed to inspect volume archive helper image")
1285+ }
1286+ return r.PullImage(ctx, imageRef)
1287+ }
1288+ return nil
1289+}
1290+
1291+func (r *Runtime) createVolumeArchiveHelper(ctx context.Context, req domain.VolumeArchiveRequest, log zerowrap.Logger) (container.CreateResponse, func() error, error) {
1292+ cmd, err := volumeArchiveCommand(req.Compression)
1293+ if err != nil {
1294+ return container.CreateResponse{}, nil, err
1295+ }
1296+ created, err := r.client.ContainerCreate(ctx, volumeArchiveContainerConfig(req.HelperImage, cmd), volumeArchiveHostConfig(req.VolumeName), nil, nil, fmt.Sprintf("gordon-volume-backup-%d", time.Now().UTC().UnixNano()))
1297+ if err != nil {
1298+ return container.CreateResponse{}, nil, log.WrapErr(err, "failed to create volume archive helper container")
1299+ }
1300+ var cleanupOnce sync.Once
1301+ cleanup := func() error {
1302+ var cleanupErr error
1303+ cleanupOnce.Do(func() {
1304+ removeCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1305+ defer cancel()
1306+ cleanupErr = r.RemoveContainer(removeCtx, created.ID, true)
1307+ })
1308+ return cleanupErr
1309+ }
1310+ return created, cleanup, nil
1311+}
1312+
1313+func volumeArchiveContainerConfig(imageRef, cmd string) *container.Config {
1314+ return &container.Config{
1315+ Image: imageRef,
1316+ Cmd: []string{"sh", "-c", cmd},
1317+ AttachStdout: true,
1318+ AttachStderr: true,
1319+ NetworkDisabled: true,
1320+ Labels: map[string]string{
1321+ domain.LabelManaged: "true",
1322+ "gordon.purpose": "volume-backup",
1323+ },
1324+ }
1325+}
1326+
1327+func volumeArchiveHostConfig(volumeName string) *container.HostConfig {
1328+ return &container.HostConfig{
1329+ AutoRemove: false,
1330+ Mounts: []mount.Mount{{
1331+ Type: mount.TypeVolume,
1332+ Source: volumeName,
1333+ Target: "/volume",
1334+ ReadOnly: true,
1335+ }},
1336+ }
1337+}
1338+
1339+func (r *Runtime) attachVolumeArchiveStream(ctx context.Context, containerID string, cleanup func() error, log zerowrap.Logger) (io.ReadCloser, error) {
1340+ logs, err := r.client.ContainerLogs(ctx, containerID, container.LogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
1341+ if err != nil {
1342+ return nil, log.WrapErr(err, "failed to attach volume archive stream")
1343+ }
1344+ statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
1345+ pr, pw := io.Pipe()
1346+ go streamVolumeArchiveLogs(logs, pw, statusCh, errCh, cleanup, log)
1347+ return &pipeReadCloser{pr: pr, pw: pw, original: logs, cleanup: cleanup}, nil
1348+}
1349+
1350+func streamVolumeArchiveLogs(logs io.ReadCloser, pw *io.PipeWriter, statusCh <-chan container.WaitResponse, errCh <-chan error, cleanup func() error, log zerowrap.Logger) {
1351+ defer logs.Close()
1352+ defer func() {
1353+ if err := cleanup(); err != nil {
1354+ log.Warn().Err(err).Msg("failed to clean up volume archive helper container")
1355+ }
1356+ }()
1357+
1358+ var stderr bytes.Buffer
1359+ _, copyErr := stdcopy.StdCopy(pw, &stderr, logs)
1360+ statusCode, waitErr := waitForVolumeArchiveContainer(statusCh, errCh)
1361+ closeVolumeArchivePipe(pw, copyErr, waitErr, statusCode, stderr.String())
1362+}
1363+
1364+func waitForVolumeArchiveContainer(statusCh <-chan container.WaitResponse, errCh <-chan error) (int64, error) {
1365+ select {
1366+ case err := <-errCh:
1367+ return 0, err
1368+ case status := <-statusCh:
1369+ return status.StatusCode, nil
1370+ }
1371+}
1372+
1373+func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
1374+ switch {
1375+ case copyErr != nil:
1376+ _ = pw.CloseWithError(copyErr)
1377+ case waitErr != nil:
1378+ _ = pw.CloseWithError(waitErr)
1379+ case statusCode != 0:
1380+ _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr)))
1381+ default:
1382+ _ = pw.Close()
1383+ }
1384+}
1385+
1386+func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) {
1387+ switch compression {
1388+ case domain.VolumeBackupCompressionGzip:
1389+ return "cd /volume && tar -czf - .", nil
1390+ case domain.VolumeBackupCompressionZstd:
1391+ 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
1392+ default:
1393+ return "", fmt.Errorf("unsupported volume backup compression: %s", compression)
1394+ }
1395+}
1396+
12071397 // InspectImageEnv gets the environment variables declared in the image.
12081398 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
12091399 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -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,165 @@ 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+ select {
1366+ case err := <-errCh:
1367+ return 0, err
1368+ case status := <-statusCh:
1369+ return status.StatusCode, nil
1370+ }
1371+}
1372+
1373+func closeVolumeArchivePipe(pw *io.PipeWriter, copyErr, waitErr error, statusCode int64, stderr string) {
1374+ switch {
1375+ case copyErr != nil:
1376+ _ = pw.CloseWithError(copyErr)
1377+ case waitErr != nil:
1378+ _ = pw.CloseWithError(waitErr)
1379+ case statusCode != 0:
1380+ _ = pw.CloseWithError(fmt.Errorf("volume archive helper exited with code %d: %s", statusCode, strings.TrimSpace(stderr)))
1381+ default:
1382+ _ = pw.Close()
1383+ }
1384+}
1385+
1386+func volumeArchiveCommand(compression domain.VolumeBackupCompression) (string, error) {
1387+ switch compression {
1388+ case domain.VolumeBackupCompressionGzip:
1389+ return "cd /volume && tar -czf - .", nil
1390+ case domain.VolumeBackupCompressionZstd:
1391+ 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
1392+ default:
1393+ return "", fmt.Errorf("unsupported volume backup compression: %s", compression)
1394+ }
1395+}
1396+
1207 // InspectImageEnv gets the environment variables declared in the image.1397 // InspectImageEnv gets the environment variables declared in the image.
1208 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {1398 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
1209 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1399 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
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+}
@@ -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+}
@@ -0,0 +1,150 @@
1+package s3
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "sort"
8+ "testing"
9+ "time"
10+
11+ "github.com/aws/aws-sdk-go-v2/aws"
12+ "github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
13+ awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
14+ awss3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+type fakeVolumeS3Client struct {
22+ objects map[string][]byte
23+ metadata map[string]map[string]string
24+ deleted []string
25+}
26+
27+func newFakeVolumeS3Client() *fakeVolumeS3Client {
28+ return &fakeVolumeS3Client{objects: make(map[string][]byte), metadata: make(map[string]map[string]string)}
29+}
30+
31+func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
32+ data := f.objects[aws.ToString(params.Key)]
33+ return &awss3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil
34+}
35+
36+func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
37+ return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
38+}
39+
40+func (f *fakeVolumeS3Client) ListObjectsV2(_ context.Context, params *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
41+ prefix := aws.ToString(params.Prefix)
42+ keys := make([]string, 0)
43+ for key := range f.objects {
44+ if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
45+ keys = append(keys, key)
46+ }
47+ }
48+ sort.Strings(keys)
49+ contents := make([]awss3types.Object, 0, len(keys))
50+ for _, key := range keys {
51+ size := int64(len(f.objects[key]))
52+ contents = append(contents, awss3types.Object{Key: aws.String(key), Size: aws.Int64(size)})
53+ }
54+ return &awss3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(false)}, nil
55+}
56+
57+func (f *fakeVolumeS3Client) DeleteObject(_ context.Context, params *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
58+ key := aws.ToString(params.Key)
59+ delete(f.objects, key)
60+ f.deleted = append(f.deleted, key)
61+ return &awss3.DeleteObjectOutput{}, nil
62+}
63+
64+type fakeVolumeUploader struct {
65+ client *fakeVolumeS3Client
66+ last *transfermanager.UploadObjectInput
67+}
68+
69+func (f *fakeVolumeUploader) UploadObject(_ context.Context, input *transfermanager.UploadObjectInput, _ ...func(*transfermanager.Options)) (*transfermanager.UploadObjectOutput, error) {
70+ data, err := io.ReadAll(input.Body)
71+ if err != nil {
72+ return nil, err
73+ }
74+ key := aws.ToString(input.Key)
75+ f.client.objects[key] = data
76+ f.client.metadata[key] = input.Metadata
77+ f.last = input
78+ return &transfermanager.UploadObjectOutput{Key: input.Key}, nil
79+}
80+
81+func TestVolumeBackupStorageStoreListGet(t *testing.T) {
82+ client := newFakeVolumeS3Client()
83+ uploader := &fakeVolumeUploader{client: client}
84+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
85+ S3Bucket: "gordon-backups",
86+ S3Prefix: "/prod/gordon/",
87+ }, client, uploader)
88+ started := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC)
89+
90+ artifact, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
91+ ID: "job/1",
92+ Domain: "app.example.com",
93+ ContainerName: "app",
94+ VolumeName: "gordon-app-data",
95+ MountPath: "/data",
96+ StartedAt: started,
97+ Metadata: map[string]string{"compression": string(domain.VolumeBackupCompressionZstd)},
98+ }, bytes.NewReader([]byte("archive")))
99+ require.NoError(t, err)
100+
101+ assert.Equal(t, "s3://gordon-backups/prod/gordon/domains/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)
102+ assert.Equal(t, "application/zstd", aws.ToString(uploader.last.ContentType))
103+
104+ jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
105+ require.NoError(t, err)
106+ require.Len(t, jobs, 1)
107+ assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
108+ assert.Equal(t, "app", jobs[0].ContainerName)
109+ assert.Equal(t, "/data", jobs[0].MountPath)
110+ assert.Equal(t, string(domain.VolumeBackupCompressionZstd), jobs[0].Metadata["compression"])
111+ assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
112+ assert.Equal(t, artifact, jobs[0].ArtifactRef)
113+
114+ rc, err := storage.GetVolumeArchive(context.Background(), artifact)
115+ require.NoError(t, err)
116+ defer rc.Close()
117+ data, err := io.ReadAll(rc)
118+ require.NoError(t, err)
119+ assert.Equal(t, []byte("archive"), data)
120+}
121+
122+func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
123+ client := newFakeVolumeS3Client()
124+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
125+ keys := []string{
126+ "domains/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst",
127+ "domains/app.example.com/volumes/gordon-data/20260619T020000Z-b.tar.zst",
128+ "domains/app.example.com/volumes/gordon-data/20260619T030000Z-c.tar.zst",
129+ "domains/app.example.com/volumes/gordon-data/not-a-backup.tar.zst",
130+ }
131+ for _, key := range keys {
132+ client.objects[key] = []byte("x")
133+ }
134+
135+ deleted, err := storage.ApplyVolumeRetention(context.Background(), "app.example.com", domain.VolumeBackupRetentionPolicy{Keep: 2})
136+ require.NoError(t, err)
137+
138+ assert.Equal(t, 1, deleted)
139+ assert.Equal(t, []string{"domains/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst"}, client.deleted)
140+ assert.Contains(t, client.objects, "domains/app.example.com/volumes/gordon-data/not-a-backup.tar.zst")
141+}
142+
143+func TestVolumeBackupStorageRejectsWrongBucketArtifact(t *testing.T) {
144+ storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "expected"}, newFakeVolumeS3Client(), nil)
145+
146+ _, err := storage.GetVolumeArchive(context.Background(), "s3://other/key")
147+
148+ require.Error(t, err)
149+ assert.Contains(t, err.Error(), "does not match")
150+}
M internal/app/kernel.go +26 -22
@@ -17,17 +17,18 @@ import (
1717 //
1818 // It intentionally does not start HTTP servers or register signal handlers.
1919 type Kernel struct {
20- authEnabled bool
21- configSvc in.ConfigService
22- secretSvc in.SecretService
23- containerSvc in.ContainerService
24- backupSvc in.BackupService
25- registrySvc in.RegistryService
26- healthSvc in.HealthService
27- logSvc in.LogService
28- volumeSvc in.VolumeService
29- publicTLSSvc in.PublicTLSService
30- cleanup func()
20+ authEnabled bool
21+ configSvc in.ConfigService
22+ secretSvc in.SecretService
23+ containerSvc in.ContainerService
24+ backupSvc in.BackupService
25+ volumeBackupSvc in.VolumeBackupService
26+ registrySvc in.RegistryService
27+ healthSvc in.HealthService
28+ logSvc in.LogService
29+ volumeSvc in.VolumeService
30+ publicTLSSvc in.PublicTLSService
31+ cleanup func()
3132 }
3233
3334 // NewKernel initializes local services without starting server listeners.
@@ -78,17 +79,18 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
7879 }
7980
8081 return &Kernel{
81- authEnabled: cfg.Auth.Enabled,
82- configSvc: svc.configSvc,
83- secretSvc: svc.secretSvc,
84- containerSvc: svc.containerSvc,
85- backupSvc: svc.backupSvc,
86- registrySvc: svc.registrySvc,
87- healthSvc: svc.healthSvc,
88- logSvc: svc.logSvc,
89- volumeSvc: svc.volumeSvc,
90- publicTLSSvc: svc.publicTLSSvc,
91- cleanup: wrappedCleanup,
82+ authEnabled: cfg.Auth.Enabled,
83+ configSvc: svc.configSvc,
84+ secretSvc: svc.secretSvc,
85+ containerSvc: svc.containerSvc,
86+ backupSvc: svc.backupSvc,
87+ volumeBackupSvc: svc.volumeBackupSvc,
88+ registrySvc: svc.registrySvc,
89+ healthSvc: svc.healthSvc,
90+ logSvc: svc.logSvc,
91+ volumeSvc: svc.volumeSvc,
92+ publicTLSSvc: svc.publicTLSSvc,
93+ cleanup: wrappedCleanup,
9294 }, nil
9395 } else {
9496 log.Warn().Err(fullErr).Msg("local kernel running in minimal mode")
@@ -136,6 +138,8 @@ func (k *Kernel) Container() in.ContainerService { return k.containerSvc }
136138
137139 func (k *Kernel) Backup() in.BackupService { return k.backupSvc }
138140
141+func (k *Kernel) VolumeBackup() in.VolumeBackupService { return k.volumeBackupSvc }
142+
139143 func (k *Kernel) Registry() in.RegistryService { return k.registrySvc }
140144
141145 func (k *Kernel) Health() in.HealthService { return k.healthSvc }
@@ -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 +311 -40
@@ -39,6 +39,7 @@ import (
3939 "github.com/bnema/gordon/internal/adapters/out/logwriter"
4040 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
4141 "github.com/bnema/gordon/internal/adapters/out/ratelimit"
42+ s3storage "github.com/bnema/gordon/internal/adapters/out/s3"
4243 "github.com/bnema/gordon/internal/adapters/out/secrets"
4344 "github.com/bnema/gordon/internal/adapters/out/telemetry"
4445 "github.com/bnema/gordon/internal/adapters/out/tokenstore"
@@ -168,6 +169,7 @@ type Config struct {
168169 } `mapstructure:"api"`
169170
170171 Backups struct {
172+ // Legacy database backup keys. Prefer backups.databases.* for new configs.
171173 Enabled bool `mapstructure:"enabled"`
172174 Schedule string `mapstructure:"schedule"`
173175 StorageDir string `mapstructure:"storage_dir"`
@@ -177,6 +179,37 @@ type Config struct {
177179 Weekly int `mapstructure:"weekly"`
178180 Monthly int `mapstructure:"monthly"`
179181 } `mapstructure:"retention"`
182+ Databases struct {
183+ Enabled bool `mapstructure:"enabled"`
184+ Schedule string `mapstructure:"schedule"`
185+ StorageDir string `mapstructure:"storage_dir"`
186+ Retention struct {
187+ Hourly int `mapstructure:"hourly"`
188+ Daily int `mapstructure:"daily"`
189+ Weekly int `mapstructure:"weekly"`
190+ Monthly int `mapstructure:"monthly"`
191+ } `mapstructure:"retention"`
192+ } `mapstructure:"databases"`
193+ Volumes struct {
194+ Enabled bool `mapstructure:"enabled"`
195+ Interval string `mapstructure:"interval"`
196+ Compression string `mapstructure:"compression"`
197+ Timeout string `mapstructure:"timeout"`
198+ MaxConcurrency int `mapstructure:"max_concurrency"`
199+ HelperImage string `mapstructure:"helper_image"`
200+ S3 struct {
201+ Bucket string `mapstructure:"bucket"`
202+ Region string `mapstructure:"region"`
203+ Prefix string `mapstructure:"prefix"`
204+ Endpoint string `mapstructure:"endpoint"`
205+ PathStyle bool `mapstructure:"path_style"`
206+ SSEAlgorithm string `mapstructure:"sse_algorithm"`
207+ SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
208+ } `mapstructure:"s3"`
209+ Retention struct {
210+ Keep int `mapstructure:"keep"`
211+ } `mapstructure:"retention"`
212+ } `mapstructure:"volumes"`
180213 } `mapstructure:"backups"`
181214
182215 Images struct {
@@ -221,6 +254,8 @@ type services struct {
221254 blobStorage *filesystem.BlobStorage
222255 manifestStorage *filesystem.ManifestStorage
223256 backupStorage *filesystem.BackupStorage
257+ volumeBackupStore out.VolumeBackupStorage
258+ volumeBackupCfg domain.VolumeBackupConfig
224259 envLoader out.EnvLoader
225260 logWriter *logwriter.LogWriter
226261 tokenStore out.TokenStore
@@ -228,6 +263,7 @@ type services struct {
228263 secretSvc *secretsSvc.Service
229264 containerSvc *container.Service
230265 backupSvc *backup.Service
266+ volumeBackupSvc *backup.VolumeService
231267 registrySvc *registrySvc.Service
232268 healthSvc *health.Service
233269 logSvc *logs.Service
@@ -673,6 +709,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
673709 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
674710 return err
675711 }
712+ if si.svc.volumeBackupStore, si.svc.volumeBackupSvc, si.svc.volumeBackupCfg, err = createVolumeBackupService(si.ctx, si.cfg, si.svc, si.log); err != nil {
713+ return err
714+ }
676715
677716 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)
678717 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
@@ -728,20 +767,21 @@ func (si *serviceInit) initHandlers() {
728767 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
729768
730769 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
731- ConfigSvc: si.svc.configSvc,
732- AuthSvc: si.svc.authSvc,
733- ContainerSvc: si.svc.containerSvc,
734- HealthSvc: si.svc.healthSvc,
735- SecretSvc: si.svc.secretSvc,
736- LogSvc: si.svc.logSvc,
737- RegistrySvc: si.svc.registrySvc,
738- ReloadTrigger: si.svc.reloadCoordinator,
739- Log: si.log,
740- BackupSvc: si.svc.backupSvc,
741- PreviewSvc: si.svc.previewService,
742- ImageSvc: si.svc.imageSvc,
743- VolumeSvc: si.svc.volumeSvc,
744- PublicTLSSvc: si.svc.publicTLSSvc,
770+ ConfigSvc: si.svc.configSvc,
771+ AuthSvc: si.svc.authSvc,
772+ ContainerSvc: si.svc.containerSvc,
773+ HealthSvc: si.svc.healthSvc,
774+ SecretSvc: si.svc.secretSvc,
775+ LogSvc: si.svc.logSvc,
776+ RegistrySvc: si.svc.registrySvc,
777+ ReloadTrigger: si.svc.reloadCoordinator,
778+ Log: si.log,
779+ BackupSvc: si.svc.backupSvc,
780+ VolumeBackupSvc: si.svc.volumeBackupSvc,
781+ PreviewSvc: si.svc.previewService,
782+ ImageSvc: si.svc.imageSvc,
783+ VolumeSvc: si.svc.volumeSvc,
784+ PublicTLSSvc: si.svc.publicTLSSvc,
745785 })
746786 }
747787
@@ -1940,12 +1980,69 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
19401980 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
19411981 }
19421982
1983+type databaseBackupSettingsConfig struct {
1984+ Enabled bool
1985+ Schedule string
1986+ StorageDir string
1987+ Retention struct {
1988+ Hourly int
1989+ Daily int
1990+ Weekly int
1991+ Monthly int
1992+ }
1993+}
1994+
1995+func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
1996+ out := databaseBackupSettingsConfig{
1997+ Enabled: cfg.Backups.Databases.Enabled,
1998+ Schedule: cfg.Backups.Databases.Schedule,
1999+ StorageDir: cfg.Backups.Databases.StorageDir,
2000+ }
2001+ out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
2002+ out.Retention.Daily = cfg.Backups.Databases.Retention.Daily
2003+ out.Retention.Weekly = cfg.Backups.Databases.Retention.Weekly
2004+ out.Retention.Monthly = cfg.Backups.Databases.Retention.Monthly
2005+ // Legacy [backups] keys intentionally override new database defaults when
2006+ // backups.enabled is true, preserving existing working pg_dump schedules.
2007+ // Otherwise, prefer the already-populated backups.databases.* values.
2008+ if cfg.Backups.Enabled {
2009+ out.Enabled = true
2010+ if cfg.Backups.Schedule != "" {
2011+ out.Schedule = cfg.Backups.Schedule
2012+ }
2013+ if cfg.Backups.StorageDir != "" {
2014+ out.StorageDir = cfg.Backups.StorageDir
2015+ }
2016+ } else {
2017+ if out.Schedule == "" {
2018+ out.Schedule = cfg.Backups.Schedule
2019+ }
2020+ if out.StorageDir == "" {
2021+ out.StorageDir = cfg.Backups.StorageDir
2022+ }
2023+ }
2024+ if out.Retention.Hourly == 0 {
2025+ out.Retention.Hourly = cfg.Backups.Retention.Hourly
2026+ }
2027+ if out.Retention.Daily == 0 {
2028+ out.Retention.Daily = cfg.Backups.Retention.Daily
2029+ }
2030+ if out.Retention.Weekly == 0 {
2031+ out.Retention.Weekly = cfg.Backups.Retention.Weekly
2032+ }
2033+ if out.Retention.Monthly == 0 {
2034+ out.Retention.Monthly = cfg.Backups.Retention.Monthly
2035+ }
2036+ return out
2037+}
2038+
19432039 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1944- if !cfg.Backups.Enabled {
2040+ dbCfg := databaseBackupSettings(cfg)
2041+ if !dbCfg.Enabled {
19452042 return nil, nil, nil
19462043 }
19472044
1948- storageDir := cfg.Backups.StorageDir
2045+ storageDir := dbCfg.StorageDir
19492046 if storageDir == "" {
19502047 dataDir := resolveDataDir(cfg.Server.DataDir)
19512048 storageDir = filepath.Join(dataDir, "backups")
@@ -1962,7 +2059,7 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
19622059 }
19632060
19642061 backupCfg := domain.BackupConfig{
1965- Enabled: cfg.Backups.Enabled,
2062+ Enabled: dbCfg.Enabled,
19662063 StorageDir: storageDir,
19672064 Retention: retention,
19682065 }
@@ -1976,28 +2073,142 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
19762073 return backupStorage, backupSvc, nil
19772074 }
19782075
2076+func createVolumeBackupService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (out.VolumeBackupStorage, *backup.VolumeService, domain.VolumeBackupConfig, error) {
2077+ if !cfg.Backups.Volumes.Enabled {
2078+ return nil, nil, domain.VolumeBackupConfig{}, nil
2079+ }
2080+ volumeCfg, err := validateVolumeBackupConfig(cfg)
2081+ if err != nil {
2082+ return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "invalid volume backup configuration")
2083+ }
2084+
2085+ storage, err := s3storage.NewVolumeBackupStorage(ctx, volumeCfg)
2086+ if err != nil {
2087+ return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "failed to create volume backup storage")
2088+ }
2089+
2090+ volumeSvc := backup.NewVolumeService(svc.runtime, svc.runtime, storage, volumeCfg, log)
2091+ log.Info().
2092+ Str("bucket", volumeCfg.S3Bucket).
2093+ Str("prefix", volumeCfg.S3Prefix).
2094+ Msg("volume backup service initialized")
2095+
2096+ return storage, volumeSvc, volumeCfg, nil
2097+}
2098+
19792099 func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1980- if cfg.Backups.Retention.Hourly < 0 {
1981- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
2100+ dbCfg := databaseBackupSettings(cfg)
2101+ if dbCfg.Retention.Hourly < 0 {
2102+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
19822103 }
1983- if cfg.Backups.Retention.Daily < 0 {
1984- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
2104+ if dbCfg.Retention.Daily < 0 {
2105+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
19852106 }
1986- if cfg.Backups.Retention.Weekly < 0 {
1987- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
2107+ if dbCfg.Retention.Weekly < 0 {
2108+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
19882109 }
1989- if cfg.Backups.Retention.Monthly < 0 {
1990- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
2110+ if dbCfg.Retention.Monthly < 0 {
2111+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
19912112 }
19922113
19932114 return domain.RetentionPolicy{
1994- Hourly: cfg.Backups.Retention.Hourly,
1995- Daily: cfg.Backups.Retention.Daily,
1996- Weekly: cfg.Backups.Retention.Weekly,
1997- Monthly: cfg.Backups.Retention.Monthly,
2115+ Hourly: dbCfg.Retention.Hourly,
2116+ Daily: dbCfg.Retention.Daily,
2117+ Weekly: dbCfg.Retention.Weekly,
2118+ Monthly: dbCfg.Retention.Monthly,
19982119 }, nil
19992120 }
20002121
2122+func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
2123+ volumeCfg := cfg.Backups.Volumes
2124+ interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")
2125+ if err != nil {
2126+ return domain.VolumeBackupConfig{}, err
2127+ }
2128+ timeout, err := parsePositiveDurationDefault(volumeCfg.Timeout, "2h", "backups.volumes.timeout")
2129+ if err != nil {
2130+ return domain.VolumeBackupConfig{}, err
2131+ }
2132+ compression, err := parseVolumeBackupCompression(volumeCfg.Compression)
2133+ if err != nil {
2134+ return domain.VolumeBackupConfig{}, err
2135+ }
2136+ maxConcurrency := volumeCfg.MaxConcurrency
2137+ if maxConcurrency == 0 {
2138+ maxConcurrency = 2
2139+ }
2140+ helperImage := strings.TrimSpace(volumeCfg.HelperImage)
2141+ if helperImage == "" {
2142+ helperImage = "alpine:3.20"
2143+ }
2144+ if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
2145+ return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
2146+ }
2147+ if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
2148+ return domain.VolumeBackupConfig{}, err
2149+ }
2150+ return domain.VolumeBackupConfig{
2151+ Enabled: volumeCfg.Enabled,
2152+ Interval: interval,
2153+ Compression: compression,
2154+ Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
2155+ Timeout: timeout,
2156+ MaxConcurrency: maxConcurrency,
2157+ HelperImage: helperImage,
2158+ VolumePrefix: "gordon",
2159+ S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
2160+ S3Region: strings.TrimSpace(volumeCfg.S3.Region),
2161+ S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
2162+ S3Endpoint: strings.TrimSpace(volumeCfg.S3.Endpoint),
2163+ S3PathStyle: volumeCfg.S3.PathStyle,
2164+ S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
2165+ S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
2166+ }, nil
2167+}
2168+
2169+func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
2170+ if strings.TrimSpace(raw) == "" {
2171+ raw = defaultValue
2172+ }
2173+ d, err := time.ParseDuration(strings.TrimSpace(raw))
2174+ if err != nil || d <= 0 {
2175+ return 0, fmt.Errorf("%s must be a positive duration", field)
2176+ }
2177+ return d, nil
2178+}
2179+
2180+func parseVolumeBackupCompression(raw string) (domain.VolumeBackupCompression, error) {
2181+ if strings.TrimSpace(raw) == "" {
2182+ raw = string(domain.VolumeBackupCompressionGzip)
2183+ }
2184+ compression := domain.VolumeBackupCompression(strings.ToLower(strings.TrimSpace(raw)))
2185+ switch compression {
2186+ case domain.VolumeBackupCompressionGzip, domain.VolumeBackupCompressionZstd:
2187+ return compression, nil
2188+ default:
2189+ return "", fmt.Errorf("backups.volumes.compression must be one of: gzip, zstd")
2190+ }
2191+}
2192+
2193+func validateVolumeBackupS3Settings(enabled bool, keep, maxConcurrency int, bucket, region string) error {
2194+ if keep < 0 {
2195+ return fmt.Errorf("backups.volumes.retention.keep cannot be negative")
2196+ }
2197+ if enabled && keep == 0 {
2198+ return fmt.Errorf("backups.volumes.retention.keep must be positive when volume backups are enabled")
2199+ }
2200+ if maxConcurrency < 1 {
2201+ return fmt.Errorf("backups.volumes.max_concurrency must be at least 1")
2202+ }
2203+ if enabled && strings.TrimSpace(bucket) == "" {
2204+ return fmt.Errorf("backups.volumes.s3.bucket is required when volume backups are enabled")
2205+ }
2206+ if enabled && strings.TrimSpace(region) == "" {
2207+ return fmt.Errorf("backups.volumes.s3.region is required when volume backups are enabled")
2208+ }
2209+ return nil
2210+}
2211+
20012212 // registerEventHandlers registers all event handlers.
20022213 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {
20032214 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
@@ -2643,7 +2854,7 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
26432854 }
26442855
26452856 func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
2646- schedulers := make([]*cronSvc.Scheduler, 0, 2)
2857+ schedulers := make([]*cronSvc.Scheduler, 0, 3)
26472858
26482859 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
26492860 if err != nil {
@@ -2653,6 +2864,14 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
26532864 schedulers = append(schedulers, backupScheduler)
26542865 }
26552866
2867+ volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
2868+ if err != nil {
2869+ return nil, err
2870+ }
2871+ if volumeBackupScheduler != nil {
2872+ schedulers = append(schedulers, volumeBackupScheduler)
2873+ }
2874+
26562875 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
26572876 return v.GetInt("images.prune.keep_last")
26582877 })
@@ -2675,11 +2894,12 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
26752894 }
26762895
26772896 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 {
2897+ dbCfg := databaseBackupSettings(cfg)
2898+ if !dbCfg.Enabled || svc == nil || svc.backupSvc == nil {
26792899 return nil, nil
26802900 }
26812901
2682- preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
2902+ preset, err := resolveBackupSchedule(dbCfg.Schedule)
26832903 if err != nil {
26842904 return nil, err
26852905 }
@@ -2712,7 +2932,44 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
27122932 }
27132933
27142934 func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
2715- return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
2935+ return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)
2936+}
2937+
2938+func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
2939+ if !cfg.Backups.Volumes.Enabled || svc == nil || svc.volumeBackupSvc == nil {
2940+ return nil, nil
2941+ }
2942+
2943+ volumeCfg, err := validateVolumeBackupConfig(cfg)
2944+ if err != nil {
2945+ return nil, err
2946+ }
2947+
2948+ scheduler := cronSvc.NewScheduler(log)
2949+ err = scheduler.Add(
2950+ "volume-backup-scheduler",
2951+ "Volume Backups",
2952+ domain.CronSchedule{Interval: volumeCfg.Interval},
2953+ func(jobCtx context.Context) error {
2954+ if _, err := svc.volumeBackupSvc.RunVolumeBackups(jobCtx, "", ""); err != nil {
2955+ return err
2956+ }
2957+ log.Info().
2958+ Dur("interval", volumeCfg.Interval).
2959+ Msg("scheduled volume backup run complete")
2960+ return nil
2961+ },
2962+ )
2963+ if err != nil {
2964+ return nil, log.WrapErr(err, "failed to register volume backup schedule")
2965+ }
2966+
2967+ scheduler.Start(ctx)
2968+ log.Info().
2969+ Dur("interval", volumeCfg.Interval).
2970+ Msg("volume backup scheduler enabled")
2971+
2972+ return scheduler, nil
27162973 }
27172974
27182975 func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
@@ -3399,13 +3656,27 @@ func loadConfig(v *viper.Viper, configPath string) error {
33993656 v.SetDefault("volumes.prefix", "gordon")
34003657 v.SetDefault("volumes.preserve", true)
34013658 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
3402- v.SetDefault("backups.enabled", false)
3403- v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
3404- v.SetDefault("backups.storage_dir", "")
3405- v.SetDefault("backups.retention.hourly", 0)
3406- v.SetDefault("backups.retention.daily", 0)
3407- v.SetDefault("backups.retention.weekly", 0)
3408- v.SetDefault("backups.retention.monthly", 0)
3659+ v.SetDefault("backups.databases.enabled", false)
3660+ v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
3661+ v.SetDefault("backups.databases.storage_dir", "")
3662+ v.SetDefault("backups.databases.retention.hourly", 0)
3663+ v.SetDefault("backups.databases.retention.daily", 0)
3664+ v.SetDefault("backups.databases.retention.weekly", 0)
3665+ v.SetDefault("backups.databases.retention.monthly", 0)
3666+ v.SetDefault("backups.volumes.enabled", false)
3667+ v.SetDefault("backups.volumes.interval", "24h")
3668+ v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
3669+ v.SetDefault("backups.volumes.timeout", "2h")
3670+ v.SetDefault("backups.volumes.max_concurrency", 2)
3671+ v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
3672+ v.SetDefault("backups.volumes.s3.bucket", "")
3673+ v.SetDefault("backups.volumes.s3.region", "")
3674+ v.SetDefault("backups.volumes.s3.prefix", "")
3675+ v.SetDefault("backups.volumes.s3.endpoint", "")
3676+ v.SetDefault("backups.volumes.s3.path_style", false)
3677+ v.SetDefault("backups.volumes.s3.sse_algorithm", "")
3678+ v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
3679+ v.SetDefault("backups.volumes.retention.keep", 14)
34093680 v.SetDefault("images.allowed_registries", []string{})
34103681 v.SetDefault("images.require_digest", false)
34113682 v.SetDefault("images.prune.enabled", false)
@@ -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"
@@ -168,6 +169,7 @@ type Config struct {
168 } `mapstructure:"api"`169 } `mapstructure:"api"`
169 170
170 Backups struct {171 Backups struct {
172+ // Legacy database backup keys. Prefer backups.databases.* for new configs.
171 Enabled bool `mapstructure:"enabled"`173 Enabled bool `mapstructure:"enabled"`
172 Schedule string `mapstructure:"schedule"`174 Schedule string `mapstructure:"schedule"`
173 StorageDir string `mapstructure:"storage_dir"`175 StorageDir string `mapstructure:"storage_dir"`
@@ -177,6 +179,37 @@ type Config struct {
177 Weekly int `mapstructure:"weekly"`179 Weekly int `mapstructure:"weekly"`
178 Monthly int `mapstructure:"monthly"`180 Monthly int `mapstructure:"monthly"`
179 } `mapstructure:"retention"`181 } `mapstructure:"retention"`
182+ Databases struct {
183+ Enabled bool `mapstructure:"enabled"`
184+ Schedule string `mapstructure:"schedule"`
185+ StorageDir string `mapstructure:"storage_dir"`
186+ Retention struct {
187+ Hourly int `mapstructure:"hourly"`
188+ Daily int `mapstructure:"daily"`
189+ Weekly int `mapstructure:"weekly"`
190+ Monthly int `mapstructure:"monthly"`
191+ } `mapstructure:"retention"`
192+ } `mapstructure:"databases"`
193+ Volumes struct {
194+ Enabled bool `mapstructure:"enabled"`
195+ Interval string `mapstructure:"interval"`
196+ Compression string `mapstructure:"compression"`
197+ Timeout string `mapstructure:"timeout"`
198+ MaxConcurrency int `mapstructure:"max_concurrency"`
199+ HelperImage string `mapstructure:"helper_image"`
200+ S3 struct {
201+ Bucket string `mapstructure:"bucket"`
202+ Region string `mapstructure:"region"`
203+ Prefix string `mapstructure:"prefix"`
204+ Endpoint string `mapstructure:"endpoint"`
205+ PathStyle bool `mapstructure:"path_style"`
206+ SSEAlgorithm string `mapstructure:"sse_algorithm"`
207+ SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
208+ } `mapstructure:"s3"`
209+ Retention struct {
210+ Keep int `mapstructure:"keep"`
211+ } `mapstructure:"retention"`
212+ } `mapstructure:"volumes"`
180 } `mapstructure:"backups"`213 } `mapstructure:"backups"`
181 214
182 Images struct {215 Images struct {
@@ -221,6 +254,8 @@ type services struct {
221 blobStorage *filesystem.BlobStorage254 blobStorage *filesystem.BlobStorage
222 manifestStorage *filesystem.ManifestStorage255 manifestStorage *filesystem.ManifestStorage
223 backupStorage *filesystem.BackupStorage256 backupStorage *filesystem.BackupStorage
257+ volumeBackupStore out.VolumeBackupStorage
258+ volumeBackupCfg domain.VolumeBackupConfig
224 envLoader out.EnvLoader259 envLoader out.EnvLoader
225 logWriter *logwriter.LogWriter260 logWriter *logwriter.LogWriter
226 tokenStore out.TokenStore261 tokenStore out.TokenStore
@@ -228,6 +263,7 @@ type services struct {
228 secretSvc *secretsSvc.Service263 secretSvc *secretsSvc.Service
229 containerSvc *container.Service264 containerSvc *container.Service
230 backupSvc *backup.Service265 backupSvc *backup.Service
266+ volumeBackupSvc *backup.VolumeService
231 registrySvc *registrySvc.Service267 registrySvc *registrySvc.Service
232 healthSvc *health.Service268 healthSvc *health.Service
233 logSvc *logs.Service269 logSvc *logs.Service
@@ -673,6 +709,9 @@ func (si *serviceInit) initRuntimeAndProxy() error {
673 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {709 if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil {
674 return err710 return err
675 }711 }
712+ if si.svc.volumeBackupStore, si.svc.volumeBackupSvc, si.svc.volumeBackupCfg, err = createVolumeBackupService(si.ctx, si.cfg, si.svc, si.log); err != nil {
713+ return err
714+ }
676 715
677 si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus)716 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)717 si.svc.imageSvc = images.NewService(si.svc.runtime, si.svc.manifestStorage, si.svc.blobStorage, si.log)
@@ -728,20 +767,21 @@ func (si *serviceInit) initHandlers() {
728 initPreviewService(si.ctx, si.cfg, si.svc, si.log)767 initPreviewService(si.ctx, si.cfg, si.svc, si.log)
729 768
730 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{769 si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{
731- ConfigSvc: si.svc.configSvc,770+ ConfigSvc: si.svc.configSvc,
732- AuthSvc: si.svc.authSvc,771+ AuthSvc: si.svc.authSvc,
733- ContainerSvc: si.svc.containerSvc,772+ ContainerSvc: si.svc.containerSvc,
734- HealthSvc: si.svc.healthSvc,773+ HealthSvc: si.svc.healthSvc,
735- SecretSvc: si.svc.secretSvc,774+ SecretSvc: si.svc.secretSvc,
736- LogSvc: si.svc.logSvc,775+ LogSvc: si.svc.logSvc,
737- RegistrySvc: si.svc.registrySvc,776+ RegistrySvc: si.svc.registrySvc,
738- ReloadTrigger: si.svc.reloadCoordinator,777+ ReloadTrigger: si.svc.reloadCoordinator,
739- Log: si.log,778+ Log: si.log,
740- BackupSvc: si.svc.backupSvc,779+ BackupSvc: si.svc.backupSvc,
741- PreviewSvc: si.svc.previewService,780+ VolumeBackupSvc: si.svc.volumeBackupSvc,
742- ImageSvc: si.svc.imageSvc,781+ PreviewSvc: si.svc.previewService,
743- VolumeSvc: si.svc.volumeSvc,782+ ImageSvc: si.svc.imageSvc,
744- PublicTLSSvc: si.svc.publicTLSSvc,783+ VolumeSvc: si.svc.volumeSvc,
784+ PublicTLSSvc: si.svc.publicTLSSvc,
745 })785 })
746 }786 }
747 787
@@ -1940,12 +1980,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), nil1980 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
1941 }1981 }
1942 1982
1983+type databaseBackupSettingsConfig struct {
1984+ Enabled bool
1985+ Schedule string
1986+ StorageDir string
1987+ Retention struct {
1988+ Hourly int
1989+ Daily int
1990+ Weekly int
1991+ Monthly int
1992+ }
1993+}
1994+
1995+func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
1996+ out := databaseBackupSettingsConfig{
1997+ Enabled: cfg.Backups.Databases.Enabled,
1998+ Schedule: cfg.Backups.Databases.Schedule,
1999+ StorageDir: cfg.Backups.Databases.StorageDir,
2000+ }
2001+ out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
2002+ out.Retention.Daily = cfg.Backups.Databases.Retention.Daily
2003+ out.Retention.Weekly = cfg.Backups.Databases.Retention.Weekly
2004+ out.Retention.Monthly = cfg.Backups.Databases.Retention.Monthly
2005+ // Legacy [backups] keys intentionally override new database defaults when
2006+ // backups.enabled is true, preserving existing working pg_dump schedules.
2007+ // Otherwise, prefer the already-populated backups.databases.* values.
2008+ if cfg.Backups.Enabled {
2009+ out.Enabled = true
2010+ if cfg.Backups.Schedule != "" {
2011+ out.Schedule = cfg.Backups.Schedule
2012+ }
2013+ if cfg.Backups.StorageDir != "" {
2014+ out.StorageDir = cfg.Backups.StorageDir
2015+ }
2016+ } else {
2017+ if out.Schedule == "" {
2018+ out.Schedule = cfg.Backups.Schedule
2019+ }
2020+ if out.StorageDir == "" {
2021+ out.StorageDir = cfg.Backups.StorageDir
2022+ }
2023+ }
2024+ if out.Retention.Hourly == 0 {
2025+ out.Retention.Hourly = cfg.Backups.Retention.Hourly
2026+ }
2027+ if out.Retention.Daily == 0 {
2028+ out.Retention.Daily = cfg.Backups.Retention.Daily
2029+ }
2030+ if out.Retention.Weekly == 0 {
2031+ out.Retention.Weekly = cfg.Backups.Retention.Weekly
2032+ }
2033+ if out.Retention.Monthly == 0 {
2034+ out.Retention.Monthly = cfg.Backups.Retention.Monthly
2035+ }
2036+ return out
2037+}
2038+
1943 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {2039 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
1944- if !cfg.Backups.Enabled {2040+ dbCfg := databaseBackupSettings(cfg)
2041+ if !dbCfg.Enabled {
1945 return nil, nil, nil2042 return nil, nil, nil
1946 }2043 }
1947 2044
1948- storageDir := cfg.Backups.StorageDir2045+ storageDir := dbCfg.StorageDir
1949 if storageDir == "" {2046 if storageDir == "" {
1950 dataDir := resolveDataDir(cfg.Server.DataDir)2047 dataDir := resolveDataDir(cfg.Server.DataDir)
1951 storageDir = filepath.Join(dataDir, "backups")2048 storageDir = filepath.Join(dataDir, "backups")
@@ -1962,7 +2059,7 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
1962 }2059 }
1963 2060
1964 backupCfg := domain.BackupConfig{2061 backupCfg := domain.BackupConfig{
1965- Enabled: cfg.Backups.Enabled,2062+ Enabled: dbCfg.Enabled,
1966 StorageDir: storageDir,2063 StorageDir: storageDir,
1967 Retention: retention,2064 Retention: retention,
1968 }2065 }
@@ -1976,28 +2073,142 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files
1976 return backupStorage, backupSvc, nil2073 return backupStorage, backupSvc, nil
1977 }2074 }
1978 2075
2076+func createVolumeBackupService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (out.VolumeBackupStorage, *backup.VolumeService, domain.VolumeBackupConfig, error) {
2077+ if !cfg.Backups.Volumes.Enabled {
2078+ return nil, nil, domain.VolumeBackupConfig{}, nil
2079+ }
2080+ volumeCfg, err := validateVolumeBackupConfig(cfg)
2081+ if err != nil {
2082+ return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "invalid volume backup configuration")
2083+ }
2084+
2085+ storage, err := s3storage.NewVolumeBackupStorage(ctx, volumeCfg)
2086+ if err != nil {
2087+ return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "failed to create volume backup storage")
2088+ }
2089+
2090+ volumeSvc := backup.NewVolumeService(svc.runtime, svc.runtime, storage, volumeCfg, log)
2091+ log.Info().
2092+ Str("bucket", volumeCfg.S3Bucket).
2093+ Str("prefix", volumeCfg.S3Prefix).
2094+ Msg("volume backup service initialized")
2095+
2096+ return storage, volumeSvc, volumeCfg, nil
2097+}
2098+
1979 func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {2099 func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1980- if cfg.Backups.Retention.Hourly < 0 {2100+ dbCfg := databaseBackupSettings(cfg)
1981- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")2101+ if dbCfg.Retention.Hourly < 0 {
2102+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
1982 }2103 }
1983- if cfg.Backups.Retention.Daily < 0 {2104+ if dbCfg.Retention.Daily < 0 {
1984- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")2105+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
1985 }2106 }
1986- if cfg.Backups.Retention.Weekly < 0 {2107+ if dbCfg.Retention.Weekly < 0 {
1987- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")2108+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
1988 }2109 }
1989- if cfg.Backups.Retention.Monthly < 0 {2110+ if dbCfg.Retention.Monthly < 0 {
1990- return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")2111+ return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
1991 }2112 }
1992 2113
1993 return domain.RetentionPolicy{2114 return domain.RetentionPolicy{
1994- Hourly: cfg.Backups.Retention.Hourly,2115+ Hourly: dbCfg.Retention.Hourly,
1995- Daily: cfg.Backups.Retention.Daily,2116+ Daily: dbCfg.Retention.Daily,
1996- Weekly: cfg.Backups.Retention.Weekly,2117+ Weekly: dbCfg.Retention.Weekly,
1997- Monthly: cfg.Backups.Retention.Monthly,2118+ Monthly: dbCfg.Retention.Monthly,
1998 }, nil2119 }, nil
1999 }2120 }
2000 2121
2122+func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
2123+ volumeCfg := cfg.Backups.Volumes
2124+ interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")
2125+ if err != nil {
2126+ return domain.VolumeBackupConfig{}, err
2127+ }
2128+ timeout, err := parsePositiveDurationDefault(volumeCfg.Timeout, "2h", "backups.volumes.timeout")
2129+ if err != nil {
2130+ return domain.VolumeBackupConfig{}, err
2131+ }
2132+ compression, err := parseVolumeBackupCompression(volumeCfg.Compression)
2133+ if err != nil {
2134+ return domain.VolumeBackupConfig{}, err
2135+ }
2136+ maxConcurrency := volumeCfg.MaxConcurrency
2137+ if maxConcurrency == 0 {
2138+ maxConcurrency = 2
2139+ }
2140+ helperImage := strings.TrimSpace(volumeCfg.HelperImage)
2141+ if helperImage == "" {
2142+ helperImage = "alpine:3.20"
2143+ }
2144+ if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
2145+ return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
2146+ }
2147+ if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
2148+ return domain.VolumeBackupConfig{}, err
2149+ }
2150+ return domain.VolumeBackupConfig{
2151+ Enabled: volumeCfg.Enabled,
2152+ Interval: interval,
2153+ Compression: compression,
2154+ Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
2155+ Timeout: timeout,
2156+ MaxConcurrency: maxConcurrency,
2157+ HelperImage: helperImage,
2158+ VolumePrefix: "gordon",
2159+ S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
2160+ S3Region: strings.TrimSpace(volumeCfg.S3.Region),
2161+ S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
2162+ S3Endpoint: strings.TrimSpace(volumeCfg.S3.Endpoint),
2163+ S3PathStyle: volumeCfg.S3.PathStyle,
2164+ S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
2165+ S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
2166+ }, nil
2167+}
2168+
2169+func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
2170+ if strings.TrimSpace(raw) == "" {
2171+ raw = defaultValue
2172+ }
2173+ d, err := time.ParseDuration(strings.TrimSpace(raw))
2174+ if err != nil || d <= 0 {
2175+ return 0, fmt.Errorf("%s must be a positive duration", field)
2176+ }
2177+ return d, nil
2178+}
2179+
2180+func parseVolumeBackupCompression(raw string) (domain.VolumeBackupCompression, error) {
2181+ if strings.TrimSpace(raw) == "" {
2182+ raw = string(domain.VolumeBackupCompressionGzip)
2183+ }
2184+ compression := domain.VolumeBackupCompression(strings.ToLower(strings.TrimSpace(raw)))
2185+ switch compression {
2186+ case domain.VolumeBackupCompressionGzip, domain.VolumeBackupCompressionZstd:
2187+ return compression, nil
2188+ default:
2189+ return "", fmt.Errorf("backups.volumes.compression must be one of: gzip, zstd")
2190+ }
2191+}
2192+
2193+func validateVolumeBackupS3Settings(enabled bool, keep, maxConcurrency int, bucket, region string) error {
2194+ if keep < 0 {
2195+ return fmt.Errorf("backups.volumes.retention.keep cannot be negative")
2196+ }
2197+ if enabled && keep == 0 {
2198+ return fmt.Errorf("backups.volumes.retention.keep must be positive when volume backups are enabled")
2199+ }
2200+ if maxConcurrency < 1 {
2201+ return fmt.Errorf("backups.volumes.max_concurrency must be at least 1")
2202+ }
2203+ if enabled && strings.TrimSpace(bucket) == "" {
2204+ return fmt.Errorf("backups.volumes.s3.bucket is required when volume backups are enabled")
2205+ }
2206+ if enabled && strings.TrimSpace(region) == "" {
2207+ return fmt.Errorf("backups.volumes.s3.region is required when volume backups are enabled")
2208+ }
2209+ return nil
2210+}
2211+
2001 // registerEventHandlers registers all event handlers.2212 // registerEventHandlers registers all event handlers.
2002 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {2213 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) {
2003 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)2214 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
@@ -2643,7 +2854,7 @@ func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2643 }2854 }
2644 2855
2645 func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {2856 func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
2646- schedulers := make([]*cronSvc.Scheduler, 0, 2)2857+ schedulers := make([]*cronSvc.Scheduler, 0, 3)
2647 2858
2648 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)2859 backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
2649 if err != nil {2860 if err != nil {
@@ -2653,6 +2864,14 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
2653 schedulers = append(schedulers, backupScheduler)2864 schedulers = append(schedulers, backupScheduler)
2654 }2865 }
2655 2866
2867+ volumeBackupScheduler, err := startVolumeBackupScheduler(ctx, cfg, svc, log)
2868+ if err != nil {
2869+ return nil, err
2870+ }
2871+ if volumeBackupScheduler != nil {
2872+ schedulers = append(schedulers, volumeBackupScheduler)
2873+ }
2874+
2656 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {2875 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
2657 return v.GetInt("images.prune.keep_last")2876 return v.GetInt("images.prune.keep_last")
2658 })2877 })
@@ -2675,11 +2894,12 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log
2675 }2894 }
2676 2895
2677 func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {2896 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 {2897+ dbCfg := databaseBackupSettings(cfg)
2898+ if !dbCfg.Enabled || svc == nil || svc.backupSvc == nil {
2679 return nil, nil2899 return nil, nil
2680 }2900 }
2681 2901
2682- preset, err := resolveBackupSchedule(cfg.Backups.Schedule)2902+ preset, err := resolveBackupSchedule(dbCfg.Schedule)
2683 if err != nil {2903 if err != nil {
2684 return nil, err2904 return nil, err
2685 }2905 }
@@ -2712,7 +2932,44 @@ func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log ze
2712 }2932 }
2713 2933
2714 func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {2934 func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
2715- return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)2935+ return resolveSchedulePreset(raw, "backups.databases.schedule", domain.ScheduleDaily)
2936+}
2937+
2938+func startVolumeBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
2939+ if !cfg.Backups.Volumes.Enabled || svc == nil || svc.volumeBackupSvc == nil {
2940+ return nil, nil
2941+ }
2942+
2943+ volumeCfg, err := validateVolumeBackupConfig(cfg)
2944+ if err != nil {
2945+ return nil, err
2946+ }
2947+
2948+ scheduler := cronSvc.NewScheduler(log)
2949+ err = scheduler.Add(
2950+ "volume-backup-scheduler",
2951+ "Volume Backups",
2952+ domain.CronSchedule{Interval: volumeCfg.Interval},
2953+ func(jobCtx context.Context) error {
2954+ if _, err := svc.volumeBackupSvc.RunVolumeBackups(jobCtx, "", ""); err != nil {
2955+ return err
2956+ }
2957+ log.Info().
2958+ Dur("interval", volumeCfg.Interval).
2959+ Msg("scheduled volume backup run complete")
2960+ return nil
2961+ },
2962+ )
2963+ if err != nil {
2964+ return nil, log.WrapErr(err, "failed to register volume backup schedule")
2965+ }
2966+
2967+ scheduler.Start(ctx)
2968+ log.Info().
2969+ Dur("interval", volumeCfg.Interval).
2970+ Msg("volume backup scheduler enabled")
2971+
2972+ return scheduler, nil
2716 }2973 }
2717 2974
2718 func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {2975 func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
@@ -3399,13 +3656,27 @@ func loadConfig(v *viper.Viper, configPath string) error {
3399 v.SetDefault("volumes.prefix", "gordon")3656 v.SetDefault("volumes.prefix", "gordon")
3400 v.SetDefault("volumes.preserve", true)3657 v.SetDefault("volumes.preserve", true)
3401 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)3658 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
3402- v.SetDefault("backups.enabled", false)3659+ v.SetDefault("backups.databases.enabled", false)
3403- v.SetDefault("backups.schedule", string(domain.ScheduleDaily))3660+ v.SetDefault("backups.databases.schedule", string(domain.ScheduleDaily))
3404- v.SetDefault("backups.storage_dir", "")3661+ v.SetDefault("backups.databases.storage_dir", "")
3405- v.SetDefault("backups.retention.hourly", 0)3662+ v.SetDefault("backups.databases.retention.hourly", 0)
3406- v.SetDefault("backups.retention.daily", 0)3663+ v.SetDefault("backups.databases.retention.daily", 0)
3407- v.SetDefault("backups.retention.weekly", 0)3664+ v.SetDefault("backups.databases.retention.weekly", 0)
3408- v.SetDefault("backups.retention.monthly", 0)3665+ v.SetDefault("backups.databases.retention.monthly", 0)
3666+ v.SetDefault("backups.volumes.enabled", false)
3667+ v.SetDefault("backups.volumes.interval", "24h")
3668+ v.SetDefault("backups.volumes.compression", string(domain.VolumeBackupCompressionGzip))
3669+ v.SetDefault("backups.volumes.timeout", "2h")
3670+ v.SetDefault("backups.volumes.max_concurrency", 2)
3671+ v.SetDefault("backups.volumes.helper_image", "alpine:3.20")
3672+ v.SetDefault("backups.volumes.s3.bucket", "")
3673+ v.SetDefault("backups.volumes.s3.region", "")
3674+ v.SetDefault("backups.volumes.s3.prefix", "")
3675+ v.SetDefault("backups.volumes.s3.endpoint", "")
3676+ v.SetDefault("backups.volumes.s3.path_style", false)
3677+ v.SetDefault("backups.volumes.s3.sse_algorithm", "")
3678+ v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
3679+ v.SetDefault("backups.volumes.retention.keep", 14)
3409 v.SetDefault("images.allowed_registries", []string{})3680 v.SetDefault("images.allowed_registries", []string{})
3410 v.SetDefault("images.require_digest", false)3681 v.SetDefault("images.require_digest", false)
3411 v.SetDefault("images.prune.enabled", false)3682 v.SetDefault("images.prune.enabled", false)
M internal/app/run_backup_test.go +117 -19
@@ -19,30 +19,30 @@ func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
1919 {
2020 name: "hourly",
2121 set: func(cfg *Config) {
22- cfg.Backups.Retention.Hourly = -1
22+ cfg.Backups.Databases.Retention.Hourly = -1
2323 },
24- err: "backups.retention.hourly",
24+ err: "backups.databases.retention.hourly",
2525 },
2626 {
2727 name: "daily",
2828 set: func(cfg *Config) {
29- cfg.Backups.Retention.Daily = -1
29+ cfg.Backups.Databases.Retention.Daily = -1
3030 },
31- err: "backups.retention.daily",
31+ err: "backups.databases.retention.daily",
3232 },
3333 {
3434 name: "weekly",
3535 set: func(cfg *Config) {
36- cfg.Backups.Retention.Weekly = -1
36+ cfg.Backups.Databases.Retention.Weekly = -1
3737 },
38- err: "backups.retention.weekly",
38+ err: "backups.databases.retention.weekly",
3939 },
4040 {
4141 name: "monthly",
4242 set: func(cfg *Config) {
43- cfg.Backups.Retention.Monthly = -1
43+ cfg.Backups.Databases.Retention.Monthly = -1
4444 },
45- err: "backups.retention.monthly",
45+ err: "backups.databases.retention.monthly",
4646 },
4747 }
4848
@@ -61,10 +61,10 @@ func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
6161 func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
6262 t.Run("positive values", func(t *testing.T) {
6363 var cfg Config
64- cfg.Backups.Retention.Hourly = 1
65- cfg.Backups.Retention.Daily = 7
66- cfg.Backups.Retention.Weekly = 4
67- cfg.Backups.Retention.Monthly = 12
64+ cfg.Backups.Databases.Retention.Hourly = 1
65+ cfg.Backups.Databases.Retention.Daily = 7
66+ cfg.Backups.Databases.Retention.Weekly = 4
67+ cfg.Backups.Databases.Retention.Monthly = 12
6868
6969 retention, err := validateBackupRetention(cfg)
7070 require.NoError(t, err)
@@ -76,10 +76,10 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
7676
7777 t.Run("zero values", func(t *testing.T) {
7878 var cfg Config
79- cfg.Backups.Retention.Hourly = 0
80- cfg.Backups.Retention.Daily = 0
81- cfg.Backups.Retention.Weekly = 0
82- cfg.Backups.Retention.Monthly = 0
79+ cfg.Backups.Databases.Retention.Hourly = 0
80+ cfg.Backups.Databases.Retention.Daily = 0
81+ cfg.Backups.Databases.Retention.Weekly = 0
82+ cfg.Backups.Databases.Retention.Monthly = 0
8383
8484 retention, err := validateBackupRetention(cfg)
8585 require.NoError(t, err)
@@ -90,12 +90,110 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
9090 })
9191 }
9292
93-func TestLoadConfigSetsBackupScheduleDefault(t *testing.T) {
93+func TestValidateBackupRetentionAcceptsLegacyBackupsConfig(t *testing.T) {
94+ var cfg Config
95+ cfg.Backups.Retention.Daily = 3
96+
97+ retention, err := validateBackupRetention(cfg)
98+
99+ require.NoError(t, err)
100+ assert.Equal(t, 3, retention.Daily)
101+}
102+
103+func TestDatabaseBackupSettingsPreferEnabledLegacySchedule(t *testing.T) {
104+ var cfg Config
105+ cfg.Backups.Enabled = true
106+ cfg.Backups.Schedule = string(domain.ScheduleHourly)
107+ cfg.Backups.Databases.Schedule = string(domain.ScheduleDaily)
108+
109+ settings := databaseBackupSettings(cfg)
110+
111+ assert.True(t, settings.Enabled)
112+ assert.Equal(t, string(domain.ScheduleHourly), settings.Schedule)
113+}
114+
115+func TestValidateVolumeBackupConfig(t *testing.T) {
116+ t.Run("disabled accepts defaults", func(t *testing.T) {
117+ var cfg Config
118+
119+ volumeCfg, err := validateVolumeBackupConfig(cfg)
120+ require.NoError(t, err)
121+ assert.False(t, volumeCfg.Enabled)
122+ assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
123+ assert.Equal(t, 0, volumeCfg.Retention.Keep)
124+ assert.Equal(t, 2, volumeCfg.MaxConcurrency)
125+ assert.Equal(t, "alpine:3.20", volumeCfg.HelperImage)
126+ })
127+
128+ t.Run("enabled requires bucket and region", func(t *testing.T) {
129+ var cfg Config
130+ cfg.Backups.Volumes.Enabled = true
131+ cfg.Backups.Volumes.Retention.Keep = 14
132+
133+ _, err := validateVolumeBackupConfig(cfg)
134+ require.Error(t, err)
135+ assert.Contains(t, err.Error(), "backups.volumes.s3.bucket")
136+
137+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
138+ _, err = validateVolumeBackupConfig(cfg)
139+ require.Error(t, err)
140+ assert.Contains(t, err.Error(), "backups.volumes.s3.region")
141+ })
142+
143+ t.Run("enabled accepts s3 config", func(t *testing.T) {
144+ var cfg Config
145+ cfg.Backups.Volumes.Enabled = true
146+ cfg.Backups.Volumes.Interval = "6h"
147+ cfg.Backups.Volumes.Timeout = "30m"
148+ cfg.Backups.Volumes.Compression = "gzip"
149+ cfg.Backups.Volumes.MaxConcurrency = 3
150+ cfg.Backups.Volumes.HelperImage = "example/helper:1"
151+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
152+ cfg.Backups.Volumes.S3.Region = "eu-west-3"
153+ cfg.Backups.Volumes.S3.Prefix = "prod/gordon"
154+ cfg.Backups.Volumes.Retention.Keep = 7
155+
156+ volumeCfg, err := validateVolumeBackupConfig(cfg)
157+ require.NoError(t, err)
158+ assert.True(t, volumeCfg.Enabled)
159+ assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
160+ assert.Equal(t, 7, volumeCfg.Retention.Keep)
161+ assert.Equal(t, 3, volumeCfg.MaxConcurrency)
162+ assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
163+ })
164+
165+ t.Run("zstd requires helper image with zstd", func(t *testing.T) {
166+ var cfg Config
167+ cfg.Backups.Volumes.Compression = "zstd"
168+
169+ _, err := validateVolumeBackupConfig(cfg)
170+
171+ require.Error(t, err)
172+ assert.Contains(t, err.Error(), "helper_image")
173+ })
174+
175+ t.Run("enabled rejects zero retention", func(t *testing.T) {
176+ var cfg Config
177+ cfg.Backups.Volumes.Enabled = true
178+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
179+ cfg.Backups.Volumes.S3.Region = "eu-west-3"
180+ cfg.Backups.Volumes.Retention.Keep = 0
181+
182+ _, err := validateVolumeBackupConfig(cfg)
183+ require.Error(t, err)
184+ assert.Contains(t, err.Error(), "backups.volumes.retention.keep")
185+ })
186+}
187+
188+func TestLoadConfigSetsBackupDefaults(t *testing.T) {
94189 v := viper.New()
95190
96191 require.NoError(t, loadConfig(v, ""))
97192
98- assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule"))
193+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))
194+ assert.Equal(t, "24h", v.GetString("backups.volumes.interval"))
195+ assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))
196+ assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))
99197 }
100198
101199 func TestResolveBackupSchedule(t *testing.T) {
@@ -123,6 +221,6 @@ func TestResolveBackupSchedule(t *testing.T) {
123221 t.Run("invalid value", func(t *testing.T) {
124222 _, err := resolveBackupSchedule("every-minute")
125223 require.Error(t, err)
126- assert.Contains(t, err.Error(), "backups.schedule")
224+ assert.Contains(t, err.Error(), "backups.databases.schedule")
127225 })
128226 }
@@ -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,110 @@ 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+ })
127+
128+ t.Run("enabled requires bucket and region", func(t *testing.T) {
129+ var cfg Config
130+ cfg.Backups.Volumes.Enabled = true
131+ cfg.Backups.Volumes.Retention.Keep = 14
132+
133+ _, err := validateVolumeBackupConfig(cfg)
134+ require.Error(t, err)
135+ assert.Contains(t, err.Error(), "backups.volumes.s3.bucket")
136+
137+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
138+ _, err = validateVolumeBackupConfig(cfg)
139+ require.Error(t, err)
140+ assert.Contains(t, err.Error(), "backups.volumes.s3.region")
141+ })
142+
143+ t.Run("enabled accepts s3 config", func(t *testing.T) {
144+ var cfg Config
145+ cfg.Backups.Volumes.Enabled = true
146+ cfg.Backups.Volumes.Interval = "6h"
147+ cfg.Backups.Volumes.Timeout = "30m"
148+ cfg.Backups.Volumes.Compression = "gzip"
149+ cfg.Backups.Volumes.MaxConcurrency = 3
150+ cfg.Backups.Volumes.HelperImage = "example/helper:1"
151+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
152+ cfg.Backups.Volumes.S3.Region = "eu-west-3"
153+ cfg.Backups.Volumes.S3.Prefix = "prod/gordon"
154+ cfg.Backups.Volumes.Retention.Keep = 7
155+
156+ volumeCfg, err := validateVolumeBackupConfig(cfg)
157+ require.NoError(t, err)
158+ assert.True(t, volumeCfg.Enabled)
159+ assert.Equal(t, domain.VolumeBackupCompressionGzip, volumeCfg.Compression)
160+ assert.Equal(t, 7, volumeCfg.Retention.Keep)
161+ assert.Equal(t, 3, volumeCfg.MaxConcurrency)
162+ assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
163+ })
164+
165+ t.Run("zstd requires helper image with zstd", func(t *testing.T) {
166+ var cfg Config
167+ cfg.Backups.Volumes.Compression = "zstd"
168+
169+ _, err := validateVolumeBackupConfig(cfg)
170+
171+ require.Error(t, err)
172+ assert.Contains(t, err.Error(), "helper_image")
173+ })
174+
175+ t.Run("enabled rejects zero retention", func(t *testing.T) {
176+ var cfg Config
177+ cfg.Backups.Volumes.Enabled = true
178+ cfg.Backups.Volumes.S3.Bucket = "gordon-backups"
179+ cfg.Backups.Volumes.S3.Region = "eu-west-3"
180+ cfg.Backups.Volumes.Retention.Keep = 0
181+
182+ _, err := validateVolumeBackupConfig(cfg)
183+ require.Error(t, err)
184+ assert.Contains(t, err.Error(), "backups.volumes.retention.keep")
185+ })
186+}
187+
188+func TestLoadConfigSetsBackupDefaults(t *testing.T) {
94 v := viper.New()189 v := viper.New()
95 190
96 require.NoError(t, loadConfig(v, ""))191 require.NoError(t, loadConfig(v, ""))
97 192
98- assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule"))193+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.databases.schedule"))
194+ assert.Equal(t, "24h", v.GetString("backups.volumes.interval"))
195+ assert.Equal(t, string(domain.VolumeBackupCompressionGzip), v.GetString("backups.volumes.compression"))
196+ assert.Equal(t, 14, v.GetInt("backups.volumes.retention.keep"))
99 }197 }
100 198
101 func TestResolveBackupSchedule(t *testing.T) {199 func TestResolveBackupSchedule(t *testing.T) {
@@ -123,6 +221,6 @@ func TestResolveBackupSchedule(t *testing.T) {
123 t.Run("invalid value", func(t *testing.T) {221 t.Run("invalid value", func(t *testing.T) {
124 _, err := resolveBackupSchedule("every-minute")222 _, err := resolveBackupSchedule("every-minute")
125 require.Error(t, err)223 require.Error(t, err)
126- assert.Contains(t, err.Error(), "backups.schedule")224+ assert.Contains(t, err.Error(), "backups.databases.schedule")
127 })225 })
128 }226 }
M internal/boundaries/in/backup.go +15 -5
@@ -7,12 +7,22 @@ import (
77 "github.com/bnema/gordon/internal/domain"
88 )
99
10-// BackupService defines backup orchestration use cases.
11-type BackupService interface {
12- ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error)
13- RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error)
10+// DatabaseBackupService defines database backup orchestration use cases.
11+type DatabaseBackupService interface {
12+ ListBackups(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error)
13+ RunBackup(ctx context.Context, domainName, dbName string) (*domain.DatabaseBackupResult, error)
1414 Restore(ctx context.Context, domainName, backupID string) error
1515 RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error
16- Status(ctx context.Context) ([]domain.BackupJob, error)
16+ Status(ctx context.Context) ([]domain.DatabaseBackupJob, error)
1717 DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error)
1818 }
19+
20+// BackupService is kept as a compatibility alias for the existing database backup feature.
21+type BackupService = DatabaseBackupService
22+
23+// VolumeBackupService defines volume archive backup orchestration use cases.
24+type VolumeBackupService interface {
25+ ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)
26+ RunVolumeBackups(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error)
27+ VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error)
28+}
@@ -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
108108 }
109109
110110 // ListBackups provides a mock function for the type MockBackupService
111-func (_mock *MockBackupService) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) {
111+func (_mock *MockBackupService) ListBackups(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error) {
112112 ret := _mock.Called(ctx, domainName)
113113
114114 if len(ret) == 0 {
115115 panic("no return value specified for ListBackups")
116116 }
117117
118- var r0 []domain.BackupJob
118+ var r0 []domain.DatabaseBackupJob
119119 var r1 error
120- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.BackupJob, error)); ok {
120+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.DatabaseBackupJob, error)); ok {
121121 return returnFunc(ctx, domainName)
122122 }
123- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.BackupJob); ok {
123+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.DatabaseBackupJob); ok {
124124 r0 = returnFunc(ctx, domainName)
125125 } else {
126126 if ret.Get(0) != nil {
127- r0 = ret.Get(0).([]domain.BackupJob)
127+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
128128 }
129129 }
130130 if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
@@ -165,12 +165,12 @@ func (_c *MockBackupService_ListBackups_Call) Run(run func(ctx context.Context,
165165 return _c
166166 }
167167
168-func (_c *MockBackupService_ListBackups_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_ListBackups_Call {
169- _c.Call.Return(backupJobs, err)
168+func (_c *MockBackupService_ListBackups_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupService_ListBackups_Call {
169+ _c.Call.Return(vs, err)
170170 return _c
171171 }
172172
173-func (_c *MockBackupService_ListBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.BackupJob, error)) *MockBackupService_ListBackups_Call {
173+func (_c *MockBackupService_ListBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.DatabaseBackupJob, error)) *MockBackupService_ListBackups_Call {
174174 _c.Call.Return(run)
175175 return _c
176176 }
@@ -302,23 +302,23 @@ func (_c *MockBackupService_RestorePITR_Call) RunAndReturn(run func(ctx context.
302302 }
303303
304304 // RunBackup provides a mock function for the type MockBackupService
305-func (_mock *MockBackupService) RunBackup(ctx context.Context, domainName string, dbName string) (*domain.BackupResult, error) {
305+func (_mock *MockBackupService) RunBackup(ctx context.Context, domainName string, dbName string) (*domain.DatabaseBackupResult, error) {
306306 ret := _mock.Called(ctx, domainName, dbName)
307307
308308 if len(ret) == 0 {
309309 panic("no return value specified for RunBackup")
310310 }
311311
312- var r0 *domain.BackupResult
312+ var r0 *domain.DatabaseBackupResult
313313 var r1 error
314- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.BackupResult, error)); ok {
314+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.DatabaseBackupResult, error)); ok {
315315 return returnFunc(ctx, domainName, dbName)
316316 }
317- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.BackupResult); ok {
317+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.DatabaseBackupResult); ok {
318318 r0 = returnFunc(ctx, domainName, dbName)
319319 } else {
320320 if ret.Get(0) != nil {
321- r0 = ret.Get(0).(*domain.BackupResult)
321+ r0 = ret.Get(0).(*domain.DatabaseBackupResult)
322322 }
323323 }
324324 if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
@@ -365,34 +365,34 @@ func (_c *MockBackupService_RunBackup_Call) Run(run func(ctx context.Context, do
365365 return _c
366366 }
367367
368-func (_c *MockBackupService_RunBackup_Call) Return(backupResult *domain.BackupResult, err error) *MockBackupService_RunBackup_Call {
369- _c.Call.Return(backupResult, err)
368+func (_c *MockBackupService_RunBackup_Call) Return(v *domain.DatabaseBackupResult, err error) *MockBackupService_RunBackup_Call {
369+ _c.Call.Return(v, err)
370370 return _c
371371 }
372372
373-func (_c *MockBackupService_RunBackup_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string) (*domain.BackupResult, error)) *MockBackupService_RunBackup_Call {
373+func (_c *MockBackupService_RunBackup_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string) (*domain.DatabaseBackupResult, error)) *MockBackupService_RunBackup_Call {
374374 _c.Call.Return(run)
375375 return _c
376376 }
377377
378378 // Status provides a mock function for the type MockBackupService
379-func (_mock *MockBackupService) Status(ctx context.Context) ([]domain.BackupJob, error) {
379+func (_mock *MockBackupService) Status(ctx context.Context) ([]domain.DatabaseBackupJob, error) {
380380 ret := _mock.Called(ctx)
381381
382382 if len(ret) == 0 {
383383 panic("no return value specified for Status")
384384 }
385385
386- var r0 []domain.BackupJob
386+ var r0 []domain.DatabaseBackupJob
387387 var r1 error
388- if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.BackupJob, error)); ok {
388+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.DatabaseBackupJob, error)); ok {
389389 return returnFunc(ctx)
390390 }
391- if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.BackupJob); ok {
391+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.DatabaseBackupJob); ok {
392392 r0 = returnFunc(ctx)
393393 } else {
394394 if ret.Get(0) != nil {
395- r0 = ret.Get(0).([]domain.BackupJob)
395+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
396396 }
397397 }
398398 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
@@ -427,12 +427,12 @@ func (_c *MockBackupService_Status_Call) Run(run func(ctx context.Context)) *Moc
427427 return _c
428428 }
429429
430-func (_c *MockBackupService_Status_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_Status_Call {
431- _c.Call.Return(backupJobs, err)
430+func (_c *MockBackupService_Status_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupService_Status_Call {
431+ _c.Call.Return(vs, err)
432432 return _c
433433 }
434434
435-func (_c *MockBackupService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.BackupJob, error)) *MockBackupService_Status_Call {
435+func (_c *MockBackupService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.DatabaseBackupJob, error)) *MockBackupService_Status_Call {
436436 _c.Call.Return(run)
437437 return _c
438438 }
@@ -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 }
M internal/boundaries/out/backupstore.go +21 -4
@@ -8,11 +8,28 @@ import (
88 "github.com/bnema/gordon/internal/domain"
99 )
1010
11-// BackupStorage defines persistence for backup artifacts and metadata.
12-type BackupStorage interface {
11+// DatabaseBackupStorage defines persistence for database backup artifacts and metadata.
12+type DatabaseBackupStorage interface {
1313 Store(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)
1414 Get(ctx context.Context, path string) (io.ReadCloser, error)
15- List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)
15+ List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error)
1616 Delete(ctx context.Context, path string) error
17- ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error)
17+ ApplyRetention(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error)
18+}
19+
20+// BackupStorage is kept as a compatibility alias for the existing database backup feature.
21+type BackupStorage = DatabaseBackupStorage
22+
23+// VolumeArchiveExporter exports named container volumes as archive streams.
24+type VolumeArchiveExporter interface {
25+ ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error)
26+}
27+
28+// VolumeBackupStorage defines persistence for volume archive backup artifacts.
29+type VolumeBackupStorage interface {
30+ StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error)
31+ GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error)
32+ ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error)
33+ DeleteVolumeArchive(ctx context.Context, artifactRef string) error
34+ ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error)
1835 }
@@ -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 {
4141 }
4242
4343 // ApplyRetention provides a mock function for the type MockBackupStorage
44-func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) {
44+func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error) {
4545 ret := _mock.Called(ctx, domainName, policy)
4646
4747 if len(ret) == 0 {
@@ -50,15 +50,15 @@ func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName s
5050
5151 var r0 int
5252 var r1 error
53- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) (int, error)); ok {
53+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.DatabaseBackupRetentionPolicy) (int, error)); ok {
5454 return returnFunc(ctx, domainName, policy)
5555 }
56- if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) int); ok {
56+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.DatabaseBackupRetentionPolicy) int); ok {
5757 r0 = returnFunc(ctx, domainName, policy)
5858 } else {
5959 r0 = ret.Get(0).(int)
6060 }
61- if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.RetentionPolicy) error); ok {
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.DatabaseBackupRetentionPolicy) error); ok {
6262 r1 = returnFunc(ctx, domainName, policy)
6363 } else {
6464 r1 = ret.Error(1)
@@ -74,12 +74,12 @@ type MockBackupStorage_ApplyRetention_Call struct {
7474 // ApplyRetention is a helper method to define mock.On call
7575 // - ctx context.Context
7676 // - domainName string
77-// - policy domain.RetentionPolicy
77+// - policy domain.DatabaseBackupRetentionPolicy
7878 func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx interface{}, domainName interface{}, policy interface{}) *MockBackupStorage_ApplyRetention_Call {
7979 return &MockBackupStorage_ApplyRetention_Call{Call: _e.mock.On("ApplyRetention", ctx, domainName, policy)}
8080 }
8181
82-func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy)) *MockBackupStorage_ApplyRetention_Call {
82+func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy)) *MockBackupStorage_ApplyRetention_Call {
8383 _c.Call.Run(func(args mock.Arguments) {
8484 var arg0 context.Context
8585 if args[0] != nil {
@@ -89,9 +89,9 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Contex
8989 if args[1] != nil {
9090 arg1 = args[1].(string)
9191 }
92- var arg2 domain.RetentionPolicy
92+ var arg2 domain.DatabaseBackupRetentionPolicy
9393 if args[2] != nil {
94- arg2 = args[2].(domain.RetentionPolicy)
94+ arg2 = args[2].(domain.DatabaseBackupRetentionPolicy)
9595 }
9696 run(
9797 arg0,
@@ -107,7 +107,7 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Return(n int, err error) *MockB
107107 return _c
108108 }
109109
110-func (_c *MockBackupStorage_ApplyRetention_Call) RunAndReturn(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error)) *MockBackupStorage_ApplyRetention_Call {
110+func (_c *MockBackupStorage_ApplyRetention_Call) RunAndReturn(run func(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error)) *MockBackupStorage_ApplyRetention_Call {
111111 _c.Call.Return(run)
112112 return _c
113113 }
@@ -238,23 +238,23 @@ func (_c *MockBackupStorage_Get_Call) RunAndReturn(run func(ctx context.Context,
238238 }
239239
240240 // List provides a mock function for the type MockBackupStorage
241-func (_mock *MockBackupStorage) List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
241+func (_mock *MockBackupStorage) List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error) {
242242 ret := _mock.Called(ctx, domainName, schedule)
243243
244244 if len(ret) == 0 {
245245 panic("no return value specified for List")
246246 }
247247
248- var r0 []domain.BackupJob
248+ var r0 []domain.DatabaseBackupJob
249249 var r1 error
250- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error)); ok {
250+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error)); ok {
251251 return returnFunc(ctx, domainName, schedule)
252252 }
253- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) []domain.BackupJob); ok {
253+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) []domain.DatabaseBackupJob); ok {
254254 r0 = returnFunc(ctx, domainName, schedule)
255255 } else {
256256 if ret.Get(0) != nil {
257- r0 = ret.Get(0).([]domain.BackupJob)
257+ r0 = ret.Get(0).([]domain.DatabaseBackupJob)
258258 }
259259 }
260260 if returnFunc, ok := ret.Get(1).(func(context.Context, string, *domain.BackupSchedule) error); ok {
@@ -301,12 +301,12 @@ func (_c *MockBackupStorage_List_Call) Run(run func(ctx context.Context, domainN
301301 return _c
302302 }
303303
304-func (_c *MockBackupStorage_List_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupStorage_List_Call {
305- _c.Call.Return(backupJobs, err)
304+func (_c *MockBackupStorage_List_Call) Return(vs []domain.DatabaseBackupJob, err error) *MockBackupStorage_List_Call {
305+ _c.Call.Return(vs, err)
306306 return _c
307307 }
308308
309-func (_c *MockBackupStorage_List_Call) RunAndReturn(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)) *MockBackupStorage_List_Call {
309+func (_c *MockBackupStorage_List_Call) RunAndReturn(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.DatabaseBackupJob, error)) *MockBackupStorage_List_Call {
310310 _c.Call.Return(run)
311311 return _c
312312 }
@@ -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 @@
11 package domain
22
3-import "time"
3+import (
4+ "io"
5+ "time"
6+)
47
58 // DBType identifies the database engine.
69 type DBType string
@@ -24,7 +27,16 @@ const (
2427 type BackupType string
2528
2629 const (
27- BackupTypeLogical BackupType = "logical"
30+ BackupTypeLogical BackupType = "logical"
31+ BackupTypeVolumeArchive BackupType = "volume_archive"
32+)
33+
34+// VolumeBackupCompression identifies the archive compression format for volume backups.
35+type VolumeBackupCompression string
36+
37+const (
38+ VolumeBackupCompressionGzip VolumeBackupCompression = "gzip"
39+ VolumeBackupCompressionZstd VolumeBackupCompression = "zstd"
2840 )
2941
3042 // BackupJobStatus tracks backup job lifecycle state.
@@ -86,6 +98,12 @@ type BackupResult struct {
8698 Duration time.Duration
8799 }
88100
101+// DatabaseBackupJob represents a logical database backup operation.
102+type DatabaseBackupJob = BackupJob
103+
104+// DatabaseBackupResult is returned after a database backup operation completes.
105+type DatabaseBackupResult = BackupResult
106+
89107 // RetentionPolicy defines how many backups to keep per schedule tier.
90108 type RetentionPolicy struct {
91109 Hourly int
@@ -94,13 +112,21 @@ type RetentionPolicy struct {
94112 Monthly int
95113 }
96114
115+// DatabaseBackupRetentionPolicy defines how many database backups to keep per schedule tier.
116+type DatabaseBackupRetentionPolicy = RetentionPolicy
117+
118+// VolumeBackupRetentionPolicy defines how many volume backup archives to keep.
119+type VolumeBackupRetentionPolicy struct {
120+ Keep int
121+}
122+
97123 // BackupOverride allows per-domain backup configuration.
98124 type BackupOverride struct {
99125 Schedules []BackupSchedule
100126 Retention *RetentionPolicy
101127 }
102128
103-// BackupConfig is the top-level backup configuration.
129+// BackupConfig is the database backup configuration.
104130 type BackupConfig struct {
105131 Enabled bool
106132 StorageDir string
@@ -108,6 +134,75 @@ type BackupConfig struct {
108134 Overrides map[string]BackupOverride
109135 }
110136
137+// DatabaseBackupConfig is the database backup configuration.
138+type DatabaseBackupConfig = BackupConfig
139+
140+// VolumeBackupConfig is the volume backup configuration.
141+type VolumeBackupConfig struct {
142+ Enabled bool
143+ Interval time.Duration
144+ Compression VolumeBackupCompression
145+ Retention VolumeBackupRetentionPolicy
146+ Timeout time.Duration
147+ MaxConcurrency int
148+ HelperImage string
149+ VolumePrefix string
150+ S3Bucket string
151+ S3Region string
152+ S3Prefix string
153+ S3Endpoint string
154+ S3PathStyle bool
155+ S3SSEAlgorithm string
156+ S3SSEKMSKeyID string
157+}
158+
159+// VolumeBackupJob represents a filesystem archive backup of a named volume.
160+type VolumeBackupJob struct {
161+ ID string
162+ Domain string
163+ ContainerName string
164+ ContainerID string
165+ VolumeName string
166+ MountPath string
167+ Type BackupType
168+ Status BackupJobStatus
169+ StartedAt time.Time
170+ CompletedAt time.Time
171+ SizeBytes int64
172+ ArtifactRef string
173+ Error string
174+ Metadata map[string]string
175+}
176+
177+// VolumeBackupTarget identifies one selected volume backup source.
178+type VolumeBackupTarget struct {
179+ Domain string
180+ ContainerName string
181+ ContainerID string
182+ VolumeName string
183+ MountPath string
184+}
185+
186+// VolumeArchiveRequest describes a volume archive export request.
187+type VolumeArchiveRequest struct {
188+ VolumeName string
189+ MountPath string
190+ Compression VolumeBackupCompression
191+ HelperImage string
192+}
193+
194+// VolumeArchiveMetadata contains metadata observed while exporting a volume archive.
195+type VolumeArchiveMetadata struct {
196+ SizeBytes int64
197+ Checksum string
198+}
199+
200+// VolumeArchiveResult contains a readable archive stream and export metadata.
201+type VolumeArchiveResult struct {
202+ Stream io.ReadCloser
203+ Metadata VolumeArchiveMetadata
204+}
205+
111206 // Backup labels for container metadata.
112207 const (
113208 LabelBackupEnabled = "gordon.backup"
@@ -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"
66
77 // Container represents a running container in the system.
88 type Container struct {
9- ID string
10- Image string
11- ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
12- Name string
13- Status string
14- ExitCode int
15- Ports []int
16- Labels map[string]string
17- Created time.Time
9+ ID string
10+ Image string
11+ ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys
12+ Name string
13+ Status string
14+ ExitCode int
15+ Ports []int
16+ Labels map[string]string
17+ VolumeMounts []ContainerVolumeMount
18+ Created time.Time
19+}
20+
21+// ContainerVolumeMount describes a mounted volume-like resource on a container.
22+type ContainerVolumeMount struct {
23+ Name string
24+ Type string
25+ Source string
26+ Destination string
27+ ReadOnly bool
1828 }
1929
2030 // NetworkInfo represents network configuration and state.
@@ -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"
88 // Timezone-aware scheduling is deferred until a timezone field is added.
99 // When timezone support lands, host system timezone should be the default.
1010 type CronSchedule struct {
11- Preset BackupSchedule
11+ Preset BackupSchedule
12+ Interval time.Duration
1213 }
1314
1415 // CronEntry represents a registered cron job.
@@ -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.
A internal/usecase/backup/volume_service.go +259 -0
@@ -0,0 +1,259 @@
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+ return s.storage.ListVolumeArchives(ctx, domainName)
44+}
45+
46+// VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
47+func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
48+ jobs, err := s.storage.ListVolumeArchives(ctx, "")
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ s.mu.Lock()
54+ for _, job := range s.recent {
55+ if job.Status == domain.BackupStatusRunning || job.Status == domain.BackupStatusFailed {
56+ jobs = append(jobs, job)
57+ }
58+ }
59+ s.mu.Unlock()
60+
61+ sort.Slice(jobs, func(i, j int) bool {
62+ if !jobs[i].StartedAt.Equal(jobs[j].StartedAt) {
63+ return jobs[i].StartedAt.After(jobs[j].StartedAt)
64+ }
65+ return jobs[i].VolumeName < jobs[j].VolumeName
66+ })
67+ return jobs, nil
68+}
69+
70+// RunVolumeBackups runs volume backups for all eligible targets, optionally scoped to a domain and volume.
71+func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volumeName string) ([]domain.VolumeBackupJob, error) {
72+ if !s.config.Enabled {
73+ return []domain.VolumeBackupJob{}, nil
74+ }
75+
76+ containers, err := s.runtime.ListContainers(ctx, true)
77+ if err != nil {
78+ return nil, fmt.Errorf("failed to list containers for volume backups: %w", err)
79+ }
80+ targets := SelectVolumeBackupTargetsForScope(containers, s.config.VolumePrefix, domainName, volumeName)
81+ s.log.Info().
82+ Str("domain", domainName).
83+ Str("volume", volumeName).
84+ Int("targets", len(targets)).
85+ Msg("selected volume backup targets")
86+ if len(targets) == 0 {
87+ return []domain.VolumeBackupJob{}, nil
88+ }
89+
90+ results, firstErr := s.runVolumeBackupTargets(ctx, targets)
91+ if err := s.applyRetentionForSuccessfulVolumeBackups(ctx, results); err != nil && firstErr == nil {
92+ firstErr = err
93+ }
94+ return results, firstErr
95+}
96+
97+func (s *VolumeService) runVolumeBackupTargets(ctx context.Context, targets []domain.VolumeBackupTarget) ([]domain.VolumeBackupJob, error) {
98+ runCtx, cancel := context.WithCancel(ctx)
99+ defer cancel()
100+
101+ sem := make(chan struct{}, max(s.config.MaxConcurrency, 1))
102+ results := make([]domain.VolumeBackupJob, 0, len(targets))
103+ var wg sync.WaitGroup
104+ var mu sync.Mutex
105+ var firstErr error
106+
107+launchLoop:
108+ for _, target := range targets {
109+ select {
110+ case <-runCtx.Done():
111+ firstErr = runCtx.Err()
112+ break launchLoop
113+ case sem <- struct{}{}:
114+ }
115+ wg.Add(1)
116+ go func(target domain.VolumeBackupTarget) {
117+ defer wg.Done()
118+ defer func() { <-sem }()
119+ job := s.runVolumeBackup(runCtx, target)
120+ mu.Lock()
121+ results = append(results, job)
122+ mu.Unlock()
123+ }(target)
124+ }
125+ if firstErr != nil {
126+ cancel()
127+ }
128+ wg.Wait()
129+ return results, firstVolumeBackupError(results, firstErr)
130+}
131+
132+func (s *VolumeService) applyRetentionForSuccessfulVolumeBackups(ctx context.Context, results []domain.VolumeBackupJob) error {
133+ successDomains, failedDomains := volumeBackupResultDomains(results)
134+ for domainName := range successDomains {
135+ if _, failed := failedDomains[domainName]; failed {
136+ continue
137+ }
138+ deleted, err := s.storage.ApplyVolumeRetention(ctx, domainName, s.config.Retention)
139+ if err != nil {
140+ return fmt.Errorf("apply volume backup retention for %s: %w", domainName, err)
141+ }
142+ s.log.Info().
143+ Str("domain", domainName).
144+ Int("deleted", deleted).
145+ Msg("volume backup retention applied")
146+ }
147+ return nil
148+}
149+
150+func firstVolumeBackupError(results []domain.VolumeBackupJob, firstErr error) error {
151+ if firstErr != nil {
152+ return firstErr
153+ }
154+ for _, job := range results {
155+ if job.Status == domain.BackupStatusFailed {
156+ return fmt.Errorf("volume backup failed for %s/%s: %s", job.Domain, job.VolumeName, job.Error)
157+ }
158+ }
159+ return nil
160+}
161+
162+func volumeBackupResultDomains(results []domain.VolumeBackupJob) (map[string]struct{}, map[string]struct{}) {
163+ successDomains := make(map[string]struct{})
164+ failedDomains := make(map[string]struct{})
165+ for _, job := range results {
166+ switch job.Status {
167+ case domain.BackupStatusCompleted:
168+ successDomains[job.Domain] = struct{}{}
169+ case domain.BackupStatusFailed:
170+ failedDomains[job.Domain] = struct{}{}
171+ }
172+ }
173+ return successDomains, failedDomains
174+}
175+
176+func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
177+ started := time.Now().UTC()
178+ job := domain.VolumeBackupJob{
179+ ID: newBackupJobID(started),
180+ Domain: target.Domain,
181+ ContainerName: target.ContainerName,
182+ ContainerID: target.ContainerID,
183+ VolumeName: target.VolumeName,
184+ MountPath: target.MountPath,
185+ Type: domain.BackupTypeVolumeArchive,
186+ Status: domain.BackupStatusRunning,
187+ StartedAt: started,
188+ Metadata: map[string]string{
189+ "compression": string(s.config.Compression),
190+ },
191+ }
192+ s.remember(job)
193+
194+ timeout := s.config.Timeout
195+ if timeout <= 0 {
196+ timeout = 2 * time.Hour
197+ }
198+ exportCtx, cancel := context.WithTimeout(ctx, timeout)
199+ defer cancel()
200+
201+ archive, err := s.exporter.ExportVolumeArchive(exportCtx, domain.VolumeArchiveRequest{
202+ VolumeName: target.VolumeName,
203+ MountPath: target.MountPath,
204+ Compression: s.config.Compression,
205+ HelperImage: s.config.HelperImage,
206+ })
207+ if err != nil {
208+ s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive export failed")
209+ return s.failJob(job, err)
210+ }
211+ defer archive.Stream.Close()
212+
213+ counter := &byteCounter{}
214+ artifactRef, err := s.storage.StoreVolumeArchive(exportCtx, job, io.TeeReader(archive.Stream, counter))
215+ if err != nil {
216+ s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive upload failed")
217+ return s.failJob(job, err)
218+ }
219+
220+ job.Status = domain.BackupStatusCompleted
221+ job.CompletedAt = time.Now().UTC()
222+ job.SizeBytes = counter.n
223+ job.ArtifactRef = artifactRef
224+ s.log.Info().
225+ Str("domain", job.Domain).
226+ Str("volume", job.VolumeName).
227+ Int64("size_bytes", job.SizeBytes).
228+ Dur("duration", job.CompletedAt.Sub(job.StartedAt)).
229+ Msg("volume backup completed")
230+ s.forget(job)
231+ return job
232+}
233+
234+func (s *VolumeService) failJob(job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {
235+ job.Status = domain.BackupStatusFailed
236+ job.CompletedAt = time.Now().UTC()
237+ job.Error = err.Error()
238+ s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume backup failed")
239+ s.remember(job)
240+ return job
241+}
242+
243+func (s *VolumeService) remember(job domain.VolumeBackupJob) {
244+ s.mu.Lock()
245+ defer s.mu.Unlock()
246+ s.recent[job.Domain+"/"+job.VolumeName] = job
247+ if len(s.recent) > 100 {
248+ for k := range s.recent {
249+ delete(s.recent, k)
250+ break
251+ }
252+ }
253+}
254+
255+func (s *VolumeService) forget(job domain.VolumeBackupJob) {
256+ s.mu.Lock()
257+ defer s.mu.Unlock()
258+ delete(s.recent, job.Domain+"/"+job.VolumeName)
259+}
@@ -0,0 +1,259 @@
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+ return s.storage.ListVolumeArchives(ctx, domainName)
44+}
45+
46+// VolumeBackupStatus returns completed backup artifacts plus current/recent in-memory job state.
47+func (s *VolumeService) VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) {
48+ jobs, err := s.storage.ListVolumeArchives(ctx, "")
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ s.mu.Lock()
54+ for _, job := range s.recent {
55+ if job.Status == domain.BackupStatusRunning || job.Status == domain.BackupStatusFailed {
56+ jobs = append(jobs, job)
57+ }
58+ }
59+ s.mu.Unlock()
60+
61+ sort.Slice(jobs, func(i, j int) bool {
62+ if !jobs[i].StartedAt.Equal(jobs[j].StartedAt) {
63+ return jobs[i].StartedAt.After(jobs[j].StartedAt)
64+ }
65+ return jobs[i].VolumeName < jobs[j].VolumeName
66+ })
67+ return jobs, nil
68+}
69+
70+// RunVolumeBackups runs volume backups for all eligible targets, optionally scoped to a domain and volume.
71+func (s *VolumeService) RunVolumeBackups(ctx context.Context, domainName, volumeName string) ([]domain.VolumeBackupJob, error) {
72+ if !s.config.Enabled {
73+ return []domain.VolumeBackupJob{}, nil
74+ }
75+
76+ containers, err := s.runtime.ListContainers(ctx, true)
77+ if err != nil {
78+ return nil, fmt.Errorf("failed to list containers for volume backups: %w", err)
79+ }
80+ targets := SelectVolumeBackupTargetsForScope(containers, s.config.VolumePrefix, domainName, volumeName)
81+ s.log.Info().
82+ Str("domain", domainName).
83+ Str("volume", volumeName).
84+ Int("targets", len(targets)).
85+ Msg("selected volume backup targets")
86+ if len(targets) == 0 {
87+ return []domain.VolumeBackupJob{}, nil
88+ }
89+
90+ results, firstErr := s.runVolumeBackupTargets(ctx, targets)
91+ if err := s.applyRetentionForSuccessfulVolumeBackups(ctx, results); err != nil && firstErr == nil {
92+ firstErr = err
93+ }
94+ return results, firstErr
95+}
96+
97+func (s *VolumeService) runVolumeBackupTargets(ctx context.Context, targets []domain.VolumeBackupTarget) ([]domain.VolumeBackupJob, error) {
98+ runCtx, cancel := context.WithCancel(ctx)
99+ defer cancel()
100+
101+ sem := make(chan struct{}, max(s.config.MaxConcurrency, 1))
102+ results := make([]domain.VolumeBackupJob, 0, len(targets))
103+ var wg sync.WaitGroup
104+ var mu sync.Mutex
105+ var firstErr error
106+
107+launchLoop:
108+ for _, target := range targets {
109+ select {
110+ case <-runCtx.Done():
111+ firstErr = runCtx.Err()
112+ break launchLoop
113+ case sem <- struct{}{}:
114+ }
115+ wg.Add(1)
116+ go func(target domain.VolumeBackupTarget) {
117+ defer wg.Done()
118+ defer func() { <-sem }()
119+ job := s.runVolumeBackup(runCtx, target)
120+ mu.Lock()
121+ results = append(results, job)
122+ mu.Unlock()
123+ }(target)
124+ }
125+ if firstErr != nil {
126+ cancel()
127+ }
128+ wg.Wait()
129+ return results, firstVolumeBackupError(results, firstErr)
130+}
131+
132+func (s *VolumeService) applyRetentionForSuccessfulVolumeBackups(ctx context.Context, results []domain.VolumeBackupJob) error {
133+ successDomains, failedDomains := volumeBackupResultDomains(results)
134+ for domainName := range successDomains {
135+ if _, failed := failedDomains[domainName]; failed {
136+ continue
137+ }
138+ deleted, err := s.storage.ApplyVolumeRetention(ctx, domainName, s.config.Retention)
139+ if err != nil {
140+ return fmt.Errorf("apply volume backup retention for %s: %w", domainName, err)
141+ }
142+ s.log.Info().
143+ Str("domain", domainName).
144+ Int("deleted", deleted).
145+ Msg("volume backup retention applied")
146+ }
147+ return nil
148+}
149+
150+func firstVolumeBackupError(results []domain.VolumeBackupJob, firstErr error) error {
151+ if firstErr != nil {
152+ return firstErr
153+ }
154+ for _, job := range results {
155+ if job.Status == domain.BackupStatusFailed {
156+ return fmt.Errorf("volume backup failed for %s/%s: %s", job.Domain, job.VolumeName, job.Error)
157+ }
158+ }
159+ return nil
160+}
161+
162+func volumeBackupResultDomains(results []domain.VolumeBackupJob) (map[string]struct{}, map[string]struct{}) {
163+ successDomains := make(map[string]struct{})
164+ failedDomains := make(map[string]struct{})
165+ for _, job := range results {
166+ switch job.Status {
167+ case domain.BackupStatusCompleted:
168+ successDomains[job.Domain] = struct{}{}
169+ case domain.BackupStatusFailed:
170+ failedDomains[job.Domain] = struct{}{}
171+ }
172+ }
173+ return successDomains, failedDomains
174+}
175+
176+func (s *VolumeService) runVolumeBackup(ctx context.Context, target domain.VolumeBackupTarget) domain.VolumeBackupJob {
177+ started := time.Now().UTC()
178+ job := domain.VolumeBackupJob{
179+ ID: newBackupJobID(started),
180+ Domain: target.Domain,
181+ ContainerName: target.ContainerName,
182+ ContainerID: target.ContainerID,
183+ VolumeName: target.VolumeName,
184+ MountPath: target.MountPath,
185+ Type: domain.BackupTypeVolumeArchive,
186+ Status: domain.BackupStatusRunning,
187+ StartedAt: started,
188+ Metadata: map[string]string{
189+ "compression": string(s.config.Compression),
190+ },
191+ }
192+ s.remember(job)
193+
194+ timeout := s.config.Timeout
195+ if timeout <= 0 {
196+ timeout = 2 * time.Hour
197+ }
198+ exportCtx, cancel := context.WithTimeout(ctx, timeout)
199+ defer cancel()
200+
201+ archive, err := s.exporter.ExportVolumeArchive(exportCtx, domain.VolumeArchiveRequest{
202+ VolumeName: target.VolumeName,
203+ MountPath: target.MountPath,
204+ Compression: s.config.Compression,
205+ HelperImage: s.config.HelperImage,
206+ })
207+ if err != nil {
208+ s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive export failed")
209+ return s.failJob(job, err)
210+ }
211+ defer archive.Stream.Close()
212+
213+ counter := &byteCounter{}
214+ artifactRef, err := s.storage.StoreVolumeArchive(exportCtx, job, io.TeeReader(archive.Stream, counter))
215+ if err != nil {
216+ s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume archive upload failed")
217+ return s.failJob(job, err)
218+ }
219+
220+ job.Status = domain.BackupStatusCompleted
221+ job.CompletedAt = time.Now().UTC()
222+ job.SizeBytes = counter.n
223+ job.ArtifactRef = artifactRef
224+ s.log.Info().
225+ Str("domain", job.Domain).
226+ Str("volume", job.VolumeName).
227+ Int64("size_bytes", job.SizeBytes).
228+ Dur("duration", job.CompletedAt.Sub(job.StartedAt)).
229+ Msg("volume backup completed")
230+ s.forget(job)
231+ return job
232+}
233+
234+func (s *VolumeService) failJob(job domain.VolumeBackupJob, err error) domain.VolumeBackupJob {
235+ job.Status = domain.BackupStatusFailed
236+ job.CompletedAt = time.Now().UTC()
237+ job.Error = err.Error()
238+ s.log.Error().Err(err).Str("domain", job.Domain).Str("volume", job.VolumeName).Msg("volume backup failed")
239+ s.remember(job)
240+ return job
241+}
242+
243+func (s *VolumeService) remember(job domain.VolumeBackupJob) {
244+ s.mu.Lock()
245+ defer s.mu.Unlock()
246+ s.recent[job.Domain+"/"+job.VolumeName] = job
247+ if len(s.recent) > 100 {
248+ for k := range s.recent {
249+ delete(s.recent, k)
250+ break
251+ }
252+ }
253+}
254+
255+func (s *VolumeService) forget(job domain.VolumeBackupJob) {
256+ s.mu.Lock()
257+ defer s.mu.Unlock()
258+ delete(s.recent, job.Domain+"/"+job.VolumeName)
259+}
A internal/usecase/backup/volume_service_test.go +139 -0
@@ -0,0 +1,139 @@
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+}
39+
40+func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
41+ if _, err := io.Copy(io.Discard, data); err != nil {
42+ return "", err
43+ }
44+ f.stored = append(f.stored, job)
45+ return "s3://bucket/" + job.VolumeName, nil
46+}
47+
48+func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.ReadCloser, error) {
49+ return io.NopCloser(bytes.NewReader(nil)), nil
50+}
51+
52+func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
53+ return nil, nil
54+}
55+
56+func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
57+
58+func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, domainName string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
59+ f.retention = append(f.retention, domainName)
60+ return 0, nil
61+}
62+
63+func TestVolumeServiceRunVolumeBackups(t *testing.T) {
64+ runtime := outmocks.NewMockContainerRuntime(t)
65+ storage := &fakeVolumeBackupStorage{}
66+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, storage, domain.VolumeBackupConfig{
67+ Enabled: true,
68+ Compression: domain.VolumeBackupCompressionGzip,
69+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
70+ Timeout: time.Minute,
71+ MaxConcurrency: 1,
72+ HelperImage: "helper:latest",
73+ VolumePrefix: "gordon",
74+ }, testLogger())
75+
76+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
77+ {
78+ ID: "app",
79+ Name: "app",
80+ Labels: map[string]string{
81+ domain.LabelManaged: "true",
82+ domain.LabelDomain: "app.example.com",
83+ },
84+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
85+ },
86+ }, nil)
87+
88+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
89+ require.NoError(t, err)
90+ require.Len(t, jobs, 1)
91+ assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
92+ assert.Equal(t, "s3://bucket/gordon-app-data", jobs[0].ArtifactRef)
93+ assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
94+ assert.Len(t, storage.stored, 1)
95+ assert.Equal(t, []string{"app.example.com"}, storage.retention)
96+}
97+
98+func TestVolumeServiceRunVolumeBackupsReturnsPartialFailure(t *testing.T) {
99+ runtime := outmocks.NewMockContainerRuntime(t)
100+ storage := &fakeVolumeBackupStorage{}
101+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{err: fmt.Errorf("boom")}, storage, domain.VolumeBackupConfig{
102+ Enabled: true,
103+ Compression: domain.VolumeBackupCompressionGzip,
104+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
105+ Timeout: time.Minute,
106+ MaxConcurrency: 1,
107+ HelperImage: "helper:latest",
108+ VolumePrefix: "gordon",
109+ }, testLogger())
110+
111+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
112+ {
113+ ID: "app",
114+ Name: "app",
115+ Labels: map[string]string{
116+ domain.LabelManaged: "true",
117+ domain.LabelDomain: "app.example.com",
118+ },
119+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
120+ },
121+ }, nil)
122+
123+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
124+ require.Error(t, err)
125+ require.Len(t, jobs, 1)
126+ assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
127+ assert.Contains(t, jobs[0].Error, "boom")
128+ assert.Empty(t, storage.stored)
129+}
130+
131+func TestVolumeServiceDisabledDoesNothing(t *testing.T) {
132+ runtime := outmocks.NewMockContainerRuntime(t)
133+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())
134+
135+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
136+
137+ require.NoError(t, err)
138+ assert.Empty(t, jobs)
139+}
@@ -0,0 +1,139 @@
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+}
39+
40+func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
41+ if _, err := io.Copy(io.Discard, data); err != nil {
42+ return "", err
43+ }
44+ f.stored = append(f.stored, job)
45+ return "s3://bucket/" + job.VolumeName, nil
46+}
47+
48+func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.ReadCloser, error) {
49+ return io.NopCloser(bytes.NewReader(nil)), nil
50+}
51+
52+func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
53+ return nil, nil
54+}
55+
56+func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
57+
58+func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, domainName string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
59+ f.retention = append(f.retention, domainName)
60+ return 0, nil
61+}
62+
63+func TestVolumeServiceRunVolumeBackups(t *testing.T) {
64+ runtime := outmocks.NewMockContainerRuntime(t)
65+ storage := &fakeVolumeBackupStorage{}
66+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, storage, domain.VolumeBackupConfig{
67+ Enabled: true,
68+ Compression: domain.VolumeBackupCompressionGzip,
69+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
70+ Timeout: time.Minute,
71+ MaxConcurrency: 1,
72+ HelperImage: "helper:latest",
73+ VolumePrefix: "gordon",
74+ }, testLogger())
75+
76+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
77+ {
78+ ID: "app",
79+ Name: "app",
80+ Labels: map[string]string{
81+ domain.LabelManaged: "true",
82+ domain.LabelDomain: "app.example.com",
83+ },
84+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
85+ },
86+ }, nil)
87+
88+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
89+ require.NoError(t, err)
90+ require.Len(t, jobs, 1)
91+ assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
92+ assert.Equal(t, "s3://bucket/gordon-app-data", jobs[0].ArtifactRef)
93+ assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
94+ assert.Len(t, storage.stored, 1)
95+ assert.Equal(t, []string{"app.example.com"}, storage.retention)
96+}
97+
98+func TestVolumeServiceRunVolumeBackupsReturnsPartialFailure(t *testing.T) {
99+ runtime := outmocks.NewMockContainerRuntime(t)
100+ storage := &fakeVolumeBackupStorage{}
101+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{err: fmt.Errorf("boom")}, storage, domain.VolumeBackupConfig{
102+ Enabled: true,
103+ Compression: domain.VolumeBackupCompressionGzip,
104+ Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
105+ Timeout: time.Minute,
106+ MaxConcurrency: 1,
107+ HelperImage: "helper:latest",
108+ VolumePrefix: "gordon",
109+ }, testLogger())
110+
111+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
112+ {
113+ ID: "app",
114+ Name: "app",
115+ Labels: map[string]string{
116+ domain.LabelManaged: "true",
117+ domain.LabelDomain: "app.example.com",
118+ },
119+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
120+ },
121+ }, nil)
122+
123+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
124+ require.Error(t, err)
125+ require.Len(t, jobs, 1)
126+ assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
127+ assert.Contains(t, jobs[0].Error, "boom")
128+ assert.Empty(t, storage.stored)
129+}
130+
131+func TestVolumeServiceDisabledDoesNothing(t *testing.T) {
132+ runtime := outmocks.NewMockContainerRuntime(t)
133+ svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())
134+
135+ jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
136+
137+ require.NoError(t, err)
138+ assert.Empty(t, jobs)
139+}
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+}
@@ -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+}
@@ -0,0 +1,115 @@
1+package backup
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestSelectVolumeBackupTargets(t *testing.T) {
13+ containers := []*domain.Container{
14+ {
15+ ID: "unmanaged",
16+ Name: "unmanaged",
17+ Labels: map[string]string{
18+ domain.LabelDomain: "app.example.com",
19+ },
20+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
21+ },
22+ {
23+ ID: "app",
24+ Name: "app",
25+ Labels: map[string]string{
26+ domain.LabelManaged: "true",
27+ domain.LabelDomain: "app.example.com",
28+ },
29+ VolumeMounts: []domain.ContainerVolumeMount{
30+ {Name: "gordon-app-example-com-data", Type: "volume", Destination: "/data"},
31+ {Name: "gordon-app-example-com-cache", Type: "bind", Destination: "/cache"},
32+ {Name: "random-anonymous", Type: "volume", Destination: "/anon"},
33+ {Name: "", Type: "volume", Destination: "/empty"},
34+ },
35+ },
36+ {
37+ ID: "postgres",
38+ Name: "postgres",
39+ Labels: map[string]string{
40+ domain.LabelManaged: "true",
41+ domain.LabelAttachment: "true",
42+ domain.LabelAttachedTo: "app.example.com",
43+ },
44+ VolumeMounts: []domain.ContainerVolumeMount{
45+ {Name: "gordon-postgres-data", Type: "volume", Destination: "/var/lib/postgresql/data"},
46+ {Name: "gordon-app-example-com-data", Type: "volume", Destination: "/shared"},
47+ },
48+ },
49+ }
50+
51+ targets := SelectVolumeBackupTargets(containers, "gordon")
52+
53+ assert.Equal(t, []domain.VolumeBackupTarget{
54+ {
55+ Domain: "app.example.com",
56+ ContainerName: "app",
57+ ContainerID: "app",
58+ VolumeName: "gordon-app-example-com-data",
59+ MountPath: "/data",
60+ },
61+ {
62+ Domain: "app.example.com",
63+ ContainerName: "postgres",
64+ ContainerID: "postgres",
65+ VolumeName: "gordon-postgres-data",
66+ MountPath: "/var/lib/postgresql/data",
67+ },
68+ }, targets)
69+}
70+
71+func TestSelectVolumeBackupTargetsForScopeDedupesAfterFiltering(t *testing.T) {
72+ containers := []*domain.Container{
73+ {
74+ ID: "alpha",
75+ Name: "alpha",
76+ Labels: map[string]string{
77+ domain.LabelManaged: "true",
78+ domain.LabelDomain: "alpha.example.com",
79+ },
80+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-shared", Type: "volume", Destination: "/data"}},
81+ },
82+ {
83+ ID: "beta",
84+ Name: "beta",
85+ Labels: map[string]string{
86+ domain.LabelManaged: "true",
87+ domain.LabelDomain: "beta.example.com",
88+ },
89+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-shared", Type: "volume", Destination: "/data"}},
90+ },
91+ }
92+
93+ targets := SelectVolumeBackupTargetsForScope(containers, "gordon", "beta.example.com", "")
94+
95+ require.Len(t, targets, 1)
96+ assert.Equal(t, "beta.example.com", targets[0].Domain)
97+ assert.Equal(t, "gordon-shared", targets[0].VolumeName)
98+}
99+
100+func TestSelectVolumeBackupTargetsRequiresDomain(t *testing.T) {
101+ containers := []*domain.Container{
102+ {
103+ ID: "missing-domain",
104+ Name: "missing-domain",
105+ Labels: map[string]string{
106+ domain.LabelManaged: "true",
107+ },
108+ VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-data", Type: "volume", Destination: "/data"}},
109+ },
110+ }
111+
112+ targets := SelectVolumeBackupTargets(containers, "gordon")
113+
114+ assert.Empty(t, targets)
115+}
M internal/usecase/cron/scheduler.go +6 -0
@@ -290,6 +290,12 @@ func (s *Scheduler) dueEntries(now time.Time) []*entry {
290290 // when no explicit timezone is configured.
291291 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
292292 now = now.UTC()
293+ if schedule.Interval > 0 && schedule.Preset != "" {
294+ return time.Time{}, fmt.Errorf("cron schedule cannot set both interval and preset")
295+ }
296+ if schedule.Interval > 0 {
297+ return now.Add(schedule.Interval), nil
298+ }
293299
294300 switch schedule.Preset {
295301 case domain.ScheduleHourly:
@@ -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) {
5151 }
5252 }
5353
54+func TestCalculateNextRunInterval(t *testing.T) {
55+ now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
56+
57+ next, err := calculateNextRun(now, domain.CronSchedule{Interval: 6 * time.Hour})
58+
59+ require.NoError(t, err)
60+ assert.Equal(t, now.Add(6*time.Hour), next)
61+}
62+
63+func TestCalculateNextRunRejectsIntervalAndPreset(t *testing.T) {
64+ _, err := calculateNextRun(time.Now(), domain.CronSchedule{Preset: domain.ScheduleDaily, Interval: time.Hour})
65+
66+ require.Error(t, err)
67+ assert.Contains(t, err.Error(), "both interval and preset")
68+}
69+
5470 func TestSchedulerAddListAndRunNow(t *testing.T) {
5571 s := NewScheduler(zerowrap.Default())
5672 now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
@@ -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