detached 634f497

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +3076 -1
M .mockery.yaml +2 -0
@@ -22,9 +22,11 @@ packages:
2222 TokenStore:
2323 DomainSecretStore:
2424 RateLimiter:
25+ BackupStorage:
2526 github.com/bnema/gordon/internal/boundaries/in:
2627 interfaces:
2728 ContainerService:
29+ BackupService:
2830 RegistryService:
2931 ProxyService:
3032 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:
2929 | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) |
3030 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
3131 | `gordon status` | Show Gordon server status | [status](./status.md) |
32+| `gordon backup` | Manage database backups | [backup](./backup.md) |
3233
3334 ## Client Commands
3435
@@ -73,6 +74,12 @@ gordon logs myapp.local -f # Follow container logs
7374 # Check version
7475 gordon version
7576
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+
7683 # Authentication
7784 gordon auth token generate --subject ci-bot --expiry 0
7885 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 Commands34 ## Client Commands
34 35
@@ -73,6 +74,12 @@ gordon logs myapp.local -f # Follow container logs
73 # Check version74 # Check version
74 gordon version75 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 # Authentication83 # Authentication
77 gordon auth token generate --subject ci-bot --expiry 084 gordon auth token generate --subject ci-bot --expiry 0
78 gordon auth token list85 gordon auth token list
M docs/concepts.md +14 -0
@@ -211,6 +211,20 @@ Gordon uses an internal event system for coordination:
211211 | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route |
212212 | `container.deployed` | Container started | Update proxy cache |
213213
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+
214228 ## Container Labels
215229
216230 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 Labels228 ## 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
109109 [attachments]
110110 "app.mydomain.com" = ["postgres:latest", "redis:latest"]
111111 "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
112123 ```
113124
114125 ## Configuration Sections
@@ -128,6 +139,7 @@ enabled = false # Auto-create routes from image names
128139 | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) |
129140 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
130141 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
142+| `[backups]` | Database backups | [Backups](./backups.md) |
131143
132144 ## Default Values
133145
@@ -158,6 +170,7 @@ enabled = false # Auto-create routes from image names
158170 | `volumes.preserve` | `true` |
159171 | `network_isolation.enabled` | `false` |
160172 | `auto_route.enabled` | `false` |
173+| `backups.enabled` | `false` |
161174
162175 ## Hot Reload
163176
@@ -193,3 +206,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
193206 - [Routes Configuration](./routes.md)
194207 - [External Routes](./external-routes.md)
195208 - [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 Sections125 ## 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 Values144 ## 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 Reload175 ## 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
121121 # =============================================================================
122122 [attachments]
123123 # "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
124137 ```
125138
126139 ## Default Values Summary
@@ -164,6 +177,12 @@ preserve = true # Keep volumes when containers are
164177 | `volumes.auto_create` | `true` | Auto-create volumes |
165178 | `volumes.prefix` | `"gordon"` | Volume prefix |
166179 | `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 |
167186
168187 ## Environment Variables
169188
@@ -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 Summary139 ## 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 Variables187 ## Environment Variables
169 188
M docs/reference/docker-labels.md +15 -0
@@ -23,6 +23,18 @@ Additional labels for attachment containers:
2323 | `gordon.attachment` | `"true"` | Container is an attachment service |
2424 | `gordon.attached-to` | Domain/group | Route or network group this serves |
2525
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+
2638 ## Image Labels
2739
2840 Labels you can set in your Dockerfile:
@@ -96,6 +108,9 @@ docker ps -f "label=gordon.attachment=true"
96108
97109 # Attachments for specific route
98110 docker ps -f "label=gordon.attached-to=app.mydomain.com"
111+
112+# Backup sidecars
113+docker ps -f "label=gordon.backup.sidecar=true"
99114 ```
100115
101116 ## 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 Labels38 ## 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 route109 # 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 ## Examples116 ## 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 {
463463 Error string `json:"error"`
464464 }
465465
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+
466541 // GetHealth returns health status for all routes with HTTP probing.
467542 func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) {
468543 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:
117117 statusCmd.GroupID = groupManage
118118 rootCmd.AddCommand(statusCmd)
119119
120+ backupCmd := newBackupCmd()
121+ backupCmd.GroupID = groupManage
122+ rootCmd.AddCommand(backupCmd)
123+
120124 // Client-only commands (no server needed)
121125 remotesCmd := newRemotesCmd()
122126 remotesCmd.GroupID = groupClient
@@ -117,6 +117,10 @@ Commands are organized by where they run:
117 statusCmd.GroupID = groupManage117 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 = groupClient126 remotesCmd.GroupID = groupClient
M internal/adapters/in/http/admin/handler.go +178 -0
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "io"
89 "net/http"
910 "strconv"
1011 "strings"
@@ -30,6 +31,7 @@ type Handler struct {
3031 configSvc in.ConfigService
3132 authSvc in.AuthService
3233 containerSvc in.ContainerService
34+ backupSvc in.BackupService
3335 healthSvc in.HealthService
3436 secretSvc in.SecretService
3537 logSvc in.LogService
@@ -79,6 +81,34 @@ func toRouteResponse(r domain.Route) dto.Route {
7981 }
8082 }
8183
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+
82112 // NewHandler creates a new admin HTTP handler.
83113 func NewHandler(
84114 configSvc in.ConfigService,
@@ -90,11 +120,18 @@ func NewHandler(
90120 registrySvc in.RegistryService,
91121 eventBus out.EventPublisher,
92122 log zerowrap.Logger,
123+ backupSvc ...in.BackupService,
93124 ) *Handler {
125+ var backup in.BackupService
126+ if len(backupSvc) > 0 {
127+ backup = backupSvc[0]
128+ }
129+
94130 return &Handler{
95131 configSvc: configSvc,
96132 authSvc: authSvc,
97133 containerSvc: containerSvc,
134+ backupSvc: backup,
98135 healthSvc: healthSvc,
99136 secretSvc: secretSvc,
100137 logSvc: logSvc,
@@ -157,6 +194,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
157194 prefix string
158195 handler routeHandler
159196 }{
197+ {"/backups", h.handleBackups},
160198 {"/attachments", h.handleAttachmentsConfig},
161199 {"/routes", h.handleRoutes},
162200 {"/secrets", h.handleSecrets},
@@ -718,6 +756,146 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
718756 h.sendJSON(w, http.StatusOK, status)
719757 }
720758
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+
721899 // handleReload handles /admin/reload endpoint.
722900 // This reloads configuration from file into memory and triggers container sync.
723901 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.ConfigService31 configSvc in.ConfigService
31 authSvc in.AuthService32 authSvc in.AuthService
32 containerSvc in.ContainerService33 containerSvc in.ContainerService
34+ backupSvc in.BackupService
33 healthSvc in.HealthService35 healthSvc in.HealthService
34 secretSvc in.SecretService36 secretSvc in.SecretService
35 logSvc in.LogService37 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 string194 prefix string
158 handler routeHandler195 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
33
44 import (
55 "archive/tar"
6+ "bytes"
67 "context"
78 "encoding/base64"
89 "encoding/json"
@@ -21,8 +22,10 @@ import (
2122 "github.com/docker/docker/api/types/registry"
2223 "github.com/docker/docker/api/types/volume"
2324 "github.com/docker/docker/client"
25+ "github.com/docker/docker/pkg/stdcopy"
2426 "github.com/docker/go-connections/nat"
2527
28+ "github.com/bnema/gordon/internal/boundaries/out"
2629 "github.com/bnema/gordon/internal/domain"
2730 )
2831
@@ -1107,6 +1110,64 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
11071110 return nil
11081111 }
11091112
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+
11101171 // CopyFromContainer copies a file from a container to the host.
11111172 // Returns the file contents as a byte slice.
11121173 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 nil1110 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 (
5454
5555 // Use cases
5656 "github.com/bnema/gordon/internal/usecase/auth"
57+ "github.com/bnema/gordon/internal/usecase/backup"
5758 "github.com/bnema/gordon/internal/usecase/config"
5859 "github.com/bnema/gordon/internal/usecase/container"
5960 "github.com/bnema/gordon/internal/usecase/health"
@@ -129,6 +130,17 @@ type Config struct {
129130 TrustedProxies []string `mapstructure:"trusted_proxies"`
130131 } `mapstructure:"rate_limit"`
131132 } `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"`
132144 }
133145
134146 // services holds all the services used by the application.
@@ -137,11 +149,13 @@ type services struct {
137149 eventBus *eventbus.InMemory
138150 blobStorage *filesystem.BlobStorage
139151 manifestStorage *filesystem.ManifestStorage
152+ backupStorage *filesystem.BackupStorage
140153 envLoader out.EnvLoader
141154 logWriter *logwriter.LogWriter
142155 tokenStore out.TokenStore
143156 configSvc *config.Service
144157 containerSvc *container.Service
158+ backupSvc *backup.Service
145159 registrySvc *registrySvc.Service
146160 proxySvc *proxy.Service
147161 authSvc *auth.Service
@@ -432,6 +446,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
432446 if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
433447 return nil, err
434448 }
449+
450+ if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil {
451+ return nil, err
452+ }
453+
435454 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
436455
437456 proxyCfg, err := buildProxyConfig(cfg, log)
@@ -458,7 +477,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
458477 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
459478
460479 // 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)
462481
463482 return svc, nil
464483 }
@@ -1060,6 +1079,42 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
10601079 return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
10611080 }
10621081
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+
10631118 // registerEventHandlers registers all event handlers.
10641119 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
10651120 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
@@ -54,6 +54,7 @@ import (
54 54
55 // Use cases55 // 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.InMemory149 eventBus *eventbus.InMemory
138 blobStorage *filesystem.BlobStorage150 blobStorage *filesystem.BlobStorage
139 manifestStorage *filesystem.ManifestStorage151 manifestStorage *filesystem.ManifestStorage
152+ backupStorage *filesystem.BackupStorage
140 envLoader out.EnvLoader153 envLoader out.EnvLoader
141 logWriter *logwriter.LogWriter154 logWriter *logwriter.LogWriter
142 tokenStore out.TokenStore155 tokenStore out.TokenStore
143 configSvc *config.Service156 configSvc *config.Service
144 containerSvc *container.Service157 containerSvc *container.Service
158+ backupSvc *backup.Service
145 registrySvc *registrySvc.Service159 registrySvc *registrySvc.Service
146 proxySvc *proxy.Service160 proxySvc *proxy.Service
147 authSvc *auth.Service161 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, err447 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 API479 // 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, nil482 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), nil1079 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 (
88 "context"
99 "io"
1010
11+ "github.com/bnema/gordon/internal/boundaries/out"
1112 "github.com/bnema/gordon/internal/domain"
1213 mock "github.com/stretchr/testify/mock"
1314 )
@@ -102,6 +103,80 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run
102103 return _c
103104 }
104105
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+
105180 // CreateContainer provides a mock function for the type MockContainerRuntime
106181 func (_mock *MockContainerRuntime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) {
107182 ret := _mock.Called(ctx, config)
@@ -353,6 +428,80 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn
353428 return _c
354429 }
355430
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+
356505 // GetContainerExposedPorts provides a mock function for the type MockContainerRuntime
357506 func (_mock *MockContainerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
358507 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 _c103 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 MockContainerRuntime180 // 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 _c428 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 MockContainerRuntime505 // 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 {
6060 // Label inspection
6161 GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error)
6262
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+
6367 // Network management
6468 CreateNetwork(ctx context.Context, name string, options map[string]string) error
6569 RemoveNetwork(ctx context.Context, name string) error
@@ -68,3 +72,10 @@ type ContainerRuntime interface {
6872 ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
6973 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error
7074 }
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 inspection60 // 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 management67 // Network management
64 CreateNetwork(ctx context.Context, name string, options map[string]string) error68 CreateNetwork(ctx context.Context, name string, options map[string]string) error
65 RemoveNetwork(ctx context.Context, name string) error69 RemoveNetwork(ctx context.Context, name string) error
@@ -68,3 +72,10 @@ type ContainerRuntime interface {
68 ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error72 ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error
69 DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error73 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close