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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
32 files · +2921 -336
M docs/cli/backup.md +34 -67
| @@ -1,96 +1,63 @@ | |||
| 1 | 1 | # Backup Command | |
| 2 | 2 | ||
| 3 | - | Manage backups for database attachments. | |
| 3 | + | Manage database backups and volume backups. | |
| 4 | 4 | ||
| 5 | - | ## gordon backups | |
| 5 | + | ## gordon backups databases | |
| 6 | + | ||
| 7 | + | Database backups are logical PostgreSQL backups made with `pg_dump`. | |
| 6 | 8 | ||
| 7 | 9 | ```bash | |
| 8 | - | gordon backups <subcommand> | |
| 10 | + | gordon backups databases <subcommand> | |
| 9 | 11 | ``` | |
| 10 | 12 | ||
| 11 | 13 | Subcommands: | |
| 12 | 14 | ||
| 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 | |
| 15 | 17 | - `detect <domain>` - Detect supported databases for a domain | |
| 16 | - | - `status` - Show backup status across domains | |
| 18 | + | - `status` - Show database backup status | |
| 17 | 19 | ||
| 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. | |
| 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 | - | ## Examples | |
| 24 | + | Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3. | |
| 28 | 25 | ||
| 29 | 26 | ```bash | |
| 30 | - | # List all backups | |
| 31 | - | gordon backups list | |
| 32 | - | gordon backups list --json | |
| 27 | + | gordon backups volumes <subcommand> | |
| 28 | + | ``` | |
| 33 | 29 | ||
| 34 | - | # List backups for one domain | |
| 35 | - | gordon backups list app.example.com | |
| 30 | + | Subcommands: | |
| 36 | 31 | ||
| 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 | |
| 39 | 35 | ||
| 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. | |
| 42 | 37 | ||
| 43 | - | # Detect DB attachments | |
| 44 | - | gordon backups detect app.example.com | |
| 38 | + | ## Examples | |
| 45 | 39 | ||
| 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 | |
| 48 | 53 | ``` | |
| 49 | 54 | ||
| 50 | 55 | ## 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 | 59 | ```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 | |
| 94 | 61 | ``` | |
| 95 | 62 | ||
| 96 | 63 | ## Required Permissions | |
| @@ -1,96 +1,63 @@ | |||||
| 1 | # Backup Command | 1 | # Backup Command | ||
| 2 | 2 | ||||
| 3 | - | Manage backups for database attachments. | 3 | + | Manage database backups and volume backups. |
| 4 | 4 | ||||
| 5 | - | ## gordon backups | 5 | + | ## gordon backups databases |
| 6 | + | ||||
| 7 | + | Database backups are logical PostgreSQL backups made with `pg_dump`. | |||
| 6 | 8 | ||||
| 7 | ```bash | 9 | ```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 domain | 15 | + | - `list [domain]` - List database backups |
| 14 | - | - `run <domain> [--db <name>]` - Trigger an immediate backup | 16 | + | - `run <domain> [--db <name>]` - Trigger an immediate database backup |
| 15 | - `detect <domain>` - Detect supported databases for a domain | 17 | - `detect <domain>` - Detect supported databases for a domain | ||
| 16 | - | - `status` - Show backup status across domains | 18 | + | - `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 | - | ## Examples | 24 | + | Volume backups are best-effort filesystem archives of Gordon-managed named volumes, uploaded to S3. |
| 28 | 25 | ||||
| 29 | ```bash | 26 | ```bash | ||
| 30 | - | # List all backups | 27 | + | gordon backups volumes <subcommand> |
| 31 | - | gordon backups list | 28 | + | ``` |
| 32 | - | gordon backups list --json | |||
| 33 | 29 | ||||
| 34 | - | # List backups for one domain | 30 | + | 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.com | 33 | + | - `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 name | 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. |
| 41 | - | gordon backups run app.example.com --db postgres | |||
| 42 | 37 | ||||
| 43 | - | # Detect DB attachments | 38 | + | ## Examples |
| 44 | - | gordon backups detect app.example.com | |||
| 45 | 39 | ||||
| 46 | - | # Status summary | 40 | + | ```bash |
| 47 | - | gordon backups status | 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 | |||
| 48 | ``` | 53 | ``` | ||
| 49 | 54 | ||||
| 50 | ## JSON Output | 55 | ## 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 | ```bash | 59 | ```bash | ||
| 55 | - | gordon backups list --json | 60 | + | 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 Permissions | 63 | ## Required Permissions | ||
M docs/config/backups.md +56 -40
| @@ -1,69 +1,85 @@ | |||
| 1 | 1 | # Backups Configuration | |
| 2 | 2 | ||
| 3 | - | Configure automatic PostgreSQL logical backups for attachment containers. | |
| 3 | + | Gordon has two backup flows: | |
| 4 | 4 | ||
| 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. | |
| 6 | 7 | ||
| 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 | |
| 20 | 9 | ||
| 21 | 10 | ```toml | |
| 22 | - | [backups] | |
| 11 | + | [backups.databases] | |
| 23 | 12 | enabled = true | |
| 24 | 13 | schedule = "daily" | |
| 25 | 14 | storage_dir = "~/.gordon/backups" | |
| 26 | 15 | ||
| 27 | - | [backups.retention] | |
| 16 | + | [backups.databases.retention] | |
| 28 | 17 | hourly = 24 | |
| 29 | 18 | daily = 7 | |
| 30 | 19 | weekly = 4 | |
| 31 | 20 | monthly = 12 | |
| 32 | 21 | ``` | |
| 33 | 22 | ||
| 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 | | |
| 45 | 29 | ||
| 46 | - | ## Storage Layout | |
| 30 | + | ## Volume backups to S3 | |
| 47 | 31 | ||
| 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 | |
| 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 | 71 | ```text | |
| 57 | - | ~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak | |
| 72 | + | <prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz | |
| 58 | 73 | ``` | |
| 59 | 74 | ||
| 60 | 75 | ## Notes | |
| 61 | 76 | ||
| 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. | |
| 67 | 83 | ||
| 68 | 84 | ## Related | |
| 69 | 85 | ||
| @@ -1,69 +1,85 @@ | |||||
| 1 | # Backups Configuration | 1 | # Backups Configuration | ||
| 2 | 2 | ||||
| 3 | - | Configure automatic PostgreSQL logical backups for attachment containers. | 3 | + | Gordon has two backup flows: |
| 4 | 4 | ||||
| 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. | |||
| 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 | ```toml | 10 | ```toml | ||
| 22 | - | [backups] | 11 | + | [backups.databases] |
| 23 | enabled = true | 12 | 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 = 24 | 17 | hourly = 24 | ||
| 29 | daily = 7 | 18 | daily = 7 | ||
| 30 | weekly = 4 | 19 | weekly = 4 | ||
| 31 | monthly = 12 | 20 | monthly = 12 | ||
| 32 | ``` | 21 | ``` | ||
| 33 | 22 | ||||
| 34 | - | ## Settings | 23 | + | | 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 Layout | 30 | + | ## Volume backups to S3 |
| 47 | 31 | ||||
| 48 | - | Backups are stored under: | 32 | + | ```toml |
| 49 | - | 33 | + | [backups.volumes] | |
| 50 | - | ```text | 34 | + | enabled = true |
| 51 | - | <storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak | 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 | |||
| 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 | ```text | 71 | ```text | ||
| 57 | - | ~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak | 72 | + | <prefix>/domains/<domain>/volumes/<volume>/<timestamp>-<id>.tar.gz |
| 58 | ``` | 73 | ``` | ||
| 59 | 74 | ||||
| 60 | ## Notes | 75 | ## 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 | ## Related | 84 | ## Related | ||
| 69 | 85 | ||||
M go.mod +19 -0
| @@ -3,6 +3,10 @@ module github.com/bnema/gordon | |||
| 3 | 3 | go 1.26.1 | |
| 4 | 4 | ||
| 5 | 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 | 10 | github.com/bnema/zerowrap v1.4.0 | |
| 7 | 11 | github.com/charmbracelet/bubbles v1.0.0 | |
| 8 | 12 | github.com/charmbracelet/bubbletea v1.3.10 | |
| @@ -33,6 +37,21 @@ require ( | |||
| 33 | 37 | ||
| 34 | 38 | require ( | |
| 35 | 39 | 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 | 55 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 37 | 56 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | |
| 38 | 57 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| @@ -3,6 +3,10 @@ module github.com/bnema/gordon | |||||
| 3 | go 1.26.1 | 3 | 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.0 | 10 | github.com/bnema/zerowrap v1.4.0 | ||
| 7 | github.com/charmbracelet/bubbles v1.0.0 | 11 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 12 | 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 // indirect | 39 | 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 // indirect | 55 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 37 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | 56 | github.com/cenkalti/backoff/v5 v5.0.3 // indirect | ||
| 38 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 57 | 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 | |||
| 4 | 4 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= | |
| 5 | 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= | |
| 6 | 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 | 45 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= | |
| 8 | 46 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= | |
| 9 | 47 | 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 | |||
| 128 | 128 | [env] | |
| 129 | 129 | # dir = "~/.gordon/env" | |
| 130 | 130 | ||
| 131 | - | [backups] | |
| 131 | + | [backups.databases] | |
| 132 | 132 | enabled = false | |
| 133 | 133 | schedule = "daily" | |
| 134 | 134 | storage_dir = "" | |
| 135 | 135 | ||
| 136 | - | [backups.retention] | |
| 136 | + | [backups.databases.retention] | |
| 137 | 137 | hourly = 0 | |
| 138 | 138 | daily = 0 | |
| 139 | 139 | weekly = 0 | |
| 140 | 140 | 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 | 163 | [routes] | |
| 143 | 164 | # "app.example.com" = { image = "myapp:latest" } | |
| 144 | 165 | ||
| @@ -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 = false | 132 | 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 = 0 | 137 | hourly = 0 | ||
| 138 | daily = 0 | 138 | daily = 0 | ||
| 139 | weekly = 0 | 139 | weekly = 0 | ||
| 140 | monthly = 0 | 140 | 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 { | |||
| 47 | 47 | type BackupDetectResponse struct { | |
| 48 | 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 | + | } | |
| @@ -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 { | |||
| 18 | 18 | cmd := &cobra.Command{ | |
| 19 | 19 | Use: "backups", | |
| 20 | 20 | Aliases: []string{"backup"}, | |
| 21 | - | Short: "Manage database backups", | |
| 22 | - | Long: `Manage database backups. | |
| 21 | + | Short: "Manage backups", | |
| 22 | + | Long: `Manage backups. | |
| 23 | 23 | ||
| 24 | 24 | Runs locally via in-process services by default, or against a remote Gordon | |
| 25 | 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 | 46 | cmd.AddCommand(newBackupListCmd()) | |
| 29 | 47 | cmd.AddCommand(newBackupRunCmd()) | |
| 30 | 48 | cmd.AddCommand(newBackupDetectCmd()) | |
| 31 | 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 | 61 | return cmd | |
| 34 | 62 | } | |
| 35 | 63 | ||
| @@ -103,6 +131,109 @@ func printBackupJobs(out io.Writer, jobs []dto.BackupJob, jsonOut bool) error { | |||
| 103 | 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 | 237 | func newBackupRunCmd() *cobra.Command { | |
| 107 | 238 | var dbName string | |
| 108 | 239 | ||
| @@ -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 Gordon | 24 | 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 cmd | 61 | 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 string | 238 | var dbName string | ||
| 108 | 239 | ||||
M internal/adapters/in/cli/controlplane.go +3 -0
| @@ -53,6 +53,9 @@ type ControlPlane interface { | |||
| 53 | 53 | BackupStatus(ctx context.Context) ([]dto.BackupJob, error) | |
| 54 | 54 | RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) | |
| 55 | 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 | 60 | GetProcessLogs(ctx context.Context, lines int) ([]string, error) | |
| 58 | 61 | 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 ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | 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 | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |
| @@ -42,16 +43,17 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | 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(), | |
| 55 | 57 | } | |
| 56 | 58 | } | |
| 57 | 59 | ||
| @@ -629,6 +631,39 @@ func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain st | |||
| 629 | 631 | 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 | 667 | func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |
| 633 | 668 | if l.logSvc == nil { | |
| 634 | 669 | return nil, fmt.Errorf("local log service unavailable") | |
| @@ -785,3 +820,41 @@ func toDTOBackupJob(job domain.BackupJob) dto.BackupJob { | |||
| 785 | 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 | + | } | |
| @@ -16,16 +16,17 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | type localControlPlane struct { | 18 | type localControlPlane struct { | ||
| 19 | - | configSvc in.ConfigService | 19 | + | configSvc in.ConfigService |
| 20 | - | secretSvc in.SecretService | 20 | + | secretSvc in.SecretService |
| 21 | - | containerSvc in.ContainerService | 21 | + | containerSvc in.ContainerService |
| 22 | - | backupSvc in.BackupService | 22 | + | backupSvc in.BackupService |
| 23 | - | registrySvc in.RegistryService | 23 | + | volumeBackupSvc in.VolumeBackupService |
| 24 | - | deployCoord in.DeployCoordinator | 24 | + | registrySvc in.RegistryService |
| 25 | - | healthSvc in.HealthService | 25 | + | deployCoord in.DeployCoordinator |
| 26 | - | logSvc in.LogService | 26 | + | healthSvc in.HealthService |
| 27 | - | volumeSvc in.VolumeService | 27 | + | logSvc in.LogService |
| 28 | - | publicTLSSvc in.PublicTLSService | 28 | + | 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, nil | 631 | 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 | |||
| 169 | 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 | 184 | func (r *remoteControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) { | |
| 173 | 185 | return r.client.GetProcessLogs(ctx, lines) | |
| 174 | 186 | } | |
| @@ -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 | |||
| 1922 | 1922 | 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 | 1993 | // ListVolumes provides a mock function for the type MockControlPlane | |
| 1926 | 1994 | func (_mock *MockControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) { | |
| 1927 | 1995 | ret := _mock.Called(ctx) | |
| @@ -2428,6 +2496,80 @@ func (_c *MockControlPlane_RunBackup_Call) RunAndReturn(run func(ctx context.Con | |||
| 2428 | 2496 | 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 | 2573 | // SetAttachmentSecrets provides a mock function for the type MockControlPlane | |
| 2432 | 2574 | func (_mock *MockControlPlane) SetAttachmentSecrets(ctx context.Context, domain1 string, service string, secrets map[string]string) error { | |
| 2433 | 2575 | ret := _mock.Called(ctx, domain1, service, secrets) | |
| @@ -2758,3 +2900,65 @@ func (_c *MockControlPlane_UpdateRoute_Call) RunAndReturn(run func(ctx context.C | |||
| 2758 | 2900 | _c.Call.Return(run) | |
| 2759 | 2901 | 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 | + | } | |
| @@ -1922,6 +1922,74 @@ func (_c *MockControlPlane_ListTags_Call) RunAndReturn(run func(ctx context.Cont | |||||
| 1922 | return _c | 1922 | 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 MockControlPlane | 1993 | // 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 _c | 2496 | 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 MockControlPlane | 2573 | // 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 _c | 2901 | 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 | |||
| 836 | 836 | 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 | 891 | // GetHealth returns health status for all routes with HTTP probing. | |
| 840 | 892 | func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) { | |
| 841 | 893 | 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, nil | 836 | 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 { | |||
| 40 | 40 | ||
| 41 | 41 | // Handler implements the HTTP handler for the admin API. | |
| 42 | 42 | 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 | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 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 | 176 | func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo { | |
| 146 | 177 | return dto.DatabaseInfo{ | |
| 147 | 178 | Type: string(db.Type), | |
| @@ -156,39 +187,41 @@ func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo { | |||
| 156 | 187 | ||
| 157 | 188 | // HandlerDeps contains all dependencies for the admin HTTP handler. | |
| 158 | 189 | 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 | |
| 173 | 205 | } | |
| 174 | 206 | ||
| 175 | 207 | // NewHandler creates a new admin HTTP handler. | |
| 176 | 208 | func NewHandler(deps HandlerDeps) *Handler { | |
| 177 | 209 | 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, | |
| 192 | 225 | } | |
| 193 | 226 | } | |
| 194 | 227 | ||
| @@ -1313,13 +1346,17 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { | |||
| 1313 | 1346 | ||
| 1314 | 1347 | // handleBackups handles /admin/backups endpoints. | |
| 1315 | 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 | 1355 | if h.backupSvc == nil { | |
| 1317 | 1356 | h.sendError(w, http.StatusServiceUnavailable, "backup service not available") | |
| 1318 | 1357 | return | |
| 1319 | 1358 | } | |
| 1320 | 1359 | ||
| 1321 | - | path = strings.TrimSuffix(path, "/") | |
| 1322 | - | ||
| 1323 | 1360 | if path == "/backups" || path == "/backups/status" { | |
| 1324 | 1361 | h.handleBackupsStatus(w, r) | |
| 1325 | 1362 | return | |
| @@ -1346,6 +1383,82 @@ func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path str | |||
| 1346 | 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 | 1462 | func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) { | |
| 1350 | 1463 | ctx := r.Context() | |
| 1351 | 1464 | if r.Method != http.MethodGet { | |
| @@ -1456,6 +1569,14 @@ func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob { | |||
| 1456 | 1569 | 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 | 1580 | // handleReload handles /admin/reload endpoint. | |
| 1460 | 1581 | // This reloads configuration from file into memory and triggers container sync. | |
| 1461 | 1582 | 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.ConfigService | 43 | + | configSvc in.ConfigService |
| 44 | - | authSvc in.AuthService | 44 | + | authSvc in.AuthService |
| 45 | - | containerSvc in.ContainerService | 45 | + | containerSvc in.ContainerService |
| 46 | - | backupSvc in.BackupService | 46 | + | backupSvc in.BackupService |
| 47 | - | imageSvc in.ImageService | 47 | + | volumeBackupSvc in.VolumeBackupService |
| 48 | - | healthSvc in.HealthService | 48 | + | imageSvc in.ImageService |
| 49 | - | secretSvc in.SecretService | 49 | + | healthSvc in.HealthService |
| 50 | - | logSvc in.LogService | 50 | + | secretSvc in.SecretService |
| 51 | - | volumeSvc in.VolumeService | 51 | + | logSvc in.LogService |
| 52 | - | registrySvc registryDeployService | 52 | + | volumeSvc in.VolumeService |
| 53 | - | previewSvc previewService | 53 | + | registrySvc registryDeployService |
| 54 | - | reloadTrigger reloadTrigger | 54 | + | previewSvc previewService |
| 55 | - | publicTLSSvc in.PublicTLSService | 55 | + | reloadTrigger reloadTrigger |
| 56 | - | log zerowrap.Logger | 56 | + | 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.ConfigService | 190 | + | ConfigSvc in.ConfigService |
| 160 | - | AuthSvc in.AuthService | 191 | + | AuthSvc in.AuthService |
| 161 | - | ContainerSvc in.ContainerService | 192 | + | ContainerSvc in.ContainerService |
| 162 | - | HealthSvc in.HealthService | 193 | + | HealthSvc in.HealthService |
| 163 | - | SecretSvc in.SecretService | 194 | + | SecretSvc in.SecretService |
| 164 | - | LogSvc in.LogService | 195 | + | LogSvc in.LogService |
| 165 | - | RegistrySvc registryDeployService | 196 | + | RegistrySvc registryDeployService |
| 166 | - | Log zerowrap.Logger | 197 | + | Log zerowrap.Logger |
| 167 | - | BackupSvc in.BackupService | 198 | + | BackupSvc in.BackupService |
| 168 | - | PreviewSvc previewService | 199 | + | VolumeBackupSvc in.VolumeBackupService |
| 169 | - | ImageSvc in.ImageService | 200 | + | PreviewSvc previewService |
| 170 | - | VolumeSvc in.VolumeService | 201 | + | ImageSvc in.ImageService |
| 171 | - | ReloadTrigger reloadTrigger | 202 | + | VolumeSvc in.VolumeService |
| 172 | - | PublicTLSSvc in.PublicTLSService | 203 | + | 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 | return | 1357 | 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 | return | 1362 | 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 response | 1569 | 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 ( | |||
| 24 | 24 | "github.com/docker/docker/api/types/container" | |
| 25 | 25 | "github.com/docker/docker/api/types/filters" | |
| 26 | 26 | "github.com/docker/docker/api/types/image" | |
| 27 | + | "github.com/docker/docker/api/types/mount" | |
| 27 | 28 | "github.com/docker/docker/api/types/network" | |
| 28 | 29 | "github.com/docker/docker/api/types/registry" | |
| 29 | 30 | "github.com/docker/docker/api/types/strslice" | |
| @@ -44,11 +45,13 @@ type Runtime struct { | |||
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | var _ out.ContainerRuntime = (*Runtime)(nil) | |
| 48 | + | var _ out.VolumeArchiveExporter = (*Runtime)(nil) | |
| 47 | 49 | ||
| 48 | 50 | type pipeReadCloser struct { | |
| 49 | 51 | pr *io.PipeReader | |
| 50 | 52 | pw *io.PipeWriter | |
| 51 | 53 | original io.Closer | |
| 54 | + | cleanup func() error | |
| 52 | 55 | once sync.Once | |
| 53 | 56 | } | |
| 54 | 57 | ||
| @@ -75,6 +78,11 @@ func (p *pipeReadCloser) Close() error { | |||
| 75 | 78 | 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 | 87 | return closeErr | |
| 80 | 88 | } | |
| @@ -394,15 +402,27 @@ func (r *Runtime) ListContainers(ctx context.Context, all bool) ([]*domain.Conta | |||
| 394 | 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 | 416 | 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), | |
| 406 | 426 | }) | |
| 407 | 427 | } | |
| 408 | 428 | ||
| @@ -442,17 +462,28 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do | |||
| 442 | 462 | name := strings.TrimPrefix(resp.Name, "/") | |
| 443 | 463 | ||
| 444 | 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 | 476 | 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, | |
| 456 | 487 | }, nil | |
| 457 | 488 | } | |
| 458 | 489 | ||
| @@ -1204,6 +1235,165 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) | |||
| 1204 | 1235 | 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 | 1397 | // InspectImageEnv gets the environment variables declared in the image. | |
| 1208 | 1398 | func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) { | |
| 1209 | 1399 | 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.PipeReader | 51 | pr *io.PipeReader | ||
| 50 | pw *io.PipeWriter | 52 | pw *io.PipeWriter | ||
| 51 | original io.Closer | 53 | original io.Closer | ||
| 54 | + | cleanup func() error | |||
| 52 | once sync.Once | 55 | once sync.Once | ||
| 53 | } | 56 | } | ||
| 54 | 57 | ||||
| @@ -75,6 +78,11 @@ func (p *pipeReadCloser) Close() error { | |||||
| 75 | closeErr = err | 78 | 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 closeErr | 87 | 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 | }, nil | 487 | }, nil | ||
| 457 | } | 488 | } | ||
| 458 | 489 | ||||
| @@ -1204,6 +1235,165 @@ func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) | |||||
| 1204 | return result, nil | 1235 | 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 ( | |||
| 17 | 17 | // | |
| 18 | 18 | // It intentionally does not start HTTP servers or register signal handlers. | |
| 19 | 19 | 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() | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 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 | 81 | 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, | |
| 92 | 94 | }, nil | |
| 93 | 95 | } else { | |
| 94 | 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 | 139 | func (k *Kernel) Backup() in.BackupService { return k.backupSvc } | |
| 138 | 140 | ||
| 141 | + | func (k *Kernel) VolumeBackup() in.VolumeBackupService { return k.volumeBackupSvc } | |
| 142 | + | ||
| 139 | 143 | func (k *Kernel) Registry() in.RegistryService { return k.registrySvc } | |
| 140 | 144 | ||
| 141 | 145 | 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 bool | 20 | + | authEnabled bool |
| 21 | - | configSvc in.ConfigService | 21 | + | configSvc in.ConfigService |
| 22 | - | secretSvc in.SecretService | 22 | + | secretSvc in.SecretService |
| 23 | - | containerSvc in.ContainerService | 23 | + | containerSvc in.ContainerService |
| 24 | - | backupSvc in.BackupService | 24 | + | backupSvc in.BackupService |
| 25 | - | registrySvc in.RegistryService | 25 | + | volumeBackupSvc in.VolumeBackupService |
| 26 | - | healthSvc in.HealthService | 26 | + | registrySvc in.RegistryService |
| 27 | - | logSvc in.LogService | 27 | + | healthSvc in.HealthService |
| 28 | - | volumeSvc in.VolumeService | 28 | + | logSvc in.LogService |
| 29 | - | publicTLSSvc in.PublicTLSService | 29 | + | 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 | }, nil | 94 | }, 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 ( | |||
| 39 | 39 | "github.com/bnema/gordon/internal/adapters/out/logwriter" | |
| 40 | 40 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 41 | 41 | "github.com/bnema/gordon/internal/adapters/out/ratelimit" | |
| 42 | + | s3storage "github.com/bnema/gordon/internal/adapters/out/s3" | |
| 42 | 43 | "github.com/bnema/gordon/internal/adapters/out/secrets" | |
| 43 | 44 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 44 | 45 | "github.com/bnema/gordon/internal/adapters/out/tokenstore" | |
| @@ -168,6 +169,7 @@ type Config struct { | |||
| 168 | 169 | } `mapstructure:"api"` | |
| 169 | 170 | ||
| 170 | 171 | Backups struct { | |
| 172 | + | // Legacy database backup keys. Prefer backups.databases.* for new configs. | |
| 171 | 173 | Enabled bool `mapstructure:"enabled"` | |
| 172 | 174 | Schedule string `mapstructure:"schedule"` | |
| 173 | 175 | StorageDir string `mapstructure:"storage_dir"` | |
| @@ -177,6 +179,37 @@ type Config struct { | |||
| 177 | 179 | Weekly int `mapstructure:"weekly"` | |
| 178 | 180 | Monthly int `mapstructure:"monthly"` | |
| 179 | 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 | 213 | } `mapstructure:"backups"` | |
| 181 | 214 | ||
| 182 | 215 | Images struct { | |
| @@ -221,6 +254,8 @@ type services struct { | |||
| 221 | 254 | blobStorage *filesystem.BlobStorage | |
| 222 | 255 | manifestStorage *filesystem.ManifestStorage | |
| 223 | 256 | backupStorage *filesystem.BackupStorage | |
| 257 | + | volumeBackupStore out.VolumeBackupStorage | |
| 258 | + | volumeBackupCfg domain.VolumeBackupConfig | |
| 224 | 259 | envLoader out.EnvLoader | |
| 225 | 260 | logWriter *logwriter.LogWriter | |
| 226 | 261 | tokenStore out.TokenStore | |
| @@ -228,6 +263,7 @@ type services struct { | |||
| 228 | 263 | secretSvc *secretsSvc.Service | |
| 229 | 264 | containerSvc *container.Service | |
| 230 | 265 | backupSvc *backup.Service | |
| 266 | + | volumeBackupSvc *backup.VolumeService | |
| 231 | 267 | registrySvc *registrySvc.Service | |
| 232 | 268 | healthSvc *health.Service | |
| 233 | 269 | logSvc *logs.Service | |
| @@ -673,6 +709,9 @@ func (si *serviceInit) initRuntimeAndProxy() error { | |||
| 673 | 709 | if si.svc.backupStorage, si.svc.backupSvc, err = createBackupService(si.cfg, si.svc, si.log); err != nil { | |
| 674 | 710 | 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 | 716 | si.svc.registrySvc = registrySvc.NewService(si.svc.blobStorage, si.svc.manifestStorage, si.svc.eventBus) | |
| 678 | 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 | 767 | initPreviewService(si.ctx, si.cfg, si.svc, si.log) | |
| 729 | 768 | ||
| 730 | 769 | 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, | |
| 745 | 785 | }) | |
| 746 | 786 | } | |
| 747 | 787 | ||
| @@ -1940,12 +1980,69 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1940 | 1980 | 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 | 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 | 2042 | return nil, nil, nil | |
| 1946 | 2043 | } | |
| 1947 | 2044 | ||
| 1948 | - | storageDir := cfg.Backups.StorageDir | |
| 2045 | + | storageDir := dbCfg.StorageDir | |
| 1949 | 2046 | if storageDir == "" { | |
| 1950 | 2047 | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 1951 | 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 | 2061 | backupCfg := domain.BackupConfig{ | |
| 1965 | - | Enabled: cfg.Backups.Enabled, | |
| 2062 | + | Enabled: dbCfg.Enabled, | |
| 1966 | 2063 | StorageDir: storageDir, | |
| 1967 | 2064 | Retention: retention, | |
| 1968 | 2065 | } | |
| @@ -1976,28 +2073,142 @@ func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*files | |||
| 1976 | 2073 | 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 | 2099 | 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") | |
| 1982 | 2103 | } | |
| 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") | |
| 1985 | 2106 | } | |
| 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") | |
| 1988 | 2109 | } | |
| 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") | |
| 1991 | 2112 | } | |
| 1992 | 2113 | ||
| 1993 | 2114 | 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, | |
| 1998 | 2119 | }, 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 | 2212 | // registerEventHandlers registers all event handlers. | |
| 2002 | 2213 | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) (func(), error) { | |
| 2003 | 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 | 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 | 2859 | backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log) | |
| 2649 | 2860 | if err != nil { | |
| @@ -2653,6 +2864,14 @@ func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log | |||
| 2653 | 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 | 2875 | imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int { | |
| 2657 | 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 | 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 | 2899 | return nil, nil | |
| 2680 | 2900 | } | |
| 2681 | 2901 | ||
| 2682 | - | preset, err := resolveBackupSchedule(cfg.Backups.Schedule) | |
| 2902 | + | preset, err := resolveBackupSchedule(dbCfg.Schedule) | |
| 2683 | 2903 | if err != nil { | |
| 2684 | 2904 | 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 | 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 | 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 | 3656 | v.SetDefault("volumes.prefix", "gordon") | |
| 3400 | 3657 | v.SetDefault("volumes.preserve", true) | |
| 3401 | 3658 | 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) | |
| 3409 | 3680 | v.SetDefault("images.allowed_registries", []string{}) | |
| 3410 | 3681 | v.SetDefault("images.require_digest", false) | |
| 3411 | 3682 | 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.BlobStorage | 254 | blobStorage *filesystem.BlobStorage | ||
| 222 | manifestStorage *filesystem.ManifestStorage | 255 | manifestStorage *filesystem.ManifestStorage | ||
| 223 | backupStorage *filesystem.BackupStorage | 256 | backupStorage *filesystem.BackupStorage | ||
| 257 | + | volumeBackupStore out.VolumeBackupStorage | |||
| 258 | + | volumeBackupCfg domain.VolumeBackupConfig | |||
| 224 | envLoader out.EnvLoader | 259 | envLoader out.EnvLoader | ||
| 225 | logWriter *logwriter.LogWriter | 260 | logWriter *logwriter.LogWriter | ||
| 226 | tokenStore out.TokenStore | 261 | tokenStore out.TokenStore | ||
| @@ -228,6 +263,7 @@ type services struct { | |||||
| 228 | secretSvc *secretsSvc.Service | 263 | secretSvc *secretsSvc.Service | ||
| 229 | containerSvc *container.Service | 264 | containerSvc *container.Service | ||
| 230 | backupSvc *backup.Service | 265 | backupSvc *backup.Service | ||
| 266 | + | volumeBackupSvc *backup.VolumeService | |||
| 231 | registrySvc *registrySvc.Service | 267 | registrySvc *registrySvc.Service | ||
| 232 | healthSvc *health.Service | 268 | healthSvc *health.Service | ||
| 233 | logSvc *logs.Service | 269 | 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 err | 710 | 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), nil | 1980 | 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, nil | 2042 | return nil, nil, nil | ||
| 1946 | } | 2043 | } | ||
| 1947 | 2044 | ||||
| 1948 | - | storageDir := cfg.Backups.StorageDir | 2045 | + | 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, nil | 2073 | 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 | }, nil | 2119 | }, 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, nil | 2899 | 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, err | 2904 | 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) { | |||
| 19 | 19 | { | |
| 20 | 20 | name: "hourly", | |
| 21 | 21 | set: func(cfg *Config) { | |
| 22 | - | cfg.Backups.Retention.Hourly = -1 | |
| 22 | + | cfg.Backups.Databases.Retention.Hourly = -1 | |
| 23 | 23 | }, | |
| 24 | - | err: "backups.retention.hourly", | |
| 24 | + | err: "backups.databases.retention.hourly", | |
| 25 | 25 | }, | |
| 26 | 26 | { | |
| 27 | 27 | name: "daily", | |
| 28 | 28 | set: func(cfg *Config) { | |
| 29 | - | cfg.Backups.Retention.Daily = -1 | |
| 29 | + | cfg.Backups.Databases.Retention.Daily = -1 | |
| 30 | 30 | }, | |
| 31 | - | err: "backups.retention.daily", | |
| 31 | + | err: "backups.databases.retention.daily", | |
| 32 | 32 | }, | |
| 33 | 33 | { | |
| 34 | 34 | name: "weekly", | |
| 35 | 35 | set: func(cfg *Config) { | |
| 36 | - | cfg.Backups.Retention.Weekly = -1 | |
| 36 | + | cfg.Backups.Databases.Retention.Weekly = -1 | |
| 37 | 37 | }, | |
| 38 | - | err: "backups.retention.weekly", | |
| 38 | + | err: "backups.databases.retention.weekly", | |
| 39 | 39 | }, | |
| 40 | 40 | { | |
| 41 | 41 | name: "monthly", | |
| 42 | 42 | set: func(cfg *Config) { | |
| 43 | - | cfg.Backups.Retention.Monthly = -1 | |
| 43 | + | 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 | 61 | func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) { | |
| 62 | 62 | t.Run("positive values", func(t *testing.T) { | |
| 63 | 63 | 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 | |
| 68 | 68 | ||
| 69 | 69 | retention, err := validateBackupRetention(cfg) | |
| 70 | 70 | require.NoError(t, err) | |
| @@ -76,10 +76,10 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) { | |||
| 76 | 76 | ||
| 77 | 77 | t.Run("zero values", func(t *testing.T) { | |
| 78 | 78 | 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 | |
| 83 | 83 | ||
| 84 | 84 | retention, err := validateBackupRetention(cfg) | |
| 85 | 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 | 189 | v := viper.New() | |
| 95 | 190 | ||
| 96 | 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 | 199 | func TestResolveBackupSchedule(t *testing.T) { | |
| @@ -123,6 +221,6 @@ func TestResolveBackupSchedule(t *testing.T) { | |||
| 123 | 221 | t.Run("invalid value", func(t *testing.T) { | |
| 124 | 222 | _, err := resolveBackupSchedule("every-minute") | |
| 125 | 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 | } | |
| @@ -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 = -1 | 22 | + | 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 = -1 | 29 | + | 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 = -1 | 36 | + | 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 = -1 | 43 | + | 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 Config | 63 | var cfg Config | ||
| 64 | - | cfg.Backups.Retention.Hourly = 1 | 64 | + | cfg.Backups.Databases.Retention.Hourly = 1 |
| 65 | - | cfg.Backups.Retention.Daily = 7 | 65 | + | cfg.Backups.Databases.Retention.Daily = 7 |
| 66 | - | cfg.Backups.Retention.Weekly = 4 | 66 | + | cfg.Backups.Databases.Retention.Weekly = 4 |
| 67 | - | cfg.Backups.Retention.Monthly = 12 | 67 | + | 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 Config | 78 | var cfg Config | ||
| 79 | - | cfg.Backups.Retention.Hourly = 0 | 79 | + | cfg.Backups.Databases.Retention.Hourly = 0 |
| 80 | - | cfg.Backups.Retention.Daily = 0 | 80 | + | cfg.Backups.Databases.Retention.Daily = 0 |
| 81 | - | cfg.Backups.Retention.Weekly = 0 | 81 | + | cfg.Backups.Databases.Retention.Weekly = 0 |
| 82 | - | cfg.Backups.Retention.Monthly = 0 | 82 | + | 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 ( | |||
| 7 | 7 | "github.com/bnema/gordon/internal/domain" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 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) | |
| 14 | 14 | Restore(ctx context.Context, domainName, backupID string) error | |
| 15 | 15 | 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 | 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 | + | } | |
| @@ -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) error | 14 | Restore(ctx context.Context, domainName, backupID string) error | ||
| 15 | RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error | 15 | RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error | ||
| 16 | - | Status(ctx context.Context) ([]domain.BackupJob, error) | 16 | + | Status(ctx context.Context) ([]domain.DatabaseBackupJob, error) |
| 17 | DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) | 17 | DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) | ||
| 18 | } | 18 | } | ||
| 19 | + | ||||
| 20 | + | // BackupService is kept as a compatibility alias for the existing database backup feature. | |||
| 21 | + | type BackupService = DatabaseBackupService | |||
| 22 | + | ||||
| 23 | + | // VolumeBackupService defines volume archive backup orchestration use cases. | |||
| 24 | + | type VolumeBackupService interface { | |||
| 25 | + | ListVolumeBackups(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) | |||
| 26 | + | RunVolumeBackups(ctx context.Context, domainName string, volumeName string) ([]domain.VolumeBackupJob, error) | |||
| 27 | + | VolumeBackupStatus(ctx context.Context) ([]domain.VolumeBackupJob, error) | |||
| 28 | + | } | |||
M internal/boundaries/in/mocks/mock_backup_service.go +24 -24
| @@ -108,23 +108,23 @@ func (_c *MockBackupService_DetectDatabases_Call) RunAndReturn(run func(ctx cont | |||
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | // 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 | 112 | ret := _mock.Called(ctx, domainName) | |
| 113 | 113 | ||
| 114 | 114 | if len(ret) == 0 { | |
| 115 | 115 | panic("no return value specified for ListBackups") | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | - | var r0 []domain.BackupJob | |
| 118 | + | var r0 []domain.DatabaseBackupJob | |
| 119 | 119 | 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 | 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 | 124 | r0 = returnFunc(ctx, domainName) | |
| 125 | 125 | } else { | |
| 126 | 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 | 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 | 165 | return _c | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 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) | |
| 170 | 170 | 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 | 174 | _c.Call.Return(run) | |
| 175 | 175 | return _c | |
| 176 | 176 | } | |
| @@ -302,23 +302,23 @@ func (_c *MockBackupService_RestorePITR_Call) RunAndReturn(run func(ctx context. | |||
| 302 | 302 | } | |
| 303 | 303 | ||
| 304 | 304 | // 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 | 306 | ret := _mock.Called(ctx, domainName, dbName) | |
| 307 | 307 | ||
| 308 | 308 | if len(ret) == 0 { | |
| 309 | 309 | panic("no return value specified for RunBackup") | |
| 310 | 310 | } | |
| 311 | 311 | ||
| 312 | - | var r0 *domain.BackupResult | |
| 312 | + | var r0 *domain.DatabaseBackupResult | |
| 313 | 313 | 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 | 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 | 318 | r0 = returnFunc(ctx, domainName, dbName) | |
| 319 | 319 | } else { | |
| 320 | 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 | 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 | 365 | return _c | |
| 366 | 366 | } | |
| 367 | 367 | ||
| 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) | |
| 370 | 370 | 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 | 374 | _c.Call.Return(run) | |
| 375 | 375 | return _c | |
| 376 | 376 | } | |
| 377 | 377 | ||
| 378 | 378 | // 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 | 380 | ret := _mock.Called(ctx) | |
| 381 | 381 | ||
| 382 | 382 | if len(ret) == 0 { | |
| 383 | 383 | panic("no return value specified for Status") | |
| 384 | 384 | } | |
| 385 | 385 | ||
| 386 | - | var r0 []domain.BackupJob | |
| 386 | + | var r0 []domain.DatabaseBackupJob | |
| 387 | 387 | 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 | 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 | 392 | r0 = returnFunc(ctx) | |
| 393 | 393 | } else { | |
| 394 | 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 | 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 | 427 | return _c | |
| 428 | 428 | } | |
| 429 | 429 | ||
| 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) | |
| 432 | 432 | 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 | 436 | _c.Call.Return(run) | |
| 437 | 437 | return _c | |
| 438 | 438 | } | |
| @@ -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 MockBackupService | 110 | // 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.BackupJob | 118 | + | var r0 []domain.DatabaseBackupJob |
| 119 | var r1 error | 119 | 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 _c | 165 | 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 _c | 170 | 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 _c | 175 | 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 MockBackupService | 304 | // 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.BackupResult | 312 | + | var r0 *domain.DatabaseBackupResult |
| 313 | var r1 error | 313 | 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 _c | 365 | 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 _c | 370 | 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 _c | 375 | return _c | ||
| 376 | } | 376 | } | ||
| 377 | 377 | ||||
| 378 | // Status provides a mock function for the type MockBackupService | 378 | // 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.BackupJob | 386 | + | var r0 []domain.DatabaseBackupJob |
| 387 | var r1 error | 387 | 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 _c | 427 | 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 _c | 432 | 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 _c | 437 | return _c | ||
| 438 | } | 438 | } | ||
M internal/boundaries/out/backupstore.go +21 -4
| @@ -8,11 +8,28 @@ import ( | |||
| 8 | 8 | "github.com/bnema/gordon/internal/domain" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 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 { | |
| 13 | 13 | Store(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) | |
| 14 | 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 | 16 | 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 | } | |
| @@ -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) error | 16 | Delete(ctx context.Context, path string) error | ||
| 17 | - | ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) | 17 | + | ApplyRetention(ctx context.Context, domainName string, policy domain.DatabaseBackupRetentionPolicy) (int, error) |
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // BackupStorage is kept as a compatibility alias for the existing database backup feature. | |||
| 21 | + | type BackupStorage = DatabaseBackupStorage | |||
| 22 | + | ||||
| 23 | + | // VolumeArchiveExporter exports named container volumes as archive streams. | |||
| 24 | + | type VolumeArchiveExporter interface { | |||
| 25 | + | ExportVolumeArchive(ctx context.Context, req domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // VolumeBackupStorage defines persistence for volume archive backup artifacts. | |||
| 29 | + | type VolumeBackupStorage interface { | |||
| 30 | + | StoreVolumeArchive(ctx context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) | |||
| 31 | + | GetVolumeArchive(ctx context.Context, artifactRef string) (io.ReadCloser, error) | |||
| 32 | + | ListVolumeArchives(ctx context.Context, domainName string) ([]domain.VolumeBackupJob, error) | |||
| 33 | + | DeleteVolumeArchive(ctx context.Context, artifactRef string) error | |||
| 34 | + | ApplyVolumeRetention(ctx context.Context, domainName string, policy domain.VolumeBackupRetentionPolicy) (int, error) | |||
| 18 | } | 35 | } | ||
M internal/boundaries/out/mocks/mock_backup_storage.go +17 -17
| @@ -41,7 +41,7 @@ func (_m *MockBackupStorage) EXPECT() *MockBackupStorage_Expecter { | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | // 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 | 45 | ret := _mock.Called(ctx, domainName, policy) | |
| 46 | 46 | ||
| 47 | 47 | if len(ret) == 0 { | |
| @@ -50,15 +50,15 @@ func (_mock *MockBackupStorage) ApplyRetention(ctx context.Context, domainName s | |||
| 50 | 50 | ||
| 51 | 51 | var r0 int | |
| 52 | 52 | 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 | 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 | 57 | r0 = returnFunc(ctx, domainName, policy) | |
| 58 | 58 | } else { | |
| 59 | 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 | 62 | r1 = returnFunc(ctx, domainName, policy) | |
| 63 | 63 | } else { | |
| 64 | 64 | r1 = ret.Error(1) | |
| @@ -74,12 +74,12 @@ type MockBackupStorage_ApplyRetention_Call struct { | |||
| 74 | 74 | // ApplyRetention is a helper method to define mock.On call | |
| 75 | 75 | // - ctx context.Context | |
| 76 | 76 | // - domainName string | |
| 77 | - | // - policy domain.RetentionPolicy | |
| 77 | + | // - policy domain.DatabaseBackupRetentionPolicy | |
| 78 | 78 | func (_e *MockBackupStorage_Expecter) ApplyRetention(ctx interface{}, domainName interface{}, policy interface{}) *MockBackupStorage_ApplyRetention_Call { | |
| 79 | 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 | 83 | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | 84 | var arg0 context.Context | |
| 85 | 85 | if args[0] != nil { | |
| @@ -89,9 +89,9 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Contex | |||
| 89 | 89 | if args[1] != nil { | |
| 90 | 90 | arg1 = args[1].(string) | |
| 91 | 91 | } | |
| 92 | - | var arg2 domain.RetentionPolicy | |
| 92 | + | var arg2 domain.DatabaseBackupRetentionPolicy | |
| 93 | 93 | if args[2] != nil { | |
| 94 | - | arg2 = args[2].(domain.RetentionPolicy) | |
| 94 | + | arg2 = args[2].(domain.DatabaseBackupRetentionPolicy) | |
| 95 | 95 | } | |
| 96 | 96 | run( | |
| 97 | 97 | arg0, | |
| @@ -107,7 +107,7 @@ func (_c *MockBackupStorage_ApplyRetention_Call) Return(n int, err error) *MockB | |||
| 107 | 107 | 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 | 111 | _c.Call.Return(run) | |
| 112 | 112 | return _c | |
| 113 | 113 | } | |
| @@ -238,23 +238,23 @@ func (_c *MockBackupStorage_Get_Call) RunAndReturn(run func(ctx context.Context, | |||
| 238 | 238 | } | |
| 239 | 239 | ||
| 240 | 240 | // 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 | 242 | ret := _mock.Called(ctx, domainName, schedule) | |
| 243 | 243 | ||
| 244 | 244 | if len(ret) == 0 { | |
| 245 | 245 | panic("no return value specified for List") | |
| 246 | 246 | } | |
| 247 | 247 | ||
| 248 | - | var r0 []domain.BackupJob | |
| 248 | + | var r0 []domain.DatabaseBackupJob | |
| 249 | 249 | 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 | 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 | 254 | r0 = returnFunc(ctx, domainName, schedule) | |
| 255 | 255 | } else { | |
| 256 | 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 | 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 | 301 | return _c | |
| 302 | 302 | } | |
| 303 | 303 | ||
| 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) | |
| 306 | 306 | 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 | 310 | _c.Call.Return(run) | |
| 311 | 311 | return _c | |
| 312 | 312 | } | |
| @@ -41,7 +41,7 @@ func (_m *MockBackupStorage) EXPECT() *MockBackupStorage_Expecter { | |||||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | // ApplyRetention provides a mock function for the type MockBackupStorage | 43 | // 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 int | 51 | var r0 int | ||
| 52 | var r1 error | 52 | 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 call | 74 | // ApplyRetention is a helper method to define mock.On call | ||
| 75 | // - ctx context.Context | 75 | // - ctx context.Context | ||
| 76 | // - domainName string | 76 | // - domainName string | ||
| 77 | - | // - policy domain.RetentionPolicy | 77 | + | // - 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.Context | 84 | 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.RetentionPolicy | 92 | + | 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 _c | 107 | 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 _c | 112 | 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 MockBackupStorage | 240 | // 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.BackupJob | 248 | + | var r0 []domain.DatabaseBackupJob |
| 249 | var r1 error | 249 | 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 _c | 301 | 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 _c | 306 | 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 _c | 311 | return _c | ||
| 312 | } | 312 | } | ||
M internal/domain/backup.go +98 -3
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // DBType identifies the database engine. | |
| 6 | 9 | type DBType string | |
| @@ -24,7 +27,16 @@ const ( | |||
| 24 | 27 | type BackupType string | |
| 25 | 28 | ||
| 26 | 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 | 42 | // BackupJobStatus tracks backup job lifecycle state. | |
| @@ -86,6 +98,12 @@ type BackupResult struct { | |||
| 86 | 98 | 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 | 107 | // RetentionPolicy defines how many backups to keep per schedule tier. | |
| 90 | 108 | type RetentionPolicy struct { | |
| 91 | 109 | Hourly int | |
| @@ -94,13 +112,21 @@ type RetentionPolicy struct { | |||
| 94 | 112 | 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 | 123 | // BackupOverride allows per-domain backup configuration. | |
| 98 | 124 | type BackupOverride struct { | |
| 99 | 125 | Schedules []BackupSchedule | |
| 100 | 126 | Retention *RetentionPolicy | |
| 101 | 127 | } | |
| 102 | 128 | ||
| 103 | - | // BackupConfig is the top-level backup configuration. | |
| 129 | + | // BackupConfig is the database backup configuration. | |
| 104 | 130 | type BackupConfig struct { | |
| 105 | 131 | Enabled bool | |
| 106 | 132 | StorageDir string | |
| @@ -108,6 +134,75 @@ type BackupConfig struct { | |||
| 108 | 134 | 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 | 206 | // Backup labels for container metadata. | |
| 112 | 207 | const ( | |
| 113 | 208 | LabelBackupEnabled = "gordon.backup" | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package domain | 1 | 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 string | 9 | type DBType string | ||
| @@ -24,7 +27,16 @@ const ( | |||||
| 24 | type BackupType string | 27 | 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.Duration | 98 | 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 int | 109 | Hourly int | ||
| @@ -94,13 +112,21 @@ type RetentionPolicy struct { | |||||
| 94 | Monthly int | 112 | 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 []BackupSchedule | 125 | Schedules []BackupSchedule | ||
| 100 | Retention *RetentionPolicy | 126 | 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 bool | 131 | Enabled bool | ||
| 106 | StorageDir string | 132 | StorageDir string | ||
| @@ -108,6 +134,75 @@ type BackupConfig struct { | |||||
| 108 | Overrides map[string]BackupOverride | 134 | Overrides map[string]BackupOverride | ||
| 109 | } | 135 | } | ||
| 110 | 136 | ||||
| 137 | + | // DatabaseBackupConfig is the database backup configuration. | |||
| 138 | + | type DatabaseBackupConfig = BackupConfig | |||
| 139 | + | ||||
| 140 | + | // VolumeBackupConfig is the volume backup configuration. | |||
| 141 | + | type VolumeBackupConfig struct { | |||
| 142 | + | Enabled bool | |||
| 143 | + | Interval time.Duration | |||
| 144 | + | Compression VolumeBackupCompression | |||
| 145 | + | Retention VolumeBackupRetentionPolicy | |||
| 146 | + | Timeout time.Duration | |||
| 147 | + | MaxConcurrency int | |||
| 148 | + | HelperImage string | |||
| 149 | + | VolumePrefix string | |||
| 150 | + | S3Bucket string | |||
| 151 | + | S3Region string | |||
| 152 | + | S3Prefix string | |||
| 153 | + | S3Endpoint string | |||
| 154 | + | S3PathStyle bool | |||
| 155 | + | S3SSEAlgorithm string | |||
| 156 | + | S3SSEKMSKeyID string | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // VolumeBackupJob represents a filesystem archive backup of a named volume. | |||
| 160 | + | type VolumeBackupJob struct { | |||
| 161 | + | ID string | |||
| 162 | + | Domain string | |||
| 163 | + | ContainerName string | |||
| 164 | + | ContainerID string | |||
| 165 | + | VolumeName string | |||
| 166 | + | MountPath string | |||
| 167 | + | Type BackupType | |||
| 168 | + | Status BackupJobStatus | |||
| 169 | + | StartedAt time.Time | |||
| 170 | + | CompletedAt time.Time | |||
| 171 | + | SizeBytes int64 | |||
| 172 | + | ArtifactRef string | |||
| 173 | + | Error string | |||
| 174 | + | Metadata map[string]string | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // VolumeBackupTarget identifies one selected volume backup source. | |||
| 178 | + | type VolumeBackupTarget struct { | |||
| 179 | + | Domain string | |||
| 180 | + | ContainerName string | |||
| 181 | + | ContainerID string | |||
| 182 | + | VolumeName string | |||
| 183 | + | MountPath string | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // VolumeArchiveRequest describes a volume archive export request. | |||
| 187 | + | type VolumeArchiveRequest struct { | |||
| 188 | + | VolumeName string | |||
| 189 | + | MountPath string | |||
| 190 | + | Compression VolumeBackupCompression | |||
| 191 | + | HelperImage string | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // VolumeArchiveMetadata contains metadata observed while exporting a volume archive. | |||
| 195 | + | type VolumeArchiveMetadata struct { | |||
| 196 | + | SizeBytes int64 | |||
| 197 | + | Checksum string | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // VolumeArchiveResult contains a readable archive stream and export metadata. | |||
| 201 | + | type VolumeArchiveResult struct { | |||
| 202 | + | Stream io.ReadCloser | |||
| 203 | + | Metadata VolumeArchiveMetadata | |||
| 204 | + | } | |||
| 205 | + | ||||
| 111 | // Backup labels for container metadata. | 206 | // Backup labels for container metadata. | ||
| 112 | const ( | 207 | const ( | ||
| 113 | LabelBackupEnabled = "gordon.backup" | 208 | LabelBackupEnabled = "gordon.backup" | ||
M internal/domain/container.go +19 -9
| @@ -6,15 +6,25 @@ import "time" | |||
| 6 | 6 | ||
| 7 | 7 | // Container represents a running container in the system. | |
| 8 | 8 | 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 | |
| 18 | 28 | } | |
| 19 | 29 | ||
| 20 | 30 | // 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 string | 9 | + | ID string |
| 10 | - | Image string | 10 | + | Image string |
| 11 | - | ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys | 11 | + | ImageID string // Docker image ID (sha256 digest) used to detect redundant deploys |
| 12 | - | Name string | 12 | + | Name string |
| 13 | - | Status string | 13 | + | Status string |
| 14 | - | ExitCode int | 14 | + | ExitCode int |
| 15 | - | Ports []int | 15 | + | Ports []int |
| 16 | - | Labels map[string]string | 16 | + | Labels map[string]string |
| 17 | - | Created time.Time | 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 | |||
| 18 | } | 28 | } | ||
| 19 | 29 | ||||
| 20 | // NetworkInfo represents network configuration and state. | 30 | // NetworkInfo represents network configuration and state. | ||
M internal/domain/cron.go +2 -1
| @@ -8,7 +8,8 @@ import "time" | |||
| 8 | 8 | // Timezone-aware scheduling is deferred until a timezone field is added. | |
| 9 | 9 | // When timezone support lands, host system timezone should be the default. | |
| 10 | 10 | type CronSchedule struct { | |
| 11 | - | Preset BackupSchedule | |
| 11 | + | Preset BackupSchedule | |
| 12 | + | Interval time.Duration | |
| 12 | 13 | } | |
| 13 | 14 | ||
| 14 | 15 | // 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 BackupSchedule | 11 | + | 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 { | |||
| 290 | 290 | // when no explicit timezone is configured. | |
| 291 | 291 | func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) { | |
| 292 | 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 | 300 | switch schedule.Preset { | |
| 295 | 301 | 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) { | |||
| 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 | 70 | func TestSchedulerAddListAndRunNow(t *testing.T) { | |
| 55 | 71 | s := NewScheduler(zerowrap.Default()) | |
| 56 | 72 | 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) | ||