feat(backup): add postgres backup MVP with admin and CLI flows
Introduce runtime exec/copy primitives, filesystem backup storage, detection/service orchestration, admin endpoints, and remote CLI commands so backups can be run and inspected end-to-end. Document backup config/commands and add unit + integration coverage including postgres 17/18 host-docker tests.
- commit
- 634f4978ee81e4de1b3f1bcdf6fb7e26a1b0cdfe
- author
- brice · · 2026-02-07 12:15 UTC
- committer
- brice · · 2026-02-07 14:40 UTC
- parent
- 639b629
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
32 files · +3076 -1
M .mockery.yaml +2 -0
| @@ -22,9 +22,11 @@ packages: | |||
| 22 | 22 | TokenStore: | |
| 23 | 23 | DomainSecretStore: | |
| 24 | 24 | RateLimiter: | |
| 25 | + | BackupStorage: | |
| 25 | 26 | github.com/bnema/gordon/internal/boundaries/in: | |
| 26 | 27 | interfaces: | |
| 27 | 28 | ContainerService: | |
| 29 | + | BackupService: | |
| 28 | 30 | RegistryService: | |
| 29 | 31 | ProxyService: | |
| 30 | 32 | ConfigService: | |
| @@ -22,9 +22,11 @@ packages: | |||||
| 22 | TokenStore: | 22 | TokenStore: | ||
| 23 | DomainSecretStore: | 23 | DomainSecretStore: | ||
| 24 | RateLimiter: | 24 | RateLimiter: | ||
| 25 | + | BackupStorage: | |||
| 25 | github.com/bnema/gordon/internal/boundaries/in: | 26 | github.com/bnema/gordon/internal/boundaries/in: | ||
| 26 | interfaces: | 27 | interfaces: | ||
| 27 | ContainerService: | 28 | ContainerService: | ||
| 29 | + | BackupService: | |||
| 28 | RegistryService: | 30 | RegistryService: | ||
| 29 | ProxyService: | 31 | ProxyService: | ||
| 30 | ConfigService: | 32 | ConfigService: | ||
A docs/cli/backup.md +72 -0
| @@ -0,0 +1,72 @@ | |||
| 1 | + | # Backup Command | |
| 2 | + | ||
| 3 | + | Manage backups for database attachments. | |
| 4 | + | ||
| 5 | + | ## gordon backup | |
| 6 | + | ||
| 7 | + | ```bash | |
| 8 | + | gordon backup <subcommand> | |
| 9 | + | ``` | |
| 10 | + | ||
| 11 | + | Subcommands: | |
| 12 | + | ||
| 13 | + | - `list [domain]` - List backups globally or for one domain | |
| 14 | + | - `run <domain> [--db <name>]` - Trigger an immediate backup | |
| 15 | + | - `detect <domain>` - Detect supported databases for a domain | |
| 16 | + | - `status` - Show backup status across domains | |
| 17 | + | ||
| 18 | + | > **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes). | |
| 19 | + | ||
| 20 | + | In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags. | |
| 21 | + | ||
| 22 | + | ## Examples | |
| 23 | + | ||
| 24 | + | ```bash | |
| 25 | + | # List all backups | |
| 26 | + | gordon backup list | |
| 27 | + | ||
| 28 | + | # List backups for one domain | |
| 29 | + | gordon backup list app.example.com | |
| 30 | + | ||
| 31 | + | # Trigger backup (auto-detect database) | |
| 32 | + | gordon backup run app.example.com | |
| 33 | + | ||
| 34 | + | # Trigger backup for specific attachment name | |
| 35 | + | gordon backup run app.example.com --db postgres | |
| 36 | + | ||
| 37 | + | # Detect DB attachments | |
| 38 | + | gordon backup detect app.example.com | |
| 39 | + | ||
| 40 | + | # Status summary | |
| 41 | + | gordon backup status | |
| 42 | + | ``` | |
| 43 | + | ||
| 44 | + | ## Output Columns | |
| 45 | + | ||
| 46 | + | ### list | |
| 47 | + | ||
| 48 | + | ```text | |
| 49 | + | <domain>\t<db>\t<status>\t<started_at>\t<file_path> | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | ### detect | |
| 53 | + | ||
| 54 | + | ```text | |
| 55 | + | <name>\t<type>\t<host>\t<port>\t<image> | |
| 56 | + | ``` | |
| 57 | + | ||
| 58 | + | ### status | |
| 59 | + | ||
| 60 | + | ```text | |
| 61 | + | <domain>\t<db>\t<status>\t<started_at> | |
| 62 | + | ``` | |
| 63 | + | ||
| 64 | + | ## Required Permissions | |
| 65 | + | ||
| 66 | + | - Read operations (`list`, `status`, `detect`) require `admin:status:read`. | |
| 67 | + | - `run` requires `admin:config:write`. | |
| 68 | + | ||
| 69 | + | ## Related | |
| 70 | + | ||
| 71 | + | - [CLI Commands](./index.md) | |
| 72 | + | - [Backups Configuration](../config/backups.md) | |
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | # Backup Command | |||
| 2 | + | ||||
| 3 | + | Manage backups for database attachments. | |||
| 4 | + | ||||
| 5 | + | ## gordon backup | |||
| 6 | + | ||||
| 7 | + | ```bash | |||
| 8 | + | gordon backup <subcommand> | |||
| 9 | + | ``` | |||
| 10 | + | ||||
| 11 | + | Subcommands: | |||
| 12 | + | ||||
| 13 | + | - `list [domain]` - List backups globally or for one domain | |||
| 14 | + | - `run <domain> [--db <name>]` - Trigger an immediate backup | |||
| 15 | + | - `detect <domain>` - Detect supported databases for a domain | |||
| 16 | + | - `status` - Show backup status across domains | |||
| 17 | + | ||||
| 18 | + | > **Note:** Backup commands currently require remote mode (`--remote` + `--token`, or configured remotes). | |||
| 19 | + | ||||
| 20 | + | In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags. | |||
| 21 | + | ||||
| 22 | + | ## Examples | |||
| 23 | + | ||||
| 24 | + | ```bash | |||
| 25 | + | # List all backups | |||
| 26 | + | gordon backup list | |||
| 27 | + | ||||
| 28 | + | # List backups for one domain | |||
| 29 | + | gordon backup list app.example.com | |||
| 30 | + | ||||
| 31 | + | # Trigger backup (auto-detect database) | |||
| 32 | + | gordon backup run app.example.com | |||
| 33 | + | ||||
| 34 | + | # Trigger backup for specific attachment name | |||
| 35 | + | gordon backup run app.example.com --db postgres | |||
| 36 | + | ||||
| 37 | + | # Detect DB attachments | |||
| 38 | + | gordon backup detect app.example.com | |||
| 39 | + | ||||
| 40 | + | # Status summary | |||
| 41 | + | gordon backup status | |||
| 42 | + | ``` | |||
| 43 | + | ||||
| 44 | + | ## Output Columns | |||
| 45 | + | ||||
| 46 | + | ### list | |||
| 47 | + | ||||
| 48 | + | ```text | |||
| 49 | + | <domain>\t<db>\t<status>\t<started_at>\t<file_path> | |||
| 50 | + | ``` | |||
| 51 | + | ||||
| 52 | + | ### detect | |||
| 53 | + | ||||
| 54 | + | ```text | |||
| 55 | + | <name>\t<type>\t<host>\t<port>\t<image> | |||
| 56 | + | ``` | |||
| 57 | + | ||||
| 58 | + | ### status | |||
| 59 | + | ||||
| 60 | + | ```text | |||
| 61 | + | <domain>\t<db>\t<status>\t<started_at> | |||
| 62 | + | ``` | |||
| 63 | + | ||||
| 64 | + | ## Required Permissions | |||
| 65 | + | ||||
| 66 | + | - Read operations (`list`, `status`, `detect`) require `admin:status:read`. | |||
| 67 | + | - `run` requires `admin:config:write`. | |||
| 68 | + | ||||
| 69 | + | ## Related | |||
| 70 | + | ||||
| 71 | + | - [CLI Commands](./index.md) | |||
| 72 | + | - [Backups Configuration](../config/backups.md) | |||
M docs/cli/index.md +7 -0
| @@ -29,6 +29,7 @@ Commands are organized by where they run: | |||
| 29 | 29 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 30 | 30 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 31 | 31 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 32 | + | | `gordon backup` | Manage database backups | [backup](./backup.md) | | |
| 32 | 33 | ||
| 33 | 34 | ## Client Commands | |
| 34 | 35 | ||
| @@ -73,6 +74,12 @@ gordon logs myapp.local -f # Follow container logs | |||
| 73 | 74 | # Check version | |
| 74 | 75 | gordon version | |
| 75 | 76 | ||
| 77 | + | # Backups | |
| 78 | + | gordon backup list | |
| 79 | + | gordon backup run app.example.com | |
| 80 | + | gordon backup detect app.example.com | |
| 81 | + | gordon backup status | |
| 82 | + | ||
| 76 | 83 | # Authentication | |
| 77 | 84 | gordon auth token generate --subject ci-bot --expiry 0 | |
| 78 | 85 | gordon auth token list | |
| @@ -29,6 +29,7 @@ Commands are organized by where they run: | |||||
| 29 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | 29 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | ||
| 30 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | 30 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | ||
| 31 | | `gordon status` | Show Gordon server status | [status](./status.md) | | 31 | | `gordon status` | Show Gordon server status | [status](./status.md) | | ||
| 32 | + | | `gordon backup` | Manage database backups | [backup](./backup.md) | | |||
| 32 | 33 | ||||
| 33 | ## Client Commands | 34 | ## Client Commands | ||
| 34 | 35 | ||||
| @@ -73,6 +74,12 @@ gordon logs myapp.local -f # Follow container logs | |||||
| 73 | # Check version | 74 | # Check version | ||
| 74 | gordon version | 75 | gordon version | ||
| 75 | 76 | ||||
| 77 | + | # Backups | |||
| 78 | + | gordon backup list | |||
| 79 | + | gordon backup run app.example.com | |||
| 80 | + | gordon backup detect app.example.com | |||
| 81 | + | gordon backup status | |||
| 82 | + | ||||
| 76 | # Authentication | 83 | # Authentication | ||
| 77 | gordon auth token generate --subject ci-bot --expiry 0 | 84 | gordon auth token generate --subject ci-bot --expiry 0 | ||
| 78 | gordon auth token list | 85 | gordon auth token list | ||
M docs/concepts.md +14 -0
| @@ -211,6 +211,20 @@ Gordon uses an internal event system for coordination: | |||
| 211 | 211 | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | |
| 212 | 212 | | `container.deployed` | Container started | Update proxy cache | | |
| 213 | 213 | ||
| 214 | + | ## Backups and Recovery | |
| 215 | + | ||
| 216 | + | Gordon can run logical PostgreSQL backups for attachment containers. | |
| 217 | + | ||
| 218 | + | Current design: | |
| 219 | + | ||
| 220 | + | 1. Detect PostgreSQL attachments attached to a route | |
| 221 | + | 2. Execute `pg_dump -Fc` through Gordon runtime operations | |
| 222 | + | 3. Store backup artifacts on local filesystem storage | |
| 223 | + | 4. Expose backup actions through admin API and CLI | |
| 224 | + | ||
| 225 | + | This is intentionally scoped for operational safety and predictable behavior. | |
| 226 | + | Future extensions can add physical backups, PITR, and remote object storage adapters. | |
| 227 | + | ||
| 214 | 228 | ## Container Labels | |
| 215 | 229 | ||
| 216 | 230 | Gordon uses labels to track managed containers: | |
| @@ -211,6 +211,20 @@ Gordon uses an internal event system for coordination: | |||||
| 211 | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | 211 | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | ||
| 212 | | `container.deployed` | Container started | Update proxy cache | | 212 | | `container.deployed` | Container started | Update proxy cache | | ||
| 213 | 213 | ||||
| 214 | + | ## Backups and Recovery | |||
| 215 | + | ||||
| 216 | + | Gordon can run logical PostgreSQL backups for attachment containers. | |||
| 217 | + | ||||
| 218 | + | Current design: | |||
| 219 | + | ||||
| 220 | + | 1. Detect PostgreSQL attachments attached to a route | |||
| 221 | + | 2. Execute `pg_dump -Fc` through Gordon runtime operations | |||
| 222 | + | 3. Store backup artifacts on local filesystem storage | |||
| 223 | + | 4. Expose backup actions through admin API and CLI | |||
| 224 | + | ||||
| 225 | + | This is intentionally scoped for operational safety and predictable behavior. | |||
| 226 | + | Future extensions can add physical backups, PITR, and remote object storage adapters. | |||
| 227 | + | ||||
| 214 | ## Container Labels | 228 | ## Container Labels | ||
| 215 | 229 | ||||
| 216 | Gordon uses labels to track managed containers: | 230 | Gordon uses labels to track managed containers: | ||
A docs/config/backups.md +68 -0
| @@ -0,0 +1,68 @@ | |||
| 1 | + | # Backups Configuration | |
| 2 | + | ||
| 3 | + | Configure automatic PostgreSQL logical backups for attachment containers. | |
| 4 | + | ||
| 5 | + | ## Overview | |
| 6 | + | ||
| 7 | + | When backups are enabled, Gordon detects PostgreSQL attachments for each route and can: | |
| 8 | + | ||
| 9 | + | - Run on-demand backups via `gordon backup 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 | + | ||
| 21 | + | ```toml | |
| 22 | + | [backups] | |
| 23 | + | enabled = true | |
| 24 | + | storage_dir = "~/.gordon/backups" | |
| 25 | + | ||
| 26 | + | [backups.retention] | |
| 27 | + | hourly = 24 | |
| 28 | + | daily = 7 | |
| 29 | + | weekly = 4 | |
| 30 | + | monthly = 12 | |
| 31 | + | ``` | |
| 32 | + | ||
| 33 | + | ## Settings | |
| 34 | + | ||
| 35 | + | | Key | Type | Default | Description | | |
| 36 | + | |-----|------|---------|-------------| | |
| 37 | + | | `backups.enabled` | bool | `false` | Enables backup service wiring | | |
| 38 | + | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | |
| 39 | + | | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB | | |
| 40 | + | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | |
| 41 | + | | `backups.retention.weekly` | int | `0` | Number of weekly backups to keep per DB | | |
| 42 | + | | `backups.retention.monthly` | int | `0` | Number of monthly backups to keep per DB | | |
| 43 | + | ||
| 44 | + | ## Storage Layout | |
| 45 | + | ||
| 46 | + | Backups are stored under: | |
| 47 | + | ||
| 48 | + | ```text | |
| 49 | + | <storage_dir>/backups/<domain>/<db>/<schedule>/<timestamp>.bak | |
| 50 | + | ``` | |
| 51 | + | ||
| 52 | + | Example: | |
| 53 | + | ||
| 54 | + | ```text | |
| 55 | + | ~/.gordon/backups/backups/app.example.com/postgres/daily/20260207T110000Z.bak | |
| 56 | + | ``` | |
| 57 | + | ||
| 58 | + | ## Notes | |
| 59 | + | ||
| 60 | + | - Backups require PostgreSQL attachment containers to be running. | |
| 61 | + | - The backup command executes inside the database container using Gordon runtime APIs. | |
| 62 | + | - Retention applies per database and per schedule. | |
| 63 | + | ||
| 64 | + | ## Related | |
| 65 | + | ||
| 66 | + | - [Attachments Configuration](./attachments.md) | |
| 67 | + | - [CLI Backup Command](../cli/backup.md) | |
| 68 | + | - [Configuration Reference](./reference.md) | |
| @@ -0,0 +1,68 @@ | |||||
| 1 | + | # Backups Configuration | |||
| 2 | + | ||||
| 3 | + | Configure automatic PostgreSQL logical backups for attachment containers. | |||
| 4 | + | ||||
| 5 | + | ## Overview | |||
| 6 | + | ||||
| 7 | + | When backups are enabled, Gordon detects PostgreSQL attachments for each route and can: | |||
| 8 | + | ||||
| 9 | + | - Run on-demand backups via `gordon backup 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 | + | ||||
| 21 | + | ```toml | |||
| 22 | + | [backups] | |||
| 23 | + | enabled = true | |||
| 24 | + | storage_dir = "~/.gordon/backups" | |||
| 25 | + | ||||
| 26 | + | [backups.retention] | |||
| 27 | + | hourly = 24 | |||
| 28 | + | daily = 7 | |||
| 29 | + | weekly = 4 | |||
| 30 | + | monthly = 12 | |||
| 31 | + | ``` | |||
| 32 | + | ||||
| 33 | + | ## Settings | |||
| 34 | + | ||||
| 35 | + | | Key | Type | Default | Description | | |||
| 36 | + | |-----|------|---------|-------------| | |||
| 37 | + | | `backups.enabled` | bool | `false` | Enables backup service wiring | | |||
| 38 | + | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | |||
| 39 | + | | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB | | |||
| 40 | + | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | |||
| 41 | + | | `backups.retention.weekly` | int | `0` | Number of weekly backups to keep per DB | | |||
| 42 | + | | `backups.retention.monthly` | int | `0` | Number of monthly backups to keep per DB | | |||
| 43 | + | ||||
| 44 | + | ## Storage Layout | |||
| 45 | + | ||||
| 46 | + | Backups are stored under: | |||
| 47 | + | ||||
| 48 | + | ```text | |||
| 49 | + | <storage_dir>/backups/<domain>/<db>/<schedule>/<timestamp>.bak | |||
| 50 | + | ``` | |||
| 51 | + | ||||
| 52 | + | Example: | |||
| 53 | + | ||||
| 54 | + | ```text | |||
| 55 | + | ~/.gordon/backups/backups/app.example.com/postgres/daily/20260207T110000Z.bak | |||
| 56 | + | ``` | |||
| 57 | + | ||||
| 58 | + | ## Notes | |||
| 59 | + | ||||
| 60 | + | - Backups require PostgreSQL attachment containers to be running. | |||
| 61 | + | - The backup command executes inside the database container using Gordon runtime APIs. | |||
| 62 | + | - Retention applies per database and per schedule. | |||
| 63 | + | ||||
| 64 | + | ## Related | |||
| 65 | + | ||||
| 66 | + | - [Attachments Configuration](./attachments.md) | |||
| 67 | + | - [CLI Backup Command](../cli/backup.md) | |||
| 68 | + | - [Configuration Reference](./reference.md) | |||
M docs/config/index.md +14 -0
| @@ -109,6 +109,17 @@ enabled = false # Auto-create routes from image names | |||
| 109 | 109 | [attachments] | |
| 110 | 110 | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 111 | 111 | "backend" = ["rabbitmq:latest"] | |
| 112 | + | ||
| 113 | + | # Backups | |
| 114 | + | [backups] | |
| 115 | + | enabled = true | |
| 116 | + | storage_dir = "~/.gordon/backups" | |
| 117 | + | ||
| 118 | + | [backups.retention] | |
| 119 | + | hourly = 24 | |
| 120 | + | daily = 7 | |
| 121 | + | weekly = 4 | |
| 122 | + | monthly = 12 | |
| 112 | 123 | ``` | |
| 113 | 124 | ||
| 114 | 125 | ## Configuration Sections | |
| @@ -128,6 +139,7 @@ enabled = false # Auto-create routes from image names | |||
| 128 | 139 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 129 | 140 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 130 | 141 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 142 | + | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| 131 | 143 | ||
| 132 | 144 | ## Default Values | |
| 133 | 145 | ||
| @@ -158,6 +170,7 @@ enabled = false # Auto-create routes from image names | |||
| 158 | 170 | | `volumes.preserve` | `true` | | |
| 159 | 171 | | `network_isolation.enabled` | `false` | | |
| 160 | 172 | | `auto_route.enabled` | `false` | | |
| 173 | + | | `backups.enabled` | `false` | | |
| 161 | 174 | ||
| 162 | 175 | ## Hot Reload | |
| 163 | 176 | ||
| @@ -193,3 +206,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 193 | 206 | - [Routes Configuration](./routes.md) | |
| 194 | 207 | - [External Routes](./external-routes.md) | |
| 195 | 208 | - [Authentication](./auth.md) | |
| 209 | + | - [Backups](./backups.md) | |
| @@ -109,6 +109,17 @@ enabled = false # Auto-create routes from image names | |||||
| 109 | [attachments] | 109 | [attachments] | ||
| 110 | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | 110 | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | ||
| 111 | "backend" = ["rabbitmq:latest"] | 111 | "backend" = ["rabbitmq:latest"] | ||
| 112 | + | ||||
| 113 | + | # Backups | |||
| 114 | + | [backups] | |||
| 115 | + | enabled = true | |||
| 116 | + | storage_dir = "~/.gordon/backups" | |||
| 117 | + | ||||
| 118 | + | [backups.retention] | |||
| 119 | + | hourly = 24 | |||
| 120 | + | daily = 7 | |||
| 121 | + | weekly = 4 | |||
| 122 | + | monthly = 12 | |||
| 112 | ``` | 123 | ``` | ||
| 113 | 124 | ||||
| 114 | ## Configuration Sections | 125 | ## Configuration Sections | ||
| @@ -128,6 +139,7 @@ enabled = false # Auto-create routes from image names | |||||
| 128 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | 139 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | ||
| 129 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | 140 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | ||
| 130 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | 141 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | ||
| 142 | + | | `[backups]` | Database backups | [Backups](./backups.md) | | |||
| 131 | 143 | ||||
| 132 | ## Default Values | 144 | ## Default Values | ||
| 133 | 145 | ||||
| @@ -158,6 +170,7 @@ enabled = false # Auto-create routes from image names | |||||
| 158 | | `volumes.preserve` | `true` | | 170 | | `volumes.preserve` | `true` | | ||
| 159 | | `network_isolation.enabled` | `false` | | 171 | | `network_isolation.enabled` | `false` | | ||
| 160 | | `auto_route.enabled` | `false` | | 172 | | `auto_route.enabled` | `false` | | ||
| 173 | + | | `backups.enabled` | `false` | | |||
| 161 | 174 | ||||
| 162 | ## Hot Reload | 175 | ## Hot Reload | ||
| 163 | 176 | ||||
| @@ -193,3 +206,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||||
| 193 | - [Routes Configuration](./routes.md) | 206 | - [Routes Configuration](./routes.md) | ||
| 194 | - [External Routes](./external-routes.md) | 207 | - [External Routes](./external-routes.md) | ||
| 195 | - [Authentication](./auth.md) | 208 | - [Authentication](./auth.md) | ||
| 209 | + | - [Backups](./backups.md) | |||
M docs/config/reference.md +19 -0
| @@ -121,6 +121,19 @@ preserve = true # Keep volumes when containers are | |||
| 121 | 121 | # ============================================================================= | |
| 122 | 122 | [attachments] | |
| 123 | 123 | # "domain-or-group" = ["image1:tag", "image2:tag"] | |
| 124 | + | ||
| 125 | + | # ============================================================================= | |
| 126 | + | # BACKUPS | |
| 127 | + | # ============================================================================= | |
| 128 | + | [backups] | |
| 129 | + | enabled = false # Enable backup service | |
| 130 | + | storage_dir = "" # Backup root (default: {data_dir}/backups) | |
| 131 | + | ||
| 132 | + | [backups.retention] | |
| 133 | + | hourly = 0 # Keep N hourly backups per DB | |
| 134 | + | daily = 0 # Keep N daily backups per DB | |
| 135 | + | weekly = 0 # Keep N weekly backups per DB | |
| 136 | + | monthly = 0 # Keep N monthly backups per DB | |
| 124 | 137 | ``` | |
| 125 | 138 | ||
| 126 | 139 | ## Default Values Summary | |
| @@ -164,6 +177,12 @@ preserve = true # Keep volumes when containers are | |||
| 164 | 177 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 165 | 178 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 166 | 179 | | `volumes.preserve` | `true` | Keep volumes | | |
| 180 | + | | `backups.enabled` | `false` | Backup service disabled | | |
| 181 | + | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | |
| 182 | + | | `backups.retention.hourly` | `0` | Keep 0 hourly backups by default | | |
| 183 | + | | `backups.retention.daily` | `0` | Keep 0 daily backups by default | | |
| 184 | + | | `backups.retention.weekly` | `0` | Keep 0 weekly backups by default | | |
| 185 | + | | `backups.retention.monthly` | `0` | Keep 0 monthly backups by default | | |
| 167 | 186 | ||
| 168 | 187 | ## Environment Variables | |
| 169 | 188 | ||
| @@ -121,6 +121,19 @@ preserve = true # Keep volumes when containers are | |||||
| 121 | # ============================================================================= | 121 | # ============================================================================= | ||
| 122 | [attachments] | 122 | [attachments] | ||
| 123 | # "domain-or-group" = ["image1:tag", "image2:tag"] | 123 | # "domain-or-group" = ["image1:tag", "image2:tag"] | ||
| 124 | + | ||||
| 125 | + | # ============================================================================= | |||
| 126 | + | # BACKUPS | |||
| 127 | + | # ============================================================================= | |||
| 128 | + | [backups] | |||
| 129 | + | enabled = false # Enable backup service | |||
| 130 | + | storage_dir = "" # Backup root (default: {data_dir}/backups) | |||
| 131 | + | ||||
| 132 | + | [backups.retention] | |||
| 133 | + | hourly = 0 # Keep N hourly backups per DB | |||
| 134 | + | daily = 0 # Keep N daily backups per DB | |||
| 135 | + | weekly = 0 # Keep N weekly backups per DB | |||
| 136 | + | monthly = 0 # Keep N monthly backups per DB | |||
| 124 | ``` | 137 | ``` | ||
| 125 | 138 | ||||
| 126 | ## Default Values Summary | 139 | ## Default Values Summary | ||
| @@ -164,6 +177,12 @@ preserve = true # Keep volumes when containers are | |||||
| 164 | | `volumes.auto_create` | `true` | Auto-create volumes | | 177 | | `volumes.auto_create` | `true` | Auto-create volumes | | ||
| 165 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 178 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
| 166 | | `volumes.preserve` | `true` | Keep volumes | | 179 | | `volumes.preserve` | `true` | Keep volumes | | ||
| 180 | + | | `backups.enabled` | `false` | Backup service disabled | | |||
| 181 | + | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | |||
| 182 | + | | `backups.retention.hourly` | `0` | Keep 0 hourly backups by default | | |||
| 183 | + | | `backups.retention.daily` | `0` | Keep 0 daily backups by default | | |||
| 184 | + | | `backups.retention.weekly` | `0` | Keep 0 weekly backups by default | | |||
| 185 | + | | `backups.retention.monthly` | `0` | Keep 0 monthly backups by default | | |||
| 167 | 186 | ||||
| 168 | ## Environment Variables | 187 | ## Environment Variables | ||
| 169 | 188 | ||||
M docs/reference/docker-labels.md +15 -0
| @@ -23,6 +23,18 @@ Additional labels for attachment containers: | |||
| 23 | 23 | | `gordon.attachment` | `"true"` | Container is an attachment service | | |
| 24 | 24 | | `gordon.attached-to` | Domain/group | Route or network group this serves | | |
| 25 | 25 | ||
| 26 | + | ### Backup Labels | |
| 27 | + | ||
| 28 | + | Labels used by the backup subsystem: | |
| 29 | + | ||
| 30 | + | | Label | Value | Description | | |
| 31 | + | |-------|-------|-------------| | |
| 32 | + | | `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container | | |
| 33 | + | | `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override | | |
| 34 | + | | `gordon.backup.version` | e.g. `"17"` | Explicit database version override | | |
| 35 | + | | `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint | | |
| 36 | + | | `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers | | |
| 37 | + | ||
| 26 | 38 | ## Image Labels | |
| 27 | 39 | ||
| 28 | 40 | Labels you can set in your Dockerfile: | |
| @@ -96,6 +108,9 @@ docker ps -f "label=gordon.attachment=true" | |||
| 96 | 108 | ||
| 97 | 109 | # Attachments for specific route | |
| 98 | 110 | docker ps -f "label=gordon.attached-to=app.mydomain.com" | |
| 111 | + | ||
| 112 | + | # Backup sidecars | |
| 113 | + | docker ps -f "label=gordon.backup.sidecar=true" | |
| 99 | 114 | ``` | |
| 100 | 115 | ||
| 101 | 116 | ## Examples | |
| @@ -23,6 +23,18 @@ Additional labels for attachment containers: | |||||
| 23 | | `gordon.attachment` | `"true"` | Container is an attachment service | | 23 | | `gordon.attachment` | `"true"` | Container is an attachment service | | ||
| 24 | | `gordon.attached-to` | Domain/group | Route or network group this serves | | 24 | | `gordon.attached-to` | Domain/group | Route or network group this serves | | ||
| 25 | 25 | ||||
| 26 | + | ### Backup Labels | |||
| 27 | + | ||||
| 28 | + | Labels used by the backup subsystem: | |||
| 29 | + | ||||
| 30 | + | | Label | Value | Description | | |||
| 31 | + | |-------|-------|-------------| | |||
| 32 | + | | `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container | | |||
| 33 | + | | `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override | | |||
| 34 | + | | `gordon.backup.version` | e.g. `"17"` | Explicit database version override | | |||
| 35 | + | | `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint | | |||
| 36 | + | | `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers | | |||
| 37 | + | ||||
| 26 | ## Image Labels | 38 | ## Image Labels | ||
| 27 | 39 | ||||
| 28 | Labels you can set in your Dockerfile: | 40 | Labels you can set in your Dockerfile: | ||
| @@ -96,6 +108,9 @@ docker ps -f "label=gordon.attachment=true" | |||||
| 96 | 108 | ||||
| 97 | # Attachments for specific route | 109 | # Attachments for specific route | ||
| 98 | docker ps -f "label=gordon.attached-to=app.mydomain.com" | 110 | docker ps -f "label=gordon.attached-to=app.mydomain.com" | ||
| 111 | + | ||||
| 112 | + | # Backup sidecars | |||
| 113 | + | docker ps -f "label=gordon.backup.sidecar=true" | |||
| 99 | ``` | 114 | ``` | ||
| 100 | 115 | ||||
| 101 | ## Examples | 116 | ## Examples | ||
A internal/adapters/dto/admin_backups.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // BackupJob represents backup metadata in admin API responses. | |
| 6 | + | type BackupJob struct { | |
| 7 | + | ID string `json:"id"` | |
| 8 | + | Domain string `json:"domain"` | |
| 9 | + | DBName string `json:"db_name"` | |
| 10 | + | Schedule string `json:"schedule,omitempty"` | |
| 11 | + | Type string `json:"type"` | |
| 12 | + | Status string `json:"status"` | |
| 13 | + | StartedAt time.Time `json:"started_at"` | |
| 14 | + | CompletedAt time.Time `json:"completed_at,omitempty"` | |
| 15 | + | SizeBytes int64 `json:"size_bytes"` | |
| 16 | + | FilePath string `json:"file_path"` | |
| 17 | + | Error string `json:"error,omitempty"` | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // BackupsResponse is returned by backup listing endpoints. | |
| 21 | + | type BackupsResponse struct { | |
| 22 | + | Backups []BackupJob `json:"backups"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // BackupRunRequest triggers a backup run. | |
| 26 | + | type BackupRunRequest struct { | |
| 27 | + | DB string `json:"db,omitempty"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // BackupRunResponse is returned after triggering a backup. | |
| 31 | + | type BackupRunResponse struct { | |
| 32 | + | Status string `json:"status"` | |
| 33 | + | Backup BackupJob `json:"backup"` | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // DatabaseInfo represents a detected database attachment. | |
| 37 | + | type DatabaseInfo struct { | |
| 38 | + | Type string `json:"type"` | |
| 39 | + | Name string `json:"name"` | |
| 40 | + | Version string `json:"version,omitempty"` | |
| 41 | + | Host string `json:"host"` | |
| 42 | + | Port int `json:"port"` | |
| 43 | + | ContainerID string `json:"container_id"` | |
| 44 | + | ImageName string `json:"image_name"` | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // BackupDetectResponse is returned by detect endpoints. | |
| 48 | + | type BackupDetectResponse struct { | |
| 49 | + | Databases []DatabaseInfo `json:"databases"` | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // BackupJob represents backup metadata in admin API responses. | |||
| 6 | + | type BackupJob struct { | |||
| 7 | + | ID string `json:"id"` | |||
| 8 | + | Domain string `json:"domain"` | |||
| 9 | + | DBName string `json:"db_name"` | |||
| 10 | + | Schedule string `json:"schedule,omitempty"` | |||
| 11 | + | Type string `json:"type"` | |||
| 12 | + | Status string `json:"status"` | |||
| 13 | + | StartedAt time.Time `json:"started_at"` | |||
| 14 | + | CompletedAt time.Time `json:"completed_at,omitempty"` | |||
| 15 | + | SizeBytes int64 `json:"size_bytes"` | |||
| 16 | + | FilePath string `json:"file_path"` | |||
| 17 | + | Error string `json:"error,omitempty"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // BackupsResponse is returned by backup listing endpoints. | |||
| 21 | + | type BackupsResponse struct { | |||
| 22 | + | Backups []BackupJob `json:"backups"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // BackupRunRequest triggers a backup run. | |||
| 26 | + | type BackupRunRequest struct { | |||
| 27 | + | DB string `json:"db,omitempty"` | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // BackupRunResponse is returned after triggering a backup. | |||
| 31 | + | type BackupRunResponse struct { | |||
| 32 | + | Status string `json:"status"` | |||
| 33 | + | Backup BackupJob `json:"backup"` | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // DatabaseInfo represents a detected database attachment. | |||
| 37 | + | type DatabaseInfo struct { | |||
| 38 | + | Type string `json:"type"` | |||
| 39 | + | Name string `json:"name"` | |||
| 40 | + | Version string `json:"version,omitempty"` | |||
| 41 | + | Host string `json:"host"` | |||
| 42 | + | Port int `json:"port"` | |||
| 43 | + | ContainerID string `json:"container_id"` | |||
| 44 | + | ImageName string `json:"image_name"` | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // BackupDetectResponse is returned by detect endpoints. | |||
| 48 | + | type BackupDetectResponse struct { | |||
| 49 | + | Databases []DatabaseInfo `json:"databases"` | |||
| 50 | + | } | |||
A internal/adapters/in/cli/backup.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/spf13/cobra" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // newBackupCmd creates the backup command group. | |
| 11 | + | func newBackupCmd() *cobra.Command { | |
| 12 | + | cmd := &cobra.Command{ | |
| 13 | + | Use: "backup", | |
| 14 | + | Short: "Manage database backups", | |
| 15 | + | Long: `Manage database backups through Gordon's admin API. | |
| 16 | + | ||
| 17 | + | These commands currently require remote mode with --remote/--token | |
| 18 | + | or configured remotes.`, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | cmd.AddCommand(newBackupListCmd()) | |
| 22 | + | cmd.AddCommand(newBackupRunCmd()) | |
| 23 | + | cmd.AddCommand(newBackupDetectCmd()) | |
| 24 | + | cmd.AddCommand(newBackupStatusCmd()) | |
| 25 | + | ||
| 26 | + | return cmd | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newBackupListCmd() *cobra.Command { | |
| 30 | + | return &cobra.Command{ | |
| 31 | + | Use: "list [domain]", | |
| 32 | + | Short: "List backups", | |
| 33 | + | Args: cobra.MaximumNArgs(1), | |
| 34 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 35 | + | client, isRemote := GetRemoteClient() | |
| 36 | + | if !isRemote { | |
| 37 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | domainName := "" | |
| 41 | + | if len(args) == 1 { | |
| 42 | + | domainName = args[0] | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | jobs, err := client.ListBackups(context.Background(), domainName) | |
| 46 | + | if err != nil { | |
| 47 | + | return fmt.Errorf("failed to list backups: %w", err) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | if len(jobs) == 0 { | |
| 51 | + | fmt.Println("No backups found") | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | for _, job := range jobs { | |
| 56 | + | fmt.Printf("%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z"), job.FilePath) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | return nil | |
| 60 | + | }, | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func newBackupRunCmd() *cobra.Command { | |
| 65 | + | var dbName string | |
| 66 | + | ||
| 67 | + | cmd := &cobra.Command{ | |
| 68 | + | Use: "run <domain>", | |
| 69 | + | Short: "Run backup now", | |
| 70 | + | Args: cobra.ExactArgs(1), | |
| 71 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 72 | + | client, isRemote := GetRemoteClient() | |
| 73 | + | if !isRemote { | |
| 74 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | result, err := client.RunBackup(context.Background(), args[0], dbName) | |
| 78 | + | if err != nil { | |
| 79 | + | return fmt.Errorf("failed to run backup: %w", err) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | fmt.Printf("Backup completed: domain=%s db=%s path=%s size=%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.FilePath, result.Backup.SizeBytes) | |
| 83 | + | return nil | |
| 84 | + | }, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)") | |
| 88 | + | return cmd | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func newBackupDetectCmd() *cobra.Command { | |
| 92 | + | return &cobra.Command{ | |
| 93 | + | Use: "detect <domain>", | |
| 94 | + | Short: "Detect databases for domain", | |
| 95 | + | Args: cobra.ExactArgs(1), | |
| 96 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 97 | + | client, isRemote := GetRemoteClient() | |
| 98 | + | if !isRemote { | |
| 99 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | dbs, err := client.DetectDatabases(context.Background(), args[0]) | |
| 103 | + | if err != nil { | |
| 104 | + | return fmt.Errorf("failed to detect databases: %w", err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | if len(dbs) == 0 { | |
| 108 | + | fmt.Println("No supported databases detected") | |
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | for _, db := range dbs { | |
| 113 | + | fmt.Printf("%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | return nil | |
| 117 | + | }, | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func newBackupStatusCmd() *cobra.Command { | |
| 122 | + | return &cobra.Command{ | |
| 123 | + | Use: "status", | |
| 124 | + | Short: "Show backup status", | |
| 125 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 126 | + | client, isRemote := GetRemoteClient() | |
| 127 | + | if !isRemote { | |
| 128 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | jobs, err := client.BackupStatus(context.Background()) | |
| 132 | + | if err != nil { | |
| 133 | + | return fmt.Errorf("failed to get backup status: %w", err) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | if len(jobs) == 0 { | |
| 137 | + | fmt.Println("No backup status available") | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | for _, job := range jobs { | |
| 142 | + | fmt.Printf("%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z")) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | return nil | |
| 146 | + | }, | |
| 147 | + | } | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/spf13/cobra" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // newBackupCmd creates the backup command group. | |||
| 11 | + | func newBackupCmd() *cobra.Command { | |||
| 12 | + | cmd := &cobra.Command{ | |||
| 13 | + | Use: "backup", | |||
| 14 | + | Short: "Manage database backups", | |||
| 15 | + | Long: `Manage database backups through Gordon's admin API. | |||
| 16 | + | ||||
| 17 | + | These commands currently require remote mode with --remote/--token | |||
| 18 | + | or configured remotes.`, | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | cmd.AddCommand(newBackupListCmd()) | |||
| 22 | + | cmd.AddCommand(newBackupRunCmd()) | |||
| 23 | + | cmd.AddCommand(newBackupDetectCmd()) | |||
| 24 | + | cmd.AddCommand(newBackupStatusCmd()) | |||
| 25 | + | ||||
| 26 | + | return cmd | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func newBackupListCmd() *cobra.Command { | |||
| 30 | + | return &cobra.Command{ | |||
| 31 | + | Use: "list [domain]", | |||
| 32 | + | Short: "List backups", | |||
| 33 | + | Args: cobra.MaximumNArgs(1), | |||
| 34 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 35 | + | client, isRemote := GetRemoteClient() | |||
| 36 | + | if !isRemote { | |||
| 37 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | domainName := "" | |||
| 41 | + | if len(args) == 1 { | |||
| 42 | + | domainName = args[0] | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | jobs, err := client.ListBackups(context.Background(), domainName) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return fmt.Errorf("failed to list backups: %w", err) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | if len(jobs) == 0 { | |||
| 51 | + | fmt.Println("No backups found") | |||
| 52 | + | return nil | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | for _, job := range jobs { | |||
| 56 | + | fmt.Printf("%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z"), job.FilePath) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | return nil | |||
| 60 | + | }, | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func newBackupRunCmd() *cobra.Command { | |||
| 65 | + | var dbName string | |||
| 66 | + | ||||
| 67 | + | cmd := &cobra.Command{ | |||
| 68 | + | Use: "run <domain>", | |||
| 69 | + | Short: "Run backup now", | |||
| 70 | + | Args: cobra.ExactArgs(1), | |||
| 71 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 72 | + | client, isRemote := GetRemoteClient() | |||
| 73 | + | if !isRemote { | |||
| 74 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | result, err := client.RunBackup(context.Background(), args[0], dbName) | |||
| 78 | + | if err != nil { | |||
| 79 | + | return fmt.Errorf("failed to run backup: %w", err) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | fmt.Printf("Backup completed: domain=%s db=%s path=%s size=%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.FilePath, result.Backup.SizeBytes) | |||
| 83 | + | return nil | |||
| 84 | + | }, | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)") | |||
| 88 | + | return cmd | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func newBackupDetectCmd() *cobra.Command { | |||
| 92 | + | return &cobra.Command{ | |||
| 93 | + | Use: "detect <domain>", | |||
| 94 | + | Short: "Detect databases for domain", | |||
| 95 | + | Args: cobra.ExactArgs(1), | |||
| 96 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 97 | + | client, isRemote := GetRemoteClient() | |||
| 98 | + | if !isRemote { | |||
| 99 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | dbs, err := client.DetectDatabases(context.Background(), args[0]) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return fmt.Errorf("failed to detect databases: %w", err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | if len(dbs) == 0 { | |||
| 108 | + | fmt.Println("No supported databases detected") | |||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | for _, db := range dbs { | |||
| 113 | + | fmt.Printf("%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return nil | |||
| 117 | + | }, | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func newBackupStatusCmd() *cobra.Command { | |||
| 122 | + | return &cobra.Command{ | |||
| 123 | + | Use: "status", | |||
| 124 | + | Short: "Show backup status", | |||
| 125 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 126 | + | client, isRemote := GetRemoteClient() | |||
| 127 | + | if !isRemote { | |||
| 128 | + | return fmt.Errorf("backup commands currently require --remote and --token") | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | jobs, err := client.BackupStatus(context.Background()) | |||
| 132 | + | if err != nil { | |||
| 133 | + | return fmt.Errorf("failed to get backup status: %w", err) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | if len(jobs) == 0 { | |||
| 137 | + | fmt.Println("No backup status available") | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | for _, job := range jobs { | |||
| 142 | + | fmt.Printf("%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z")) | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | return nil | |||
| 146 | + | }, | |||
| 147 | + | } | |||
| 148 | + | } | |||
M internal/adapters/in/cli/remote/client.go +75 -0
| @@ -463,6 +463,81 @@ type RouteHealth struct { | |||
| 463 | 463 | Error string `json:"error"` | |
| 464 | 464 | } | |
| 465 | 465 | ||
| 466 | + | // Backups API | |
| 467 | + | ||
| 468 | + | // ListBackups returns backups globally or for a domain. | |
| 469 | + | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 470 | + | path := "/backups" | |
| 471 | + | if backupDomain != "" { | |
| 472 | + | path += "/" + url.PathEscape(backupDomain) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| 476 | + | if err != nil { | |
| 477 | + | return nil, err | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | var result dto.BackupsResponse | |
| 481 | + | if err := parseResponse(resp, &result); err != nil { | |
| 482 | + | return nil, err | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | return result.Backups, nil | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // BackupStatus returns aggregate backup status. | |
| 489 | + | func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 490 | + | resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil) | |
| 491 | + | if err != nil { | |
| 492 | + | return nil, err | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | var result dto.BackupsResponse | |
| 496 | + | if err := parseResponse(resp, &result); err != nil { | |
| 497 | + | return nil, err | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | return result.Backups, nil | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // RunBackup triggers a backup for a domain. | |
| 504 | + | func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 505 | + | if backupDomain == "" { | |
| 506 | + | return nil, fmt.Errorf("domain cannot be empty") | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName}) | |
| 510 | + | if err != nil { | |
| 511 | + | return nil, err | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | var result dto.BackupRunResponse | |
| 515 | + | if err := parseResponse(resp, &result); err != nil { | |
| 516 | + | return nil, err | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | return &result, nil | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | // DetectDatabases detects supported databases for a domain. | |
| 523 | + | func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 524 | + | if backupDomain == "" { | |
| 525 | + | return nil, fmt.Errorf("domain cannot be empty") | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil) | |
| 529 | + | if err != nil { | |
| 530 | + | return nil, err | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | var result dto.BackupDetectResponse | |
| 534 | + | if err := parseResponse(resp, &result); err != nil { | |
| 535 | + | return nil, err | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | return result.Databases, nil | |
| 539 | + | } | |
| 540 | + | ||
| 466 | 541 | // GetHealth returns health status for all routes with HTTP probing. | |
| 467 | 542 | func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) { | |
| 468 | 543 | resp, err := c.request(ctx, http.MethodGet, "/health", nil) | |
| @@ -463,6 +463,81 @@ type RouteHealth struct { | |||||
| 463 | Error string `json:"error"` | 463 | Error string `json:"error"` | ||
| 464 | } | 464 | } | ||
| 465 | 465 | ||||
| 466 | + | // Backups API | |||
| 467 | + | ||||
| 468 | + | // ListBackups returns backups globally or for a domain. | |||
| 469 | + | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |||
| 470 | + | path := "/backups" | |||
| 471 | + | if backupDomain != "" { | |||
| 472 | + | path += "/" + url.PathEscape(backupDomain) | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | resp, err := c.request(ctx, http.MethodGet, path, nil) | |||
| 476 | + | if err != nil { | |||
| 477 | + | return nil, err | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | var result dto.BackupsResponse | |||
| 481 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 482 | + | return nil, err | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | return result.Backups, nil | |||
| 486 | + | } | |||
| 487 | + | ||||
| 488 | + | // BackupStatus returns aggregate backup status. | |||
| 489 | + | func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |||
| 490 | + | resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil) | |||
| 491 | + | if err != nil { | |||
| 492 | + | return nil, err | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | var result dto.BackupsResponse | |||
| 496 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 497 | + | return nil, err | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | return result.Backups, nil | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // RunBackup triggers a backup for a domain. | |||
| 504 | + | func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |||
| 505 | + | if backupDomain == "" { | |||
| 506 | + | return nil, fmt.Errorf("domain cannot be empty") | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName}) | |||
| 510 | + | if err != nil { | |||
| 511 | + | return nil, err | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | var result dto.BackupRunResponse | |||
| 515 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 516 | + | return nil, err | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | return &result, nil | |||
| 520 | + | } | |||
| 521 | + | ||||
| 522 | + | // DetectDatabases detects supported databases for a domain. | |||
| 523 | + | func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |||
| 524 | + | if backupDomain == "" { | |||
| 525 | + | return nil, fmt.Errorf("domain cannot be empty") | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil) | |||
| 529 | + | if err != nil { | |||
| 530 | + | return nil, err | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | var result dto.BackupDetectResponse | |||
| 534 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 535 | + | return nil, err | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | return result.Databases, nil | |||
| 539 | + | } | |||
| 540 | + | ||||
| 466 | // GetHealth returns health status for all routes with HTTP probing. | 541 | // GetHealth returns health status for all routes with HTTP probing. | ||
| 467 | func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) { | 542 | func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) { | ||
| 468 | resp, err := c.request(ctx, http.MethodGet, "/health", nil) | 543 | resp, err := c.request(ctx, http.MethodGet, "/health", nil) | ||
M internal/adapters/in/cli/root.go +4 -0
| @@ -117,6 +117,10 @@ Commands are organized by where they run: | |||
| 117 | 117 | statusCmd.GroupID = groupManage | |
| 118 | 118 | rootCmd.AddCommand(statusCmd) | |
| 119 | 119 | ||
| 120 | + | backupCmd := newBackupCmd() | |
| 121 | + | backupCmd.GroupID = groupManage | |
| 122 | + | rootCmd.AddCommand(backupCmd) | |
| 123 | + | ||
| 120 | 124 | // Client-only commands (no server needed) | |
| 121 | 125 | remotesCmd := newRemotesCmd() | |
| 122 | 126 | remotesCmd.GroupID = groupClient | |
| @@ -117,6 +117,10 @@ Commands are organized by where they run: | |||||
| 117 | statusCmd.GroupID = groupManage | 117 | statusCmd.GroupID = groupManage | ||
| 118 | rootCmd.AddCommand(statusCmd) | 118 | rootCmd.AddCommand(statusCmd) | ||
| 119 | 119 | ||||
| 120 | + | backupCmd := newBackupCmd() | |||
| 121 | + | backupCmd.GroupID = groupManage | |||
| 122 | + | rootCmd.AddCommand(backupCmd) | |||
| 123 | + | ||||
| 120 | // Client-only commands (no server needed) | 124 | // Client-only commands (no server needed) | ||
| 121 | remotesCmd := newRemotesCmd() | 125 | remotesCmd := newRemotesCmd() | ||
| 122 | remotesCmd.GroupID = groupClient | 126 | remotesCmd.GroupID = groupClient | ||
M internal/adapters/in/http/admin/handler.go +178 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "io" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| @@ -30,6 +31,7 @@ type Handler struct { | |||
| 30 | 31 | configSvc in.ConfigService | |
| 31 | 32 | authSvc in.AuthService | |
| 32 | 33 | containerSvc in.ContainerService | |
| 34 | + | backupSvc in.BackupService | |
| 33 | 35 | healthSvc in.HealthService | |
| 34 | 36 | secretSvc in.SecretService | |
| 35 | 37 | logSvc in.LogService | |
| @@ -79,6 +81,34 @@ func toRouteResponse(r domain.Route) dto.Route { | |||
| 79 | 81 | } | |
| 80 | 82 | } | |
| 81 | 83 | ||
| 84 | + | func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { | |
| 85 | + | return dto.BackupJob{ | |
| 86 | + | ID: job.ID, | |
| 87 | + | Domain: job.Domain, | |
| 88 | + | DBName: job.DBName, | |
| 89 | + | Schedule: string(job.Schedule), | |
| 90 | + | Type: string(job.Type), | |
| 91 | + | Status: string(job.Status), | |
| 92 | + | StartedAt: job.StartedAt, | |
| 93 | + | CompletedAt: job.CompletedAt, | |
| 94 | + | SizeBytes: job.SizeBytes, | |
| 95 | + | FilePath: job.FilePath, | |
| 96 | + | Error: job.Error, | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo { | |
| 101 | + | return dto.DatabaseInfo{ | |
| 102 | + | Type: string(db.Type), | |
| 103 | + | Name: db.Name, | |
| 104 | + | Version: db.Version, | |
| 105 | + | Host: db.Host, | |
| 106 | + | Port: db.Port, | |
| 107 | + | ContainerID: db.ContainerID, | |
| 108 | + | ImageName: db.ImageName, | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 82 | 112 | // NewHandler creates a new admin HTTP handler. | |
| 83 | 113 | func NewHandler( | |
| 84 | 114 | configSvc in.ConfigService, | |
| @@ -90,11 +120,18 @@ func NewHandler( | |||
| 90 | 120 | registrySvc in.RegistryService, | |
| 91 | 121 | eventBus out.EventPublisher, | |
| 92 | 122 | log zerowrap.Logger, | |
| 123 | + | backupSvc ...in.BackupService, | |
| 93 | 124 | ) *Handler { | |
| 125 | + | var backup in.BackupService | |
| 126 | + | if len(backupSvc) > 0 { | |
| 127 | + | backup = backupSvc[0] | |
| 128 | + | } | |
| 129 | + | ||
| 94 | 130 | return &Handler{ | |
| 95 | 131 | configSvc: configSvc, | |
| 96 | 132 | authSvc: authSvc, | |
| 97 | 133 | containerSvc: containerSvc, | |
| 134 | + | backupSvc: backup, | |
| 98 | 135 | healthSvc: healthSvc, | |
| 99 | 136 | secretSvc: secretSvc, | |
| 100 | 137 | logSvc: logSvc, | |
| @@ -157,6 +194,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 157 | 194 | prefix string | |
| 158 | 195 | handler routeHandler | |
| 159 | 196 | }{ | |
| 197 | + | {"/backups", h.handleBackups}, | |
| 160 | 198 | {"/attachments", h.handleAttachmentsConfig}, | |
| 161 | 199 | {"/routes", h.handleRoutes}, | |
| 162 | 200 | {"/secrets", h.handleSecrets}, | |
| @@ -718,6 +756,146 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { | |||
| 718 | 756 | h.sendJSON(w, http.StatusOK, status) | |
| 719 | 757 | } | |
| 720 | 758 | ||
| 759 | + | // handleBackups handles /admin/backups endpoints. | |
| 760 | + | func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) { | |
| 761 | + | if h.backupSvc == nil { | |
| 762 | + | h.sendError(w, http.StatusServiceUnavailable, "backup service not available") | |
| 763 | + | return | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | if path == "/backups" || path == "/backups/status" { | |
| 767 | + | h.handleBackupsStatus(w, r) | |
| 768 | + | return | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | suffix := strings.TrimPrefix(path, "/backups/") | |
| 772 | + | parts := strings.Split(suffix, "/") | |
| 773 | + | if len(parts) == 0 || parts[0] == "" { | |
| 774 | + | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 775 | + | return | |
| 776 | + | } | |
| 777 | + | ||
| 778 | + | backupDomain := parts[0] | |
| 779 | + | if len(parts) == 1 { | |
| 780 | + | h.handleBackupsDomain(w, r, backupDomain) | |
| 781 | + | return | |
| 782 | + | } | |
| 783 | + | ||
| 784 | + | if len(parts) == 2 && parts[1] == "detect" { | |
| 785 | + | h.handleBackupsDetect(w, r, backupDomain) | |
| 786 | + | return | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | h.sendError(w, http.StatusNotFound, "route not found") | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) { | |
| 793 | + | ctx := r.Context() | |
| 794 | + | if r.Method != http.MethodGet { | |
| 795 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 796 | + | return | |
| 797 | + | } | |
| 798 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 799 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 800 | + | return | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | jobs, err := h.backupSvc.Status(ctx) | |
| 804 | + | if err != nil { | |
| 805 | + | h.sendError(w, http.StatusInternalServerError, "failed to get backup status") | |
| 806 | + | return | |
| 807 | + | } | |
| 808 | + | h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)}) | |
| 809 | + | } | |
| 810 | + | ||
| 811 | + | func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) { | |
| 812 | + | switch r.Method { | |
| 813 | + | case http.MethodGet: | |
| 814 | + | h.handleBackupsDomainList(w, r, backupDomain) | |
| 815 | + | case http.MethodPost: | |
| 816 | + | h.handleBackupsDomainRun(w, r, backupDomain) | |
| 817 | + | default: | |
| 818 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 819 | + | } | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) { | |
| 823 | + | ctx := r.Context() | |
| 824 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 825 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 826 | + | return | |
| 827 | + | } | |
| 828 | + | ||
| 829 | + | jobs, err := h.backupSvc.ListBackups(ctx, backupDomain) | |
| 830 | + | if err != nil { | |
| 831 | + | h.sendError(w, http.StatusInternalServerError, "failed to list backups") | |
| 832 | + | return | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)}) | |
| 836 | + | } | |
| 837 | + | ||
| 838 | + | func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) { | |
| 839 | + | ctx := r.Context() | |
| 840 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 841 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 842 | + | return | |
| 843 | + | } | |
| 844 | + | ||
| 845 | + | var req dto.BackupRunRequest | |
| 846 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |
| 847 | + | if r.ContentLength > 0 { | |
| 848 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { | |
| 849 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |
| 850 | + | return | |
| 851 | + | } | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB) | |
| 855 | + | if err != nil { | |
| 856 | + | h.sendError(w, http.StatusInternalServerError, err.Error()) | |
| 857 | + | return | |
| 858 | + | } | |
| 859 | + | ||
| 860 | + | h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{ | |
| 861 | + | Status: "completed", | |
| 862 | + | Backup: toBackupJobResponse(result.Job), | |
| 863 | + | }) | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) { | |
| 867 | + | ctx := r.Context() | |
| 868 | + | if r.Method != http.MethodGet { | |
| 869 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 870 | + | return | |
| 871 | + | } | |
| 872 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 873 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 874 | + | return | |
| 875 | + | } | |
| 876 | + | ||
| 877 | + | dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain) | |
| 878 | + | if err != nil { | |
| 879 | + | h.sendError(w, http.StatusInternalServerError, "failed to detect databases") | |
| 880 | + | return | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | response := make([]dto.DatabaseInfo, 0, len(dbs)) | |
| 884 | + | for _, db := range dbs { | |
| 885 | + | response = append(response, toDatabaseInfoResponse(db)) | |
| 886 | + | } | |
| 887 | + | ||
| 888 | + | h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response}) | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob { | |
| 892 | + | response := make([]dto.BackupJob, 0, len(jobs)) | |
| 893 | + | for _, job := range jobs { | |
| 894 | + | response = append(response, toBackupJobResponse(job)) | |
| 895 | + | } | |
| 896 | + | return response | |
| 897 | + | } | |
| 898 | + | ||
| 721 | 899 | // handleReload handles /admin/reload endpoint. | |
| 722 | 900 | // This reloads configuration from file into memory and triggers container sync. | |
| 723 | 901 | func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) { | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "io" | |||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "strconv" | 10 | "strconv" | ||
| 10 | "strings" | 11 | "strings" | ||
| @@ -30,6 +31,7 @@ type Handler struct { | |||||
| 30 | configSvc in.ConfigService | 31 | configSvc in.ConfigService | ||
| 31 | authSvc in.AuthService | 32 | authSvc in.AuthService | ||
| 32 | containerSvc in.ContainerService | 33 | containerSvc in.ContainerService | ||
| 34 | + | backupSvc in.BackupService | |||
| 33 | healthSvc in.HealthService | 35 | healthSvc in.HealthService | ||
| 34 | secretSvc in.SecretService | 36 | secretSvc in.SecretService | ||
| 35 | logSvc in.LogService | 37 | logSvc in.LogService | ||
| @@ -79,6 +81,34 @@ func toRouteResponse(r domain.Route) dto.Route { | |||||
| 79 | } | 81 | } | ||
| 80 | } | 82 | } | ||
| 81 | 83 | ||||
| 84 | + | func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { | |||
| 85 | + | return dto.BackupJob{ | |||
| 86 | + | ID: job.ID, | |||
| 87 | + | Domain: job.Domain, | |||
| 88 | + | DBName: job.DBName, | |||
| 89 | + | Schedule: string(job.Schedule), | |||
| 90 | + | Type: string(job.Type), | |||
| 91 | + | Status: string(job.Status), | |||
| 92 | + | StartedAt: job.StartedAt, | |||
| 93 | + | CompletedAt: job.CompletedAt, | |||
| 94 | + | SizeBytes: job.SizeBytes, | |||
| 95 | + | FilePath: job.FilePath, | |||
| 96 | + | Error: job.Error, | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo { | |||
| 101 | + | return dto.DatabaseInfo{ | |||
| 102 | + | Type: string(db.Type), | |||
| 103 | + | Name: db.Name, | |||
| 104 | + | Version: db.Version, | |||
| 105 | + | Host: db.Host, | |||
| 106 | + | Port: db.Port, | |||
| 107 | + | ContainerID: db.ContainerID, | |||
| 108 | + | ImageName: db.ImageName, | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 82 | // NewHandler creates a new admin HTTP handler. | 112 | // NewHandler creates a new admin HTTP handler. | ||
| 83 | func NewHandler( | 113 | func NewHandler( | ||
| 84 | configSvc in.ConfigService, | 114 | configSvc in.ConfigService, | ||
| @@ -90,11 +120,18 @@ func NewHandler( | |||||
| 90 | registrySvc in.RegistryService, | 120 | registrySvc in.RegistryService, | ||
| 91 | eventBus out.EventPublisher, | 121 | eventBus out.EventPublisher, | ||
| 92 | log zerowrap.Logger, | 122 | log zerowrap.Logger, | ||
| 123 | + | backupSvc ...in.BackupService, | |||
| 93 | ) *Handler { | 124 | ) *Handler { | ||
| 125 | + | var backup in.BackupService | |||
| 126 | + | if len(backupSvc) > 0 { | |||
| 127 | + | backup = backupSvc[0] | |||
| 128 | + | } | |||
| 129 | + | ||||
| 94 | return &Handler{ | 130 | return &Handler{ | ||
| 95 | configSvc: configSvc, | 131 | configSvc: configSvc, | ||
| 96 | authSvc: authSvc, | 132 | authSvc: authSvc, | ||
| 97 | containerSvc: containerSvc, | 133 | containerSvc: containerSvc, | ||
| 134 | + | backupSvc: backup, | |||
| 98 | healthSvc: healthSvc, | 135 | healthSvc: healthSvc, | ||
| 99 | secretSvc: secretSvc, | 136 | secretSvc: secretSvc, | ||
| 100 | logSvc: logSvc, | 137 | logSvc: logSvc, | ||
| @@ -157,6 +194,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 157 | prefix string | 194 | prefix string | ||
| 158 | handler routeHandler | 195 | handler routeHandler | ||
| 159 | }{ | 196 | }{ | ||
| 197 | + | {"/backups", h.handleBackups}, | |||
| 160 | {"/attachments", h.handleAttachmentsConfig}, | 198 | {"/attachments", h.handleAttachmentsConfig}, | ||
| 161 | {"/routes", h.handleRoutes}, | 199 | {"/routes", h.handleRoutes}, | ||
| 162 | {"/secrets", h.handleSecrets}, | 200 | {"/secrets", h.handleSecrets}, | ||
| @@ -718,6 +756,146 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { | |||||
| 718 | h.sendJSON(w, http.StatusOK, status) | 756 | h.sendJSON(w, http.StatusOK, status) | ||
| 719 | } | 757 | } | ||
| 720 | 758 | ||||
| 759 | + | // handleBackups handles /admin/backups endpoints. | |||
| 760 | + | func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) { | |||
| 761 | + | if h.backupSvc == nil { | |||
| 762 | + | h.sendError(w, http.StatusServiceUnavailable, "backup service not available") | |||
| 763 | + | return | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | if path == "/backups" || path == "/backups/status" { | |||
| 767 | + | h.handleBackupsStatus(w, r) | |||
| 768 | + | return | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | suffix := strings.TrimPrefix(path, "/backups/") | |||
| 772 | + | parts := strings.Split(suffix, "/") | |||
| 773 | + | if len(parts) == 0 || parts[0] == "" { | |||
| 774 | + | h.sendError(w, http.StatusBadRequest, "domain required in path") | |||
| 775 | + | return | |||
| 776 | + | } | |||
| 777 | + | ||||
| 778 | + | backupDomain := parts[0] | |||
| 779 | + | if len(parts) == 1 { | |||
| 780 | + | h.handleBackupsDomain(w, r, backupDomain) | |||
| 781 | + | return | |||
| 782 | + | } | |||
| 783 | + | ||||
| 784 | + | if len(parts) == 2 && parts[1] == "detect" { | |||
| 785 | + | h.handleBackupsDetect(w, r, backupDomain) | |||
| 786 | + | return | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | h.sendError(w, http.StatusNotFound, "route not found") | |||
| 790 | + | } | |||
| 791 | + | ||||
| 792 | + | func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) { | |||
| 793 | + | ctx := r.Context() | |||
| 794 | + | if r.Method != http.MethodGet { | |||
| 795 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 796 | + | return | |||
| 797 | + | } | |||
| 798 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 799 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 800 | + | return | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | jobs, err := h.backupSvc.Status(ctx) | |||
| 804 | + | if err != nil { | |||
| 805 | + | h.sendError(w, http.StatusInternalServerError, "failed to get backup status") | |||
| 806 | + | return | |||
| 807 | + | } | |||
| 808 | + | h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)}) | |||
| 809 | + | } | |||
| 810 | + | ||||
| 811 | + | func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) { | |||
| 812 | + | switch r.Method { | |||
| 813 | + | case http.MethodGet: | |||
| 814 | + | h.handleBackupsDomainList(w, r, backupDomain) | |||
| 815 | + | case http.MethodPost: | |||
| 816 | + | h.handleBackupsDomainRun(w, r, backupDomain) | |||
| 817 | + | default: | |||
| 818 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 819 | + | } | |||
| 820 | + | } | |||
| 821 | + | ||||
| 822 | + | func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) { | |||
| 823 | + | ctx := r.Context() | |||
| 824 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 825 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 826 | + | return | |||
| 827 | + | } | |||
| 828 | + | ||||
| 829 | + | jobs, err := h.backupSvc.ListBackups(ctx, backupDomain) | |||
| 830 | + | if err != nil { | |||
| 831 | + | h.sendError(w, http.StatusInternalServerError, "failed to list backups") | |||
| 832 | + | return | |||
| 833 | + | } | |||
| 834 | + | ||||
| 835 | + | h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)}) | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) { | |||
| 839 | + | ctx := r.Context() | |||
| 840 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 841 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 842 | + | return | |||
| 843 | + | } | |||
| 844 | + | ||||
| 845 | + | var req dto.BackupRunRequest | |||
| 846 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 847 | + | if r.ContentLength > 0 { | |||
| 848 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { | |||
| 849 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 850 | + | return | |||
| 851 | + | } | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB) | |||
| 855 | + | if err != nil { | |||
| 856 | + | h.sendError(w, http.StatusInternalServerError, err.Error()) | |||
| 857 | + | return | |||
| 858 | + | } | |||
| 859 | + | ||||
| 860 | + | h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{ | |||
| 861 | + | Status: "completed", | |||
| 862 | + | Backup: toBackupJobResponse(result.Job), | |||
| 863 | + | }) | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) { | |||
| 867 | + | ctx := r.Context() | |||
| 868 | + | if r.Method != http.MethodGet { | |||
| 869 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 870 | + | return | |||
| 871 | + | } | |||
| 872 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 873 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 874 | + | return | |||
| 875 | + | } | |||
| 876 | + | ||||
| 877 | + | dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain) | |||
| 878 | + | if err != nil { | |||
| 879 | + | h.sendError(w, http.StatusInternalServerError, "failed to detect databases") | |||
| 880 | + | return | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | response := make([]dto.DatabaseInfo, 0, len(dbs)) | |||
| 884 | + | for _, db := range dbs { | |||
| 885 | + | response = append(response, toDatabaseInfoResponse(db)) | |||
| 886 | + | } | |||
| 887 | + | ||||
| 888 | + | h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response}) | |||
| 889 | + | } | |||
| 890 | + | ||||
| 891 | + | func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob { | |||
| 892 | + | response := make([]dto.BackupJob, 0, len(jobs)) | |||
| 893 | + | for _, job := range jobs { | |||
| 894 | + | response = append(response, toBackupJobResponse(job)) | |||
| 895 | + | } | |||
| 896 | + | return response | |||
| 897 | + | } | |||
| 898 | + | ||||
| 721 | // handleReload handles /admin/reload endpoint. | 899 | // handleReload handles /admin/reload endpoint. | ||
| 722 | // This reloads configuration from file into memory and triggers container sync. | 900 | // This reloads configuration from file into memory and triggers container sync. | ||
| 723 | func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) { | 901 | func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) { | ||
M internal/adapters/out/docker/runtime.go +61 -0
| @@ -3,6 +3,7 @@ package docker | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "archive/tar" | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "encoding/base64" | |
| 8 | 9 | "encoding/json" | |
| @@ -21,8 +22,10 @@ import ( | |||
| 21 | 22 | "github.com/docker/docker/api/types/registry" | |
| 22 | 23 | "github.com/docker/docker/api/types/volume" | |
| 23 | 24 | "github.com/docker/docker/client" | |
| 25 | + | "github.com/docker/docker/pkg/stdcopy" | |
| 24 | 26 | "github.com/docker/go-connections/nat" | |
| 25 | 27 | ||
| 28 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 26 | 29 | "github.com/bnema/gordon/internal/domain" | |
| 27 | 30 | ) | |
| 28 | 31 | ||
| @@ -1107,6 +1110,64 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN | |||
| 1107 | 1110 | return nil | |
| 1108 | 1111 | } | |
| 1109 | 1112 | ||
| 1113 | + | // ExecInContainer executes a command in a running container. | |
| 1114 | + | func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { | |
| 1115 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1116 | + | zerowrap.FieldLayer: "adapter", | |
| 1117 | + | zerowrap.FieldAdapter: "docker", | |
| 1118 | + | zerowrap.FieldAction: "ExecInContainer", | |
| 1119 | + | zerowrap.FieldEntityID: containerID, | |
| 1120 | + | "command": strings.Join(cmd, " "), | |
| 1121 | + | }) | |
| 1122 | + | log := zerowrap.FromCtx(ctx) | |
| 1123 | + | ||
| 1124 | + | if len(cmd) == 0 { | |
| 1125 | + | return nil, fmt.Errorf("command cannot be empty") | |
| 1126 | + | } | |
| 1127 | + | ||
| 1128 | + | execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{ | |
| 1129 | + | Cmd: cmd, | |
| 1130 | + | AttachStdout: true, | |
| 1131 | + | AttachStderr: true, | |
| 1132 | + | }) | |
| 1133 | + | if err != nil { | |
| 1134 | + | return nil, log.WrapErr(err, "failed to create exec") | |
| 1135 | + | } | |
| 1136 | + | ||
| 1137 | + | attachResp, err := r.client.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) | |
| 1138 | + | if err != nil { | |
| 1139 | + | return nil, log.WrapErr(err, "failed to attach to exec") | |
| 1140 | + | } | |
| 1141 | + | defer attachResp.Close() | |
| 1142 | + | ||
| 1143 | + | stdout, stderr, err := parseExecOutput(attachResp.Reader) | |
| 1144 | + | if err != nil { | |
| 1145 | + | return nil, log.WrapErr(err, "failed to read exec output") | |
| 1146 | + | } | |
| 1147 | + | ||
| 1148 | + | inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID) | |
| 1149 | + | if err != nil { | |
| 1150 | + | return nil, log.WrapErr(err, "failed to inspect exec result") | |
| 1151 | + | } | |
| 1152 | + | ||
| 1153 | + | return &out.ExecResult{ | |
| 1154 | + | ExitCode: inspectResp.ExitCode, | |
| 1155 | + | Stdout: stdout, | |
| 1156 | + | Stderr: stderr, | |
| 1157 | + | }, nil | |
| 1158 | + | } | |
| 1159 | + | ||
| 1160 | + | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | |
| 1161 | + | var stdout bytes.Buffer | |
| 1162 | + | var stderr bytes.Buffer | |
| 1163 | + | ||
| 1164 | + | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | |
| 1165 | + | return nil, nil, err | |
| 1166 | + | } | |
| 1167 | + | ||
| 1168 | + | return stdout.Bytes(), stderr.Bytes(), nil | |
| 1169 | + | } | |
| 1170 | + | ||
| 1110 | 1171 | // CopyFromContainer copies a file from a container to the host. | |
| 1111 | 1172 | // Returns the file contents as a byte slice. | |
| 1112 | 1173 | func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) { | |
| @@ -3,6 +3,7 @@ package docker | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "archive/tar" | 5 | "archive/tar" | ||
| 6 | + | "bytes" | |||
| 6 | "context" | 7 | "context" | ||
| 7 | "encoding/base64" | 8 | "encoding/base64" | ||
| 8 | "encoding/json" | 9 | "encoding/json" | ||
| @@ -21,8 +22,10 @@ import ( | |||||
| 21 | "github.com/docker/docker/api/types/registry" | 22 | "github.com/docker/docker/api/types/registry" | ||
| 22 | "github.com/docker/docker/api/types/volume" | 23 | "github.com/docker/docker/api/types/volume" | ||
| 23 | "github.com/docker/docker/client" | 24 | "github.com/docker/docker/client" | ||
| 25 | + | "github.com/docker/docker/pkg/stdcopy" | |||
| 24 | "github.com/docker/go-connections/nat" | 26 | "github.com/docker/go-connections/nat" | ||
| 25 | 27 | ||||
| 28 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 26 | "github.com/bnema/gordon/internal/domain" | 29 | "github.com/bnema/gordon/internal/domain" | ||
| 27 | ) | 30 | ) | ||
| 28 | 31 | ||||
| @@ -1107,6 +1110,64 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN | |||||
| 1107 | return nil | 1110 | return nil | ||
| 1108 | } | 1111 | } | ||
| 1109 | 1112 | ||||
| 1113 | + | // ExecInContainer executes a command in a running container. | |||
| 1114 | + | func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { | |||
| 1115 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 1116 | + | zerowrap.FieldLayer: "adapter", | |||
| 1117 | + | zerowrap.FieldAdapter: "docker", | |||
| 1118 | + | zerowrap.FieldAction: "ExecInContainer", | |||
| 1119 | + | zerowrap.FieldEntityID: containerID, | |||
| 1120 | + | "command": strings.Join(cmd, " "), | |||
| 1121 | + | }) | |||
| 1122 | + | log := zerowrap.FromCtx(ctx) | |||
| 1123 | + | ||||
| 1124 | + | if len(cmd) == 0 { | |||
| 1125 | + | return nil, fmt.Errorf("command cannot be empty") | |||
| 1126 | + | } | |||
| 1127 | + | ||||
| 1128 | + | execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{ | |||
| 1129 | + | Cmd: cmd, | |||
| 1130 | + | AttachStdout: true, | |||
| 1131 | + | AttachStderr: true, | |||
| 1132 | + | }) | |||
| 1133 | + | if err != nil { | |||
| 1134 | + | return nil, log.WrapErr(err, "failed to create exec") | |||
| 1135 | + | } | |||
| 1136 | + | ||||
| 1137 | + | attachResp, err := r.client.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) | |||
| 1138 | + | if err != nil { | |||
| 1139 | + | return nil, log.WrapErr(err, "failed to attach to exec") | |||
| 1140 | + | } | |||
| 1141 | + | defer attachResp.Close() | |||
| 1142 | + | ||||
| 1143 | + | stdout, stderr, err := parseExecOutput(attachResp.Reader) | |||
| 1144 | + | if err != nil { | |||
| 1145 | + | return nil, log.WrapErr(err, "failed to read exec output") | |||
| 1146 | + | } | |||
| 1147 | + | ||||
| 1148 | + | inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID) | |||
| 1149 | + | if err != nil { | |||
| 1150 | + | return nil, log.WrapErr(err, "failed to inspect exec result") | |||
| 1151 | + | } | |||
| 1152 | + | ||||
| 1153 | + | return &out.ExecResult{ | |||
| 1154 | + | ExitCode: inspectResp.ExitCode, | |||
| 1155 | + | Stdout: stdout, | |||
| 1156 | + | Stderr: stderr, | |||
| 1157 | + | }, nil | |||
| 1158 | + | } | |||
| 1159 | + | ||||
| 1160 | + | func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | |||
| 1161 | + | var stdout bytes.Buffer | |||
| 1162 | + | var stderr bytes.Buffer | |||
| 1163 | + | ||||
| 1164 | + | if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil { | |||
| 1165 | + | return nil, nil, err | |||
| 1166 | + | } | |||
| 1167 | + | ||||
| 1168 | + | return stdout.Bytes(), stderr.Bytes(), nil | |||
| 1169 | + | } | |||
| 1170 | + | ||||
| 1110 | // CopyFromContainer copies a file from a container to the host. | 1171 | // CopyFromContainer copies a file from a container to the host. | ||
| 1111 | // Returns the file contents as a byte slice. | 1172 | // Returns the file contents as a byte slice. | ||
| 1112 | func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) { | 1173 | func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) { | ||
A internal/adapters/out/docker/runtime_exec_test.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | package docker | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |
| 14 | + | stream := append(frameDockerStream(1, []byte("hello\n")), frameDockerStream(2, []byte("warn\n"))...) | |
| 15 | + | ||
| 16 | + | stdout, stderr, err := parseExecOutput(bytes.NewReader(stream)) | |
| 17 | + | ||
| 18 | + | require.NoError(t, err) | |
| 19 | + | assert.Equal(t, []byte("hello\n"), stdout) | |
| 20 | + | assert.Equal(t, []byte("warn\n"), stderr) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | |
| 24 | + | r := &Runtime{} | |
| 25 | + | ||
| 26 | + | result, err := r.ExecInContainer(context.Background(), "abc123", nil) | |
| 27 | + | ||
| 28 | + | require.Error(t, err) | |
| 29 | + | assert.Nil(t, result) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func frameDockerStream(streamID byte, payload []byte) []byte { | |
| 33 | + | frame := make([]byte, 8+len(payload)) | |
| 34 | + | frame[0] = streamID | |
| 35 | + | binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload))) | |
| 36 | + | copy(frame[8:], payload) | |
| 37 | + | return frame | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | package docker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |||
| 14 | + | stream := append(frameDockerStream(1, []byte("hello\n")), frameDockerStream(2, []byte("warn\n"))...) | |||
| 15 | + | ||||
| 16 | + | stdout, stderr, err := parseExecOutput(bytes.NewReader(stream)) | |||
| 17 | + | ||||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | assert.Equal(t, []byte("hello\n"), stdout) | |||
| 20 | + | assert.Equal(t, []byte("warn\n"), stderr) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | |||
| 24 | + | r := &Runtime{} | |||
| 25 | + | ||||
| 26 | + | result, err := r.ExecInContainer(context.Background(), "abc123", nil) | |||
| 27 | + | ||||
| 28 | + | require.Error(t, err) | |||
| 29 | + | assert.Nil(t, result) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func frameDockerStream(streamID byte, payload []byte) []byte { | |||
| 33 | + | frame := make([]byte, 8+len(payload)) | |||
| 34 | + | frame[0] = streamID | |||
| 35 | + | binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload))) | |||
| 36 | + | copy(frame[8:], payload) | |||
| 37 | + | return frame | |||
| 38 | + | } | |||
A internal/adapters/out/filesystem/backup_storage.go +242 -0
| @@ -0,0 +1,242 @@ | |||
| 1 | + | package filesystem | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sort" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | backupDirName = "backups" | |
| 20 | + | backupTimestampLayout = "20060102T150405Z" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // BackupStorage implements backup artifact persistence on local filesystem. | |
| 24 | + | type BackupStorage struct { | |
| 25 | + | rootDir string | |
| 26 | + | log zerowrap.Logger | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // NewBackupStorage creates a new filesystem backup storage. | |
| 30 | + | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | |
| 31 | + | backupRoot := filepath.Join(rootDir, backupDirName) | |
| 32 | + | if err := os.MkdirAll(backupRoot, 0750); err != nil { | |
| 33 | + | return nil, fmt.Errorf("failed to create backup directory: %w", err) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | return &BackupStorage{rootDir: rootDir, log: log}, nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Store saves backup data and returns the absolute storage path. | |
| 40 | + | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | |
| 41 | + | domainPart := sanitizeBackupPathComponent(domainName) | |
| 42 | + | dbPart := sanitizeBackupPathComponent(dbName) | |
| 43 | + | schedulePart := string(schedule) | |
| 44 | + | if schedulePart == "" { | |
| 45 | + | schedulePart = "manual" | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | backupDir := filepath.Join(s.rootDir, backupDirName, domainPart, dbPart, schedulePart) | |
| 49 | + | if err := os.MkdirAll(backupDir, 0750); err != nil { | |
| 50 | + | return "", fmt.Errorf("failed to create backup path: %w", err) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak" | |
| 54 | + | finalPath := filepath.Join(backupDir, fileName) | |
| 55 | + | tmpPath := finalPath + ".tmp" | |
| 56 | + | ||
| 57 | + | f, err := os.Create(tmpPath) | |
| 58 | + | if err != nil { | |
| 59 | + | return "", fmt.Errorf("failed to create temp backup file: %w", err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | if _, err := io.Copy(f, data); err != nil { | |
| 63 | + | _ = f.Close() | |
| 64 | + | _ = os.Remove(tmpPath) | |
| 65 | + | return "", fmt.Errorf("failed to write backup data: %w", err) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | if err := f.Close(); err != nil { | |
| 69 | + | _ = os.Remove(tmpPath) | |
| 70 | + | return "", fmt.Errorf("failed to close temp backup file: %w", err) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | if err := os.Rename(tmpPath, finalPath); err != nil { | |
| 74 | + | _ = os.Remove(tmpPath) | |
| 75 | + | return "", fmt.Errorf("failed to finalize backup file: %w", err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return finalPath, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Get retrieves a backup file by path. | |
| 82 | + | func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) { | |
| 83 | + | if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) { | |
| 84 | + | return nil, fmt.Errorf("backup path escapes storage root") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | f, err := os.Open(path) | |
| 88 | + | if err != nil { | |
| 89 | + | return nil, err | |
| 90 | + | } | |
| 91 | + | return f, nil | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // List returns backups for a domain, optionally filtered by schedule. | |
| 95 | + | func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) { | |
| 96 | + | domainRoot := filepath.Join(s.rootDir, backupDirName, sanitizeBackupPathComponent(domainName)) | |
| 97 | + | if _, err := os.Stat(domainRoot); err != nil { | |
| 98 | + | if os.IsNotExist(err) { | |
| 99 | + | return []domain.BackupJob{}, nil | |
| 100 | + | } | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | jobs := make([]domain.BackupJob, 0) | |
| 105 | + | err := filepath.WalkDir(domainRoot, func(path string, d os.DirEntry, walkErr error) error { | |
| 106 | + | if walkErr != nil { | |
| 107 | + | return walkErr | |
| 108 | + | } | |
| 109 | + | if d.IsDir() { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | rel, err := filepath.Rel(domainRoot, path) | |
| 114 | + | if err != nil { | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | parts := strings.Split(rel, string(filepath.Separator)) | |
| 118 | + | if len(parts) != 3 { | |
| 119 | + | return nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | dbName := parts[0] | |
| 123 | + | sched := domain.BackupSchedule(parts[1]) | |
| 124 | + | if schedule != nil && *schedule != sched { | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | info, err := d.Info() | |
| 129 | + | if err != nil { | |
| 130 | + | return nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | startedAt := info.ModTime().UTC() | |
| 134 | + | base := filepath.Base(path) | |
| 135 | + | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | |
| 136 | + | startedAt = ts.UTC() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | jobs = append(jobs, domain.BackupJob{ | |
| 140 | + | ID: base, | |
| 141 | + | Domain: domainName, | |
| 142 | + | DBName: dbName, | |
| 143 | + | Schedule: sched, | |
| 144 | + | Type: domain.BackupTypeLogical, | |
| 145 | + | Status: domain.BackupStatusCompleted, | |
| 146 | + | StartedAt: startedAt, | |
| 147 | + | SizeBytes: info.Size(), | |
| 148 | + | FilePath: path, | |
| 149 | + | }) | |
| 150 | + | ||
| 151 | + | return nil | |
| 152 | + | }) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, err | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | sort.Slice(jobs, func(i, j int) bool { | |
| 158 | + | return jobs[i].StartedAt.After(jobs[j].StartedAt) | |
| 159 | + | }) | |
| 160 | + | ||
| 161 | + | return jobs, nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Delete removes a backup file. | |
| 165 | + | func (s *BackupStorage) Delete(_ context.Context, path string) error { | |
| 166 | + | if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) { | |
| 167 | + | return fmt.Errorf("backup path escapes storage root") | |
| 168 | + | } | |
| 169 | + | return os.Remove(path) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // ApplyRetention removes old backups according to the schedule policy. | |
| 173 | + | func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) { | |
| 174 | + | jobs, err := s.List(ctx, domainName, nil) | |
| 175 | + | if err != nil { | |
| 176 | + | return 0, err | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | groups := make(map[string][]domain.BackupJob) | |
| 180 | + | for _, job := range jobs { | |
| 181 | + | key := fmt.Sprintf("%s|%s", job.DBName, job.Schedule) | |
| 182 | + | groups[key] = append(groups[key], job) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | deleted := 0 | |
| 186 | + | for _, group := range groups { | |
| 187 | + | sort.Slice(group, func(i, j int) bool { | |
| 188 | + | return group[i].StartedAt.After(group[j].StartedAt) | |
| 189 | + | }) | |
| 190 | + | ||
| 191 | + | keep := retentionKeepCount(policy, group[0].Schedule) | |
| 192 | + | if keep < 0 { | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | for idx := keep; idx < len(group); idx++ { | |
| 197 | + | if err := s.Delete(ctx, group[idx].FilePath); err != nil { | |
| 198 | + | if os.IsNotExist(err) { | |
| 199 | + | continue | |
| 200 | + | } | |
| 201 | + | return deleted, err | |
| 202 | + | } | |
| 203 | + | deleted++ | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | return deleted, nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int { | |
| 211 | + | switch schedule { | |
| 212 | + | case domain.ScheduleHourly: | |
| 213 | + | return policy.Hourly | |
| 214 | + | case domain.ScheduleDaily: | |
| 215 | + | return policy.Daily | |
| 216 | + | case domain.ScheduleWeekly: | |
| 217 | + | return policy.Weekly | |
| 218 | + | case domain.ScheduleMonthly: | |
| 219 | + | return policy.Monthly | |
| 220 | + | default: | |
| 221 | + | return -1 | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func sanitizeBackupPathComponent(input string) string { | |
| 226 | + | clean := strings.TrimSpace(input) | |
| 227 | + | if clean == "" { | |
| 228 | + | return "unknown" | |
| 229 | + | } | |
| 230 | + | replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") | |
| 231 | + | return replacer.Replace(clean) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func pathWithinRoot(root, path string) bool { | |
| 235 | + | rootClean := filepath.Clean(root) | |
| 236 | + | pathClean := filepath.Clean(path) | |
| 237 | + | rel, err := filepath.Rel(rootClean, pathClean) | |
| 238 | + | if err != nil { | |
| 239 | + | return false | |
| 240 | + | } | |
| 241 | + | return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 242 | + | } | |
| @@ -0,0 +1,242 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "sort" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | backupDirName = "backups" | |||
| 20 | + | backupTimestampLayout = "20060102T150405Z" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // BackupStorage implements backup artifact persistence on local filesystem. | |||
| 24 | + | type BackupStorage struct { | |||
| 25 | + | rootDir string | |||
| 26 | + | log zerowrap.Logger | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // NewBackupStorage creates a new filesystem backup storage. | |||
| 30 | + | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | |||
| 31 | + | backupRoot := filepath.Join(rootDir, backupDirName) | |||
| 32 | + | if err := os.MkdirAll(backupRoot, 0750); err != nil { | |||
| 33 | + | return nil, fmt.Errorf("failed to create backup directory: %w", err) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | return &BackupStorage{rootDir: rootDir, log: log}, nil | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Store saves backup data and returns the absolute storage path. | |||
| 40 | + | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | |||
| 41 | + | domainPart := sanitizeBackupPathComponent(domainName) | |||
| 42 | + | dbPart := sanitizeBackupPathComponent(dbName) | |||
| 43 | + | schedulePart := string(schedule) | |||
| 44 | + | if schedulePart == "" { | |||
| 45 | + | schedulePart = "manual" | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | backupDir := filepath.Join(s.rootDir, backupDirName, domainPart, dbPart, schedulePart) | |||
| 49 | + | if err := os.MkdirAll(backupDir, 0750); err != nil { | |||
| 50 | + | return "", fmt.Errorf("failed to create backup path: %w", err) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak" | |||
| 54 | + | finalPath := filepath.Join(backupDir, fileName) | |||
| 55 | + | tmpPath := finalPath + ".tmp" | |||
| 56 | + | ||||
| 57 | + | f, err := os.Create(tmpPath) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return "", fmt.Errorf("failed to create temp backup file: %w", err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | if _, err := io.Copy(f, data); err != nil { | |||
| 63 | + | _ = f.Close() | |||
| 64 | + | _ = os.Remove(tmpPath) | |||
| 65 | + | return "", fmt.Errorf("failed to write backup data: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | if err := f.Close(); err != nil { | |||
| 69 | + | _ = os.Remove(tmpPath) | |||
| 70 | + | return "", fmt.Errorf("failed to close temp backup file: %w", err) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | if err := os.Rename(tmpPath, finalPath); err != nil { | |||
| 74 | + | _ = os.Remove(tmpPath) | |||
| 75 | + | return "", fmt.Errorf("failed to finalize backup file: %w", err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return finalPath, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // Get retrieves a backup file by path. | |||
| 82 | + | func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) { | |||
| 83 | + | if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) { | |||
| 84 | + | return nil, fmt.Errorf("backup path escapes storage root") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | f, err := os.Open(path) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return nil, err | |||
| 90 | + | } | |||
| 91 | + | return f, nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // List returns backups for a domain, optionally filtered by schedule. | |||
| 95 | + | func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) { | |||
| 96 | + | domainRoot := filepath.Join(s.rootDir, backupDirName, sanitizeBackupPathComponent(domainName)) | |||
| 97 | + | if _, err := os.Stat(domainRoot); err != nil { | |||
| 98 | + | if os.IsNotExist(err) { | |||
| 99 | + | return []domain.BackupJob{}, nil | |||
| 100 | + | } | |||
| 101 | + | return nil, err | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | jobs := make([]domain.BackupJob, 0) | |||
| 105 | + | err := filepath.WalkDir(domainRoot, func(path string, d os.DirEntry, walkErr error) error { | |||
| 106 | + | if walkErr != nil { | |||
| 107 | + | return walkErr | |||
| 108 | + | } | |||
| 109 | + | if d.IsDir() { | |||
| 110 | + | return nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | rel, err := filepath.Rel(domainRoot, path) | |||
| 114 | + | if err != nil { | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | parts := strings.Split(rel, string(filepath.Separator)) | |||
| 118 | + | if len(parts) != 3 { | |||
| 119 | + | return nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | dbName := parts[0] | |||
| 123 | + | sched := domain.BackupSchedule(parts[1]) | |||
| 124 | + | if schedule != nil && *schedule != sched { | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | info, err := d.Info() | |||
| 129 | + | if err != nil { | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | startedAt := info.ModTime().UTC() | |||
| 134 | + | base := filepath.Base(path) | |||
| 135 | + | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | |||
| 136 | + | startedAt = ts.UTC() | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | jobs = append(jobs, domain.BackupJob{ | |||
| 140 | + | ID: base, | |||
| 141 | + | Domain: domainName, | |||
| 142 | + | DBName: dbName, | |||
| 143 | + | Schedule: sched, | |||
| 144 | + | Type: domain.BackupTypeLogical, | |||
| 145 | + | Status: domain.BackupStatusCompleted, | |||
| 146 | + | StartedAt: startedAt, | |||
| 147 | + | SizeBytes: info.Size(), | |||
| 148 | + | FilePath: path, | |||
| 149 | + | }) | |||
| 150 | + | ||||
| 151 | + | return nil | |||
| 152 | + | }) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | sort.Slice(jobs, func(i, j int) bool { | |||
| 158 | + | return jobs[i].StartedAt.After(jobs[j].StartedAt) | |||
| 159 | + | }) | |||
| 160 | + | ||||
| 161 | + | return jobs, nil | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Delete removes a backup file. | |||
| 165 | + | func (s *BackupStorage) Delete(_ context.Context, path string) error { | |||
| 166 | + | if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) { | |||
| 167 | + | return fmt.Errorf("backup path escapes storage root") | |||
| 168 | + | } | |||
| 169 | + | return os.Remove(path) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // ApplyRetention removes old backups according to the schedule policy. | |||
| 173 | + | func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) { | |||
| 174 | + | jobs, err := s.List(ctx, domainName, nil) | |||
| 175 | + | if err != nil { | |||
| 176 | + | return 0, err | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | groups := make(map[string][]domain.BackupJob) | |||
| 180 | + | for _, job := range jobs { | |||
| 181 | + | key := fmt.Sprintf("%s|%s", job.DBName, job.Schedule) | |||
| 182 | + | groups[key] = append(groups[key], job) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | deleted := 0 | |||
| 186 | + | for _, group := range groups { | |||
| 187 | + | sort.Slice(group, func(i, j int) bool { | |||
| 188 | + | return group[i].StartedAt.After(group[j].StartedAt) | |||
| 189 | + | }) | |||
| 190 | + | ||||
| 191 | + | keep := retentionKeepCount(policy, group[0].Schedule) | |||
| 192 | + | if keep < 0 { | |||
| 193 | + | continue | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | for idx := keep; idx < len(group); idx++ { | |||
| 197 | + | if err := s.Delete(ctx, group[idx].FilePath); err != nil { | |||
| 198 | + | if os.IsNotExist(err) { | |||
| 199 | + | continue | |||
| 200 | + | } | |||
| 201 | + | return deleted, err | |||
| 202 | + | } | |||
| 203 | + | deleted++ | |||
| 204 | + | } | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | return deleted, nil | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int { | |||
| 211 | + | switch schedule { | |||
| 212 | + | case domain.ScheduleHourly: | |||
| 213 | + | return policy.Hourly | |||
| 214 | + | case domain.ScheduleDaily: | |||
| 215 | + | return policy.Daily | |||
| 216 | + | case domain.ScheduleWeekly: | |||
| 217 | + | return policy.Weekly | |||
| 218 | + | case domain.ScheduleMonthly: | |||
| 219 | + | return policy.Monthly | |||
| 220 | + | default: | |||
| 221 | + | return -1 | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func sanitizeBackupPathComponent(input string) string { | |||
| 226 | + | clean := strings.TrimSpace(input) | |||
| 227 | + | if clean == "" { | |||
| 228 | + | return "unknown" | |||
| 229 | + | } | |||
| 230 | + | replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") | |||
| 231 | + | return replacer.Replace(clean) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func pathWithinRoot(root, path string) bool { | |||
| 235 | + | rootClean := filepath.Clean(root) | |||
| 236 | + | pathClean := filepath.Clean(path) | |||
| 237 | + | rel, err := filepath.Rel(rootClean, pathClean) | |||
| 238 | + | if err != nil { | |||
| 239 | + | return false | |||
| 240 | + | } | |||
| 241 | + | return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |||
| 242 | + | } | |||
A internal/adapters/out/filesystem/backup_storage_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package filesystem | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestBackupStorage_StoreAndGet(t *testing.T) { | |
| 15 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | ||
| 18 | + | now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |
| 19 | + | payload := []byte("backup-content") | |
| 20 | + | path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(payload)) | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | ||
| 23 | + | rc, err := storage.Get(context.Background(), path) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | defer rc.Close() | |
| 26 | + | ||
| 27 | + | data := make([]byte, len(payload)) | |
| 28 | + | _, err = rc.Read(data) | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | assert.Equal(t, payload, data) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | |
| 34 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | ||
| 37 | + | base := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC) | |
| 38 | + | _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base, bytes.NewReader([]byte("d1"))) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Hour), bytes.NewReader([]byte("d2"))) | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleWeekly, base.Add(2*time.Hour), bytes.NewReader([]byte("w1"))) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | ||
| 45 | + | schedule := domain.ScheduleDaily | |
| 46 | + | jobs, err := storage.List(context.Background(), "app.example.com", &schedule) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Len(t, jobs, 2) | |
| 49 | + | assert.Equal(t, domain.ScheduleDaily, jobs[0].Schedule) | |
| 50 | + | assert.Equal(t, domain.ScheduleDaily, jobs[1].Schedule) | |
| 51 | + | assert.True(t, jobs[0].StartedAt.After(jobs[1].StartedAt)) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestBackupStorage_Delete(t *testing.T) { | |
| 55 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | ||
| 58 | + | path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data"))) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | ||
| 61 | + | err = storage.Delete(context.Background(), path) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | ||
| 64 | + | _, err = storage.Get(context.Background(), path) | |
| 65 | + | assert.Error(t, err) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestBackupStorage_ApplyRetention(t *testing.T) { | |
| 69 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | ||
| 72 | + | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | |
| 73 | + | for i := 0; i < 4; i++ { | |
| 74 | + | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | deleted, err := storage.ApplyRetention(context.Background(), "app.example.com", domain.RetentionPolicy{Daily: 2}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | assert.Equal(t, 2, deleted) | |
| 81 | + | ||
| 82 | + | schedule := domain.ScheduleDaily | |
| 83 | + | jobs, err := storage.List(context.Background(), "app.example.com", &schedule) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | assert.Len(t, jobs, 2) | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestBackupStorage_StoreAndGet(t *testing.T) { | |||
| 15 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | ||||
| 18 | + | now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |||
| 19 | + | payload := []byte("backup-content") | |||
| 20 | + | path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(payload)) | |||
| 21 | + | require.NoError(t, err) | |||
| 22 | + | ||||
| 23 | + | rc, err := storage.Get(context.Background(), path) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | defer rc.Close() | |||
| 26 | + | ||||
| 27 | + | data := make([]byte, len(payload)) | |||
| 28 | + | _, err = rc.Read(data) | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | assert.Equal(t, payload, data) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | |||
| 34 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | ||||
| 37 | + | base := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC) | |||
| 38 | + | _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base, bytes.NewReader([]byte("d1"))) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Hour), bytes.NewReader([]byte("d2"))) | |||
| 41 | + | require.NoError(t, err) | |||
| 42 | + | _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleWeekly, base.Add(2*time.Hour), bytes.NewReader([]byte("w1"))) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | ||||
| 45 | + | schedule := domain.ScheduleDaily | |||
| 46 | + | jobs, err := storage.List(context.Background(), "app.example.com", &schedule) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Len(t, jobs, 2) | |||
| 49 | + | assert.Equal(t, domain.ScheduleDaily, jobs[0].Schedule) | |||
| 50 | + | assert.Equal(t, domain.ScheduleDaily, jobs[1].Schedule) | |||
| 51 | + | assert.True(t, jobs[0].StartedAt.After(jobs[1].StartedAt)) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestBackupStorage_Delete(t *testing.T) { | |||
| 55 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | ||||
| 58 | + | path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data"))) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | ||||
| 61 | + | err = storage.Delete(context.Background(), path) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | ||||
| 64 | + | _, err = storage.Get(context.Background(), path) | |||
| 65 | + | assert.Error(t, err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestBackupStorage_ApplyRetention(t *testing.T) { | |||
| 69 | + | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | ||||
| 72 | + | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | |||
| 73 | + | for i := 0; i < 4; i++ { | |||
| 74 | + | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | deleted, err := storage.ApplyRetention(context.Background(), "app.example.com", domain.RetentionPolicy{Daily: 2}) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | assert.Equal(t, 2, deleted) | |||
| 81 | + | ||||
| 82 | + | schedule := domain.ScheduleDaily | |||
| 83 | + | jobs, err := storage.List(context.Background(), "app.example.com", &schedule) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | assert.Len(t, jobs, 2) | |||
| 86 | + | } | |||
M internal/app/run.go +56 -1
| @@ -54,6 +54,7 @@ import ( | |||
| 54 | 54 | ||
| 55 | 55 | // Use cases | |
| 56 | 56 | "github.com/bnema/gordon/internal/usecase/auth" | |
| 57 | + | "github.com/bnema/gordon/internal/usecase/backup" | |
| 57 | 58 | "github.com/bnema/gordon/internal/usecase/config" | |
| 58 | 59 | "github.com/bnema/gordon/internal/usecase/container" | |
| 59 | 60 | "github.com/bnema/gordon/internal/usecase/health" | |
| @@ -129,6 +130,17 @@ type Config struct { | |||
| 129 | 130 | TrustedProxies []string `mapstructure:"trusted_proxies"` | |
| 130 | 131 | } `mapstructure:"rate_limit"` | |
| 131 | 132 | } `mapstructure:"api"` | |
| 133 | + | ||
| 134 | + | Backups struct { | |
| 135 | + | Enabled bool `mapstructure:"enabled"` | |
| 136 | + | StorageDir string `mapstructure:"storage_dir"` | |
| 137 | + | Retention struct { | |
| 138 | + | Hourly int `mapstructure:"hourly"` | |
| 139 | + | Daily int `mapstructure:"daily"` | |
| 140 | + | Weekly int `mapstructure:"weekly"` | |
| 141 | + | Monthly int `mapstructure:"monthly"` | |
| 142 | + | } `mapstructure:"retention"` | |
| 143 | + | } `mapstructure:"backups"` | |
| 132 | 144 | } | |
| 133 | 145 | ||
| 134 | 146 | // services holds all the services used by the application. | |
| @@ -137,11 +149,13 @@ type services struct { | |||
| 137 | 149 | eventBus *eventbus.InMemory | |
| 138 | 150 | blobStorage *filesystem.BlobStorage | |
| 139 | 151 | manifestStorage *filesystem.ManifestStorage | |
| 152 | + | backupStorage *filesystem.BackupStorage | |
| 140 | 153 | envLoader out.EnvLoader | |
| 141 | 154 | logWriter *logwriter.LogWriter | |
| 142 | 155 | tokenStore out.TokenStore | |
| 143 | 156 | configSvc *config.Service | |
| 144 | 157 | containerSvc *container.Service | |
| 158 | + | backupSvc *backup.Service | |
| 145 | 159 | registrySvc *registrySvc.Service | |
| 146 | 160 | proxySvc *proxy.Service | |
| 147 | 161 | authSvc *auth.Service | |
| @@ -432,6 +446,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 432 | 446 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 433 | 447 | return nil, err | |
| 434 | 448 | } | |
| 449 | + | ||
| 450 | + | if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil { | |
| 451 | + | return nil, err | |
| 452 | + | } | |
| 453 | + | ||
| 435 | 454 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 436 | 455 | ||
| 437 | 456 | proxyCfg, err := buildProxyConfig(cfg, log) | |
| @@ -458,7 +477,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 458 | 477 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | |
| 459 | 478 | ||
| 460 | 479 | // Create admin handler for admin API | |
| 461 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log) | |
| 480 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) | |
| 462 | 481 | ||
| 463 | 482 | return svc, nil | |
| 464 | 483 | } | |
| @@ -1060,6 +1079,42 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1060 | 1079 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | |
| 1061 | 1080 | } | |
| 1062 | 1081 | ||
| 1082 | + | func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) { | |
| 1083 | + | if !cfg.Backups.Enabled { | |
| 1084 | + | return nil, nil, nil | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | storageDir := cfg.Backups.StorageDir | |
| 1088 | + | if storageDir == "" { | |
| 1089 | + | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 1090 | + | storageDir = filepath.Join(dataDir, "backups") | |
| 1091 | + | } | |
| 1092 | + | ||
| 1093 | + | backupStorage, err := filesystem.NewBackupStorage(storageDir, log) | |
| 1094 | + | if err != nil { | |
| 1095 | + | return nil, nil, log.WrapErr(err, "failed to create backup storage") | |
| 1096 | + | } | |
| 1097 | + | ||
| 1098 | + | backupCfg := domain.BackupConfig{ | |
| 1099 | + | Enabled: cfg.Backups.Enabled, | |
| 1100 | + | StorageDir: storageDir, | |
| 1101 | + | Retention: domain.RetentionPolicy{ | |
| 1102 | + | Hourly: cfg.Backups.Retention.Hourly, | |
| 1103 | + | Daily: cfg.Backups.Retention.Daily, | |
| 1104 | + | Weekly: cfg.Backups.Retention.Weekly, | |
| 1105 | + | Monthly: cfg.Backups.Retention.Monthly, | |
| 1106 | + | }, | |
| 1107 | + | } | |
| 1108 | + | ||
| 1109 | + | backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log) | |
| 1110 | + | ||
| 1111 | + | log.Info(). | |
| 1112 | + | Str("storage_dir", storageDir). | |
| 1113 | + | Msg("backup service initialized") | |
| 1114 | + | ||
| 1115 | + | return backupStorage, backupSvc, nil | |
| 1116 | + | } | |
| 1117 | + | ||
| 1063 | 1118 | // registerEventHandlers registers all event handlers. | |
| 1064 | 1119 | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { | |
| 1065 | 1120 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | |
| @@ -54,6 +54,7 @@ import ( | |||||
| 54 | 54 | ||||
| 55 | // Use cases | 55 | // Use cases | ||
| 56 | "github.com/bnema/gordon/internal/usecase/auth" | 56 | "github.com/bnema/gordon/internal/usecase/auth" | ||
| 57 | + | "github.com/bnema/gordon/internal/usecase/backup" | |||
| 57 | "github.com/bnema/gordon/internal/usecase/config" | 58 | "github.com/bnema/gordon/internal/usecase/config" | ||
| 58 | "github.com/bnema/gordon/internal/usecase/container" | 59 | "github.com/bnema/gordon/internal/usecase/container" | ||
| 59 | "github.com/bnema/gordon/internal/usecase/health" | 60 | "github.com/bnema/gordon/internal/usecase/health" | ||
| @@ -129,6 +130,17 @@ type Config struct { | |||||
| 129 | TrustedProxies []string `mapstructure:"trusted_proxies"` | 130 | TrustedProxies []string `mapstructure:"trusted_proxies"` | ||
| 130 | } `mapstructure:"rate_limit"` | 131 | } `mapstructure:"rate_limit"` | ||
| 131 | } `mapstructure:"api"` | 132 | } `mapstructure:"api"` | ||
| 133 | + | ||||
| 134 | + | Backups struct { | |||
| 135 | + | Enabled bool `mapstructure:"enabled"` | |||
| 136 | + | StorageDir string `mapstructure:"storage_dir"` | |||
| 137 | + | Retention struct { | |||
| 138 | + | Hourly int `mapstructure:"hourly"` | |||
| 139 | + | Daily int `mapstructure:"daily"` | |||
| 140 | + | Weekly int `mapstructure:"weekly"` | |||
| 141 | + | Monthly int `mapstructure:"monthly"` | |||
| 142 | + | } `mapstructure:"retention"` | |||
| 143 | + | } `mapstructure:"backups"` | |||
| 132 | } | 144 | } | ||
| 133 | 145 | ||||
| 134 | // services holds all the services used by the application. | 146 | // services holds all the services used by the application. | ||
| @@ -137,11 +149,13 @@ type services struct { | |||||
| 137 | eventBus *eventbus.InMemory | 149 | eventBus *eventbus.InMemory | ||
| 138 | blobStorage *filesystem.BlobStorage | 150 | blobStorage *filesystem.BlobStorage | ||
| 139 | manifestStorage *filesystem.ManifestStorage | 151 | manifestStorage *filesystem.ManifestStorage | ||
| 152 | + | backupStorage *filesystem.BackupStorage | |||
| 140 | envLoader out.EnvLoader | 153 | envLoader out.EnvLoader | ||
| 141 | logWriter *logwriter.LogWriter | 154 | logWriter *logwriter.LogWriter | ||
| 142 | tokenStore out.TokenStore | 155 | tokenStore out.TokenStore | ||
| 143 | configSvc *config.Service | 156 | configSvc *config.Service | ||
| 144 | containerSvc *container.Service | 157 | containerSvc *container.Service | ||
| 158 | + | backupSvc *backup.Service | |||
| 145 | registrySvc *registrySvc.Service | 159 | registrySvc *registrySvc.Service | ||
| 146 | proxySvc *proxy.Service | 160 | proxySvc *proxy.Service | ||
| 147 | authSvc *auth.Service | 161 | authSvc *auth.Service | ||
| @@ -432,6 +446,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 432 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | 446 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | ||
| 433 | return nil, err | 447 | return nil, err | ||
| 434 | } | 448 | } | ||
| 449 | + | ||||
| 450 | + | if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil { | |||
| 451 | + | return nil, err | |||
| 452 | + | } | |||
| 453 | + | ||||
| 435 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 454 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | ||
| 436 | 455 | ||||
| 437 | proxyCfg, err := buildProxyConfig(cfg, log) | 456 | proxyCfg, err := buildProxyConfig(cfg, log) | ||
| @@ -458,7 +477,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 458 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | 477 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | ||
| 459 | 478 | ||||
| 460 | // Create admin handler for admin API | 479 | // Create admin handler for admin API | ||
| 461 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log) | 480 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) |
| 462 | 481 | ||||
| 463 | return svc, nil | 482 | return svc, nil | ||
| 464 | } | 483 | } | ||
| @@ -1060,6 +1079,42 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||||
| 1060 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | 1079 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | ||
| 1061 | } | 1080 | } | ||
| 1062 | 1081 | ||||
| 1082 | + | func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) { | |||
| 1083 | + | if !cfg.Backups.Enabled { | |||
| 1084 | + | return nil, nil, nil | |||
| 1085 | + | } | |||
| 1086 | + | ||||
| 1087 | + | storageDir := cfg.Backups.StorageDir | |||
| 1088 | + | if storageDir == "" { | |||
| 1089 | + | dataDir := resolveDataDir(cfg.Server.DataDir) | |||
| 1090 | + | storageDir = filepath.Join(dataDir, "backups") | |||
| 1091 | + | } | |||
| 1092 | + | ||||
| 1093 | + | backupStorage, err := filesystem.NewBackupStorage(storageDir, log) | |||
| 1094 | + | if err != nil { | |||
| 1095 | + | return nil, nil, log.WrapErr(err, "failed to create backup storage") | |||
| 1096 | + | } | |||
| 1097 | + | ||||
| 1098 | + | backupCfg := domain.BackupConfig{ | |||
| 1099 | + | Enabled: cfg.Backups.Enabled, | |||
| 1100 | + | StorageDir: storageDir, | |||
| 1101 | + | Retention: domain.RetentionPolicy{ | |||
| 1102 | + | Hourly: cfg.Backups.Retention.Hourly, | |||
| 1103 | + | Daily: cfg.Backups.Retention.Daily, | |||
| 1104 | + | Weekly: cfg.Backups.Retention.Weekly, | |||
| 1105 | + | Monthly: cfg.Backups.Retention.Monthly, | |||
| 1106 | + | }, | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | backupSvc := backup.NewService(svc.runtime, backupStorage, svc.containerSvc, backupCfg, log) | |||
| 1110 | + | ||||
| 1111 | + | log.Info(). | |||
| 1112 | + | Str("storage_dir", storageDir). | |||
| 1113 | + | Msg("backup service initialized") | |||
| 1114 | + | ||||
| 1115 | + | return backupStorage, backupSvc, nil | |||
| 1116 | + | } | |||
| 1117 | + | ||||
| 1063 | // registerEventHandlers registers all event handlers. | 1118 | // registerEventHandlers registers all event handlers. | ||
| 1064 | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { | 1119 | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { | ||
| 1065 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | 1120 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | ||
A internal/boundaries/in/backup.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package in | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 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) | |
| 14 | + | Restore(ctx context.Context, domainName, backupID string) error | |
| 15 | + | RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error | |
| 16 | + | Status(ctx context.Context) ([]domain.BackupJob, error) | |
| 17 | + | DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package in | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 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) | |||
| 14 | + | Restore(ctx context.Context, domainName, backupID string) error | |||
| 15 | + | RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error | |||
| 16 | + | Status(ctx context.Context) ([]domain.BackupJob, error) | |||
| 17 | + | DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) | |||
| 18 | + | } | |||
A internal/boundaries/in/mocks/mock_backup_service.go +438 -0
| @@ -0,0 +1,438 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewMockBackupService creates a new instance of MockBackupService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewMockBackupService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MockBackupService { | |
| 21 | + | mock := &MockBackupService{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 25 | + | ||
| 26 | + | return mock | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // MockBackupService is an autogenerated mock type for the BackupService type | |
| 30 | + | type MockBackupService struct { | |
| 31 | + | mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type MockBackupService_Expecter struct { | |
| 35 | + | mock *mock.Mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (_m *MockBackupService) EXPECT() *MockBackupService_Expecter { | |
| 39 | + | return &MockBackupService_Expecter{mock: &_m.Mock} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // DetectDatabases provides a mock function for the type MockBackupService | |
| 43 | + | func (_mock *MockBackupService) DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) { | |
| 44 | + | ret := _mock.Called(ctx, domainName) | |
| 45 | + | ||
| 46 | + | if len(ret) == 0 { | |
| 47 | + | panic("no return value specified for DetectDatabases") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | var r0 []domain.DBInfo | |
| 51 | + | var r1 error | |
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.DBInfo, error)); ok { | |
| 53 | + | return returnFunc(ctx, domainName) | |
| 54 | + | } | |
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.DBInfo); ok { | |
| 56 | + | r0 = returnFunc(ctx, domainName) | |
| 57 | + | } else { | |
| 58 | + | if ret.Get(0) != nil { | |
| 59 | + | r0 = ret.Get(0).([]domain.DBInfo) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 63 | + | r1 = returnFunc(ctx, domainName) | |
| 64 | + | } else { | |
| 65 | + | r1 = ret.Error(1) | |
| 66 | + | } | |
| 67 | + | return r0, r1 | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // MockBackupService_DetectDatabases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectDatabases' | |
| 71 | + | type MockBackupService_DetectDatabases_Call struct { | |
| 72 | + | *mock.Call | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // DetectDatabases is a helper method to define mock.On call | |
| 76 | + | // - ctx context.Context | |
| 77 | + | // - domainName string | |
| 78 | + | func (_e *MockBackupService_Expecter) DetectDatabases(ctx interface{}, domainName interface{}) *MockBackupService_DetectDatabases_Call { | |
| 79 | + | return &MockBackupService_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, domainName)} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *MockBackupService_DetectDatabases_Call) Run(run func(ctx context.Context, domainName string)) *MockBackupService_DetectDatabases_Call { | |
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | + | var arg0 context.Context | |
| 85 | + | if args[0] != nil { | |
| 86 | + | arg0 = args[0].(context.Context) | |
| 87 | + | } | |
| 88 | + | var arg1 string | |
| 89 | + | if args[1] != nil { | |
| 90 | + | arg1 = args[1].(string) | |
| 91 | + | } | |
| 92 | + | run( | |
| 93 | + | arg0, | |
| 94 | + | arg1, | |
| 95 | + | ) | |
| 96 | + | }) | |
| 97 | + | return _c | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (_c *MockBackupService_DetectDatabases_Call) Return(dBInfos []domain.DBInfo, err error) *MockBackupService_DetectDatabases_Call { | |
| 101 | + | _c.Call.Return(dBInfos, err) | |
| 102 | + | return _c | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (_c *MockBackupService_DetectDatabases_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.DBInfo, error)) *MockBackupService_DetectDatabases_Call { | |
| 106 | + | _c.Call.Return(run) | |
| 107 | + | return _c | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // ListBackups provides a mock function for the type MockBackupService | |
| 111 | + | func (_mock *MockBackupService) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) { | |
| 112 | + | ret := _mock.Called(ctx, domainName) | |
| 113 | + | ||
| 114 | + | if len(ret) == 0 { | |
| 115 | + | panic("no return value specified for ListBackups") | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | var r0 []domain.BackupJob | |
| 119 | + | var r1 error | |
| 120 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.BackupJob, error)); ok { | |
| 121 | + | return returnFunc(ctx, domainName) | |
| 122 | + | } | |
| 123 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.BackupJob); ok { | |
| 124 | + | r0 = returnFunc(ctx, domainName) | |
| 125 | + | } else { | |
| 126 | + | if ret.Get(0) != nil { | |
| 127 | + | r0 = ret.Get(0).([]domain.BackupJob) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 131 | + | r1 = returnFunc(ctx, domainName) | |
| 132 | + | } else { | |
| 133 | + | r1 = ret.Error(1) | |
| 134 | + | } | |
| 135 | + | return r0, r1 | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // MockBackupService_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups' | |
| 139 | + | type MockBackupService_ListBackups_Call struct { | |
| 140 | + | *mock.Call | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // ListBackups is a helper method to define mock.On call | |
| 144 | + | // - ctx context.Context | |
| 145 | + | // - domainName string | |
| 146 | + | func (_e *MockBackupService_Expecter) ListBackups(ctx interface{}, domainName interface{}) *MockBackupService_ListBackups_Call { | |
| 147 | + | return &MockBackupService_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, domainName)} | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (_c *MockBackupService_ListBackups_Call) Run(run func(ctx context.Context, domainName string)) *MockBackupService_ListBackups_Call { | |
| 151 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 152 | + | var arg0 context.Context | |
| 153 | + | if args[0] != nil { | |
| 154 | + | arg0 = args[0].(context.Context) | |
| 155 | + | } | |
| 156 | + | var arg1 string | |
| 157 | + | if args[1] != nil { | |
| 158 | + | arg1 = args[1].(string) | |
| 159 | + | } | |
| 160 | + | run( | |
| 161 | + | arg0, | |
| 162 | + | arg1, | |
| 163 | + | ) | |
| 164 | + | }) | |
| 165 | + | return _c | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (_c *MockBackupService_ListBackups_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_ListBackups_Call { | |
| 169 | + | _c.Call.Return(backupJobs, err) | |
| 170 | + | return _c | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (_c *MockBackupService_ListBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.BackupJob, error)) *MockBackupService_ListBackups_Call { | |
| 174 | + | _c.Call.Return(run) | |
| 175 | + | return _c | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // Restore provides a mock function for the type MockBackupService | |
| 179 | + | func (_mock *MockBackupService) Restore(ctx context.Context, domainName string, backupID string) error { | |
| 180 | + | ret := _mock.Called(ctx, domainName, backupID) | |
| 181 | + | ||
| 182 | + | if len(ret) == 0 { | |
| 183 | + | panic("no return value specified for Restore") | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | var r0 error | |
| 187 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |
| 188 | + | r0 = returnFunc(ctx, domainName, backupID) | |
| 189 | + | } else { | |
| 190 | + | r0 = ret.Error(0) | |
| 191 | + | } | |
| 192 | + | return r0 | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // MockBackupService_Restore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restore' | |
| 196 | + | type MockBackupService_Restore_Call struct { | |
| 197 | + | *mock.Call | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Restore is a helper method to define mock.On call | |
| 201 | + | // - ctx context.Context | |
| 202 | + | // - domainName string | |
| 203 | + | // - backupID string | |
| 204 | + | func (_e *MockBackupService_Expecter) Restore(ctx interface{}, domainName interface{}, backupID interface{}) *MockBackupService_Restore_Call { | |
| 205 | + | return &MockBackupService_Restore_Call{Call: _e.mock.On("Restore", ctx, domainName, backupID)} | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func (_c *MockBackupService_Restore_Call) Run(run func(ctx context.Context, domainName string, backupID string)) *MockBackupService_Restore_Call { | |
| 209 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 210 | + | var arg0 context.Context | |
| 211 | + | if args[0] != nil { | |
| 212 | + | arg0 = args[0].(context.Context) | |
| 213 | + | } | |
| 214 | + | var arg1 string | |
| 215 | + | if args[1] != nil { | |
| 216 | + | arg1 = args[1].(string) | |
| 217 | + | } | |
| 218 | + | var arg2 string | |
| 219 | + | if args[2] != nil { | |
| 220 | + | arg2 = args[2].(string) | |
| 221 | + | } | |
| 222 | + | run( | |
| 223 | + | arg0, | |
| 224 | + | arg1, | |
| 225 | + | arg2, | |
| 226 | + | ) | |
| 227 | + | }) | |
| 228 | + | return _c | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (_c *MockBackupService_Restore_Call) Return(err error) *MockBackupService_Restore_Call { | |
| 232 | + | _c.Call.Return(err) | |
| 233 | + | return _c | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (_c *MockBackupService_Restore_Call) RunAndReturn(run func(ctx context.Context, domainName string, backupID string) error) *MockBackupService_Restore_Call { | |
| 237 | + | _c.Call.Return(run) | |
| 238 | + | return _c | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // RestorePITR provides a mock function for the type MockBackupService | |
| 242 | + | func (_mock *MockBackupService) RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error { | |
| 243 | + | ret := _mock.Called(ctx, domainName, targetTime) | |
| 244 | + | ||
| 245 | + | if len(ret) == 0 { | |
| 246 | + | panic("no return value specified for RestorePITR") | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | var r0 error | |
| 250 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) error); ok { | |
| 251 | + | r0 = returnFunc(ctx, domainName, targetTime) | |
| 252 | + | } else { | |
| 253 | + | r0 = ret.Error(0) | |
| 254 | + | } | |
| 255 | + | return r0 | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // MockBackupService_RestorePITR_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestorePITR' | |
| 259 | + | type MockBackupService_RestorePITR_Call struct { | |
| 260 | + | *mock.Call | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // RestorePITR is a helper method to define mock.On call | |
| 264 | + | // - ctx context.Context | |
| 265 | + | // - domainName string | |
| 266 | + | // - targetTime time.Time | |
| 267 | + | func (_e *MockBackupService_Expecter) RestorePITR(ctx interface{}, domainName interface{}, targetTime interface{}) *MockBackupService_RestorePITR_Call { | |
| 268 | + | return &MockBackupService_RestorePITR_Call{Call: _e.mock.On("RestorePITR", ctx, domainName, targetTime)} | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (_c *MockBackupService_RestorePITR_Call) Run(run func(ctx context.Context, domainName string, targetTime time.Time)) *MockBackupService_RestorePITR_Call { | |
| 272 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 273 | + | var arg0 context.Context | |
| 274 | + | if args[0] != nil { | |
| 275 | + | arg0 = args[0].(context.Context) | |
| 276 | + | } | |
| 277 | + | var arg1 string | |
| 278 | + | if args[1] != nil { | |
| 279 | + | arg1 = args[1].(string) | |
| 280 | + | } | |
| 281 | + | var arg2 time.Time | |
| 282 | + | if args[2] != nil { | |
| 283 | + | arg2 = args[2].(time.Time) | |
| 284 | + | } | |
| 285 | + | run( | |
| 286 | + | arg0, | |
| 287 | + | arg1, | |
| 288 | + | arg2, | |
| 289 | + | ) | |
| 290 | + | }) | |
| 291 | + | return _c | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (_c *MockBackupService_RestorePITR_Call) Return(err error) *MockBackupService_RestorePITR_Call { | |
| 295 | + | _c.Call.Return(err) | |
| 296 | + | return _c | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func (_c *MockBackupService_RestorePITR_Call) RunAndReturn(run func(ctx context.Context, domainName string, targetTime time.Time) error) *MockBackupService_RestorePITR_Call { | |
| 300 | + | _c.Call.Return(run) | |
| 301 | + | return _c | |
| 302 | + | } | |
| 303 | + | ||
| 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) { | |
| 306 | + | ret := _mock.Called(ctx, domainName, dbName) | |
| 307 | + | ||
| 308 | + | if len(ret) == 0 { | |
| 309 | + | panic("no return value specified for RunBackup") | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | var r0 *domain.BackupResult | |
| 313 | + | var r1 error | |
| 314 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.BackupResult, error)); ok { | |
| 315 | + | return returnFunc(ctx, domainName, dbName) | |
| 316 | + | } | |
| 317 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.BackupResult); ok { | |
| 318 | + | r0 = returnFunc(ctx, domainName, dbName) | |
| 319 | + | } else { | |
| 320 | + | if ret.Get(0) != nil { | |
| 321 | + | r0 = ret.Get(0).(*domain.BackupResult) | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 325 | + | r1 = returnFunc(ctx, domainName, dbName) | |
| 326 | + | } else { | |
| 327 | + | r1 = ret.Error(1) | |
| 328 | + | } | |
| 329 | + | return r0, r1 | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | // MockBackupService_RunBackup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunBackup' | |
| 333 | + | type MockBackupService_RunBackup_Call struct { | |
| 334 | + | *mock.Call | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // RunBackup is a helper method to define mock.On call | |
| 338 | + | // - ctx context.Context | |
| 339 | + | // - domainName string | |
| 340 | + | // - dbName string | |
| 341 | + | func (_e *MockBackupService_Expecter) RunBackup(ctx interface{}, domainName interface{}, dbName interface{}) *MockBackupService_RunBackup_Call { | |
| 342 | + | return &MockBackupService_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, domainName, dbName)} | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | func (_c *MockBackupService_RunBackup_Call) Run(run func(ctx context.Context, domainName string, dbName string)) *MockBackupService_RunBackup_Call { | |
| 346 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 347 | + | var arg0 context.Context | |
| 348 | + | if args[0] != nil { | |
| 349 | + | arg0 = args[0].(context.Context) | |
| 350 | + | } | |
| 351 | + | var arg1 string | |
| 352 | + | if args[1] != nil { | |
| 353 | + | arg1 = args[1].(string) | |
| 354 | + | } | |
| 355 | + | var arg2 string | |
| 356 | + | if args[2] != nil { | |
| 357 | + | arg2 = args[2].(string) | |
| 358 | + | } | |
| 359 | + | run( | |
| 360 | + | arg0, | |
| 361 | + | arg1, | |
| 362 | + | arg2, | |
| 363 | + | ) | |
| 364 | + | }) | |
| 365 | + | return _c | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func (_c *MockBackupService_RunBackup_Call) Return(backupResult *domain.BackupResult, err error) *MockBackupService_RunBackup_Call { | |
| 369 | + | _c.Call.Return(backupResult, err) | |
| 370 | + | return _c | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func (_c *MockBackupService_RunBackup_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string) (*domain.BackupResult, error)) *MockBackupService_RunBackup_Call { | |
| 374 | + | _c.Call.Return(run) | |
| 375 | + | return _c | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // Status provides a mock function for the type MockBackupService | |
| 379 | + | func (_mock *MockBackupService) Status(ctx context.Context) ([]domain.BackupJob, error) { | |
| 380 | + | ret := _mock.Called(ctx) | |
| 381 | + | ||
| 382 | + | if len(ret) == 0 { | |
| 383 | + | panic("no return value specified for Status") | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | var r0 []domain.BackupJob | |
| 387 | + | var r1 error | |
| 388 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.BackupJob, error)); ok { | |
| 389 | + | return returnFunc(ctx) | |
| 390 | + | } | |
| 391 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.BackupJob); ok { | |
| 392 | + | r0 = returnFunc(ctx) | |
| 393 | + | } else { | |
| 394 | + | if ret.Get(0) != nil { | |
| 395 | + | r0 = ret.Get(0).([]domain.BackupJob) | |
| 396 | + | } | |
| 397 | + | } | |
| 398 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 399 | + | r1 = returnFunc(ctx) | |
| 400 | + | } else { | |
| 401 | + | r1 = ret.Error(1) | |
| 402 | + | } | |
| 403 | + | return r0, r1 | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // MockBackupService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status' | |
| 407 | + | type MockBackupService_Status_Call struct { | |
| 408 | + | *mock.Call | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // Status is a helper method to define mock.On call | |
| 412 | + | // - ctx context.Context | |
| 413 | + | func (_e *MockBackupService_Expecter) Status(ctx interface{}) *MockBackupService_Status_Call { | |
| 414 | + | return &MockBackupService_Status_Call{Call: _e.mock.On("Status", ctx)} | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (_c *MockBackupService_Status_Call) Run(run func(ctx context.Context)) *MockBackupService_Status_Call { | |
| 418 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 419 | + | var arg0 context.Context | |
| 420 | + | if args[0] != nil { | |
| 421 | + | arg0 = args[0].(context.Context) | |
| 422 | + | } | |
| 423 | + | run( | |
| 424 | + | arg0, | |
| 425 | + | ) | |
| 426 | + | }) | |
| 427 | + | return _c | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (_c *MockBackupService_Status_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_Status_Call { | |
| 431 | + | _c.Call.Return(backupJobs, err) | |
| 432 | + | return _c | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | func (_c *MockBackupService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.BackupJob, error)) *MockBackupService_Status_Call { | |
| 436 | + | _c.Call.Return(run) | |
| 437 | + | return _c | |
| 438 | + | } | |
| @@ -0,0 +1,438 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | mock "github.com/stretchr/testify/mock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // NewMockBackupService creates a new instance of MockBackupService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 16 | + | // The first argument is typically a *testing.T value. | |||
| 17 | + | func NewMockBackupService(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *MockBackupService { | |||
| 21 | + | mock := &MockBackupService{} | |||
| 22 | + | mock.Mock.Test(t) | |||
| 23 | + | ||||
| 24 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 25 | + | ||||
| 26 | + | return mock | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // MockBackupService is an autogenerated mock type for the BackupService type | |||
| 30 | + | type MockBackupService struct { | |||
| 31 | + | mock.Mock | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type MockBackupService_Expecter struct { | |||
| 35 | + | mock *mock.Mock | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (_m *MockBackupService) EXPECT() *MockBackupService_Expecter { | |||
| 39 | + | return &MockBackupService_Expecter{mock: &_m.Mock} | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // DetectDatabases provides a mock function for the type MockBackupService | |||
| 43 | + | func (_mock *MockBackupService) DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) { | |||
| 44 | + | ret := _mock.Called(ctx, domainName) | |||
| 45 | + | ||||
| 46 | + | if len(ret) == 0 { | |||
| 47 | + | panic("no return value specified for DetectDatabases") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | var r0 []domain.DBInfo | |||
| 51 | + | var r1 error | |||
| 52 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.DBInfo, error)); ok { | |||
| 53 | + | return returnFunc(ctx, domainName) | |||
| 54 | + | } | |||
| 55 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.DBInfo); ok { | |||
| 56 | + | r0 = returnFunc(ctx, domainName) | |||
| 57 | + | } else { | |||
| 58 | + | if ret.Get(0) != nil { | |||
| 59 | + | r0 = ret.Get(0).([]domain.DBInfo) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 63 | + | r1 = returnFunc(ctx, domainName) | |||
| 64 | + | } else { | |||
| 65 | + | r1 = ret.Error(1) | |||
| 66 | + | } | |||
| 67 | + | return r0, r1 | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // MockBackupService_DetectDatabases_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DetectDatabases' | |||
| 71 | + | type MockBackupService_DetectDatabases_Call struct { | |||
| 72 | + | *mock.Call | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // DetectDatabases is a helper method to define mock.On call | |||
| 76 | + | // - ctx context.Context | |||
| 77 | + | // - domainName string | |||
| 78 | + | func (_e *MockBackupService_Expecter) DetectDatabases(ctx interface{}, domainName interface{}) *MockBackupService_DetectDatabases_Call { | |||
| 79 | + | return &MockBackupService_DetectDatabases_Call{Call: _e.mock.On("DetectDatabases", ctx, domainName)} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockBackupService_DetectDatabases_Call) Run(run func(ctx context.Context, domainName string)) *MockBackupService_DetectDatabases_Call { | |||
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 84 | + | var arg0 context.Context | |||
| 85 | + | if args[0] != nil { | |||
| 86 | + | arg0 = args[0].(context.Context) | |||
| 87 | + | } | |||
| 88 | + | var arg1 string | |||
| 89 | + | if args[1] != nil { | |||
| 90 | + | arg1 = args[1].(string) | |||
| 91 | + | } | |||
| 92 | + | run( | |||
| 93 | + | arg0, | |||
| 94 | + | arg1, | |||
| 95 | + | ) | |||
| 96 | + | }) | |||
| 97 | + | return _c | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (_c *MockBackupService_DetectDatabases_Call) Return(dBInfos []domain.DBInfo, err error) *MockBackupService_DetectDatabases_Call { | |||
| 101 | + | _c.Call.Return(dBInfos, err) | |||
| 102 | + | return _c | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (_c *MockBackupService_DetectDatabases_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.DBInfo, error)) *MockBackupService_DetectDatabases_Call { | |||
| 106 | + | _c.Call.Return(run) | |||
| 107 | + | return _c | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // ListBackups provides a mock function for the type MockBackupService | |||
| 111 | + | func (_mock *MockBackupService) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) { | |||
| 112 | + | ret := _mock.Called(ctx, domainName) | |||
| 113 | + | ||||
| 114 | + | if len(ret) == 0 { | |||
| 115 | + | panic("no return value specified for ListBackups") | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | var r0 []domain.BackupJob | |||
| 119 | + | var r1 error | |||
| 120 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]domain.BackupJob, error)); ok { | |||
| 121 | + | return returnFunc(ctx, domainName) | |||
| 122 | + | } | |||
| 123 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.BackupJob); ok { | |||
| 124 | + | r0 = returnFunc(ctx, domainName) | |||
| 125 | + | } else { | |||
| 126 | + | if ret.Get(0) != nil { | |||
| 127 | + | r0 = ret.Get(0).([]domain.BackupJob) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 131 | + | r1 = returnFunc(ctx, domainName) | |||
| 132 | + | } else { | |||
| 133 | + | r1 = ret.Error(1) | |||
| 134 | + | } | |||
| 135 | + | return r0, r1 | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // MockBackupService_ListBackups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBackups' | |||
| 139 | + | type MockBackupService_ListBackups_Call struct { | |||
| 140 | + | *mock.Call | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // ListBackups is a helper method to define mock.On call | |||
| 144 | + | // - ctx context.Context | |||
| 145 | + | // - domainName string | |||
| 146 | + | func (_e *MockBackupService_Expecter) ListBackups(ctx interface{}, domainName interface{}) *MockBackupService_ListBackups_Call { | |||
| 147 | + | return &MockBackupService_ListBackups_Call{Call: _e.mock.On("ListBackups", ctx, domainName)} | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (_c *MockBackupService_ListBackups_Call) Run(run func(ctx context.Context, domainName string)) *MockBackupService_ListBackups_Call { | |||
| 151 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 152 | + | var arg0 context.Context | |||
| 153 | + | if args[0] != nil { | |||
| 154 | + | arg0 = args[0].(context.Context) | |||
| 155 | + | } | |||
| 156 | + | var arg1 string | |||
| 157 | + | if args[1] != nil { | |||
| 158 | + | arg1 = args[1].(string) | |||
| 159 | + | } | |||
| 160 | + | run( | |||
| 161 | + | arg0, | |||
| 162 | + | arg1, | |||
| 163 | + | ) | |||
| 164 | + | }) | |||
| 165 | + | return _c | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func (_c *MockBackupService_ListBackups_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_ListBackups_Call { | |||
| 169 | + | _c.Call.Return(backupJobs, err) | |||
| 170 | + | return _c | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (_c *MockBackupService_ListBackups_Call) RunAndReturn(run func(ctx context.Context, domainName string) ([]domain.BackupJob, error)) *MockBackupService_ListBackups_Call { | |||
| 174 | + | _c.Call.Return(run) | |||
| 175 | + | return _c | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // Restore provides a mock function for the type MockBackupService | |||
| 179 | + | func (_mock *MockBackupService) Restore(ctx context.Context, domainName string, backupID string) error { | |||
| 180 | + | ret := _mock.Called(ctx, domainName, backupID) | |||
| 181 | + | ||||
| 182 | + | if len(ret) == 0 { | |||
| 183 | + | panic("no return value specified for Restore") | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | var r0 error | |||
| 187 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok { | |||
| 188 | + | r0 = returnFunc(ctx, domainName, backupID) | |||
| 189 | + | } else { | |||
| 190 | + | r0 = ret.Error(0) | |||
| 191 | + | } | |||
| 192 | + | return r0 | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // MockBackupService_Restore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Restore' | |||
| 196 | + | type MockBackupService_Restore_Call struct { | |||
| 197 | + | *mock.Call | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Restore is a helper method to define mock.On call | |||
| 201 | + | // - ctx context.Context | |||
| 202 | + | // - domainName string | |||
| 203 | + | // - backupID string | |||
| 204 | + | func (_e *MockBackupService_Expecter) Restore(ctx interface{}, domainName interface{}, backupID interface{}) *MockBackupService_Restore_Call { | |||
| 205 | + | return &MockBackupService_Restore_Call{Call: _e.mock.On("Restore", ctx, domainName, backupID)} | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func (_c *MockBackupService_Restore_Call) Run(run func(ctx context.Context, domainName string, backupID string)) *MockBackupService_Restore_Call { | |||
| 209 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 210 | + | var arg0 context.Context | |||
| 211 | + | if args[0] != nil { | |||
| 212 | + | arg0 = args[0].(context.Context) | |||
| 213 | + | } | |||
| 214 | + | var arg1 string | |||
| 215 | + | if args[1] != nil { | |||
| 216 | + | arg1 = args[1].(string) | |||
| 217 | + | } | |||
| 218 | + | var arg2 string | |||
| 219 | + | if args[2] != nil { | |||
| 220 | + | arg2 = args[2].(string) | |||
| 221 | + | } | |||
| 222 | + | run( | |||
| 223 | + | arg0, | |||
| 224 | + | arg1, | |||
| 225 | + | arg2, | |||
| 226 | + | ) | |||
| 227 | + | }) | |||
| 228 | + | return _c | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (_c *MockBackupService_Restore_Call) Return(err error) *MockBackupService_Restore_Call { | |||
| 232 | + | _c.Call.Return(err) | |||
| 233 | + | return _c | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (_c *MockBackupService_Restore_Call) RunAndReturn(run func(ctx context.Context, domainName string, backupID string) error) *MockBackupService_Restore_Call { | |||
| 237 | + | _c.Call.Return(run) | |||
| 238 | + | return _c | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // RestorePITR provides a mock function for the type MockBackupService | |||
| 242 | + | func (_mock *MockBackupService) RestorePITR(ctx context.Context, domainName string, targetTime time.Time) error { | |||
| 243 | + | ret := _mock.Called(ctx, domainName, targetTime) | |||
| 244 | + | ||||
| 245 | + | if len(ret) == 0 { | |||
| 246 | + | panic("no return value specified for RestorePITR") | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | var r0 error | |||
| 250 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) error); ok { | |||
| 251 | + | r0 = returnFunc(ctx, domainName, targetTime) | |||
| 252 | + | } else { | |||
| 253 | + | r0 = ret.Error(0) | |||
| 254 | + | } | |||
| 255 | + | return r0 | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // MockBackupService_RestorePITR_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RestorePITR' | |||
| 259 | + | type MockBackupService_RestorePITR_Call struct { | |||
| 260 | + | *mock.Call | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // RestorePITR is a helper method to define mock.On call | |||
| 264 | + | // - ctx context.Context | |||
| 265 | + | // - domainName string | |||
| 266 | + | // - targetTime time.Time | |||
| 267 | + | func (_e *MockBackupService_Expecter) RestorePITR(ctx interface{}, domainName interface{}, targetTime interface{}) *MockBackupService_RestorePITR_Call { | |||
| 268 | + | return &MockBackupService_RestorePITR_Call{Call: _e.mock.On("RestorePITR", ctx, domainName, targetTime)} | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (_c *MockBackupService_RestorePITR_Call) Run(run func(ctx context.Context, domainName string, targetTime time.Time)) *MockBackupService_RestorePITR_Call { | |||
| 272 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 273 | + | var arg0 context.Context | |||
| 274 | + | if args[0] != nil { | |||
| 275 | + | arg0 = args[0].(context.Context) | |||
| 276 | + | } | |||
| 277 | + | var arg1 string | |||
| 278 | + | if args[1] != nil { | |||
| 279 | + | arg1 = args[1].(string) | |||
| 280 | + | } | |||
| 281 | + | var arg2 time.Time | |||
| 282 | + | if args[2] != nil { | |||
| 283 | + | arg2 = args[2].(time.Time) | |||
| 284 | + | } | |||
| 285 | + | run( | |||
| 286 | + | arg0, | |||
| 287 | + | arg1, | |||
| 288 | + | arg2, | |||
| 289 | + | ) | |||
| 290 | + | }) | |||
| 291 | + | return _c | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (_c *MockBackupService_RestorePITR_Call) Return(err error) *MockBackupService_RestorePITR_Call { | |||
| 295 | + | _c.Call.Return(err) | |||
| 296 | + | return _c | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func (_c *MockBackupService_RestorePITR_Call) RunAndReturn(run func(ctx context.Context, domainName string, targetTime time.Time) error) *MockBackupService_RestorePITR_Call { | |||
| 300 | + | _c.Call.Return(run) | |||
| 301 | + | return _c | |||
| 302 | + | } | |||
| 303 | + | ||||
| 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) { | |||
| 306 | + | ret := _mock.Called(ctx, domainName, dbName) | |||
| 307 | + | ||||
| 308 | + | if len(ret) == 0 { | |||
| 309 | + | panic("no return value specified for RunBackup") | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | var r0 *domain.BackupResult | |||
| 313 | + | var r1 error | |||
| 314 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*domain.BackupResult, error)); ok { | |||
| 315 | + | return returnFunc(ctx, domainName, dbName) | |||
| 316 | + | } | |||
| 317 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *domain.BackupResult); ok { | |||
| 318 | + | r0 = returnFunc(ctx, domainName, dbName) | |||
| 319 | + | } else { | |||
| 320 | + | if ret.Get(0) != nil { | |||
| 321 | + | r0 = ret.Get(0).(*domain.BackupResult) | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |||
| 325 | + | r1 = returnFunc(ctx, domainName, dbName) | |||
| 326 | + | } else { | |||
| 327 | + | r1 = ret.Error(1) | |||
| 328 | + | } | |||
| 329 | + | return r0, r1 | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | // MockBackupService_RunBackup_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunBackup' | |||
| 333 | + | type MockBackupService_RunBackup_Call struct { | |||
| 334 | + | *mock.Call | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // RunBackup is a helper method to define mock.On call | |||
| 338 | + | // - ctx context.Context | |||
| 339 | + | // - domainName string | |||
| 340 | + | // - dbName string | |||
| 341 | + | func (_e *MockBackupService_Expecter) RunBackup(ctx interface{}, domainName interface{}, dbName interface{}) *MockBackupService_RunBackup_Call { | |||
| 342 | + | return &MockBackupService_RunBackup_Call{Call: _e.mock.On("RunBackup", ctx, domainName, dbName)} | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func (_c *MockBackupService_RunBackup_Call) Run(run func(ctx context.Context, domainName string, dbName string)) *MockBackupService_RunBackup_Call { | |||
| 346 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 347 | + | var arg0 context.Context | |||
| 348 | + | if args[0] != nil { | |||
| 349 | + | arg0 = args[0].(context.Context) | |||
| 350 | + | } | |||
| 351 | + | var arg1 string | |||
| 352 | + | if args[1] != nil { | |||
| 353 | + | arg1 = args[1].(string) | |||
| 354 | + | } | |||
| 355 | + | var arg2 string | |||
| 356 | + | if args[2] != nil { | |||
| 357 | + | arg2 = args[2].(string) | |||
| 358 | + | } | |||
| 359 | + | run( | |||
| 360 | + | arg0, | |||
| 361 | + | arg1, | |||
| 362 | + | arg2, | |||
| 363 | + | ) | |||
| 364 | + | }) | |||
| 365 | + | return _c | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | func (_c *MockBackupService_RunBackup_Call) Return(backupResult *domain.BackupResult, err error) *MockBackupService_RunBackup_Call { | |||
| 369 | + | _c.Call.Return(backupResult, err) | |||
| 370 | + | return _c | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func (_c *MockBackupService_RunBackup_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string) (*domain.BackupResult, error)) *MockBackupService_RunBackup_Call { | |||
| 374 | + | _c.Call.Return(run) | |||
| 375 | + | return _c | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // Status provides a mock function for the type MockBackupService | |||
| 379 | + | func (_mock *MockBackupService) Status(ctx context.Context) ([]domain.BackupJob, error) { | |||
| 380 | + | ret := _mock.Called(ctx) | |||
| 381 | + | ||||
| 382 | + | if len(ret) == 0 { | |||
| 383 | + | panic("no return value specified for Status") | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | var r0 []domain.BackupJob | |||
| 387 | + | var r1 error | |||
| 388 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.BackupJob, error)); ok { | |||
| 389 | + | return returnFunc(ctx) | |||
| 390 | + | } | |||
| 391 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.BackupJob); ok { | |||
| 392 | + | r0 = returnFunc(ctx) | |||
| 393 | + | } else { | |||
| 394 | + | if ret.Get(0) != nil { | |||
| 395 | + | r0 = ret.Get(0).([]domain.BackupJob) | |||
| 396 | + | } | |||
| 397 | + | } | |||
| 398 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |||
| 399 | + | r1 = returnFunc(ctx) | |||
| 400 | + | } else { | |||
| 401 | + | r1 = ret.Error(1) | |||
| 402 | + | } | |||
| 403 | + | return r0, r1 | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | // MockBackupService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status' | |||
| 407 | + | type MockBackupService_Status_Call struct { | |||
| 408 | + | *mock.Call | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | // Status is a helper method to define mock.On call | |||
| 412 | + | // - ctx context.Context | |||
| 413 | + | func (_e *MockBackupService_Expecter) Status(ctx interface{}) *MockBackupService_Status_Call { | |||
| 414 | + | return &MockBackupService_Status_Call{Call: _e.mock.On("Status", ctx)} | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func (_c *MockBackupService_Status_Call) Run(run func(ctx context.Context)) *MockBackupService_Status_Call { | |||
| 418 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 419 | + | var arg0 context.Context | |||
| 420 | + | if args[0] != nil { | |||
| 421 | + | arg0 = args[0].(context.Context) | |||
| 422 | + | } | |||
| 423 | + | run( | |||
| 424 | + | arg0, | |||
| 425 | + | ) | |||
| 426 | + | }) | |||
| 427 | + | return _c | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func (_c *MockBackupService_Status_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupService_Status_Call { | |||
| 431 | + | _c.Call.Return(backupJobs, err) | |||
| 432 | + | return _c | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | func (_c *MockBackupService_Status_Call) RunAndReturn(run func(ctx context.Context) ([]domain.BackupJob, error)) *MockBackupService_Status_Call { | |||
| 436 | + | _c.Call.Return(run) | |||
| 437 | + | return _c | |||
| 438 | + | } | |||
A internal/boundaries/out/backupstore.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // BackupStorage defines persistence for backup artifacts and metadata. | |
| 12 | + | type BackupStorage interface { | |
| 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) | |
| 15 | + | List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) | |
| 16 | + | Delete(ctx context.Context, path string) error | |
| 17 | + | ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // BackupStorage defines persistence for backup artifacts and metadata. | |||
| 12 | + | type BackupStorage interface { | |||
| 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) | |||
| 15 | + | List(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) | |||
| 16 | + | Delete(ctx context.Context, path string) error | |||
| 17 | + | ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) | |||
| 18 | + | } | |||
A internal/boundaries/out/mocks/mock_backup_storage.go +402 -0
| @@ -0,0 +1,402 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "io" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 13 | + | mock "github.com/stretchr/testify/mock" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // NewMockBackupStorage creates a new instance of MockBackupStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 17 | + | // The first argument is typically a *testing.T value. | |
| 18 | + | func NewMockBackupStorage(t interface { | |
| 19 | + | mock.TestingT | |
| 20 | + | Cleanup(func()) | |
| 21 | + | }) *MockBackupStorage { | |
| 22 | + | mock := &MockBackupStorage{} | |
| 23 | + | mock.Mock.Test(t) | |
| 24 | + | ||
| 25 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 26 | + | ||
| 27 | + | return mock | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // MockBackupStorage is an autogenerated mock type for the BackupStorage type | |
| 31 | + | type MockBackupStorage struct { | |
| 32 | + | mock.Mock | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type MockBackupStorage_Expecter struct { | |
| 36 | + | mock *mock.Mock | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (_m *MockBackupStorage) EXPECT() *MockBackupStorage_Expecter { | |
| 40 | + | return &MockBackupStorage_Expecter{mock: &_m.Mock} | |
| 41 | + | } | |
| 42 | + | ||
| 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) { | |
| 45 | + | ret := _mock.Called(ctx, domainName, policy) | |
| 46 | + | ||
| 47 | + | if len(ret) == 0 { | |
| 48 | + | panic("no return value specified for ApplyRetention") | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | var r0 int | |
| 52 | + | var r1 error | |
| 53 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) (int, error)); ok { | |
| 54 | + | return returnFunc(ctx, domainName, policy) | |
| 55 | + | } | |
| 56 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) int); ok { | |
| 57 | + | r0 = returnFunc(ctx, domainName, policy) | |
| 58 | + | } else { | |
| 59 | + | r0 = ret.Get(0).(int) | |
| 60 | + | } | |
| 61 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.RetentionPolicy) error); ok { | |
| 62 | + | r1 = returnFunc(ctx, domainName, policy) | |
| 63 | + | } else { | |
| 64 | + | r1 = ret.Error(1) | |
| 65 | + | } | |
| 66 | + | return r0, r1 | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // MockBackupStorage_ApplyRetention_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyRetention' | |
| 70 | + | type MockBackupStorage_ApplyRetention_Call struct { | |
| 71 | + | *mock.Call | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ApplyRetention is a helper method to define mock.On call | |
| 75 | + | // - ctx context.Context | |
| 76 | + | // - domainName string | |
| 77 | + | // - policy domain.RetentionPolicy | |
| 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)} | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy)) *MockBackupStorage_ApplyRetention_Call { | |
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 84 | + | var arg0 context.Context | |
| 85 | + | if args[0] != nil { | |
| 86 | + | arg0 = args[0].(context.Context) | |
| 87 | + | } | |
| 88 | + | var arg1 string | |
| 89 | + | if args[1] != nil { | |
| 90 | + | arg1 = args[1].(string) | |
| 91 | + | } | |
| 92 | + | var arg2 domain.RetentionPolicy | |
| 93 | + | if args[2] != nil { | |
| 94 | + | arg2 = args[2].(domain.RetentionPolicy) | |
| 95 | + | } | |
| 96 | + | run( | |
| 97 | + | arg0, | |
| 98 | + | arg1, | |
| 99 | + | arg2, | |
| 100 | + | ) | |
| 101 | + | }) | |
| 102 | + | return _c | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (_c *MockBackupStorage_ApplyRetention_Call) Return(n int, err error) *MockBackupStorage_ApplyRetention_Call { | |
| 106 | + | _c.Call.Return(n, err) | |
| 107 | + | return _c | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (_c *MockBackupStorage_ApplyRetention_Call) RunAndReturn(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error)) *MockBackupStorage_ApplyRetention_Call { | |
| 111 | + | _c.Call.Return(run) | |
| 112 | + | return _c | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Delete provides a mock function for the type MockBackupStorage | |
| 116 | + | func (_mock *MockBackupStorage) Delete(ctx context.Context, path string) error { | |
| 117 | + | ret := _mock.Called(ctx, path) | |
| 118 | + | ||
| 119 | + | if len(ret) == 0 { | |
| 120 | + | panic("no return value specified for Delete") | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | var r0 error | |
| 124 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |
| 125 | + | r0 = returnFunc(ctx, path) | |
| 126 | + | } else { | |
| 127 | + | r0 = ret.Error(0) | |
| 128 | + | } | |
| 129 | + | return r0 | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // MockBackupStorage_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |
| 133 | + | type MockBackupStorage_Delete_Call struct { | |
| 134 | + | *mock.Call | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Delete is a helper method to define mock.On call | |
| 138 | + | // - ctx context.Context | |
| 139 | + | // - path string | |
| 140 | + | func (_e *MockBackupStorage_Expecter) Delete(ctx interface{}, path interface{}) *MockBackupStorage_Delete_Call { | |
| 141 | + | return &MockBackupStorage_Delete_Call{Call: _e.mock.On("Delete", ctx, path)} | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (_c *MockBackupStorage_Delete_Call) Run(run func(ctx context.Context, path string)) *MockBackupStorage_Delete_Call { | |
| 145 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 146 | + | var arg0 context.Context | |
| 147 | + | if args[0] != nil { | |
| 148 | + | arg0 = args[0].(context.Context) | |
| 149 | + | } | |
| 150 | + | var arg1 string | |
| 151 | + | if args[1] != nil { | |
| 152 | + | arg1 = args[1].(string) | |
| 153 | + | } | |
| 154 | + | run( | |
| 155 | + | arg0, | |
| 156 | + | arg1, | |
| 157 | + | ) | |
| 158 | + | }) | |
| 159 | + | return _c | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func (_c *MockBackupStorage_Delete_Call) Return(err error) *MockBackupStorage_Delete_Call { | |
| 163 | + | _c.Call.Return(err) | |
| 164 | + | return _c | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (_c *MockBackupStorage_Delete_Call) RunAndReturn(run func(ctx context.Context, path string) error) *MockBackupStorage_Delete_Call { | |
| 168 | + | _c.Call.Return(run) | |
| 169 | + | return _c | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Get provides a mock function for the type MockBackupStorage | |
| 173 | + | func (_mock *MockBackupStorage) Get(ctx context.Context, path string) (io.ReadCloser, error) { | |
| 174 | + | ret := _mock.Called(ctx, path) | |
| 175 | + | ||
| 176 | + | if len(ret) == 0 { | |
| 177 | + | panic("no return value specified for Get") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | var r0 io.ReadCloser | |
| 181 | + | var r1 error | |
| 182 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (io.ReadCloser, error)); ok { | |
| 183 | + | return returnFunc(ctx, path) | |
| 184 | + | } | |
| 185 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) io.ReadCloser); ok { | |
| 186 | + | r0 = returnFunc(ctx, path) | |
| 187 | + | } else { | |
| 188 | + | if ret.Get(0) != nil { | |
| 189 | + | r0 = ret.Get(0).(io.ReadCloser) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 193 | + | r1 = returnFunc(ctx, path) | |
| 194 | + | } else { | |
| 195 | + | r1 = ret.Error(1) | |
| 196 | + | } | |
| 197 | + | return r0, r1 | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // MockBackupStorage_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |
| 201 | + | type MockBackupStorage_Get_Call struct { | |
| 202 | + | *mock.Call | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Get is a helper method to define mock.On call | |
| 206 | + | // - ctx context.Context | |
| 207 | + | // - path string | |
| 208 | + | func (_e *MockBackupStorage_Expecter) Get(ctx interface{}, path interface{}) *MockBackupStorage_Get_Call { | |
| 209 | + | return &MockBackupStorage_Get_Call{Call: _e.mock.On("Get", ctx, path)} | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func (_c *MockBackupStorage_Get_Call) Run(run func(ctx context.Context, path string)) *MockBackupStorage_Get_Call { | |
| 213 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 214 | + | var arg0 context.Context | |
| 215 | + | if args[0] != nil { | |
| 216 | + | arg0 = args[0].(context.Context) | |
| 217 | + | } | |
| 218 | + | var arg1 string | |
| 219 | + | if args[1] != nil { | |
| 220 | + | arg1 = args[1].(string) | |
| 221 | + | } | |
| 222 | + | run( | |
| 223 | + | arg0, | |
| 224 | + | arg1, | |
| 225 | + | ) | |
| 226 | + | }) | |
| 227 | + | return _c | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (_c *MockBackupStorage_Get_Call) Return(readCloser io.ReadCloser, err error) *MockBackupStorage_Get_Call { | |
| 231 | + | _c.Call.Return(readCloser, err) | |
| 232 | + | return _c | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func (_c *MockBackupStorage_Get_Call) RunAndReturn(run func(ctx context.Context, path string) (io.ReadCloser, error)) *MockBackupStorage_Get_Call { | |
| 236 | + | _c.Call.Return(run) | |
| 237 | + | return _c | |
| 238 | + | } | |
| 239 | + | ||
| 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) { | |
| 242 | + | ret := _mock.Called(ctx, domainName, schedule) | |
| 243 | + | ||
| 244 | + | if len(ret) == 0 { | |
| 245 | + | panic("no return value specified for List") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | var r0 []domain.BackupJob | |
| 249 | + | var r1 error | |
| 250 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error)); ok { | |
| 251 | + | return returnFunc(ctx, domainName, schedule) | |
| 252 | + | } | |
| 253 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) []domain.BackupJob); ok { | |
| 254 | + | r0 = returnFunc(ctx, domainName, schedule) | |
| 255 | + | } else { | |
| 256 | + | if ret.Get(0) != nil { | |
| 257 | + | r0 = ret.Get(0).([]domain.BackupJob) | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, *domain.BackupSchedule) error); ok { | |
| 261 | + | r1 = returnFunc(ctx, domainName, schedule) | |
| 262 | + | } else { | |
| 263 | + | r1 = ret.Error(1) | |
| 264 | + | } | |
| 265 | + | return r0, r1 | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // MockBackupStorage_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |
| 269 | + | type MockBackupStorage_List_Call struct { | |
| 270 | + | *mock.Call | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // List is a helper method to define mock.On call | |
| 274 | + | // - ctx context.Context | |
| 275 | + | // - domainName string | |
| 276 | + | // - schedule *domain.BackupSchedule | |
| 277 | + | func (_e *MockBackupStorage_Expecter) List(ctx interface{}, domainName interface{}, schedule interface{}) *MockBackupStorage_List_Call { | |
| 278 | + | return &MockBackupStorage_List_Call{Call: _e.mock.On("List", ctx, domainName, schedule)} | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func (_c *MockBackupStorage_List_Call) Run(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule)) *MockBackupStorage_List_Call { | |
| 282 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 283 | + | var arg0 context.Context | |
| 284 | + | if args[0] != nil { | |
| 285 | + | arg0 = args[0].(context.Context) | |
| 286 | + | } | |
| 287 | + | var arg1 string | |
| 288 | + | if args[1] != nil { | |
| 289 | + | arg1 = args[1].(string) | |
| 290 | + | } | |
| 291 | + | var arg2 *domain.BackupSchedule | |
| 292 | + | if args[2] != nil { | |
| 293 | + | arg2 = args[2].(*domain.BackupSchedule) | |
| 294 | + | } | |
| 295 | + | run( | |
| 296 | + | arg0, | |
| 297 | + | arg1, | |
| 298 | + | arg2, | |
| 299 | + | ) | |
| 300 | + | }) | |
| 301 | + | return _c | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (_c *MockBackupStorage_List_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupStorage_List_Call { | |
| 305 | + | _c.Call.Return(backupJobs, err) | |
| 306 | + | return _c | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | func (_c *MockBackupStorage_List_Call) RunAndReturn(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)) *MockBackupStorage_List_Call { | |
| 310 | + | _c.Call.Return(run) | |
| 311 | + | return _c | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | // Store provides a mock function for the type MockBackupStorage | |
| 315 | + | func (_mock *MockBackupStorage) Store(ctx context.Context, domainName string, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | |
| 316 | + | ret := _mock.Called(ctx, domainName, dbName, schedule, timestamp, data) | |
| 317 | + | ||
| 318 | + | if len(ret) == 0 { | |
| 319 | + | panic("no return value specified for Store") | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | var r0 string | |
| 323 | + | var r1 error | |
| 324 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, domain.BackupSchedule, time.Time, io.Reader) (string, error)); ok { | |
| 325 | + | return returnFunc(ctx, domainName, dbName, schedule, timestamp, data) | |
| 326 | + | } | |
| 327 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, domain.BackupSchedule, time.Time, io.Reader) string); ok { | |
| 328 | + | r0 = returnFunc(ctx, domainName, dbName, schedule, timestamp, data) | |
| 329 | + | } else { | |
| 330 | + | r0 = ret.Get(0).(string) | |
| 331 | + | } | |
| 332 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, domain.BackupSchedule, time.Time, io.Reader) error); ok { | |
| 333 | + | r1 = returnFunc(ctx, domainName, dbName, schedule, timestamp, data) | |
| 334 | + | } else { | |
| 335 | + | r1 = ret.Error(1) | |
| 336 | + | } | |
| 337 | + | return r0, r1 | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // MockBackupStorage_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store' | |
| 341 | + | type MockBackupStorage_Store_Call struct { | |
| 342 | + | *mock.Call | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // Store is a helper method to define mock.On call | |
| 346 | + | // - ctx context.Context | |
| 347 | + | // - domainName string | |
| 348 | + | // - dbName string | |
| 349 | + | // - schedule domain.BackupSchedule | |
| 350 | + | // - timestamp time.Time | |
| 351 | + | // - data io.Reader | |
| 352 | + | func (_e *MockBackupStorage_Expecter) Store(ctx interface{}, domainName interface{}, dbName interface{}, schedule interface{}, timestamp interface{}, data interface{}) *MockBackupStorage_Store_Call { | |
| 353 | + | return &MockBackupStorage_Store_Call{Call: _e.mock.On("Store", ctx, domainName, dbName, schedule, timestamp, data)} | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | func (_c *MockBackupStorage_Store_Call) Run(run func(ctx context.Context, domainName string, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader)) *MockBackupStorage_Store_Call { | |
| 357 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 358 | + | var arg0 context.Context | |
| 359 | + | if args[0] != nil { | |
| 360 | + | arg0 = args[0].(context.Context) | |
| 361 | + | } | |
| 362 | + | var arg1 string | |
| 363 | + | if args[1] != nil { | |
| 364 | + | arg1 = args[1].(string) | |
| 365 | + | } | |
| 366 | + | var arg2 string | |
| 367 | + | if args[2] != nil { | |
| 368 | + | arg2 = args[2].(string) | |
| 369 | + | } | |
| 370 | + | var arg3 domain.BackupSchedule | |
| 371 | + | if args[3] != nil { | |
| 372 | + | arg3 = args[3].(domain.BackupSchedule) | |
| 373 | + | } | |
| 374 | + | var arg4 time.Time | |
| 375 | + | if args[4] != nil { | |
| 376 | + | arg4 = args[4].(time.Time) | |
| 377 | + | } | |
| 378 | + | var arg5 io.Reader | |
| 379 | + | if args[5] != nil { | |
| 380 | + | arg5 = args[5].(io.Reader) | |
| 381 | + | } | |
| 382 | + | run( | |
| 383 | + | arg0, | |
| 384 | + | arg1, | |
| 385 | + | arg2, | |
| 386 | + | arg3, | |
| 387 | + | arg4, | |
| 388 | + | arg5, | |
| 389 | + | ) | |
| 390 | + | }) | |
| 391 | + | return _c | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func (_c *MockBackupStorage_Store_Call) Return(s string, err error) *MockBackupStorage_Store_Call { | |
| 395 | + | _c.Call.Return(s, err) | |
| 396 | + | return _c | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func (_c *MockBackupStorage_Store_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)) *MockBackupStorage_Store_Call { | |
| 400 | + | _c.Call.Return(run) | |
| 401 | + | return _c | |
| 402 | + | } | |
| @@ -0,0 +1,402 @@ | |||||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |||
| 2 | + | // github.com/vektra/mockery | |||
| 3 | + | // template: testify | |||
| 4 | + | ||||
| 5 | + | package mocks | |||
| 6 | + | ||||
| 7 | + | import ( | |||
| 8 | + | "context" | |||
| 9 | + | "io" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | mock "github.com/stretchr/testify/mock" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // NewMockBackupStorage creates a new instance of MockBackupStorage. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 17 | + | // The first argument is typically a *testing.T value. | |||
| 18 | + | func NewMockBackupStorage(t interface { | |||
| 19 | + | mock.TestingT | |||
| 20 | + | Cleanup(func()) | |||
| 21 | + | }) *MockBackupStorage { | |||
| 22 | + | mock := &MockBackupStorage{} | |||
| 23 | + | mock.Mock.Test(t) | |||
| 24 | + | ||||
| 25 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 26 | + | ||||
| 27 | + | return mock | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // MockBackupStorage is an autogenerated mock type for the BackupStorage type | |||
| 31 | + | type MockBackupStorage struct { | |||
| 32 | + | mock.Mock | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | type MockBackupStorage_Expecter struct { | |||
| 36 | + | mock *mock.Mock | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (_m *MockBackupStorage) EXPECT() *MockBackupStorage_Expecter { | |||
| 40 | + | return &MockBackupStorage_Expecter{mock: &_m.Mock} | |||
| 41 | + | } | |||
| 42 | + | ||||
| 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) { | |||
| 45 | + | ret := _mock.Called(ctx, domainName, policy) | |||
| 46 | + | ||||
| 47 | + | if len(ret) == 0 { | |||
| 48 | + | panic("no return value specified for ApplyRetention") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | var r0 int | |||
| 52 | + | var r1 error | |||
| 53 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) (int, error)); ok { | |||
| 54 | + | return returnFunc(ctx, domainName, policy) | |||
| 55 | + | } | |||
| 56 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, domain.RetentionPolicy) int); ok { | |||
| 57 | + | r0 = returnFunc(ctx, domainName, policy) | |||
| 58 | + | } else { | |||
| 59 | + | r0 = ret.Get(0).(int) | |||
| 60 | + | } | |||
| 61 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, domain.RetentionPolicy) error); ok { | |||
| 62 | + | r1 = returnFunc(ctx, domainName, policy) | |||
| 63 | + | } else { | |||
| 64 | + | r1 = ret.Error(1) | |||
| 65 | + | } | |||
| 66 | + | return r0, r1 | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // MockBackupStorage_ApplyRetention_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyRetention' | |||
| 70 | + | type MockBackupStorage_ApplyRetention_Call struct { | |||
| 71 | + | *mock.Call | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ApplyRetention is a helper method to define mock.On call | |||
| 75 | + | // - ctx context.Context | |||
| 76 | + | // - domainName string | |||
| 77 | + | // - policy domain.RetentionPolicy | |||
| 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)} | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockBackupStorage_ApplyRetention_Call) Run(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy)) *MockBackupStorage_ApplyRetention_Call { | |||
| 83 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 84 | + | var arg0 context.Context | |||
| 85 | + | if args[0] != nil { | |||
| 86 | + | arg0 = args[0].(context.Context) | |||
| 87 | + | } | |||
| 88 | + | var arg1 string | |||
| 89 | + | if args[1] != nil { | |||
| 90 | + | arg1 = args[1].(string) | |||
| 91 | + | } | |||
| 92 | + | var arg2 domain.RetentionPolicy | |||
| 93 | + | if args[2] != nil { | |||
| 94 | + | arg2 = args[2].(domain.RetentionPolicy) | |||
| 95 | + | } | |||
| 96 | + | run( | |||
| 97 | + | arg0, | |||
| 98 | + | arg1, | |||
| 99 | + | arg2, | |||
| 100 | + | ) | |||
| 101 | + | }) | |||
| 102 | + | return _c | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (_c *MockBackupStorage_ApplyRetention_Call) Return(n int, err error) *MockBackupStorage_ApplyRetention_Call { | |||
| 106 | + | _c.Call.Return(n, err) | |||
| 107 | + | return _c | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (_c *MockBackupStorage_ApplyRetention_Call) RunAndReturn(run func(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error)) *MockBackupStorage_ApplyRetention_Call { | |||
| 111 | + | _c.Call.Return(run) | |||
| 112 | + | return _c | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // Delete provides a mock function for the type MockBackupStorage | |||
| 116 | + | func (_mock *MockBackupStorage) Delete(ctx context.Context, path string) error { | |||
| 117 | + | ret := _mock.Called(ctx, path) | |||
| 118 | + | ||||
| 119 | + | if len(ret) == 0 { | |||
| 120 | + | panic("no return value specified for Delete") | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | var r0 error | |||
| 124 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { | |||
| 125 | + | r0 = returnFunc(ctx, path) | |||
| 126 | + | } else { | |||
| 127 | + | r0 = ret.Error(0) | |||
| 128 | + | } | |||
| 129 | + | return r0 | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // MockBackupStorage_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' | |||
| 133 | + | type MockBackupStorage_Delete_Call struct { | |||
| 134 | + | *mock.Call | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // Delete is a helper method to define mock.On call | |||
| 138 | + | // - ctx context.Context | |||
| 139 | + | // - path string | |||
| 140 | + | func (_e *MockBackupStorage_Expecter) Delete(ctx interface{}, path interface{}) *MockBackupStorage_Delete_Call { | |||
| 141 | + | return &MockBackupStorage_Delete_Call{Call: _e.mock.On("Delete", ctx, path)} | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func (_c *MockBackupStorage_Delete_Call) Run(run func(ctx context.Context, path string)) *MockBackupStorage_Delete_Call { | |||
| 145 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 146 | + | var arg0 context.Context | |||
| 147 | + | if args[0] != nil { | |||
| 148 | + | arg0 = args[0].(context.Context) | |||
| 149 | + | } | |||
| 150 | + | var arg1 string | |||
| 151 | + | if args[1] != nil { | |||
| 152 | + | arg1 = args[1].(string) | |||
| 153 | + | } | |||
| 154 | + | run( | |||
| 155 | + | arg0, | |||
| 156 | + | arg1, | |||
| 157 | + | ) | |||
| 158 | + | }) | |||
| 159 | + | return _c | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func (_c *MockBackupStorage_Delete_Call) Return(err error) *MockBackupStorage_Delete_Call { | |||
| 163 | + | _c.Call.Return(err) | |||
| 164 | + | return _c | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (_c *MockBackupStorage_Delete_Call) RunAndReturn(run func(ctx context.Context, path string) error) *MockBackupStorage_Delete_Call { | |||
| 168 | + | _c.Call.Return(run) | |||
| 169 | + | return _c | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Get provides a mock function for the type MockBackupStorage | |||
| 173 | + | func (_mock *MockBackupStorage) Get(ctx context.Context, path string) (io.ReadCloser, error) { | |||
| 174 | + | ret := _mock.Called(ctx, path) | |||
| 175 | + | ||||
| 176 | + | if len(ret) == 0 { | |||
| 177 | + | panic("no return value specified for Get") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | var r0 io.ReadCloser | |||
| 181 | + | var r1 error | |||
| 182 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (io.ReadCloser, error)); ok { | |||
| 183 | + | return returnFunc(ctx, path) | |||
| 184 | + | } | |||
| 185 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) io.ReadCloser); ok { | |||
| 186 | + | r0 = returnFunc(ctx, path) | |||
| 187 | + | } else { | |||
| 188 | + | if ret.Get(0) != nil { | |||
| 189 | + | r0 = ret.Get(0).(io.ReadCloser) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |||
| 193 | + | r1 = returnFunc(ctx, path) | |||
| 194 | + | } else { | |||
| 195 | + | r1 = ret.Error(1) | |||
| 196 | + | } | |||
| 197 | + | return r0, r1 | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // MockBackupStorage_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' | |||
| 201 | + | type MockBackupStorage_Get_Call struct { | |||
| 202 | + | *mock.Call | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // Get is a helper method to define mock.On call | |||
| 206 | + | // - ctx context.Context | |||
| 207 | + | // - path string | |||
| 208 | + | func (_e *MockBackupStorage_Expecter) Get(ctx interface{}, path interface{}) *MockBackupStorage_Get_Call { | |||
| 209 | + | return &MockBackupStorage_Get_Call{Call: _e.mock.On("Get", ctx, path)} | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func (_c *MockBackupStorage_Get_Call) Run(run func(ctx context.Context, path string)) *MockBackupStorage_Get_Call { | |||
| 213 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 214 | + | var arg0 context.Context | |||
| 215 | + | if args[0] != nil { | |||
| 216 | + | arg0 = args[0].(context.Context) | |||
| 217 | + | } | |||
| 218 | + | var arg1 string | |||
| 219 | + | if args[1] != nil { | |||
| 220 | + | arg1 = args[1].(string) | |||
| 221 | + | } | |||
| 222 | + | run( | |||
| 223 | + | arg0, | |||
| 224 | + | arg1, | |||
| 225 | + | ) | |||
| 226 | + | }) | |||
| 227 | + | return _c | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func (_c *MockBackupStorage_Get_Call) Return(readCloser io.ReadCloser, err error) *MockBackupStorage_Get_Call { | |||
| 231 | + | _c.Call.Return(readCloser, err) | |||
| 232 | + | return _c | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func (_c *MockBackupStorage_Get_Call) RunAndReturn(run func(ctx context.Context, path string) (io.ReadCloser, error)) *MockBackupStorage_Get_Call { | |||
| 236 | + | _c.Call.Return(run) | |||
| 237 | + | return _c | |||
| 238 | + | } | |||
| 239 | + | ||||
| 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) { | |||
| 242 | + | ret := _mock.Called(ctx, domainName, schedule) | |||
| 243 | + | ||||
| 244 | + | if len(ret) == 0 { | |||
| 245 | + | panic("no return value specified for List") | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | var r0 []domain.BackupJob | |||
| 249 | + | var r1 error | |||
| 250 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error)); ok { | |||
| 251 | + | return returnFunc(ctx, domainName, schedule) | |||
| 252 | + | } | |||
| 253 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, *domain.BackupSchedule) []domain.BackupJob); ok { | |||
| 254 | + | r0 = returnFunc(ctx, domainName, schedule) | |||
| 255 | + | } else { | |||
| 256 | + | if ret.Get(0) != nil { | |||
| 257 | + | r0 = ret.Get(0).([]domain.BackupJob) | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, *domain.BackupSchedule) error); ok { | |||
| 261 | + | r1 = returnFunc(ctx, domainName, schedule) | |||
| 262 | + | } else { | |||
| 263 | + | r1 = ret.Error(1) | |||
| 264 | + | } | |||
| 265 | + | return r0, r1 | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | // MockBackupStorage_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' | |||
| 269 | + | type MockBackupStorage_List_Call struct { | |||
| 270 | + | *mock.Call | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // List is a helper method to define mock.On call | |||
| 274 | + | // - ctx context.Context | |||
| 275 | + | // - domainName string | |||
| 276 | + | // - schedule *domain.BackupSchedule | |||
| 277 | + | func (_e *MockBackupStorage_Expecter) List(ctx interface{}, domainName interface{}, schedule interface{}) *MockBackupStorage_List_Call { | |||
| 278 | + | return &MockBackupStorage_List_Call{Call: _e.mock.On("List", ctx, domainName, schedule)} | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func (_c *MockBackupStorage_List_Call) Run(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule)) *MockBackupStorage_List_Call { | |||
| 282 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 283 | + | var arg0 context.Context | |||
| 284 | + | if args[0] != nil { | |||
| 285 | + | arg0 = args[0].(context.Context) | |||
| 286 | + | } | |||
| 287 | + | var arg1 string | |||
| 288 | + | if args[1] != nil { | |||
| 289 | + | arg1 = args[1].(string) | |||
| 290 | + | } | |||
| 291 | + | var arg2 *domain.BackupSchedule | |||
| 292 | + | if args[2] != nil { | |||
| 293 | + | arg2 = args[2].(*domain.BackupSchedule) | |||
| 294 | + | } | |||
| 295 | + | run( | |||
| 296 | + | arg0, | |||
| 297 | + | arg1, | |||
| 298 | + | arg2, | |||
| 299 | + | ) | |||
| 300 | + | }) | |||
| 301 | + | return _c | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func (_c *MockBackupStorage_List_Call) Return(backupJobs []domain.BackupJob, err error) *MockBackupStorage_List_Call { | |||
| 305 | + | _c.Call.Return(backupJobs, err) | |||
| 306 | + | return _c | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | func (_c *MockBackupStorage_List_Call) RunAndReturn(run func(ctx context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)) *MockBackupStorage_List_Call { | |||
| 310 | + | _c.Call.Return(run) | |||
| 311 | + | return _c | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | // Store provides a mock function for the type MockBackupStorage | |||
| 315 | + | func (_mock *MockBackupStorage) Store(ctx context.Context, domainName string, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | |||
| 316 | + | ret := _mock.Called(ctx, domainName, dbName, schedule, timestamp, data) | |||
| 317 | + | ||||
| 318 | + | if len(ret) == 0 { | |||
| 319 | + | panic("no return value specified for Store") | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | var r0 string | |||
| 323 | + | var r1 error | |||
| 324 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, domain.BackupSchedule, time.Time, io.Reader) (string, error)); ok { | |||
| 325 | + | return returnFunc(ctx, domainName, dbName, schedule, timestamp, data) | |||
| 326 | + | } | |||
| 327 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, domain.BackupSchedule, time.Time, io.Reader) string); ok { | |||
| 328 | + | r0 = returnFunc(ctx, domainName, dbName, schedule, timestamp, data) | |||
| 329 | + | } else { | |||
| 330 | + | r0 = ret.Get(0).(string) | |||
| 331 | + | } | |||
| 332 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, domain.BackupSchedule, time.Time, io.Reader) error); ok { | |||
| 333 | + | r1 = returnFunc(ctx, domainName, dbName, schedule, timestamp, data) | |||
| 334 | + | } else { | |||
| 335 | + | r1 = ret.Error(1) | |||
| 336 | + | } | |||
| 337 | + | return r0, r1 | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // MockBackupStorage_Store_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Store' | |||
| 341 | + | type MockBackupStorage_Store_Call struct { | |||
| 342 | + | *mock.Call | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // Store is a helper method to define mock.On call | |||
| 346 | + | // - ctx context.Context | |||
| 347 | + | // - domainName string | |||
| 348 | + | // - dbName string | |||
| 349 | + | // - schedule domain.BackupSchedule | |||
| 350 | + | // - timestamp time.Time | |||
| 351 | + | // - data io.Reader | |||
| 352 | + | func (_e *MockBackupStorage_Expecter) Store(ctx interface{}, domainName interface{}, dbName interface{}, schedule interface{}, timestamp interface{}, data interface{}) *MockBackupStorage_Store_Call { | |||
| 353 | + | return &MockBackupStorage_Store_Call{Call: _e.mock.On("Store", ctx, domainName, dbName, schedule, timestamp, data)} | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | func (_c *MockBackupStorage_Store_Call) Run(run func(ctx context.Context, domainName string, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader)) *MockBackupStorage_Store_Call { | |||
| 357 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 358 | + | var arg0 context.Context | |||
| 359 | + | if args[0] != nil { | |||
| 360 | + | arg0 = args[0].(context.Context) | |||
| 361 | + | } | |||
| 362 | + | var arg1 string | |||
| 363 | + | if args[1] != nil { | |||
| 364 | + | arg1 = args[1].(string) | |||
| 365 | + | } | |||
| 366 | + | var arg2 string | |||
| 367 | + | if args[2] != nil { | |||
| 368 | + | arg2 = args[2].(string) | |||
| 369 | + | } | |||
| 370 | + | var arg3 domain.BackupSchedule | |||
| 371 | + | if args[3] != nil { | |||
| 372 | + | arg3 = args[3].(domain.BackupSchedule) | |||
| 373 | + | } | |||
| 374 | + | var arg4 time.Time | |||
| 375 | + | if args[4] != nil { | |||
| 376 | + | arg4 = args[4].(time.Time) | |||
| 377 | + | } | |||
| 378 | + | var arg5 io.Reader | |||
| 379 | + | if args[5] != nil { | |||
| 380 | + | arg5 = args[5].(io.Reader) | |||
| 381 | + | } | |||
| 382 | + | run( | |||
| 383 | + | arg0, | |||
| 384 | + | arg1, | |||
| 385 | + | arg2, | |||
| 386 | + | arg3, | |||
| 387 | + | arg4, | |||
| 388 | + | arg5, | |||
| 389 | + | ) | |||
| 390 | + | }) | |||
| 391 | + | return _c | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func (_c *MockBackupStorage_Store_Call) Return(s string, err error) *MockBackupStorage_Store_Call { | |||
| 395 | + | _c.Call.Return(s, err) | |||
| 396 | + | return _c | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func (_c *MockBackupStorage_Store_Call) RunAndReturn(run func(ctx context.Context, domainName string, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)) *MockBackupStorage_Store_Call { | |||
| 400 | + | _c.Call.Return(run) | |||
| 401 | + | return _c | |||
| 402 | + | } | |||
M internal/boundaries/out/mocks/mock_container_runtime.go +149 -0
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "io" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | 12 | "github.com/bnema/gordon/internal/domain" | |
| 12 | 13 | mock "github.com/stretchr/testify/mock" | |
| 13 | 14 | ) | |
| @@ -102,6 +103,80 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run | |||
| 102 | 103 | return _c | |
| 103 | 104 | } | |
| 104 | 105 | ||
| 106 | + | // CopyFromContainer provides a mock function for the type MockContainerRuntime | |
| 107 | + | func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) ([]byte, error) { | |
| 108 | + | ret := _mock.Called(ctx, containerID, srcPath) | |
| 109 | + | ||
| 110 | + | if len(ret) == 0 { | |
| 111 | + | panic("no return value specified for CopyFromContainer") | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | var r0 []byte | |
| 115 | + | var r1 error | |
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]byte, error)); ok { | |
| 117 | + | return returnFunc(ctx, containerID, srcPath) | |
| 118 | + | } | |
| 119 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []byte); ok { | |
| 120 | + | r0 = returnFunc(ctx, containerID, srcPath) | |
| 121 | + | } else { | |
| 122 | + | if ret.Get(0) != nil { | |
| 123 | + | r0 = ret.Get(0).([]byte) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |
| 127 | + | r1 = returnFunc(ctx, containerID, srcPath) | |
| 128 | + | } else { | |
| 129 | + | r1 = ret.Error(1) | |
| 130 | + | } | |
| 131 | + | return r0, r1 | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // MockContainerRuntime_CopyFromContainer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CopyFromContainer' | |
| 135 | + | type MockContainerRuntime_CopyFromContainer_Call struct { | |
| 136 | + | *mock.Call | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // CopyFromContainer is a helper method to define mock.On call | |
| 140 | + | // - ctx context.Context | |
| 141 | + | // - containerID string | |
| 142 | + | // - srcPath string | |
| 143 | + | func (_e *MockContainerRuntime_Expecter) CopyFromContainer(ctx interface{}, containerID interface{}, srcPath interface{}) *MockContainerRuntime_CopyFromContainer_Call { | |
| 144 | + | return &MockContainerRuntime_CopyFromContainer_Call{Call: _e.mock.On("CopyFromContainer", ctx, containerID, srcPath)} | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func (_c *MockContainerRuntime_CopyFromContainer_Call) Run(run func(ctx context.Context, containerID string, srcPath string)) *MockContainerRuntime_CopyFromContainer_Call { | |
| 148 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 149 | + | var arg0 context.Context | |
| 150 | + | if args[0] != nil { | |
| 151 | + | arg0 = args[0].(context.Context) | |
| 152 | + | } | |
| 153 | + | var arg1 string | |
| 154 | + | if args[1] != nil { | |
| 155 | + | arg1 = args[1].(string) | |
| 156 | + | } | |
| 157 | + | var arg2 string | |
| 158 | + | if args[2] != nil { | |
| 159 | + | arg2 = args[2].(string) | |
| 160 | + | } | |
| 161 | + | run( | |
| 162 | + | arg0, | |
| 163 | + | arg1, | |
| 164 | + | arg2, | |
| 165 | + | ) | |
| 166 | + | }) | |
| 167 | + | return _c | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func (_c *MockContainerRuntime_CopyFromContainer_Call) Return(bytes []byte, err error) *MockContainerRuntime_CopyFromContainer_Call { | |
| 171 | + | _c.Call.Return(bytes, err) | |
| 172 | + | return _c | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) ([]byte, error)) *MockContainerRuntime_CopyFromContainer_Call { | |
| 176 | + | _c.Call.Return(run) | |
| 177 | + | return _c | |
| 178 | + | } | |
| 179 | + | ||
| 105 | 180 | // CreateContainer provides a mock function for the type MockContainerRuntime | |
| 106 | 181 | func (_mock *MockContainerRuntime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) { | |
| 107 | 182 | ret := _mock.Called(ctx, config) | |
| @@ -353,6 +428,80 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn | |||
| 353 | 428 | return _c | |
| 354 | 429 | } | |
| 355 | 430 | ||
| 431 | + | // ExecInContainer provides a mock function for the type MockContainerRuntime | |
| 432 | + | func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { | |
| 433 | + | ret := _mock.Called(ctx, containerID, cmd) | |
| 434 | + | ||
| 435 | + | if len(ret) == 0 { | |
| 436 | + | panic("no return value specified for ExecInContainer") | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | var r0 *out.ExecResult | |
| 440 | + | var r1 error | |
| 441 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) (*out.ExecResult, error)); ok { | |
| 442 | + | return returnFunc(ctx, containerID, cmd) | |
| 443 | + | } | |
| 444 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) *out.ExecResult); ok { | |
| 445 | + | r0 = returnFunc(ctx, containerID, cmd) | |
| 446 | + | } else { | |
| 447 | + | if ret.Get(0) != nil { | |
| 448 | + | r0 = ret.Get(0).(*out.ExecResult) | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string) error); ok { | |
| 452 | + | r1 = returnFunc(ctx, containerID, cmd) | |
| 453 | + | } else { | |
| 454 | + | r1 = ret.Error(1) | |
| 455 | + | } | |
| 456 | + | return r0, r1 | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // MockContainerRuntime_ExecInContainer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecInContainer' | |
| 460 | + | type MockContainerRuntime_ExecInContainer_Call struct { | |
| 461 | + | *mock.Call | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // ExecInContainer is a helper method to define mock.On call | |
| 465 | + | // - ctx context.Context | |
| 466 | + | // - containerID string | |
| 467 | + | // - cmd []string | |
| 468 | + | func (_e *MockContainerRuntime_Expecter) ExecInContainer(ctx interface{}, containerID interface{}, cmd interface{}) *MockContainerRuntime_ExecInContainer_Call { | |
| 469 | + | return &MockContainerRuntime_ExecInContainer_Call{Call: _e.mock.On("ExecInContainer", ctx, containerID, cmd)} | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) Run(run func(ctx context.Context, containerID string, cmd []string)) *MockContainerRuntime_ExecInContainer_Call { | |
| 473 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 474 | + | var arg0 context.Context | |
| 475 | + | if args[0] != nil { | |
| 476 | + | arg0 = args[0].(context.Context) | |
| 477 | + | } | |
| 478 | + | var arg1 string | |
| 479 | + | if args[1] != nil { | |
| 480 | + | arg1 = args[1].(string) | |
| 481 | + | } | |
| 482 | + | var arg2 []string | |
| 483 | + | if args[2] != nil { | |
| 484 | + | arg2 = args[2].([]string) | |
| 485 | + | } | |
| 486 | + | run( | |
| 487 | + | arg0, | |
| 488 | + | arg1, | |
| 489 | + | arg2, | |
| 490 | + | ) | |
| 491 | + | }) | |
| 492 | + | return _c | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) Return(execResult *out.ExecResult, err error) *MockContainerRuntime_ExecInContainer_Call { | |
| 496 | + | _c.Call.Return(execResult, err) | |
| 497 | + | return _c | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error)) *MockContainerRuntime_ExecInContainer_Call { | |
| 501 | + | _c.Call.Return(run) | |
| 502 | + | return _c | |
| 503 | + | } | |
| 504 | + | ||
| 356 | 505 | // GetContainerExposedPorts provides a mock function for the type MockContainerRuntime | |
| 357 | 506 | func (_mock *MockContainerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | |
| 358 | 507 | ret := _mock.Called(ctx, containerID) | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "context" | 8 | "context" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | 10 | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 11 | "github.com/bnema/gordon/internal/domain" | 12 | "github.com/bnema/gordon/internal/domain" | ||
| 12 | mock "github.com/stretchr/testify/mock" | 13 | mock "github.com/stretchr/testify/mock" | ||
| 13 | ) | 14 | ) | ||
| @@ -102,6 +103,80 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run | |||||
| 102 | return _c | 103 | return _c | ||
| 103 | } | 104 | } | ||
| 104 | 105 | ||||
| 106 | + | // CopyFromContainer provides a mock function for the type MockContainerRuntime | |||
| 107 | + | func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) ([]byte, error) { | |||
| 108 | + | ret := _mock.Called(ctx, containerID, srcPath) | |||
| 109 | + | ||||
| 110 | + | if len(ret) == 0 { | |||
| 111 | + | panic("no return value specified for CopyFromContainer") | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | var r0 []byte | |||
| 115 | + | var r1 error | |||
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]byte, error)); ok { | |||
| 117 | + | return returnFunc(ctx, containerID, srcPath) | |||
| 118 | + | } | |||
| 119 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []byte); ok { | |||
| 120 | + | r0 = returnFunc(ctx, containerID, srcPath) | |||
| 121 | + | } else { | |||
| 122 | + | if ret.Get(0) != nil { | |||
| 123 | + | r0 = ret.Get(0).([]byte) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { | |||
| 127 | + | r1 = returnFunc(ctx, containerID, srcPath) | |||
| 128 | + | } else { | |||
| 129 | + | r1 = ret.Error(1) | |||
| 130 | + | } | |||
| 131 | + | return r0, r1 | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // MockContainerRuntime_CopyFromContainer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CopyFromContainer' | |||
| 135 | + | type MockContainerRuntime_CopyFromContainer_Call struct { | |||
| 136 | + | *mock.Call | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // CopyFromContainer is a helper method to define mock.On call | |||
| 140 | + | // - ctx context.Context | |||
| 141 | + | // - containerID string | |||
| 142 | + | // - srcPath string | |||
| 143 | + | func (_e *MockContainerRuntime_Expecter) CopyFromContainer(ctx interface{}, containerID interface{}, srcPath interface{}) *MockContainerRuntime_CopyFromContainer_Call { | |||
| 144 | + | return &MockContainerRuntime_CopyFromContainer_Call{Call: _e.mock.On("CopyFromContainer", ctx, containerID, srcPath)} | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func (_c *MockContainerRuntime_CopyFromContainer_Call) Run(run func(ctx context.Context, containerID string, srcPath string)) *MockContainerRuntime_CopyFromContainer_Call { | |||
| 148 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 149 | + | var arg0 context.Context | |||
| 150 | + | if args[0] != nil { | |||
| 151 | + | arg0 = args[0].(context.Context) | |||
| 152 | + | } | |||
| 153 | + | var arg1 string | |||
| 154 | + | if args[1] != nil { | |||
| 155 | + | arg1 = args[1].(string) | |||
| 156 | + | } | |||
| 157 | + | var arg2 string | |||
| 158 | + | if args[2] != nil { | |||
| 159 | + | arg2 = args[2].(string) | |||
| 160 | + | } | |||
| 161 | + | run( | |||
| 162 | + | arg0, | |||
| 163 | + | arg1, | |||
| 164 | + | arg2, | |||
| 165 | + | ) | |||
| 166 | + | }) | |||
| 167 | + | return _c | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func (_c *MockContainerRuntime_CopyFromContainer_Call) Return(bytes []byte, err error) *MockContainerRuntime_CopyFromContainer_Call { | |||
| 171 | + | _c.Call.Return(bytes, err) | |||
| 172 | + | return _c | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) ([]byte, error)) *MockContainerRuntime_CopyFromContainer_Call { | |||
| 176 | + | _c.Call.Return(run) | |||
| 177 | + | return _c | |||
| 178 | + | } | |||
| 179 | + | ||||
| 105 | // CreateContainer provides a mock function for the type MockContainerRuntime | 180 | // CreateContainer provides a mock function for the type MockContainerRuntime | ||
| 106 | func (_mock *MockContainerRuntime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) { | 181 | func (_mock *MockContainerRuntime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) { | ||
| 107 | ret := _mock.Called(ctx, config) | 182 | ret := _mock.Called(ctx, config) | ||
| @@ -353,6 +428,80 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn | |||||
| 353 | return _c | 428 | return _c | ||
| 354 | } | 429 | } | ||
| 355 | 430 | ||||
| 431 | + | // ExecInContainer provides a mock function for the type MockContainerRuntime | |||
| 432 | + | func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { | |||
| 433 | + | ret := _mock.Called(ctx, containerID, cmd) | |||
| 434 | + | ||||
| 435 | + | if len(ret) == 0 { | |||
| 436 | + | panic("no return value specified for ExecInContainer") | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | var r0 *out.ExecResult | |||
| 440 | + | var r1 error | |||
| 441 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) (*out.ExecResult, error)); ok { | |||
| 442 | + | return returnFunc(ctx, containerID, cmd) | |||
| 443 | + | } | |||
| 444 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) *out.ExecResult); ok { | |||
| 445 | + | r0 = returnFunc(ctx, containerID, cmd) | |||
| 446 | + | } else { | |||
| 447 | + | if ret.Get(0) != nil { | |||
| 448 | + | r0 = ret.Get(0).(*out.ExecResult) | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string) error); ok { | |||
| 452 | + | r1 = returnFunc(ctx, containerID, cmd) | |||
| 453 | + | } else { | |||
| 454 | + | r1 = ret.Error(1) | |||
| 455 | + | } | |||
| 456 | + | return r0, r1 | |||
| 457 | + | } | |||
| 458 | + | ||||
| 459 | + | // MockContainerRuntime_ExecInContainer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecInContainer' | |||
| 460 | + | type MockContainerRuntime_ExecInContainer_Call struct { | |||
| 461 | + | *mock.Call | |||
| 462 | + | } | |||
| 463 | + | ||||
| 464 | + | // ExecInContainer is a helper method to define mock.On call | |||
| 465 | + | // - ctx context.Context | |||
| 466 | + | // - containerID string | |||
| 467 | + | // - cmd []string | |||
| 468 | + | func (_e *MockContainerRuntime_Expecter) ExecInContainer(ctx interface{}, containerID interface{}, cmd interface{}) *MockContainerRuntime_ExecInContainer_Call { | |||
| 469 | + | return &MockContainerRuntime_ExecInContainer_Call{Call: _e.mock.On("ExecInContainer", ctx, containerID, cmd)} | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) Run(run func(ctx context.Context, containerID string, cmd []string)) *MockContainerRuntime_ExecInContainer_Call { | |||
| 473 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 474 | + | var arg0 context.Context | |||
| 475 | + | if args[0] != nil { | |||
| 476 | + | arg0 = args[0].(context.Context) | |||
| 477 | + | } | |||
| 478 | + | var arg1 string | |||
| 479 | + | if args[1] != nil { | |||
| 480 | + | arg1 = args[1].(string) | |||
| 481 | + | } | |||
| 482 | + | var arg2 []string | |||
| 483 | + | if args[2] != nil { | |||
| 484 | + | arg2 = args[2].([]string) | |||
| 485 | + | } | |||
| 486 | + | run( | |||
| 487 | + | arg0, | |||
| 488 | + | arg1, | |||
| 489 | + | arg2, | |||
| 490 | + | ) | |||
| 491 | + | }) | |||
| 492 | + | return _c | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) Return(execResult *out.ExecResult, err error) *MockContainerRuntime_ExecInContainer_Call { | |||
| 496 | + | _c.Call.Return(execResult, err) | |||
| 497 | + | return _c | |||
| 498 | + | } | |||
| 499 | + | ||||
| 500 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error)) *MockContainerRuntime_ExecInContainer_Call { | |||
| 501 | + | _c.Call.Return(run) | |||
| 502 | + | return _c | |||
| 503 | + | } | |||
| 504 | + | ||||
| 356 | // GetContainerExposedPorts provides a mock function for the type MockContainerRuntime | 505 | // GetContainerExposedPorts provides a mock function for the type MockContainerRuntime | ||
| 357 | func (_mock *MockContainerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | 506 | func (_mock *MockContainerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | ||
| 358 | ret := _mock.Called(ctx, containerID) | 507 | ret := _mock.Called(ctx, containerID) | ||
M internal/boundaries/out/runtime.go +11 -0
| @@ -60,6 +60,10 @@ type ContainerRuntime interface { | |||
| 60 | 60 | // Label inspection | |
| 61 | 61 | GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) | |
| 62 | 62 | ||
| 63 | + | // In-container operations | |
| 64 | + | ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error) | |
| 65 | + | CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) | |
| 66 | + | ||
| 63 | 67 | // Network management | |
| 64 | 68 | CreateNetwork(ctx context.Context, name string, options map[string]string) error | |
| 65 | 69 | RemoveNetwork(ctx context.Context, name string) error | |
| @@ -68,3 +72,10 @@ type ContainerRuntime interface { | |||
| 68 | 72 | ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error | |
| 69 | 73 | DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error | |
| 70 | 74 | } | |
| 75 | + | ||
| 76 | + | // ExecResult holds the result of executing a command in a container. | |
| 77 | + | type ExecResult struct { | |
| 78 | + | ExitCode int | |
| 79 | + | Stdout []byte | |
| 80 | + | Stderr []byte | |
| 81 | + | } | |
| @@ -60,6 +60,10 @@ type ContainerRuntime interface { | |||||
| 60 | // Label inspection | 60 | // Label inspection | ||
| 61 | GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) | 61 | GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) | ||
| 62 | 62 | ||||
| 63 | + | // In-container operations | |||
| 64 | + | ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error) | |||
| 65 | + | CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) | |||
| 66 | + | ||||
| 63 | // Network management | 67 | // Network management | ||
| 64 | CreateNetwork(ctx context.Context, name string, options map[string]string) error | 68 | CreateNetwork(ctx context.Context, name string, options map[string]string) error | ||
| 65 | RemoveNetwork(ctx context.Context, name string) error | 69 | RemoveNetwork(ctx context.Context, name string) error | ||
| @@ -68,3 +72,10 @@ type ContainerRuntime interface { | |||||
| 68 | ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error | 72 | ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error | ||
| 69 | DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error | 73 | DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error | ||
| 70 | } | 74 | } | ||
| 75 | + | ||||
| 76 | + | // ExecResult holds the result of executing a command in a container. | |||
| 77 | + | type ExecResult struct { | |||
| 78 | + | ExitCode int | |||
| 79 | + | Stdout []byte | |||
| 80 | + | Stderr []byte | |||
| 81 | + | } | |||
A internal/domain/backup.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // DBType identifies the database engine. | |
| 6 | + | type DBType string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | DBTypePostgreSQL DBType = "postgresql" | |
| 10 | + | DBTypeUnknown DBType = "unknown" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // BackupSchedule defines when backups run. | |
| 14 | + | type BackupSchedule string | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | ScheduleHourly BackupSchedule = "hourly" | |
| 18 | + | ScheduleDaily BackupSchedule = "daily" | |
| 19 | + | ScheduleWeekly BackupSchedule = "weekly" | |
| 20 | + | ScheduleMonthly BackupSchedule = "monthly" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // BackupType distinguishes backup methods. | |
| 24 | + | type BackupType string | |
| 25 | + | ||
| 26 | + | const ( | |
| 27 | + | BackupTypeLogical BackupType = "logical" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // BackupJobStatus tracks backup job lifecycle state. | |
| 31 | + | type BackupJobStatus string | |
| 32 | + | ||
| 33 | + | const ( | |
| 34 | + | BackupStatusPending BackupJobStatus = "pending" | |
| 35 | + | BackupStatusRunning BackupJobStatus = "running" | |
| 36 | + | BackupStatusCompleted BackupJobStatus = "completed" | |
| 37 | + | BackupStatusFailed BackupJobStatus = "failed" | |
| 38 | + | ) | |
| 39 | + | ||
| 40 | + | // DBInfo holds detected database information from an attachment container. | |
| 41 | + | type DBInfo struct { | |
| 42 | + | Type DBType | |
| 43 | + | Version string | |
| 44 | + | Domain string | |
| 45 | + | Name string | |
| 46 | + | Host string | |
| 47 | + | Port int | |
| 48 | + | ContainerID string | |
| 49 | + | ImageName string | |
| 50 | + | Credentials map[string]string | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // BackupJob represents a scheduled or manual backup operation. | |
| 54 | + | type BackupJob struct { | |
| 55 | + | ID string | |
| 56 | + | Domain string | |
| 57 | + | DBName string | |
| 58 | + | Schedule BackupSchedule | |
| 59 | + | Type BackupType | |
| 60 | + | Status BackupJobStatus | |
| 61 | + | StartedAt time.Time | |
| 62 | + | CompletedAt time.Time | |
| 63 | + | SizeBytes int64 | |
| 64 | + | FilePath string | |
| 65 | + | Error string | |
| 66 | + | Metadata map[string]string | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // BackupResult is returned after a backup operation completes. | |
| 70 | + | type BackupResult struct { | |
| 71 | + | Job BackupJob | |
| 72 | + | Duration time.Duration | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // RetentionPolicy defines how many backups to keep per schedule tier. | |
| 76 | + | type RetentionPolicy struct { | |
| 77 | + | Hourly int | |
| 78 | + | Daily int | |
| 79 | + | Weekly int | |
| 80 | + | Monthly int | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // BackupOverride allows per-domain backup configuration. | |
| 84 | + | type BackupOverride struct { | |
| 85 | + | Schedules []BackupSchedule | |
| 86 | + | Retention *RetentionPolicy | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // BackupConfig is the top-level backup configuration. | |
| 90 | + | type BackupConfig struct { | |
| 91 | + | Enabled bool | |
| 92 | + | StorageDir string | |
| 93 | + | Retention RetentionPolicy | |
| 94 | + | Overrides map[string]BackupOverride | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Backup labels for container metadata. | |
| 98 | + | const ( | |
| 99 | + | LabelBackupEnabled = "gordon.backup" | |
| 100 | + | LabelBackupType = "gordon.backup.type" | |
| 101 | + | LabelBackupVersion = "gordon.backup.version" | |
| 102 | + | LabelBackupSchedule = "gordon.backup.schedule" | |
| 103 | + | LabelBackupSidecar = "gordon.backup.sidecar" | |
| 104 | + | ) | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // DBType identifies the database engine. | |||
| 6 | + | type DBType string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | DBTypePostgreSQL DBType = "postgresql" | |||
| 10 | + | DBTypeUnknown DBType = "unknown" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // BackupSchedule defines when backups run. | |||
| 14 | + | type BackupSchedule string | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | ScheduleHourly BackupSchedule = "hourly" | |||
| 18 | + | ScheduleDaily BackupSchedule = "daily" | |||
| 19 | + | ScheduleWeekly BackupSchedule = "weekly" | |||
| 20 | + | ScheduleMonthly BackupSchedule = "monthly" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // BackupType distinguishes backup methods. | |||
| 24 | + | type BackupType string | |||
| 25 | + | ||||
| 26 | + | const ( | |||
| 27 | + | BackupTypeLogical BackupType = "logical" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | // BackupJobStatus tracks backup job lifecycle state. | |||
| 31 | + | type BackupJobStatus string | |||
| 32 | + | ||||
| 33 | + | const ( | |||
| 34 | + | BackupStatusPending BackupJobStatus = "pending" | |||
| 35 | + | BackupStatusRunning BackupJobStatus = "running" | |||
| 36 | + | BackupStatusCompleted BackupJobStatus = "completed" | |||
| 37 | + | BackupStatusFailed BackupJobStatus = "failed" | |||
| 38 | + | ) | |||
| 39 | + | ||||
| 40 | + | // DBInfo holds detected database information from an attachment container. | |||
| 41 | + | type DBInfo struct { | |||
| 42 | + | Type DBType | |||
| 43 | + | Version string | |||
| 44 | + | Domain string | |||
| 45 | + | Name string | |||
| 46 | + | Host string | |||
| 47 | + | Port int | |||
| 48 | + | ContainerID string | |||
| 49 | + | ImageName string | |||
| 50 | + | Credentials map[string]string | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // BackupJob represents a scheduled or manual backup operation. | |||
| 54 | + | type BackupJob struct { | |||
| 55 | + | ID string | |||
| 56 | + | Domain string | |||
| 57 | + | DBName string | |||
| 58 | + | Schedule BackupSchedule | |||
| 59 | + | Type BackupType | |||
| 60 | + | Status BackupJobStatus | |||
| 61 | + | StartedAt time.Time | |||
| 62 | + | CompletedAt time.Time | |||
| 63 | + | SizeBytes int64 | |||
| 64 | + | FilePath string | |||
| 65 | + | Error string | |||
| 66 | + | Metadata map[string]string | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // BackupResult is returned after a backup operation completes. | |||
| 70 | + | type BackupResult struct { | |||
| 71 | + | Job BackupJob | |||
| 72 | + | Duration time.Duration | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // RetentionPolicy defines how many backups to keep per schedule tier. | |||
| 76 | + | type RetentionPolicy struct { | |||
| 77 | + | Hourly int | |||
| 78 | + | Daily int | |||
| 79 | + | Weekly int | |||
| 80 | + | Monthly int | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // BackupOverride allows per-domain backup configuration. | |||
| 84 | + | type BackupOverride struct { | |||
| 85 | + | Schedules []BackupSchedule | |||
| 86 | + | Retention *RetentionPolicy | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // BackupConfig is the top-level backup configuration. | |||
| 90 | + | type BackupConfig struct { | |||
| 91 | + | Enabled bool | |||
| 92 | + | StorageDir string | |||
| 93 | + | Retention RetentionPolicy | |||
| 94 | + | Overrides map[string]BackupOverride | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Backup labels for container metadata. | |||
| 98 | + | const ( | |||
| 99 | + | LabelBackupEnabled = "gordon.backup" | |||
| 100 | + | LabelBackupType = "gordon.backup.type" | |||
| 101 | + | LabelBackupVersion = "gordon.backup.version" | |||
| 102 | + | LabelBackupSchedule = "gordon.backup.schedule" | |||
| 103 | + | LabelBackupSidecar = "gordon.backup.sidecar" | |||
| 104 | + | ) | |||
A internal/domain/cron.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // CronSchedule represents a recurring schedule. | |
| 6 | + | type CronSchedule struct { | |
| 7 | + | Preset BackupSchedule | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | // CronEntry represents a registered cron job. | |
| 11 | + | type CronEntry struct { | |
| 12 | + | ID string | |
| 13 | + | Name string | |
| 14 | + | Schedule CronSchedule | |
| 15 | + | LastRun time.Time | |
| 16 | + | NextRun time.Time | |
| 17 | + | Running bool | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // CronSchedule represents a recurring schedule. | |||
| 6 | + | type CronSchedule struct { | |||
| 7 | + | Preset BackupSchedule | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | // CronEntry represents a registered cron job. | |||
| 11 | + | type CronEntry struct { | |||
| 12 | + | ID string | |||
| 13 | + | Name string | |||
| 14 | + | Schedule CronSchedule | |||
| 15 | + | LastRun time.Time | |||
| 16 | + | NextRun time.Time | |||
| 17 | + | Running bool | |||
| 18 | + | } | |||
A internal/usecase/backup/detector.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package backup | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | |
| 10 | + | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | |
| 11 | + | image := strings.ToLower(a.Image) | |
| 12 | + | name := strings.ToLower(a.Name) | |
| 13 | + | ||
| 14 | + | switch { | |
| 15 | + | case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"): | |
| 16 | + | return domain.DBInfo{ | |
| 17 | + | Type: domain.DBTypePostgreSQL, | |
| 18 | + | Domain: domainName, | |
| 19 | + | Name: a.Name, | |
| 20 | + | Host: a.Name, | |
| 21 | + | Port: 5432, | |
| 22 | + | ContainerID: a.ContainerID, | |
| 23 | + | ImageName: a.Image, | |
| 24 | + | }, true | |
| 25 | + | default: | |
| 26 | + | return domain.DBInfo{}, false | |
| 27 | + | } | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package backup | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | |||
| 10 | + | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | |||
| 11 | + | image := strings.ToLower(a.Image) | |||
| 12 | + | name := strings.ToLower(a.Name) | |||
| 13 | + | ||||
| 14 | + | switch { | |||
| 15 | + | case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"): | |||
| 16 | + | return domain.DBInfo{ | |||
| 17 | + | Type: domain.DBTypePostgreSQL, | |||
| 18 | + | Domain: domainName, | |||
| 19 | + | Name: a.Name, | |||
| 20 | + | Host: a.Name, | |||
| 21 | + | Port: 5432, | |||
| 22 | + | ContainerID: a.ContainerID, | |||
| 23 | + | ImageName: a.Image, | |||
| 24 | + | }, true | |||
| 25 | + | default: | |||
| 26 | + | return domain.DBInfo{}, false | |||
| 27 | + | } | |||
| 28 | + | } | |||
A internal/usecase/backup/integration_test.go +219 -0
| @@ -0,0 +1,219 @@ | |||
| 1 | + | //go:build integration | |
| 2 | + | ||
| 3 | + | package backup_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/out/docker" | |
| 15 | + | "github.com/bnema/gordon/internal/adapters/out/filesystem" | |
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | "github.com/bnema/gordon/internal/usecase/backup" | |
| 18 | + | "github.com/bnema/zerowrap" | |
| 19 | + | "github.com/stretchr/testify/assert" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func TestBackupService_Integration_Postgres17And18(t *testing.T) { | |
| 24 | + | if testing.Short() { | |
| 25 | + | t.Skip("skipping integration test in short mode") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | runtime := requireDockerRuntime(t) | |
| 29 | + | ctx := context.Background() | |
| 30 | + | ||
| 31 | + | versions := []string{"17", "18"} | |
| 32 | + | for _, version := range versions { | |
| 33 | + | t.Run("postgres_"+version, func(t *testing.T) { | |
| 34 | + | runPostgresBackupFlow(t, ctx, runtime, version) | |
| 35 | + | }) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Runtime, version string) { | |
| 40 | + | image := fmt.Sprintf("postgres:%s", version) | |
| 41 | + | domainName := fmt.Sprintf("backup-it-%s.example.com", version) | |
| 42 | + | networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano()) | |
| 43 | + | containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano()) | |
| 44 | + | ||
| 45 | + | require.NoError(t, runtime.PullImage(ctx, image)) | |
| 46 | + | require.NoError(t, runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"})) | |
| 47 | + | t.Cleanup(func() { _ = runtime.RemoveNetwork(context.Background(), networkName) }) | |
| 48 | + | ||
| 49 | + | containerCfg := &domain.ContainerConfig{ | |
| 50 | + | Image: image, | |
| 51 | + | Name: containerName, | |
| 52 | + | Hostname: "postgres", | |
| 53 | + | NetworkMode: networkName, | |
| 54 | + | Env: []string{ | |
| 55 | + | "POSTGRES_USER=postgres", | |
| 56 | + | "POSTGRES_PASSWORD=postgres", | |
| 57 | + | "POSTGRES_DB=appdb", | |
| 58 | + | }, | |
| 59 | + | Labels: map[string]string{ | |
| 60 | + | domain.LabelManaged: "true", | |
| 61 | + | domain.LabelAttachment: "true", | |
| 62 | + | domain.LabelAttachedTo: domainName, | |
| 63 | + | domain.LabelImage: image, | |
| 64 | + | }, | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | container, err := runtime.CreateContainer(ctx, containerCfg) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | t.Cleanup(func() { | |
| 70 | + | _ = runtime.StopContainer(context.Background(), container.ID) | |
| 71 | + | _ = runtime.RemoveContainer(context.Background(), container.ID, true) | |
| 72 | + | }) | |
| 73 | + | require.NoError(t, runtime.StartContainer(ctx, container.ID)) | |
| 74 | + | ||
| 75 | + | require.NoError(t, waitForPostgresReady(ctx, runtime, container.ID, 60*time.Second)) | |
| 76 | + | require.NoError(t, seedPostgresData(ctx, runtime, container.ID)) | |
| 77 | + | ||
| 78 | + | storage, err := filesystem.NewBackupStorage(t.TempDir(), zerowrap.Default()) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | ||
| 81 | + | containerSvc := &integrationContainerService{ | |
| 82 | + | routes: map[string]*domain.Container{ | |
| 83 | + | domainName: container, | |
| 84 | + | }, | |
| 85 | + | attachments: map[string][]domain.Attachment{ | |
| 86 | + | domainName: { | |
| 87 | + | { | |
| 88 | + | Name: "postgres", | |
| 89 | + | Image: image, | |
| 90 | + | ContainerID: container.ID, | |
| 91 | + | Status: container.Status, | |
| 92 | + | Network: networkName, | |
| 93 | + | }, | |
| 94 | + | }, | |
| 95 | + | }, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | svc := backup.NewService(runtime, storage, containerSvc, domain.BackupConfig{Enabled: true}, zerowrap.Default()) | |
| 99 | + | ||
| 100 | + | detected, err := svc.DetectDatabases(ctx, domainName) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | require.Len(t, detected, 1) | |
| 103 | + | assert.Equal(t, domain.DBTypePostgreSQL, detected[0].Type) | |
| 104 | + | ||
| 105 | + | result, err := svc.RunBackup(ctx, domainName, "postgres") | |
| 106 | + | require.NoError(t, err) | |
| 107 | + | require.NotNil(t, result) | |
| 108 | + | assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status) | |
| 109 | + | ||
| 110 | + | backupBytes, err := os.ReadFile(result.Job.FilePath) | |
| 111 | + | require.NoError(t, err) | |
| 112 | + | assert.Greater(t, len(backupBytes), 32) | |
| 113 | + | assert.True(t, bytes.HasPrefix(backupBytes, []byte("PGDMP")), "expected pg_dump custom format header") | |
| 114 | + | ||
| 115 | + | jobs, err := svc.ListBackups(ctx, domainName) | |
| 116 | + | require.NoError(t, err) | |
| 117 | + | assert.Len(t, jobs, 1) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func requireDockerRuntime(t *testing.T) *docker.Runtime { | |
| 121 | + | t.Helper() | |
| 122 | + | runtime, err := docker.NewRuntime() | |
| 123 | + | if err != nil { | |
| 124 | + | t.Skipf("docker runtime unavailable: %v", err) | |
| 125 | + | } | |
| 126 | + | if err := runtime.Ping(context.Background()); err != nil { | |
| 127 | + | t.Skipf("docker daemon unreachable: %v", err) | |
| 128 | + | } | |
| 129 | + | return runtime | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error { | |
| 133 | + | deadline := time.Now().Add(timeout) | |
| 134 | + | for time.Now().Before(deadline) { | |
| 135 | + | res, err := runtime.ExecInContainer(ctx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"}) | |
| 136 | + | if err == nil && res.ExitCode == 0 { | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | time.Sleep(1 * time.Second) | |
| 140 | + | } | |
| 141 | + | return fmt.Errorf("postgres did not become ready within %s", timeout) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error { | |
| 145 | + | cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"` | |
| 146 | + | res, err := runtime.ExecInContainer(ctx, containerID, []string{"sh", "-c", cmd}) | |
| 147 | + | if err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | if res.ExitCode != 0 { | |
| 151 | + | return fmt.Errorf("seed command failed: %s", strings.TrimSpace(string(res.Stderr))) | |
| 152 | + | } | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | type integrationContainerService struct { | |
| 157 | + | routes map[string]*domain.Container | |
| 158 | + | attachments map[string][]domain.Attachment | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (s *integrationContainerService) Deploy(context.Context, domain.Route) (*domain.Container, error) { | |
| 162 | + | return nil, fmt.Errorf("not implemented") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (s *integrationContainerService) Stop(context.Context, string) error { | |
| 166 | + | return fmt.Errorf("not implemented") | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func (s *integrationContainerService) Remove(context.Context, string, bool) error { | |
| 170 | + | return fmt.Errorf("not implemented") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (s *integrationContainerService) Get(_ context.Context, domainName string) (*domain.Container, bool) { | |
| 174 | + | c, ok := s.routes[domainName] | |
| 175 | + | return c, ok | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (s *integrationContainerService) Restart(context.Context, string, bool) error { | |
| 179 | + | return fmt.Errorf("not implemented") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (s *integrationContainerService) List(context.Context) map[string]*domain.Container { | |
| 183 | + | out := make(map[string]*domain.Container, len(s.routes)) | |
| 184 | + | for k, v := range s.routes { | |
| 185 | + | out[k] = v | |
| 186 | + | } | |
| 187 | + | return out | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (s *integrationContainerService) ListRoutesWithDetails(context.Context) []domain.RouteInfo { | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (s *integrationContainerService) ListAttachments(_ context.Context, domainName string) []domain.Attachment { | |
| 195 | + | attachments := s.attachments[domainName] | |
| 196 | + | out := make([]domain.Attachment, len(attachments)) | |
| 197 | + | copy(out, attachments) | |
| 198 | + | return out | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func (s *integrationContainerService) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) { | |
| 202 | + | return nil, fmt.Errorf("not implemented") | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (s *integrationContainerService) HealthCheck(context.Context) map[string]bool { | |
| 206 | + | return map[string]bool{} | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (s *integrationContainerService) SyncContainers(context.Context) error { | |
| 210 | + | return nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error { | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (s *integrationContainerService) Shutdown(context.Context) error { | |
| 218 | + | return nil | |
| 219 | + | } | |
| @@ -0,0 +1,219 @@ | |||||
| 1 | + | //go:build integration | |||
| 2 | + | ||||
| 3 | + | package backup_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "fmt" | |||
| 9 | + | "os" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/out/docker" | |||
| 15 | + | "github.com/bnema/gordon/internal/adapters/out/filesystem" | |||
| 16 | + | "github.com/bnema/gordon/internal/domain" | |||
| 17 | + | "github.com/bnema/gordon/internal/usecase/backup" | |||
| 18 | + | "github.com/bnema/zerowrap" | |||
| 19 | + | "github.com/stretchr/testify/assert" | |||
| 20 | + | "github.com/stretchr/testify/require" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func TestBackupService_Integration_Postgres17And18(t *testing.T) { | |||
| 24 | + | if testing.Short() { | |||
| 25 | + | t.Skip("skipping integration test in short mode") | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | runtime := requireDockerRuntime(t) | |||
| 29 | + | ctx := context.Background() | |||
| 30 | + | ||||
| 31 | + | versions := []string{"17", "18"} | |||
| 32 | + | for _, version := range versions { | |||
| 33 | + | t.Run("postgres_"+version, func(t *testing.T) { | |||
| 34 | + | runPostgresBackupFlow(t, ctx, runtime, version) | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Runtime, version string) { | |||
| 40 | + | image := fmt.Sprintf("postgres:%s", version) | |||
| 41 | + | domainName := fmt.Sprintf("backup-it-%s.example.com", version) | |||
| 42 | + | networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano()) | |||
| 43 | + | containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano()) | |||
| 44 | + | ||||
| 45 | + | require.NoError(t, runtime.PullImage(ctx, image)) | |||
| 46 | + | require.NoError(t, runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"})) | |||
| 47 | + | t.Cleanup(func() { _ = runtime.RemoveNetwork(context.Background(), networkName) }) | |||
| 48 | + | ||||
| 49 | + | containerCfg := &domain.ContainerConfig{ | |||
| 50 | + | Image: image, | |||
| 51 | + | Name: containerName, | |||
| 52 | + | Hostname: "postgres", | |||
| 53 | + | NetworkMode: networkName, | |||
| 54 | + | Env: []string{ | |||
| 55 | + | "POSTGRES_USER=postgres", | |||
| 56 | + | "POSTGRES_PASSWORD=postgres", | |||
| 57 | + | "POSTGRES_DB=appdb", | |||
| 58 | + | }, | |||
| 59 | + | Labels: map[string]string{ | |||
| 60 | + | domain.LabelManaged: "true", | |||
| 61 | + | domain.LabelAttachment: "true", | |||
| 62 | + | domain.LabelAttachedTo: domainName, | |||
| 63 | + | domain.LabelImage: image, | |||
| 64 | + | }, | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | container, err := runtime.CreateContainer(ctx, containerCfg) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | t.Cleanup(func() { | |||
| 70 | + | _ = runtime.StopContainer(context.Background(), container.ID) | |||
| 71 | + | _ = runtime.RemoveContainer(context.Background(), container.ID, true) | |||
| 72 | + | }) | |||
| 73 | + | require.NoError(t, runtime.StartContainer(ctx, container.ID)) | |||
| 74 | + | ||||
| 75 | + | require.NoError(t, waitForPostgresReady(ctx, runtime, container.ID, 60*time.Second)) | |||
| 76 | + | require.NoError(t, seedPostgresData(ctx, runtime, container.ID)) | |||
| 77 | + | ||||
| 78 | + | storage, err := filesystem.NewBackupStorage(t.TempDir(), zerowrap.Default()) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | ||||
| 81 | + | containerSvc := &integrationContainerService{ | |||
| 82 | + | routes: map[string]*domain.Container{ | |||
| 83 | + | domainName: container, | |||
| 84 | + | }, | |||
| 85 | + | attachments: map[string][]domain.Attachment{ | |||
| 86 | + | domainName: { | |||
| 87 | + | { | |||
| 88 | + | Name: "postgres", | |||
| 89 | + | Image: image, | |||
| 90 | + | ContainerID: container.ID, | |||
| 91 | + | Status: container.Status, | |||
| 92 | + | Network: networkName, | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | }, | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | svc := backup.NewService(runtime, storage, containerSvc, domain.BackupConfig{Enabled: true}, zerowrap.Default()) | |||
| 99 | + | ||||
| 100 | + | detected, err := svc.DetectDatabases(ctx, domainName) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | require.Len(t, detected, 1) | |||
| 103 | + | assert.Equal(t, domain.DBTypePostgreSQL, detected[0].Type) | |||
| 104 | + | ||||
| 105 | + | result, err := svc.RunBackup(ctx, domainName, "postgres") | |||
| 106 | + | require.NoError(t, err) | |||
| 107 | + | require.NotNil(t, result) | |||
| 108 | + | assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status) | |||
| 109 | + | ||||
| 110 | + | backupBytes, err := os.ReadFile(result.Job.FilePath) | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | assert.Greater(t, len(backupBytes), 32) | |||
| 113 | + | assert.True(t, bytes.HasPrefix(backupBytes, []byte("PGDMP")), "expected pg_dump custom format header") | |||
| 114 | + | ||||
| 115 | + | jobs, err := svc.ListBackups(ctx, domainName) | |||
| 116 | + | require.NoError(t, err) | |||
| 117 | + | assert.Len(t, jobs, 1) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func requireDockerRuntime(t *testing.T) *docker.Runtime { | |||
| 121 | + | t.Helper() | |||
| 122 | + | runtime, err := docker.NewRuntime() | |||
| 123 | + | if err != nil { | |||
| 124 | + | t.Skipf("docker runtime unavailable: %v", err) | |||
| 125 | + | } | |||
| 126 | + | if err := runtime.Ping(context.Background()); err != nil { | |||
| 127 | + | t.Skipf("docker daemon unreachable: %v", err) | |||
| 128 | + | } | |||
| 129 | + | return runtime | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error { | |||
| 133 | + | deadline := time.Now().Add(timeout) | |||
| 134 | + | for time.Now().Before(deadline) { | |||
| 135 | + | res, err := runtime.ExecInContainer(ctx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"}) | |||
| 136 | + | if err == nil && res.ExitCode == 0 { | |||
| 137 | + | return nil | |||
| 138 | + | } | |||
| 139 | + | time.Sleep(1 * time.Second) | |||
| 140 | + | } | |||
| 141 | + | return fmt.Errorf("postgres did not become ready within %s", timeout) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error { | |||
| 145 | + | cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"` | |||
| 146 | + | res, err := runtime.ExecInContainer(ctx, containerID, []string{"sh", "-c", cmd}) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return err | |||
| 149 | + | } | |||
| 150 | + | if res.ExitCode != 0 { | |||
| 151 | + | return fmt.Errorf("seed command failed: %s", strings.TrimSpace(string(res.Stderr))) | |||
| 152 | + | } | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | type integrationContainerService struct { | |||
| 157 | + | routes map[string]*domain.Container | |||
| 158 | + | attachments map[string][]domain.Attachment | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (s *integrationContainerService) Deploy(context.Context, domain.Route) (*domain.Container, error) { | |||
| 162 | + | return nil, fmt.Errorf("not implemented") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (s *integrationContainerService) Stop(context.Context, string) error { | |||
| 166 | + | return fmt.Errorf("not implemented") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func (s *integrationContainerService) Remove(context.Context, string, bool) error { | |||
| 170 | + | return fmt.Errorf("not implemented") | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func (s *integrationContainerService) Get(_ context.Context, domainName string) (*domain.Container, bool) { | |||
| 174 | + | c, ok := s.routes[domainName] | |||
| 175 | + | return c, ok | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (s *integrationContainerService) Restart(context.Context, string, bool) error { | |||
| 179 | + | return fmt.Errorf("not implemented") | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (s *integrationContainerService) List(context.Context) map[string]*domain.Container { | |||
| 183 | + | out := make(map[string]*domain.Container, len(s.routes)) | |||
| 184 | + | for k, v := range s.routes { | |||
| 185 | + | out[k] = v | |||
| 186 | + | } | |||
| 187 | + | return out | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (s *integrationContainerService) ListRoutesWithDetails(context.Context) []domain.RouteInfo { | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (s *integrationContainerService) ListAttachments(_ context.Context, domainName string) []domain.Attachment { | |||
| 195 | + | attachments := s.attachments[domainName] | |||
| 196 | + | out := make([]domain.Attachment, len(attachments)) | |||
| 197 | + | copy(out, attachments) | |||
| 198 | + | return out | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func (s *integrationContainerService) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) { | |||
| 202 | + | return nil, fmt.Errorf("not implemented") | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (s *integrationContainerService) HealthCheck(context.Context) map[string]bool { | |||
| 206 | + | return map[string]bool{} | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (s *integrationContainerService) SyncContainers(context.Context) error { | |||
| 210 | + | return nil | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error { | |||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (s *integrationContainerService) Shutdown(context.Context) error { | |||
| 218 | + | return nil | |||
| 219 | + | } | |||
A internal/usecase/backup/service.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package backup | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/in" | |
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Service orchestrates backup operations. | |
| 18 | + | type Service struct { | |
| 19 | + | runtime out.ContainerRuntime | |
| 20 | + | storage out.BackupStorage | |
| 21 | + | containerSvc in.ContainerService | |
| 22 | + | config domain.BackupConfig | |
| 23 | + | log zerowrap.Logger | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // NewService creates a backup service. | |
| 27 | + | func NewService( | |
| 28 | + | runtime out.ContainerRuntime, | |
| 29 | + | storage out.BackupStorage, | |
| 30 | + | containerSvc in.ContainerService, | |
| 31 | + | config domain.BackupConfig, | |
| 32 | + | log zerowrap.Logger, | |
| 33 | + | ) *Service { | |
| 34 | + | return &Service{ | |
| 35 | + | runtime: runtime, | |
| 36 | + | storage: storage, | |
| 37 | + | containerSvc: containerSvc, | |
| 38 | + | config: config, | |
| 39 | + | log: log, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // ListBackups returns backups for a domain. | |
| 44 | + | func (s *Service) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) { | |
| 45 | + | return s.storage.List(ctx, domainName, nil) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // DetectDatabases inspects attachments and returns detected DBs. | |
| 49 | + | func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) { | |
| 50 | + | attachments := s.containerSvc.ListAttachments(ctx, domainName) | |
| 51 | + | dbs := make([]domain.DBInfo, 0, len(attachments)) | |
| 52 | + | for _, attachment := range attachments { | |
| 53 | + | if db, ok := detectDatabaseFromAttachment(domainName, attachment); ok { | |
| 54 | + | dbs = append(dbs, db) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return dbs, nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | |
| 61 | + | func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) { | |
| 62 | + | started := time.Now().UTC() | |
| 63 | + | ||
| 64 | + | dbs, err := s.DetectDatabases(ctx, domainName) | |
| 65 | + | if err != nil { | |
| 66 | + | return nil, err | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | db, err := selectDatabase(dbs, dbName) | |
| 70 | + | if err != nil { | |
| 71 | + | return nil, err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if db.Type != domain.DBTypePostgreSQL { | |
| 75 | + | return nil, fmt.Errorf("unsupported database type: %s", db.Type) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | execResult, err := s.runtime.ExecInContainer(ctx, db.ContainerID, []string{"sh", "-c", postgresDumpCommand()}) | |
| 79 | + | if err != nil { | |
| 80 | + | return nil, err | |
| 81 | + | } | |
| 82 | + | if execResult.ExitCode != 0 { | |
| 83 | + | return nil, fmt.Errorf("pg_dump failed with exit code %d: %s", execResult.ExitCode, string(execResult.Stderr)) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, bytes.NewReader(execResult.Stdout)) | |
| 87 | + | if err != nil { | |
| 88 | + | return nil, err | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | job := domain.BackupJob{ | |
| 92 | + | ID: started.Format(time.RFC3339Nano), | |
| 93 | + | Domain: domainName, | |
| 94 | + | DBName: db.Name, | |
| 95 | + | Type: domain.BackupTypeLogical, | |
| 96 | + | Status: domain.BackupStatusCompleted, | |
| 97 | + | StartedAt: started, | |
| 98 | + | CompletedAt: time.Now().UTC(), | |
| 99 | + | SizeBytes: int64(len(execResult.Stdout)), | |
| 100 | + | FilePath: path, | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | return &domain.BackupResult{ | |
| 104 | + | Job: job, | |
| 105 | + | Duration: time.Since(started), | |
| 106 | + | }, nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Restore restores a backup by ID. | |
| 110 | + | func (s *Service) Restore(context.Context, string, string) error { | |
| 111 | + | return fmt.Errorf("backup restore not implemented yet") | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // RestorePITR restores to a point in time. | |
| 115 | + | func (s *Service) RestorePITR(context.Context, string, time.Time) error { | |
| 116 | + | return fmt.Errorf("pitr restore not implemented yet") | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Status returns aggregate backup status for all managed domains. | |
| 120 | + | func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) { | |
| 121 | + | routes := s.containerSvc.List(ctx) | |
| 122 | + | jobs := make([]domain.BackupJob, 0) | |
| 123 | + | for domainName := range routes { | |
| 124 | + | domainJobs, err := s.ListBackups(ctx, domainName) | |
| 125 | + | if err != nil { | |
| 126 | + | return nil, err | |
| 127 | + | } | |
| 128 | + | jobs = append(jobs, domainJobs...) | |
| 129 | + | } | |
| 130 | + | return jobs, nil | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) { | |
| 134 | + | if len(dbs) == 0 { | |
| 135 | + | return domain.DBInfo{}, fmt.Errorf("no supported database attachments detected") | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | if requested == "" { | |
| 139 | + | return dbs[0], nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | for _, db := range dbs { | |
| 143 | + | if strings.EqualFold(db.Name, requested) { | |
| 144 | + | return db, nil | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func postgresDumpCommand() string { | |
| 152 | + | return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\"" | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package backup | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/in" | |||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Service orchestrates backup operations. | |||
| 18 | + | type Service struct { | |||
| 19 | + | runtime out.ContainerRuntime | |||
| 20 | + | storage out.BackupStorage | |||
| 21 | + | containerSvc in.ContainerService | |||
| 22 | + | config domain.BackupConfig | |||
| 23 | + | log zerowrap.Logger | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // NewService creates a backup service. | |||
| 27 | + | func NewService( | |||
| 28 | + | runtime out.ContainerRuntime, | |||
| 29 | + | storage out.BackupStorage, | |||
| 30 | + | containerSvc in.ContainerService, | |||
| 31 | + | config domain.BackupConfig, | |||
| 32 | + | log zerowrap.Logger, | |||
| 33 | + | ) *Service { | |||
| 34 | + | return &Service{ | |||
| 35 | + | runtime: runtime, | |||
| 36 | + | storage: storage, | |||
| 37 | + | containerSvc: containerSvc, | |||
| 38 | + | config: config, | |||
| 39 | + | log: log, | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // ListBackups returns backups for a domain. | |||
| 44 | + | func (s *Service) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) { | |||
| 45 | + | return s.storage.List(ctx, domainName, nil) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // DetectDatabases inspects attachments and returns detected DBs. | |||
| 49 | + | func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) { | |||
| 50 | + | attachments := s.containerSvc.ListAttachments(ctx, domainName) | |||
| 51 | + | dbs := make([]domain.DBInfo, 0, len(attachments)) | |||
| 52 | + | for _, attachment := range attachments { | |||
| 53 | + | if db, ok := detectDatabaseFromAttachment(domainName, attachment); ok { | |||
| 54 | + | dbs = append(dbs, db) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return dbs, nil | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | |||
| 61 | + | func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) { | |||
| 62 | + | started := time.Now().UTC() | |||
| 63 | + | ||||
| 64 | + | dbs, err := s.DetectDatabases(ctx, domainName) | |||
| 65 | + | if err != nil { | |||
| 66 | + | return nil, err | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | db, err := selectDatabase(dbs, dbName) | |||
| 70 | + | if err != nil { | |||
| 71 | + | return nil, err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if db.Type != domain.DBTypePostgreSQL { | |||
| 75 | + | return nil, fmt.Errorf("unsupported database type: %s", db.Type) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | execResult, err := s.runtime.ExecInContainer(ctx, db.ContainerID, []string{"sh", "-c", postgresDumpCommand()}) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return nil, err | |||
| 81 | + | } | |||
| 82 | + | if execResult.ExitCode != 0 { | |||
| 83 | + | return nil, fmt.Errorf("pg_dump failed with exit code %d: %s", execResult.ExitCode, string(execResult.Stderr)) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, bytes.NewReader(execResult.Stdout)) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return nil, err | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | job := domain.BackupJob{ | |||
| 92 | + | ID: started.Format(time.RFC3339Nano), | |||
| 93 | + | Domain: domainName, | |||
| 94 | + | DBName: db.Name, | |||
| 95 | + | Type: domain.BackupTypeLogical, | |||
| 96 | + | Status: domain.BackupStatusCompleted, | |||
| 97 | + | StartedAt: started, | |||
| 98 | + | CompletedAt: time.Now().UTC(), | |||
| 99 | + | SizeBytes: int64(len(execResult.Stdout)), | |||
| 100 | + | FilePath: path, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | return &domain.BackupResult{ | |||
| 104 | + | Job: job, | |||
| 105 | + | Duration: time.Since(started), | |||
| 106 | + | }, nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Restore restores a backup by ID. | |||
| 110 | + | func (s *Service) Restore(context.Context, string, string) error { | |||
| 111 | + | return fmt.Errorf("backup restore not implemented yet") | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | // RestorePITR restores to a point in time. | |||
| 115 | + | func (s *Service) RestorePITR(context.Context, string, time.Time) error { | |||
| 116 | + | return fmt.Errorf("pitr restore not implemented yet") | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Status returns aggregate backup status for all managed domains. | |||
| 120 | + | func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) { | |||
| 121 | + | routes := s.containerSvc.List(ctx) | |||
| 122 | + | jobs := make([]domain.BackupJob, 0) | |||
| 123 | + | for domainName := range routes { | |||
| 124 | + | domainJobs, err := s.ListBackups(ctx, domainName) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return nil, err | |||
| 127 | + | } | |||
| 128 | + | jobs = append(jobs, domainJobs...) | |||
| 129 | + | } | |||
| 130 | + | return jobs, nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) { | |||
| 134 | + | if len(dbs) == 0 { | |||
| 135 | + | return domain.DBInfo{}, fmt.Errorf("no supported database attachments detected") | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | if requested == "" { | |||
| 139 | + | return dbs[0], nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | for _, db := range dbs { | |||
| 143 | + | if strings.EqualFold(db.Name, requested) { | |||
| 144 | + | return db, nil | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func postgresDumpCommand() string { | |||
| 152 | + | return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\"" | |||
| 153 | + | } | |||
A internal/usecase/backup/service_test.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package backup | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 8 | + | outiface "github.com/bnema/gordon/internal/boundaries/out" | |
| 9 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 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 | + | ||
| 17 | + | func TestService_DetectDatabases_PostgresAttachment(t *testing.T) { | |
| 18 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 19 | + | storage := outmocks.NewMockBackupStorage(t) | |
| 20 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 21 | + | ||
| 22 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |
| 23 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db1", Status: "running"}, | |
| 24 | + | }) | |
| 25 | + | ||
| 26 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |
| 27 | + | ||
| 28 | + | dbs, err := svc.DetectDatabases(context.Background(), "app.example.com") | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | require.Len(t, dbs, 1) | |
| 31 | + | assert.Equal(t, domain.DBTypePostgreSQL, dbs[0].Type) | |
| 32 | + | assert.Equal(t, "db1", dbs[0].ContainerID) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestService_RunBackup_Postgres(t *testing.T) { | |
| 36 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 37 | + | storage := outmocks.NewMockBackupStorage(t) | |
| 38 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 39 | + | ||
| 40 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |
| 41 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"}, | |
| 42 | + | }) | |
| 43 | + | ||
| 44 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |
| 45 | + | return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" | |
| 46 | + | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |
| 47 | + | ||
| 48 | + | storage.EXPECT().Store( | |
| 49 | + | mock.Anything, | |
| 50 | + | "app.example.com", | |
| 51 | + | "postgres", | |
| 52 | + | domain.BackupSchedule(""), | |
| 53 | + | mock.Anything, | |
| 54 | + | mock.Anything, | |
| 55 | + | ).Return("/tmp/backup.bak", nil) | |
| 56 | + | ||
| 57 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |
| 58 | + | ||
| 59 | + | result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres") | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.NotNil(t, result) | |
| 62 | + | assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status) | |
| 63 | + | assert.Equal(t, "/tmp/backup.bak", result.Job.FilePath) | |
| 64 | + | } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package backup | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 8 | + | outiface "github.com/bnema/gordon/internal/boundaries/out" | |||
| 9 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 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 | + | ||||
| 17 | + | func TestService_DetectDatabases_PostgresAttachment(t *testing.T) { | |||
| 18 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 19 | + | storage := outmocks.NewMockBackupStorage(t) | |||
| 20 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 21 | + | ||||
| 22 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |||
| 23 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db1", Status: "running"}, | |||
| 24 | + | }) | |||
| 25 | + | ||||
| 26 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |||
| 27 | + | ||||
| 28 | + | dbs, err := svc.DetectDatabases(context.Background(), "app.example.com") | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | require.Len(t, dbs, 1) | |||
| 31 | + | assert.Equal(t, domain.DBTypePostgreSQL, dbs[0].Type) | |||
| 32 | + | assert.Equal(t, "db1", dbs[0].ContainerID) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestService_RunBackup_Postgres(t *testing.T) { | |||
| 36 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 37 | + | storage := outmocks.NewMockBackupStorage(t) | |||
| 38 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 39 | + | ||||
| 40 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |||
| 41 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"}, | |||
| 42 | + | }) | |||
| 43 | + | ||||
| 44 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |||
| 45 | + | return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" | |||
| 46 | + | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |||
| 47 | + | ||||
| 48 | + | storage.EXPECT().Store( | |||
| 49 | + | mock.Anything, | |||
| 50 | + | "app.example.com", | |||
| 51 | + | "postgres", | |||
| 52 | + | domain.BackupSchedule(""), | |||
| 53 | + | mock.Anything, | |||
| 54 | + | mock.Anything, | |||
| 55 | + | ).Return("/tmp/backup.bak", nil) | |||
| 56 | + | ||||
| 57 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |||
| 58 | + | ||||
| 59 | + | result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres") | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | require.NotNil(t, result) | |||
| 62 | + | assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status) | |||
| 63 | + | assert.Equal(t, "/tmp/backup.bak", result.Job.FilePath) | |||
| 64 | + | } | |||
A internal/usecase/cron/scheduler.go +230 -0
| @@ -0,0 +1,230 @@ | |||
| 1 | + | package cron | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Scheduler runs recurring jobs based on backup schedules. | |
| 15 | + | type Scheduler struct { | |
| 16 | + | entries map[string]*entry | |
| 17 | + | mu sync.RWMutex | |
| 18 | + | stopCh chan struct{} | |
| 19 | + | log zerowrap.Logger | |
| 20 | + | nowFn func() time.Time | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type entry struct { | |
| 24 | + | id string | |
| 25 | + | name string | |
| 26 | + | schedule domain.CronSchedule | |
| 27 | + | job func(ctx context.Context) error | |
| 28 | + | lastRun time.Time | |
| 29 | + | nextRun time.Time | |
| 30 | + | running atomic.Bool | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewScheduler creates a scheduler instance. | |
| 34 | + | func NewScheduler(log zerowrap.Logger) *Scheduler { | |
| 35 | + | return &Scheduler{ | |
| 36 | + | entries: make(map[string]*entry), | |
| 37 | + | stopCh: make(chan struct{}), | |
| 38 | + | log: log, | |
| 39 | + | nowFn: func() time.Time { | |
| 40 | + | return time.Now().UTC() | |
| 41 | + | }, | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Add registers a new scheduled job. | |
| 46 | + | func (s *Scheduler) Add(id, name string, sched domain.CronSchedule, job func(ctx context.Context) error) error { | |
| 47 | + | if id == "" { | |
| 48 | + | return fmt.Errorf("id is required") | |
| 49 | + | } | |
| 50 | + | if job == nil { | |
| 51 | + | return fmt.Errorf("job is required") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | nextRun, err := calculateNextRun(s.nowFn(), sched) | |
| 55 | + | if err != nil { | |
| 56 | + | return err | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | s.mu.Lock() | |
| 60 | + | defer s.mu.Unlock() | |
| 61 | + | ||
| 62 | + | if _, exists := s.entries[id]; exists { | |
| 63 | + | return fmt.Errorf("schedule %q already exists", id) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | s.entries[id] = &entry{ | |
| 67 | + | id: id, | |
| 68 | + | name: name, | |
| 69 | + | schedule: sched, | |
| 70 | + | job: job, | |
| 71 | + | nextRun: nextRun, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Remove unregisters a scheduled job. | |
| 78 | + | func (s *Scheduler) Remove(id string) { | |
| 79 | + | s.mu.Lock() | |
| 80 | + | defer s.mu.Unlock() | |
| 81 | + | delete(s.entries, id) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // Start begins the scheduler loop. | |
| 85 | + | func (s *Scheduler) Start(ctx context.Context) { | |
| 86 | + | ticker := time.NewTicker(time.Minute) | |
| 87 | + | go func() { | |
| 88 | + | defer ticker.Stop() | |
| 89 | + | for { | |
| 90 | + | select { | |
| 91 | + | case <-ctx.Done(): | |
| 92 | + | return | |
| 93 | + | case <-s.stopCh: | |
| 94 | + | return | |
| 95 | + | case <-ticker.C: | |
| 96 | + | s.runDue(ctx) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | }() | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Stop stops the scheduler loop. | |
| 103 | + | func (s *Scheduler) Stop() { | |
| 104 | + | select { | |
| 105 | + | case <-s.stopCh: | |
| 106 | + | return | |
| 107 | + | default: | |
| 108 | + | close(s.stopCh) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // List returns current scheduler entries. | |
| 113 | + | func (s *Scheduler) List() []domain.CronEntry { | |
| 114 | + | s.mu.RLock() | |
| 115 | + | defer s.mu.RUnlock() | |
| 116 | + | ||
| 117 | + | entries := make([]domain.CronEntry, 0, len(s.entries)) | |
| 118 | + | for _, e := range s.entries { | |
| 119 | + | entries = append(entries, domain.CronEntry{ | |
| 120 | + | ID: e.id, | |
| 121 | + | Name: e.name, | |
| 122 | + | Schedule: e.schedule, | |
| 123 | + | LastRun: e.lastRun, | |
| 124 | + | NextRun: e.nextRun, | |
| 125 | + | Running: e.running.Load(), | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | return entries | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // RunNow triggers a registered job immediately. | |
| 133 | + | func (s *Scheduler) RunNow(id string) error { | |
| 134 | + | e := s.getEntry(id) | |
| 135 | + | if e == nil { | |
| 136 | + | return fmt.Errorf("schedule %q not found", id) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return s.executeEntry(context.Background(), e) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (s *Scheduler) runDue(ctx context.Context) { | |
| 143 | + | now := s.nowFn() | |
| 144 | + | entries := s.snapshotEntries() | |
| 145 | + | for _, e := range entries { | |
| 146 | + | if now.Before(e.nextRun) { | |
| 147 | + | continue | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | e := e | |
| 151 | + | go func() { | |
| 152 | + | if err := s.executeEntry(ctx, e); err != nil { | |
| 153 | + | s.log.Warn().Err(err).Str("schedule_id", e.id).Msg("scheduled job failed") | |
| 154 | + | } | |
| 155 | + | }() | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error { | |
| 160 | + | if !e.running.CompareAndSwap(false, true) { | |
| 161 | + | return fmt.Errorf("schedule %q is already running", e.id) | |
| 162 | + | } | |
| 163 | + | defer e.running.Store(false) | |
| 164 | + | ||
| 165 | + | now := s.nowFn() | |
| 166 | + | err := e.job(ctx) | |
| 167 | + | ||
| 168 | + | nextRun, nextErr := calculateNextRun(now, e.schedule) | |
| 169 | + | if nextErr != nil { | |
| 170 | + | return nextErr | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | s.mu.Lock() | |
| 174 | + | e.lastRun = now | |
| 175 | + | e.nextRun = nextRun | |
| 176 | + | s.mu.Unlock() | |
| 177 | + | ||
| 178 | + | return err | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func (s *Scheduler) getEntry(id string) *entry { | |
| 182 | + | s.mu.RLock() | |
| 183 | + | defer s.mu.RUnlock() | |
| 184 | + | return s.entries[id] | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (s *Scheduler) snapshotEntries() []*entry { | |
| 188 | + | s.mu.RLock() | |
| 189 | + | defer s.mu.RUnlock() | |
| 190 | + | ||
| 191 | + | entries := make([]*entry, 0, len(s.entries)) | |
| 192 | + | for _, e := range s.entries { | |
| 193 | + | entries = append(entries, e) | |
| 194 | + | } | |
| 195 | + | return entries | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) { | |
| 199 | + | now = now.UTC() | |
| 200 | + | ||
| 201 | + | switch schedule.Preset { | |
| 202 | + | case domain.ScheduleHourly: | |
| 203 | + | next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC) | |
| 204 | + | if !next.After(now) { | |
| 205 | + | next = next.Add(time.Hour) | |
| 206 | + | } | |
| 207 | + | return next, nil | |
| 208 | + | case domain.ScheduleDaily: | |
| 209 | + | next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, time.UTC) | |
| 210 | + | if !next.After(now) { | |
| 211 | + | next = next.AddDate(0, 0, 1) | |
| 212 | + | } | |
| 213 | + | return next, nil | |
| 214 | + | case domain.ScheduleWeekly: | |
| 215 | + | daysUntilSunday := (7 - int(now.Weekday())) % 7 | |
| 216 | + | next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, time.UTC).AddDate(0, 0, daysUntilSunday) | |
| 217 | + | if !next.After(now) { | |
| 218 | + | next = next.AddDate(0, 0, 7) | |
| 219 | + | } | |
| 220 | + | return next, nil | |
| 221 | + | case domain.ScheduleMonthly: | |
| 222 | + | next := time.Date(now.Year(), now.Month(), 1, 4, 0, 0, 0, time.UTC) | |
| 223 | + | if !next.After(now) { | |
| 224 | + | next = next.AddDate(0, 1, 0) | |
| 225 | + | } | |
| 226 | + | return next, nil | |
| 227 | + | default: | |
| 228 | + | return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset) | |
| 229 | + | } | |
| 230 | + | } | |
| @@ -0,0 +1,230 @@ | |||||
| 1 | + | package cron | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Scheduler runs recurring jobs based on backup schedules. | |||
| 15 | + | type Scheduler struct { | |||
| 16 | + | entries map[string]*entry | |||
| 17 | + | mu sync.RWMutex | |||
| 18 | + | stopCh chan struct{} | |||
| 19 | + | log zerowrap.Logger | |||
| 20 | + | nowFn func() time.Time | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type entry struct { | |||
| 24 | + | id string | |||
| 25 | + | name string | |||
| 26 | + | schedule domain.CronSchedule | |||
| 27 | + | job func(ctx context.Context) error | |||
| 28 | + | lastRun time.Time | |||
| 29 | + | nextRun time.Time | |||
| 30 | + | running atomic.Bool | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewScheduler creates a scheduler instance. | |||
| 34 | + | func NewScheduler(log zerowrap.Logger) *Scheduler { | |||
| 35 | + | return &Scheduler{ | |||
| 36 | + | entries: make(map[string]*entry), | |||
| 37 | + | stopCh: make(chan struct{}), | |||
| 38 | + | log: log, | |||
| 39 | + | nowFn: func() time.Time { | |||
| 40 | + | return time.Now().UTC() | |||
| 41 | + | }, | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Add registers a new scheduled job. | |||
| 46 | + | func (s *Scheduler) Add(id, name string, sched domain.CronSchedule, job func(ctx context.Context) error) error { | |||
| 47 | + | if id == "" { | |||
| 48 | + | return fmt.Errorf("id is required") | |||
| 49 | + | } | |||
| 50 | + | if job == nil { | |||
| 51 | + | return fmt.Errorf("job is required") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | nextRun, err := calculateNextRun(s.nowFn(), sched) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return err | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | s.mu.Lock() | |||
| 60 | + | defer s.mu.Unlock() | |||
| 61 | + | ||||
| 62 | + | if _, exists := s.entries[id]; exists { | |||
| 63 | + | return fmt.Errorf("schedule %q already exists", id) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | s.entries[id] = &entry{ | |||
| 67 | + | id: id, | |||
| 68 | + | name: name, | |||
| 69 | + | schedule: sched, | |||
| 70 | + | job: job, | |||
| 71 | + | nextRun: nextRun, | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Remove unregisters a scheduled job. | |||
| 78 | + | func (s *Scheduler) Remove(id string) { | |||
| 79 | + | s.mu.Lock() | |||
| 80 | + | defer s.mu.Unlock() | |||
| 81 | + | delete(s.entries, id) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // Start begins the scheduler loop. | |||
| 85 | + | func (s *Scheduler) Start(ctx context.Context) { | |||
| 86 | + | ticker := time.NewTicker(time.Minute) | |||
| 87 | + | go func() { | |||
| 88 | + | defer ticker.Stop() | |||
| 89 | + | for { | |||
| 90 | + | select { | |||
| 91 | + | case <-ctx.Done(): | |||
| 92 | + | return | |||
| 93 | + | case <-s.stopCh: | |||
| 94 | + | return | |||
| 95 | + | case <-ticker.C: | |||
| 96 | + | s.runDue(ctx) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | }() | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Stop stops the scheduler loop. | |||
| 103 | + | func (s *Scheduler) Stop() { | |||
| 104 | + | select { | |||
| 105 | + | case <-s.stopCh: | |||
| 106 | + | return | |||
| 107 | + | default: | |||
| 108 | + | close(s.stopCh) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // List returns current scheduler entries. | |||
| 113 | + | func (s *Scheduler) List() []domain.CronEntry { | |||
| 114 | + | s.mu.RLock() | |||
| 115 | + | defer s.mu.RUnlock() | |||
| 116 | + | ||||
| 117 | + | entries := make([]domain.CronEntry, 0, len(s.entries)) | |||
| 118 | + | for _, e := range s.entries { | |||
| 119 | + | entries = append(entries, domain.CronEntry{ | |||
| 120 | + | ID: e.id, | |||
| 121 | + | Name: e.name, | |||
| 122 | + | Schedule: e.schedule, | |||
| 123 | + | LastRun: e.lastRun, | |||
| 124 | + | NextRun: e.nextRun, | |||
| 125 | + | Running: e.running.Load(), | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | return entries | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // RunNow triggers a registered job immediately. | |||
| 133 | + | func (s *Scheduler) RunNow(id string) error { | |||
| 134 | + | e := s.getEntry(id) | |||
| 135 | + | if e == nil { | |||
| 136 | + | return fmt.Errorf("schedule %q not found", id) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return s.executeEntry(context.Background(), e) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func (s *Scheduler) runDue(ctx context.Context) { | |||
| 143 | + | now := s.nowFn() | |||
| 144 | + | entries := s.snapshotEntries() | |||
| 145 | + | for _, e := range entries { | |||
| 146 | + | if now.Before(e.nextRun) { | |||
| 147 | + | continue | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | e := e | |||
| 151 | + | go func() { | |||
| 152 | + | if err := s.executeEntry(ctx, e); err != nil { | |||
| 153 | + | s.log.Warn().Err(err).Str("schedule_id", e.id).Msg("scheduled job failed") | |||
| 154 | + | } | |||
| 155 | + | }() | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error { | |||
| 160 | + | if !e.running.CompareAndSwap(false, true) { | |||
| 161 | + | return fmt.Errorf("schedule %q is already running", e.id) | |||
| 162 | + | } | |||
| 163 | + | defer e.running.Store(false) | |||
| 164 | + | ||||
| 165 | + | now := s.nowFn() | |||
| 166 | + | err := e.job(ctx) | |||
| 167 | + | ||||
| 168 | + | nextRun, nextErr := calculateNextRun(now, e.schedule) | |||
| 169 | + | if nextErr != nil { | |||
| 170 | + | return nextErr | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | s.mu.Lock() | |||
| 174 | + | e.lastRun = now | |||
| 175 | + | e.nextRun = nextRun | |||
| 176 | + | s.mu.Unlock() | |||
| 177 | + | ||||
| 178 | + | return err | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func (s *Scheduler) getEntry(id string) *entry { | |||
| 182 | + | s.mu.RLock() | |||
| 183 | + | defer s.mu.RUnlock() | |||
| 184 | + | return s.entries[id] | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (s *Scheduler) snapshotEntries() []*entry { | |||
| 188 | + | s.mu.RLock() | |||
| 189 | + | defer s.mu.RUnlock() | |||
| 190 | + | ||||
| 191 | + | entries := make([]*entry, 0, len(s.entries)) | |||
| 192 | + | for _, e := range s.entries { | |||
| 193 | + | entries = append(entries, e) | |||
| 194 | + | } | |||
| 195 | + | return entries | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) { | |||
| 199 | + | now = now.UTC() | |||
| 200 | + | ||||
| 201 | + | switch schedule.Preset { | |||
| 202 | + | case domain.ScheduleHourly: | |||
| 203 | + | next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC) | |||
| 204 | + | if !next.After(now) { | |||
| 205 | + | next = next.Add(time.Hour) | |||
| 206 | + | } | |||
| 207 | + | return next, nil | |||
| 208 | + | case domain.ScheduleDaily: | |||
| 209 | + | next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, time.UTC) | |||
| 210 | + | if !next.After(now) { | |||
| 211 | + | next = next.AddDate(0, 0, 1) | |||
| 212 | + | } | |||
| 213 | + | return next, nil | |||
| 214 | + | case domain.ScheduleWeekly: | |||
| 215 | + | daysUntilSunday := (7 - int(now.Weekday())) % 7 | |||
| 216 | + | next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, time.UTC).AddDate(0, 0, daysUntilSunday) | |||
| 217 | + | if !next.After(now) { | |||
| 218 | + | next = next.AddDate(0, 0, 7) | |||
| 219 | + | } | |||
| 220 | + | return next, nil | |||
| 221 | + | case domain.ScheduleMonthly: | |||
| 222 | + | next := time.Date(now.Year(), now.Month(), 1, 4, 0, 0, 0, time.UTC) | |||
| 223 | + | if !next.After(now) { | |||
| 224 | + | next = next.AddDate(0, 1, 0) | |||
| 225 | + | } | |||
| 226 | + | return next, nil | |||
| 227 | + | default: | |||
| 228 | + | return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset) | |||
| 229 | + | } | |||
| 230 | + | } | |||
A internal/usecase/cron/scheduler_test.go +75 -0
| @@ -0,0 +1,75 @@ | |||
| 1 | + | package cron | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | "github.com/bnema/zerowrap" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestCalculateNextRunHourly(t *testing.T) { | |
| 16 | + | now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC) | |
| 17 | + | ||
| 18 | + | next, err := calculateNextRun(now, domain.CronSchedule{Preset: domain.ScheduleHourly}) | |
| 19 | + | ||
| 20 | + | require.NoError(t, err) | |
| 21 | + | assert.Equal(t, time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC), next) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestSchedulerAddListAndRunNow(t *testing.T) { | |
| 25 | + | s := NewScheduler(zerowrap.Default()) | |
| 26 | + | now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC) | |
| 27 | + | s.nowFn = func() time.Time { return now } | |
| 28 | + | ||
| 29 | + | runs := 0 | |
| 30 | + | err := s.Add("backup-daily", "daily backup", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error { | |
| 31 | + | runs++ | |
| 32 | + | return nil | |
| 33 | + | }) | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | ||
| 36 | + | entries := s.List() | |
| 37 | + | require.Len(t, entries, 1) | |
| 38 | + | assert.Equal(t, "backup-daily", entries[0].ID) | |
| 39 | + | assert.Equal(t, "daily backup", entries[0].Name) | |
| 40 | + | ||
| 41 | + | err = s.RunNow("backup-daily") | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.Equal(t, 1, runs) | |
| 44 | + | ||
| 45 | + | entries = s.List() | |
| 46 | + | require.Len(t, entries, 1) | |
| 47 | + | assert.Equal(t, now, entries[0].LastRun) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) { | |
| 51 | + | s := NewScheduler(zerowrap.Default()) | |
| 52 | + | ||
| 53 | + | started := make(chan struct{}) | |
| 54 | + | release := make(chan struct{}) | |
| 55 | + | err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error { | |
| 56 | + | close(started) | |
| 57 | + | <-release | |
| 58 | + | return nil | |
| 59 | + | }) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | ||
| 62 | + | var wg sync.WaitGroup | |
| 63 | + | wg.Add(1) | |
| 64 | + | go func() { | |
| 65 | + | defer wg.Done() | |
| 66 | + | _ = s.RunNow("backup-hourly") | |
| 67 | + | }() | |
| 68 | + | ||
| 69 | + | <-started | |
| 70 | + | err = s.RunNow("backup-hourly") | |
| 71 | + | assert.Error(t, err) | |
| 72 | + | ||
| 73 | + | close(release) | |
| 74 | + | wg.Wait() | |
| 75 | + | } | |
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package cron | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestCalculateNextRunHourly(t *testing.T) { | |||
| 16 | + | now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC) | |||
| 17 | + | ||||
| 18 | + | next, err := calculateNextRun(now, domain.CronSchedule{Preset: domain.ScheduleHourly}) | |||
| 19 | + | ||||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | assert.Equal(t, time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC), next) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestSchedulerAddListAndRunNow(t *testing.T) { | |||
| 25 | + | s := NewScheduler(zerowrap.Default()) | |||
| 26 | + | now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC) | |||
| 27 | + | s.nowFn = func() time.Time { return now } | |||
| 28 | + | ||||
| 29 | + | runs := 0 | |||
| 30 | + | err := s.Add("backup-daily", "daily backup", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error { | |||
| 31 | + | runs++ | |||
| 32 | + | return nil | |||
| 33 | + | }) | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | ||||
| 36 | + | entries := s.List() | |||
| 37 | + | require.Len(t, entries, 1) | |||
| 38 | + | assert.Equal(t, "backup-daily", entries[0].ID) | |||
| 39 | + | assert.Equal(t, "daily backup", entries[0].Name) | |||
| 40 | + | ||||
| 41 | + | err = s.RunNow("backup-daily") | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | assert.Equal(t, 1, runs) | |||
| 44 | + | ||||
| 45 | + | entries = s.List() | |||
| 46 | + | require.Len(t, entries, 1) | |||
| 47 | + | assert.Equal(t, now, entries[0].LastRun) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) { | |||
| 51 | + | s := NewScheduler(zerowrap.Default()) | |||
| 52 | + | ||||
| 53 | + | started := make(chan struct{}) | |||
| 54 | + | release := make(chan struct{}) | |||
| 55 | + | err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error { | |||
| 56 | + | close(started) | |||
| 57 | + | <-release | |||
| 58 | + | return nil | |||
| 59 | + | }) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | ||||
| 62 | + | var wg sync.WaitGroup | |||
| 63 | + | wg.Add(1) | |||
| 64 | + | go func() { | |||
| 65 | + | defer wg.Done() | |||
| 66 | + | _ = s.RunNow("backup-hourly") | |||
| 67 | + | }() | |||
| 68 | + | ||||
| 69 | + | <-started | |||
| 70 | + | err = s.RunNow("backup-hourly") | |||
| 71 | + | assert.Error(t, err) | |||
| 72 | + | ||||
| 73 | + | close(release) | |||
| 74 | + | wg.Wait() | |||
| 75 | + | } | |||