detached 711b2ac

Merge pull request #65 from bnema/feat/backup-cron-services

feat: add postgres backup MVP with admin and CLI support
commit
711b2ac14e7e695b75a242d3f82db0f5c97f5a1b
author
bnema · · 2026-02-07 17:01 UTC
committer
GitHub · · 2026-02-07 17:01 UTC
merge of
5b20b25 + 6ee3fd8 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
38 files · +4220 -51
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:
A docs/cli/backup.md +80 -0
@@ -0,0 +1,80 @@
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+> `gordon backup run <domain>` auto-selects the database only when exactly one supported DB attachment is detected. If multiple DB attachments are present, pass `--db <name>`.
21+
22+In normal usage, configure remotes once (`gordon remotes ...`) and run backup commands without per-command remote flags.
23+
24+## Examples
25+
26+```bash
27+# List all backups
28+gordon backup list
29+
30+# List backups for one domain
31+gordon backup list app.example.com
32+
33+# Trigger backup (auto-detect when exactly one DB attachment exists)
34+gordon backup run app.example.com
35+
36+# Trigger backup for specific attachment name
37+gordon backup run app.example.com --db postgres
38+
39+# Detect DB attachments
40+gordon backup detect app.example.com
41+
42+# Status summary
43+gordon backup status
44+```
45+
46+## Output Columns
47+
48+### list
49+
50+```text
51+<domain>\t<db>\t<status>\t<started_at>\t<backup_id>
52+```
53+
54+### detect
55+
56+```text
57+<name>\t<type>\t<host>\t<port>\t<image>
58+```
59+
60+### status
61+
62+```text
63+<domain>\t<db>\t<status>\t<started_at>
64+```
65+
66+### run
67+
68+```text
69+<domain>\t<db>\t<status>\t<started_at>\t<backup_id>\t<size_bytes>
70+```
71+
72+## Required Permissions
73+
74+- Read operations (`list`, `status`, `detect`) require `admin:status:read`.
75+- `run` requires `admin:config:write`.
76+
77+## Related
78+
79+- [CLI Commands](./index.md)
80+- [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
M docs/concepts.md +15 -0
@@ -211,6 +211,21 @@ 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+For configuration details and usage examples, see the [Backups Configuration guide](./config/backups.md), [Backup CLI reference](./cli/backup.md), and [Configuration Reference](./config/reference.md).
228+
214229 ## Container Labels
215230
216231 Gordon uses labels to track managed containers:
A docs/config/backups.md +69 -0
@@ -0,0 +1,69 @@
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>/<domain>/<db>/<schedule>/<timestamp>.bak
50+```
51+
52+Example:
53+
54+```text
55+~/.gordon/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+- `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.
64+
65+## Related
66+
67+- [Attachments Configuration](./attachments.md)
68+- [CLI Backup Command](../cli/backup.md)
69+- [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)
M docs/config/reference.md +24 -0
@@ -121,6 +121,22 @@ 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
137+
138+# Note: retention values set to 0 keep no backups for that tier.
139+# For practical defaults, consider setting daily = 7.
124140 ```
125141
126142 ## Default Values Summary
@@ -164,6 +180,14 @@ preserve = true # Keep volumes when containers are
164180 | `volumes.auto_create` | `true` | Auto-create volumes |
165181 | `volumes.prefix` | `"gordon"` | Volume prefix |
166182 | `volumes.preserve` | `true` | Keep volumes |
183+| `backups.enabled` | `false` | Backup service disabled |
184+| `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
185+| `backups.retention.hourly` | `0` | Keep no hourly backups by default |
186+| `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
187+| `backups.retention.weekly` | `0` | Keep no weekly backups by default |
188+| `backups.retention.monthly` | `0` | Keep no monthly backups by default |
189+
190+Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
167191
168192 ## Environment Variables
169193
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
A internal/adapters/dto/admin_backups.go +49 -0
@@ -0,0 +1,49 @@
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,omitempty"`
14+ CompletedAt *time.Time `json:"completed_at,omitempty"`
15+ SizeBytes int64 `json:"size_bytes"`
16+ Error string `json:"error,omitempty"`
17+}
18+
19+// BackupsResponse is returned by backup listing endpoints.
20+type BackupsResponse struct {
21+ Backups []BackupJob `json:"backups"`
22+}
23+
24+// BackupRunRequest triggers a backup run.
25+type BackupRunRequest struct {
26+ DB string `json:"db,omitempty"`
27+}
28+
29+// BackupRunResponse is returned after triggering a backup.
30+type BackupRunResponse struct {
31+ Status string `json:"status"`
32+ Backup *BackupJob `json:"backup,omitempty"`
33+}
34+
35+// DatabaseInfo represents a detected database attachment.
36+type DatabaseInfo struct {
37+ Type string `json:"type"`
38+ Name string `json:"name"`
39+ Version string `json:"version,omitempty"`
40+ Host string `json:"host"`
41+ Port int `json:"port"`
42+ ContainerID string `json:"container_id"`
43+ ImageName string `json:"image_name"`
44+}
45+
46+// BackupDetectResponse is returned by detect endpoints.
47+type BackupDetectResponse struct {
48+ Databases []DatabaseInfo `json:"databases"`
49+}
A internal/adapters/in/cli/backup.go +196 -0
@@ -0,0 +1,196 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+ "text/tabwriter"
7+ "time"
8+
9+ "github.com/spf13/cobra"
10+)
11+
12+// newBackupCmd creates the backup command group.
13+func newBackupCmd() *cobra.Command {
14+ cmd := &cobra.Command{
15+ Use: "backup",
16+ Short: "Manage database backups",
17+ Long: `Manage database backups through Gordon's admin API.
18+
19+These commands currently require remote mode with a configured target.`,
20+ }
21+
22+ cmd.AddCommand(newBackupListCmd())
23+ cmd.AddCommand(newBackupRunCmd())
24+ cmd.AddCommand(newBackupDetectCmd())
25+ cmd.AddCommand(newBackupStatusCmd())
26+
27+ return cmd
28+}
29+
30+func newBackupListCmd() *cobra.Command {
31+ return &cobra.Command{
32+ Use: "list [domain]",
33+ Short: "List backups",
34+ Args: cobra.MaximumNArgs(1),
35+ RunE: func(cmd *cobra.Command, args []string) error {
36+ client, isRemote := GetRemoteClient()
37+ if !isRemote {
38+ return fmt.Errorf("backup commands require a configured remote target")
39+ }
40+
41+ domainName := ""
42+ if len(args) == 1 {
43+ domainName = args[0]
44+ }
45+
46+ jobs, err := client.ListBackups(cmd.Context(), domainName)
47+ if err != nil {
48+ return fmt.Errorf("failed to list backups: %w", err)
49+ }
50+
51+ if len(jobs) == 0 {
52+ fmt.Println("No backups found")
53+ return nil
54+ }
55+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
56+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID"); err != nil {
57+ return err
58+ }
59+
60+ for _, job := range jobs {
61+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.ID); err != nil {
62+ return err
63+ }
64+ }
65+ if err := w.Flush(); err != nil {
66+ return err
67+ }
68+
69+ return nil
70+ },
71+ }
72+}
73+
74+func newBackupRunCmd() *cobra.Command {
75+ var dbName string
76+
77+ cmd := &cobra.Command{
78+ Use: "run <domain>",
79+ Short: "Run backup now",
80+ Args: cobra.ExactArgs(1),
81+ RunE: func(cmd *cobra.Command, args []string) error {
82+ client, isRemote := GetRemoteClient()
83+ if !isRemote {
84+ return fmt.Errorf("backup commands require a configured remote target")
85+ }
86+
87+ result, err := client.RunBackup(cmd.Context(), args[0], dbName)
88+ if err != nil {
89+ return fmt.Errorf("failed to run backup: %w", err)
90+ }
91+
92+ if result.Backup == nil {
93+ return fmt.Errorf("backup run completed without backup payload")
94+ }
95+
96+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
97+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT\tBACKUP_ID\tSIZE_BYTES"); err != nil {
98+ return err
99+ }
100+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.ID, result.Backup.SizeBytes); err != nil {
101+ return err
102+ }
103+ if err := w.Flush(); err != nil {
104+ return err
105+ }
106+ return nil
107+ },
108+ }
109+
110+ cmd.Flags().StringVar(&dbName, "db", "", "Database attachment name (optional)")
111+ return cmd
112+}
113+
114+func newBackupDetectCmd() *cobra.Command {
115+ return &cobra.Command{
116+ Use: "detect <domain>",
117+ Short: "Detect databases for domain",
118+ Args: cobra.ExactArgs(1),
119+ RunE: func(cmd *cobra.Command, args []string) error {
120+ client, isRemote := GetRemoteClient()
121+ if !isRemote {
122+ return fmt.Errorf("backup commands require a configured remote target")
123+ }
124+
125+ dbs, err := client.DetectDatabases(cmd.Context(), args[0])
126+ if err != nil {
127+ return fmt.Errorf("failed to detect databases: %w", err)
128+ }
129+
130+ if len(dbs) == 0 {
131+ fmt.Println("No supported databases detected")
132+ return nil
133+ }
134+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
135+ if _, err := fmt.Fprintln(w, "NAME\tTYPE\tHOST\tPORT\tIMAGE"); err != nil {
136+ return err
137+ }
138+
139+ for _, db := range dbs {
140+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName); err != nil {
141+ return err
142+ }
143+ }
144+ if err := w.Flush(); err != nil {
145+ return err
146+ }
147+
148+ return nil
149+ },
150+ }
151+}
152+
153+func newBackupStatusCmd() *cobra.Command {
154+ return &cobra.Command{
155+ Use: "status",
156+ Short: "Show backup status",
157+ RunE: func(cmd *cobra.Command, args []string) error {
158+ client, isRemote := GetRemoteClient()
159+ if !isRemote {
160+ return fmt.Errorf("backup commands require a configured remote target")
161+ }
162+
163+ jobs, err := client.BackupStatus(cmd.Context())
164+ if err != nil {
165+ return fmt.Errorf("failed to get backup status: %w", err)
166+ }
167+
168+ if len(jobs) == 0 {
169+ fmt.Println("No backup status available")
170+ return nil
171+ }
172+ w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
173+ if _, err := fmt.Fprintln(w, "DOMAIN\tDB\tSTATUS\tSTARTED_AT"); err != nil {
174+ return err
175+ }
176+
177+ for _, job := range jobs {
178+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt)); err != nil {
179+ return err
180+ }
181+ }
182+ if err := w.Flush(); err != nil {
183+ return err
184+ }
185+
186+ return nil
187+ },
188+ }
189+}
190+
191+func formatBackupTime(t *time.Time) string {
192+ if t == nil {
193+ return ""
194+ }
195+ return t.UTC().Format("2006-01-02T15:04:05Z")
196+}
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)
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
M internal/adapters/in/http/admin/handler.go +189 -0
@@ -5,9 +5,11 @@ import (
55 "encoding/json"
66 "errors"
77 "fmt"
8+ "io"
89 "net/http"
910 "strconv"
1011 "strings"
12+ "time"
1113
1214 "github.com/bnema/zerowrap"
1315
@@ -30,6 +32,7 @@ type Handler struct {
3032 configSvc in.ConfigService
3133 authSvc in.AuthService
3234 containerSvc in.ContainerService
35+ backupSvc in.BackupService
3336 healthSvc in.HealthService
3437 secretSvc in.SecretService
3538 logSvc in.LogService
@@ -79,6 +82,44 @@ func toRouteResponse(r domain.Route) dto.Route {
7982 }
8083 }
8184
85+func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
86+ var startedAt *time.Time
87+ if !job.StartedAt.IsZero() {
88+ t := job.StartedAt
89+ startedAt = &t
90+ }
91+ var completedAt *time.Time
92+ if !job.CompletedAt.IsZero() {
93+ t := job.CompletedAt
94+ completedAt = &t
95+ }
96+
97+ return dto.BackupJob{
98+ ID: job.ID,
99+ Domain: job.Domain,
100+ DBName: job.DBName,
101+ Schedule: string(job.Schedule),
102+ Type: string(job.Type),
103+ Status: string(job.Status),
104+ StartedAt: startedAt,
105+ CompletedAt: completedAt,
106+ SizeBytes: job.SizeBytes,
107+ Error: job.Error,
108+ }
109+}
110+
111+func toDatabaseInfoResponse(db domain.DBInfo) dto.DatabaseInfo {
112+ return dto.DatabaseInfo{
113+ Type: string(db.Type),
114+ Name: db.Name,
115+ Version: db.Version,
116+ Host: db.Host,
117+ Port: db.Port,
118+ ContainerID: db.ContainerID,
119+ ImageName: db.ImageName,
120+ }
121+}
122+
82123 // NewHandler creates a new admin HTTP handler.
83124 func NewHandler(
84125 configSvc in.ConfigService,
@@ -90,11 +131,13 @@ func NewHandler(
90131 registrySvc in.RegistryService,
91132 eventBus out.EventPublisher,
92133 log zerowrap.Logger,
134+ backupSvc in.BackupService,
93135 ) *Handler {
94136 return &Handler{
95137 configSvc: configSvc,
96138 authSvc: authSvc,
97139 containerSvc: containerSvc,
140+ backupSvc: backupSvc,
98141 healthSvc: healthSvc,
99142 secretSvc: secretSvc,
100143 logSvc: logSvc,
@@ -157,6 +200,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
157200 prefix string
158201 handler routeHandler
159202 }{
203+ {"/backups", h.handleBackups},
160204 {"/attachments", h.handleAttachmentsConfig},
161205 {"/routes", h.handleRoutes},
162206 {"/secrets", h.handleSecrets},
@@ -718,6 +762,151 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
718762 h.sendJSON(w, http.StatusOK, status)
719763 }
720764
765+// handleBackups handles /admin/backups endpoints.
766+func (h *Handler) handleBackups(w http.ResponseWriter, r *http.Request, path string) {
767+ if h.backupSvc == nil {
768+ h.sendError(w, http.StatusServiceUnavailable, "backup service not available")
769+ return
770+ }
771+
772+ path = strings.TrimSuffix(path, "/")
773+
774+ if path == "/backups" || path == "/backups/status" {
775+ h.handleBackupsStatus(w, r)
776+ return
777+ }
778+
779+ suffix := strings.TrimPrefix(path, "/backups/")
780+ parts := strings.Split(suffix, "/")
781+ if len(parts) == 0 || parts[0] == "" {
782+ h.sendError(w, http.StatusBadRequest, "domain required in path")
783+ return
784+ }
785+
786+ backupDomain := parts[0]
787+ if len(parts) == 1 {
788+ h.handleBackupsDomain(w, r, backupDomain)
789+ return
790+ }
791+
792+ if len(parts) == 2 && parts[1] == "detect" {
793+ h.handleBackupsDetect(w, r, backupDomain)
794+ return
795+ }
796+
797+ h.sendError(w, http.StatusNotFound, "route not found")
798+}
799+
800+func (h *Handler) handleBackupsStatus(w http.ResponseWriter, r *http.Request) {
801+ ctx := r.Context()
802+ if r.Method != http.MethodGet {
803+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
804+ return
805+ }
806+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
807+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
808+ return
809+ }
810+
811+ jobs, err := h.backupSvc.Status(ctx)
812+ if err != nil {
813+ h.sendError(w, http.StatusInternalServerError, "failed to get backup status")
814+ return
815+ }
816+ h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
817+}
818+
819+func (h *Handler) handleBackupsDomain(w http.ResponseWriter, r *http.Request, backupDomain string) {
820+ switch r.Method {
821+ case http.MethodGet:
822+ h.handleBackupsDomainList(w, r, backupDomain)
823+ case http.MethodPost:
824+ h.handleBackupsDomainRun(w, r, backupDomain)
825+ default:
826+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
827+ }
828+}
829+
830+func (h *Handler) handleBackupsDomainList(w http.ResponseWriter, r *http.Request, backupDomain string) {
831+ ctx := r.Context()
832+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
833+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
834+ return
835+ }
836+
837+ jobs, err := h.backupSvc.ListBackups(ctx, backupDomain)
838+ if err != nil {
839+ h.sendError(w, http.StatusInternalServerError, "failed to list backups")
840+ return
841+ }
842+
843+ h.sendJSON(w, http.StatusOK, dto.BackupsResponse{Backups: mapBackupJobsResponse(jobs)})
844+}
845+
846+func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, backupDomain string) {
847+ ctx := r.Context()
848+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
849+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
850+ return
851+ }
852+
853+ var req dto.BackupRunRequest
854+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
855+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
856+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
857+ return
858+ }
859+
860+ result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)
861+ if err != nil {
862+ log := zerowrap.FromCtx(ctx)
863+ log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed")
864+ h.sendError(w, http.StatusInternalServerError, "failed to run backup")
865+ return
866+ }
867+ log := zerowrap.FromCtx(ctx)
868+ log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API")
869+
870+ job := toBackupJobResponse(result.Job)
871+ h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
872+ Status: "completed",
873+ Backup: &job,
874+ })
875+}
876+
877+func (h *Handler) handleBackupsDetect(w http.ResponseWriter, r *http.Request, backupDomain string) {
878+ ctx := r.Context()
879+ if r.Method != http.MethodGet {
880+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
881+ return
882+ }
883+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
884+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
885+ return
886+ }
887+
888+ dbs, err := h.backupSvc.DetectDatabases(ctx, backupDomain)
889+ if err != nil {
890+ h.sendError(w, http.StatusInternalServerError, "failed to detect databases")
891+ return
892+ }
893+
894+ response := make([]dto.DatabaseInfo, 0, len(dbs))
895+ for _, db := range dbs {
896+ response = append(response, toDatabaseInfoResponse(db))
897+ }
898+
899+ h.sendJSON(w, http.StatusOK, dto.BackupDetectResponse{Databases: response})
900+}
901+
902+func mapBackupJobsResponse(jobs []domain.BackupJob) []dto.BackupJob {
903+ response := make([]dto.BackupJob, 0, len(jobs))
904+ for _, job := range jobs {
905+ response = append(response, toBackupJobResponse(job))
906+ }
907+ return response
908+}
909+
721910 // handleReload handles /admin/reload endpoint.
722911 // This reloads configuration from file into memory and triggers container sync.
723912 func (h *Handler) handleReload(w http.ResponseWriter, r *http.Request) {
M internal/adapters/in/http/admin/handler_test.go +143 -34
@@ -34,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
3434 containerSvc := inmocks.NewMockContainerService(t)
3535 secretSvc := inmocks.NewMockSecretService(t)
3636
37- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
3838
3939 tests := []struct {
4040 name string
@@ -97,7 +97,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
9797 secretSvc := inmocks.NewMockSecretService(t)
9898 registrySvc := inmocks.NewMockRegistryService(t)
9999
100- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
100+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
101101
102102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
103103
@@ -158,7 +158,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
158158 secretSvc := inmocks.NewMockSecretService(t)
159159 registrySvc := inmocks.NewMockRegistryService(t)
160160
161- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
161+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
162162
163163 routeJSON := `{"image": "myapp:v2"}`
164164
@@ -203,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
203203 containerSvc := inmocks.NewMockContainerService(t)
204204 secretSvc := inmocks.NewMockSecretService(t)
205205
206- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
206+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
207207
208208 tests := []struct {
209209 name string
@@ -247,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
247247 containerSvc := inmocks.NewMockContainerService(t)
248248 secretSvc := inmocks.NewMockSecretService(t)
249249
250- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
250+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
251251
252252 tests := []struct {
253253 name string
@@ -304,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
304304 containerSvc := inmocks.NewMockContainerService(t)
305305 secretSvc := inmocks.NewMockSecretService(t)
306306
307- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
307+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
308308
309309 secretsJSON := `{"API_KEY": "secret123"}`
310310
@@ -353,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
353353 containerSvc := inmocks.NewMockContainerService(t)
354354 secretSvc := inmocks.NewMockSecretService(t)
355355
356- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
356+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
357357
358358 tests := []struct {
359359 name string
@@ -397,7 +397,7 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) {
397397 containerSvc := inmocks.NewMockContainerService(t)
398398 secretSvc := inmocks.NewMockSecretService(t)
399399
400- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
401401
402402 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403403
@@ -423,7 +423,7 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
423423 containerSvc := inmocks.NewMockContainerService(t)
424424 secretSvc := inmocks.NewMockSecretService(t)
425425
426- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
426+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
427427
428428 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429429
@@ -450,7 +450,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
450450 containerSvc := inmocks.NewMockContainerService(t)
451451 secretSvc := inmocks.NewMockSecretService(t)
452452
453- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
453+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
454454
455455 tests := []struct {
456456 name string
@@ -504,7 +504,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
504504 containerSvc := inmocks.NewMockContainerService(t)
505505 secretSvc := inmocks.NewMockSecretService(t)
506506
507- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
507+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
508508
509509 tests := []struct {
510510 name string
@@ -561,7 +561,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
561561 containerSvc := inmocks.NewMockContainerService(t)
562562 secretSvc := inmocks.NewMockSecretService(t)
563563
564- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
565565
566566 tests := []struct {
567567 name string
@@ -615,7 +615,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
615615 containerSvc := inmocks.NewMockContainerService(t)
616616 secretSvc := inmocks.NewMockSecretService(t)
617617
618- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
618+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
619619
620620 expectedRoutes := []domain.Route{
621621 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -650,7 +650,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
650650 containerSvc := inmocks.NewMockContainerService(t)
651651 secretSvc := inmocks.NewMockSecretService(t)
652652
653- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
653+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
654654
655655 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
656656 Domain: "app.example.com", Image: "app:latest",
@@ -681,7 +681,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
681681 containerSvc := inmocks.NewMockContainerService(t)
682682 secretSvc := inmocks.NewMockSecretService(t)
683683
684- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
684+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
685685
686686 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
687687
@@ -725,7 +725,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
725725 secretSvc := inmocks.NewMockSecretService(t)
726726 registrySvc := inmocks.NewMockRegistryService(t)
727727
728- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
728+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
729729
730730 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
731731 registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
@@ -748,7 +748,7 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
748748 secretSvc := inmocks.NewMockSecretService(t)
749749 registrySvc := inmocks.NewMockRegistryService(t)
750750
751- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
751+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
752752
753753 tests := []struct {
754754 name string
@@ -802,7 +802,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
802802 containerSvc := inmocks.NewMockContainerService(t)
803803 secretSvc := inmocks.NewMockSecretService(t)
804804
805- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
805+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
806806
807807 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
808808 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -819,7 +819,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
819819 containerSvc := inmocks.NewMockContainerService(t)
820820 secretSvc := inmocks.NewMockSecretService(t)
821821
822- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
822+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
823823
824824 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
825825 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -836,7 +836,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
836836 containerSvc := inmocks.NewMockContainerService(t)
837837 secretSvc := inmocks.NewMockSecretService(t)
838838
839- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
839+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
840840
841841 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
842842 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -853,7 +853,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
853853 containerSvc := inmocks.NewMockContainerService(t)
854854 secretSvc := inmocks.NewMockSecretService(t)
855855
856- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
856+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
857857
858858 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
859859 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -870,7 +870,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
870870 containerSvc := inmocks.NewMockContainerService(t)
871871 secretSvc := inmocks.NewMockSecretService(t)
872872
873- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
873+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
874874
875875 tests := []struct {
876876 method string
@@ -903,7 +903,7 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
903903 containerSvc := inmocks.NewMockContainerService(t)
904904 secretSvc := inmocks.NewMockSecretService(t)
905905
906- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
906+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
907907
908908 tests := []struct {
909909 name string
@@ -933,7 +933,7 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) {
933933 containerSvc := inmocks.NewMockContainerService(t)
934934 secretSvc := inmocks.NewMockSecretService(t)
935935
936- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
936+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
937937
938938 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
939939
@@ -952,7 +952,7 @@ func TestHandler_Restart_GenericError(t *testing.T) {
952952 containerSvc := inmocks.NewMockContainerService(t)
953953 secretSvc := inmocks.NewMockSecretService(t)
954954
955- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
955+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
956956
957957 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
958958
@@ -974,7 +974,7 @@ func TestHandler_Restart_Success(t *testing.T) {
974974 containerSvc := inmocks.NewMockContainerService(t)
975975 secretSvc := inmocks.NewMockSecretService(t)
976976
977- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
977+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
978978
979979 tests := []struct {
980980 name string
@@ -1021,7 +1021,7 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
10211021 containerSvc := inmocks.NewMockContainerService(t)
10221022 secretSvc := inmocks.NewMockSecretService(t)
10231023
1024- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1024+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
10251025
10261026 tests := []struct {
10271027 name string
@@ -1052,7 +1052,7 @@ func TestHandler_Tags_Success(t *testing.T) {
10521052 secretSvc := inmocks.NewMockSecretService(t)
10531053 registrySvc := inmocks.NewMockRegistryService(t)
10541054
1055- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1055+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
10561056
10571057 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
10581058
@@ -1079,7 +1079,7 @@ func TestHandler_Tags_Error(t *testing.T) {
10791079 secretSvc := inmocks.NewMockSecretService(t)
10801080 registrySvc := inmocks.NewMockRegistryService(t)
10811081
1082- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1082+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
10831083
10841084 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
10851085
@@ -1099,7 +1099,7 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
10991099 containerSvc := inmocks.NewMockContainerService(t)
11001100 secretSvc := inmocks.NewMockSecretService(t)
11011101
1102- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1102+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11031103
11041104 body := `{"REDIS_URL": "redis://localhost:6379"}`
11051105 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1117,7 +1117,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
11171117 containerSvc := inmocks.NewMockContainerService(t)
11181118 secretSvc := inmocks.NewMockSecretService(t)
11191119
1120- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1120+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11211121
11221122 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
11231123 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1134,7 +1134,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
11341134 containerSvc := inmocks.NewMockContainerService(t)
11351135 secretSvc := inmocks.NewMockSecretService(t)
11361136
1137- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1137+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11381138
11391139 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
11401140 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1152,7 +1152,7 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
11521152 containerSvc := inmocks.NewMockContainerService(t)
11531153 secretSvc := inmocks.NewMockSecretService(t)
11541154
1155- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1155+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11561156
11571157 body := `{not valid json`
11581158 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1171,7 +1171,7 @@ func TestHandler_NotFound(t *testing.T) {
11711171 containerSvc := inmocks.NewMockContainerService(t)
11721172 secretSvc := inmocks.NewMockSecretService(t)
11731173
1174- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1174+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11751175
11761176 req := httptest.NewRequest("GET", "/admin/unknown", nil)
11771177 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -1181,3 +1181,112 @@ func TestHandler_NotFound(t *testing.T) {
11811181
11821182 assert.Equal(t, http.StatusNotFound, rec.Code)
11831183 }
1184+
1185+func TestHandler_BackupsStatus(t *testing.T) {
1186+ configSvc := inmocks.NewMockConfigService(t)
1187+ authSvc := inmocks.NewMockAuthService(t)
1188+ containerSvc := inmocks.NewMockContainerService(t)
1189+ secretSvc := inmocks.NewMockSecretService(t)
1190+ backupSvc := inmocks.NewMockBackupService(t)
1191+
1192+ backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil)
1193+
1194+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1195+
1196+ req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1197+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1198+ rec := httptest.NewRecorder()
1199+
1200+ handler.ServeHTTP(rec, req)
1201+
1202+ assert.Equal(t, http.StatusOK, rec.Code)
1203+ assert.Contains(t, rec.Body.String(), "app.example.com")
1204+ assert.NotContains(t, rec.Body.String(), "file_path")
1205+}
1206+
1207+func TestHandler_BackupsListDomain(t *testing.T) {
1208+ configSvc := inmocks.NewMockConfigService(t)
1209+ authSvc := inmocks.NewMockAuthService(t)
1210+ containerSvc := inmocks.NewMockContainerService(t)
1211+ secretSvc := inmocks.NewMockSecretService(t)
1212+ backupSvc := inmocks.NewMockBackupService(t)
1213+
1214+ backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1215+
1216+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1217+
1218+ req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1219+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1220+ rec := httptest.NewRecorder()
1221+
1222+ handler.ServeHTTP(rec, req)
1223+
1224+ assert.Equal(t, http.StatusOK, rec.Code)
1225+ assert.Contains(t, rec.Body.String(), "app.example.com")
1226+}
1227+
1228+func TestHandler_BackupsRunDomain(t *testing.T) {
1229+ configSvc := inmocks.NewMockConfigService(t)
1230+ authSvc := inmocks.NewMockAuthService(t)
1231+ containerSvc := inmocks.NewMockContainerService(t)
1232+ secretSvc := inmocks.NewMockSecretService(t)
1233+ backupSvc := inmocks.NewMockBackupService(t)
1234+
1235+ backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1236+
1237+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1238+
1239+ body := bytes.NewBufferString(`{"db":"postgres"}`)
1240+ req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1241+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1242+ rec := httptest.NewRecorder()
1243+
1244+ handler.ServeHTTP(rec, req)
1245+
1246+ assert.Equal(t, http.StatusOK, rec.Code)
1247+ assert.Contains(t, rec.Body.String(), "completed")
1248+}
1249+
1250+func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) {
1251+ configSvc := inmocks.NewMockConfigService(t)
1252+ authSvc := inmocks.NewMockAuthService(t)
1253+ containerSvc := inmocks.NewMockContainerService(t)
1254+ secretSvc := inmocks.NewMockSecretService(t)
1255+ backupSvc := inmocks.NewMockBackupService(t)
1256+
1257+ backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1258+
1259+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1260+
1261+ body := bytes.NewBufferString(`{"db":"postgres"}`)
1262+ req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1263+ req.ContentLength = -1
1264+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1265+ rec := httptest.NewRecorder()
1266+
1267+ handler.ServeHTTP(rec, req)
1268+
1269+ assert.Equal(t, http.StatusOK, rec.Code)
1270+ assert.Contains(t, rec.Body.String(), "completed")
1271+}
1272+
1273+func TestHandler_BackupsDetectDomain(t *testing.T) {
1274+ configSvc := inmocks.NewMockConfigService(t)
1275+ authSvc := inmocks.NewMockAuthService(t)
1276+ containerSvc := inmocks.NewMockContainerService(t)
1277+ secretSvc := inmocks.NewMockSecretService(t)
1278+ backupSvc := inmocks.NewMockBackupService(t)
1279+
1280+ backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
1281+
1282+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1283+
1284+ req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
1285+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1286+ rec := httptest.NewRecorder()
1287+
1288+ handler.ServeHTTP(rec, req)
1289+
1290+ assert.Equal(t, http.StatusOK, rec.Code)
1291+ assert.Contains(t, rec.Body.String(), "postgres")
1292+}
M internal/adapters/out/docker/runtime.go +132 -16
@@ -3,15 +3,19 @@ package docker
33
44 import (
55 "archive/tar"
6+ "bytes"
67 "context"
78 "encoding/base64"
89 "encoding/json"
10+ "errors"
911 "fmt"
1012 "io"
1113 "path/filepath"
1214 "sort"
1315 "strconv"
1416 "strings"
17+ "sync"
18+ "time"
1519
1620 "github.com/bnema/zerowrap"
1721 cerrdefs "github.com/containerd/errdefs"
@@ -21,8 +25,10 @@ import (
2125 "github.com/docker/docker/api/types/registry"
2226 "github.com/docker/docker/api/types/volume"
2327 "github.com/docker/docker/client"
28+ "github.com/docker/docker/pkg/stdcopy"
2429 "github.com/docker/go-connections/nat"
2530
31+ "github.com/bnema/gordon/internal/boundaries/out"
2632 "github.com/bnema/gordon/internal/domain"
2733 )
2834
@@ -31,6 +37,35 @@ type Runtime struct {
3137 client *client.Client
3238 }
3339
40+type pipeReadCloser struct {
41+ pr *io.PipeReader
42+ pw *io.PipeWriter
43+ original io.Closer
44+ once sync.Once
45+}
46+
47+func (p *pipeReadCloser) Read(b []byte) (int, error) {
48+ return p.pr.Read(b)
49+}
50+
51+func (p *pipeReadCloser) Close() error {
52+ var closeErr error
53+ p.once.Do(func() {
54+ if err := p.pw.CloseWithError(errors.New("reader closed")); err != nil && !errors.Is(err, io.ErrClosedPipe) {
55+ closeErr = err
56+ }
57+ if err := p.pr.Close(); err != nil && closeErr == nil {
58+ closeErr = err
59+ }
60+ if p.original != nil {
61+ if err := p.original.Close(); err != nil && closeErr == nil {
62+ closeErr = err
63+ }
64+ }
65+ })
66+ return closeErr
67+}
68+
3469 // NewRuntime creates a new Docker runtime instance.
3570 func NewRuntime() (*Runtime, error) {
3671 cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
@@ -1107,9 +1142,77 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN
11071142 return nil
11081143 }
11091144
1110-// CopyFromContainer copies a file from a container to the host.
1111-// Returns the file contents as a byte slice.
1112-func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1145+// ExecInContainer executes a command in a running container.
1146+func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
1147+ if len(cmd) == 0 {
1148+ return nil, fmt.Errorf("command cannot be empty")
1149+ }
1150+
1151+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1152+ zerowrap.FieldLayer: "adapter",
1153+ zerowrap.FieldAdapter: "docker",
1154+ zerowrap.FieldAction: "ExecInContainer",
1155+ zerowrap.FieldEntityID: containerID,
1156+ "command": cmd[0],
1157+ "arg_count": len(cmd) - 1,
1158+ })
1159+ log := zerowrap.FromCtx(ctx)
1160+
1161+ execResp, err := r.client.ContainerExecCreate(ctx, containerID, container.ExecOptions{
1162+ Cmd: cmd,
1163+ AttachStdout: true,
1164+ AttachStderr: true,
1165+ })
1166+ if err != nil {
1167+ return nil, log.WrapErr(err, "failed to create exec")
1168+ }
1169+
1170+ attachCtx := ctx
1171+ var cancel context.CancelFunc
1172+ if _, hasDeadline := ctx.Deadline(); !hasDeadline {
1173+ attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
1174+ defer cancel()
1175+ }
1176+
1177+ attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{})
1178+ if err != nil {
1179+ return nil, log.WrapErr(err, "failed to attach to exec")
1180+ }
1181+ defer attachResp.Close()
1182+ if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
1183+ _ = attachResp.Conn.SetReadDeadline(deadline)
1184+ }
1185+
1186+ stdout, stderr, err := parseExecOutput(attachResp.Reader)
1187+ if err != nil {
1188+ return nil, log.WrapErr(err, "failed to read exec output")
1189+ }
1190+
1191+ inspectResp, err := r.client.ContainerExecInspect(ctx, execResp.ID)
1192+ if err != nil {
1193+ return nil, log.WrapErr(err, "failed to inspect exec result")
1194+ }
1195+
1196+ return &out.ExecResult{
1197+ ExitCode: inspectResp.ExitCode,
1198+ Stdout: stdout,
1199+ Stderr: stderr,
1200+ }, nil
1201+}
1202+
1203+func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1204+ var stdout bytes.Buffer
1205+ var stderr bytes.Buffer
1206+
1207+ if _, err := stdcopy.StdCopy(&stdout, &stderr, reader); err != nil {
1208+ return nil, nil, err
1209+ }
1210+
1211+ return stdout.Bytes(), stderr.Bytes(), nil
1212+}
1213+
1214+// CopyFromContainer copies a file from a container and returns a content stream.
1215+func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
11131216 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
11141217 zerowrap.FieldLayer: "adapter",
11151218 zerowrap.FieldAdapter: "docker",
@@ -1119,24 +1222,23 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
11191222 })
11201223 log := zerowrap.FromCtx(ctx)
11211224
1122- reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1225+ tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
11231226 if err != nil {
11241227 return nil, log.WrapErr(err, "failed to copy from container")
11251228 }
1126- defer reader.Close()
11271229
1128- // The response is a tar archive - we need to extract the file
1129- data, err := extractFileFromTar(reader, srcPath)
1230+ // The response is a tar archive - we need to extract the requested file stream.
1231+ reader, err := extractFileFromTar(tarReader, srcPath)
11301232 if err != nil {
1233+ _ = tarReader.Close()
11311234 return nil, log.WrapErr(err, "failed to extract file from tar")
11321235 }
11331236
1134- log.Debug().Int("size", len(data)).Msg("file copied from container")
1135- return data, nil
1237+ return reader, nil
11361238 }
11371239
11381240 // extractFileFromTar extracts a single file from a tar archive.
1139-func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1241+func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
11401242 tr := tar.NewReader(reader)
11411243
11421244 // The path in the tar may be relative or absolute
@@ -1156,15 +1258,23 @@ func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
11561258 if header.Typeflag == tar.TypeReg {
11571259 headerName := filepath.Base(header.Name)
11581260 if headerName == targetName {
1159- data, err := io.ReadAll(tr)
1160- if err != nil {
1161- return nil, err
1162- }
1163- return data, nil
1261+ size := header.Size
1262+ pr, pw := io.Pipe()
1263+ go func() {
1264+ defer reader.Close()
1265+ _, copyErr := io.CopyN(pw, tr, size)
1266+ if copyErr != nil {
1267+ _ = pw.CloseWithError(copyErr)
1268+ return
1269+ }
1270+ _ = pw.Close()
1271+ }()
1272+ return &pipeReadCloser{pr: pr, pw: pw, original: reader}, nil
11641273 }
11651274 }
11661275 }
11671276
1277+ _ = reader.Close()
11681278 return nil, fmt.Errorf("file not found in container: %s", targetPath)
11691279 }
11701280
@@ -1204,10 +1314,16 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
12041314 }()
12051315
12061316 // Copy the env file from the container
1207- data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1317+ reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
12081318 if err != nil {
12091319 return nil, err
12101320 }
1321+ defer reader.Close()
1322+
1323+ data, err := io.ReadAll(reader)
1324+ if err != nil {
1325+ return nil, log.WrapErr(err, "failed to read extracted env file")
1326+ }
12111327
12121328 log.Info().Int("size", len(data)).Msg("env file extracted from image")
12131329 return data, nil
A internal/adapters/out/docker/runtime_exec_test.go +49 -0
@@ -0,0 +1,49 @@
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+ tests := []struct {
27+ name string
28+ cmd []string
29+ }{
30+ {name: "nil", cmd: nil},
31+ {name: "empty slice", cmd: []string{}},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ result, err := r.ExecInContainer(context.Background(), "abc123", tt.cmd)
37+ require.Error(t, err)
38+ assert.Nil(t, result)
39+ })
40+ }
41+}
42+
43+func frameDockerStream(streamID byte, payload []byte) []byte {
44+ frame := make([]byte, 8+len(payload))
45+ frame[0] = streamID
46+ binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
47+ copy(frame[8:], payload)
48+ return frame
49+}
A internal/adapters/out/filesystem/backup_storage.go +290 -0
@@ -0,0 +1,290 @@
1+package filesystem
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "io"
8+ "os"
9+ "path/filepath"
10+ "sort"
11+ "strings"
12+ "syscall"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+
17+ "github.com/bnema/gordon/internal/domain"
18+)
19+
20+const backupTimestampLayout = "20060102T150405Z"
21+
22+// BackupStorage implements backup artifact persistence on local filesystem.
23+type BackupStorage struct {
24+ rootDir string
25+ log zerowrap.Logger
26+}
27+
28+// NewBackupStorage creates a new filesystem backup storage.
29+func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
30+ if err := os.MkdirAll(rootDir, 0750); err != nil {
31+ return nil, fmt.Errorf("failed to create backup directory: %w", err)
32+ }
33+
34+ return &BackupStorage{rootDir: rootDir, log: log}, nil
35+}
36+
37+// Store saves backup data and returns the absolute storage path.
38+func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) {
39+ domainPart := sanitizeBackupPathComponent(domainName)
40+ dbPart := sanitizeBackupPathComponent(dbName)
41+ schedulePart := string(schedule)
42+ if schedulePart == "" {
43+ schedulePart = "manual"
44+ }
45+ schedulePart = sanitizeBackupPathComponent(schedulePart)
46+
47+ backupDir := filepath.Join(s.rootDir, domainPart, dbPart, schedulePart)
48+ if err := os.MkdirAll(backupDir, 0750); err != nil {
49+ return "", fmt.Errorf("failed to create backup path: %w", err)
50+ }
51+
52+ fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak"
53+ finalPath := filepath.Join(backupDir, fileName)
54+ tmpPath := finalPath + ".tmp"
55+
56+ f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
57+ if err != nil {
58+ return "", fmt.Errorf("failed to create temp backup file: %w", err)
59+ }
60+
61+ if _, err := io.Copy(f, data); err != nil {
62+ _ = f.Close()
63+ _ = os.Remove(tmpPath)
64+ return "", fmt.Errorf("failed to write backup data: %w", err)
65+ }
66+
67+ if err := f.Close(); err != nil {
68+ _ = os.Remove(tmpPath)
69+ return "", fmt.Errorf("failed to close temp backup file: %w", err)
70+ }
71+
72+ if err := os.Rename(tmpPath, finalPath); err != nil {
73+ _ = os.Remove(tmpPath)
74+ return "", fmt.Errorf("failed to finalize backup file: %w", err)
75+ }
76+
77+ return finalPath, nil
78+}
79+
80+// Get retrieves a backup file by path.
81+func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) {
82+ if !pathWithinRoot(s.rootDir, path) {
83+ return nil, fmt.Errorf("backup path escapes storage root")
84+ }
85+
86+ f, err := os.Open(path)
87+ if err != nil {
88+ return nil, err
89+ }
90+ return f, nil
91+}
92+
93+// List returns backups for a domain, optionally filtered by schedule.
94+func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
95+ domainRoot := filepath.Join(s.rootDir, sanitizeBackupPathComponent(domainName))
96+ if _, err := os.Stat(domainRoot); err != nil {
97+ if os.IsNotExist(err) {
98+ return []domain.BackupJob{}, nil
99+ }
100+ return nil, err
101+ }
102+
103+ jobs := make([]domain.BackupJob, 0)
104+ err := filepath.WalkDir(domainRoot, func(path string, d os.DirEntry, walkErr error) error {
105+ if walkErr != nil {
106+ return walkErr
107+ }
108+ if d.IsDir() {
109+ return nil
110+ }
111+
112+ rel, err := filepath.Rel(domainRoot, path)
113+ if err != nil {
114+ return nil
115+ }
116+ parts := strings.Split(rel, string(filepath.Separator))
117+ if len(parts) != 3 {
118+ return nil
119+ }
120+
121+ dbName := parts[0]
122+ sched := domain.BackupSchedule(parts[1])
123+ if schedule != nil && *schedule != sched {
124+ return nil
125+ }
126+
127+ info, err := d.Info()
128+ if err != nil {
129+ return nil
130+ }
131+
132+ startedAt := info.ModTime().UTC()
133+ base := filepath.Base(path)
134+ if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {
135+ startedAt = ts.UTC()
136+ }
137+
138+ jobs = append(jobs, domain.BackupJob{
139+ ID: base,
140+ Domain: domainName,
141+ DBName: dbName,
142+ Schedule: sched,
143+ Type: domain.BackupTypeLogical,
144+ Status: domain.BackupStatusCompleted,
145+ StartedAt: startedAt,
146+ SizeBytes: info.Size(),
147+ FilePath: path,
148+ })
149+
150+ return nil
151+ })
152+ if err != nil {
153+ return nil, err
154+ }
155+
156+ sort.Slice(jobs, func(i, j int) bool {
157+ return jobs[i].StartedAt.After(jobs[j].StartedAt)
158+ })
159+
160+ return jobs, nil
161+}
162+
163+// Delete removes a backup file.
164+func (s *BackupStorage) Delete(_ context.Context, path string) error {
165+ if !pathWithinRoot(s.rootDir, path) {
166+ return fmt.Errorf("backup path escapes storage root")
167+ }
168+ return os.Remove(path)
169+}
170+
171+// ApplyRetention removes old backups according to the schedule policy.
172+func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, policy domain.RetentionPolicy) (int, error) {
173+ jobs, err := s.List(ctx, domainName, nil)
174+ if err != nil {
175+ return 0, err
176+ }
177+
178+ groups := make(map[string][]domain.BackupJob)
179+ for _, job := range jobs {
180+ key := fmt.Sprintf("%s|%s", job.DBName, job.Schedule)
181+ groups[key] = append(groups[key], job)
182+ }
183+
184+ deleted := 0
185+ for _, group := range groups {
186+ sort.Slice(group, func(i, j int) bool {
187+ return group[i].StartedAt.After(group[j].StartedAt)
188+ })
189+
190+ keep := retentionKeepCount(policy, group[0].Schedule)
191+ if keep < 0 {
192+ continue
193+ }
194+
195+ for idx := keep; idx < len(group); idx++ {
196+ if err := s.Delete(ctx, group[idx].FilePath); err != nil {
197+ if os.IsNotExist(err) {
198+ continue
199+ }
200+ return deleted, err
201+ }
202+ if err := s.removeEmptyBackupDirs(group[idx].FilePath, domainName); err != nil {
203+ return deleted, err
204+ }
205+ deleted++
206+ }
207+ }
208+
209+ return deleted, nil
210+}
211+
212+func (s *BackupStorage) removeEmptyBackupDirs(filePath, domainName string) error {
213+ backupRoot := s.rootDir
214+ domainRoot := filepath.Join(backupRoot, sanitizeBackupPathComponent(domainName))
215+
216+ dir := filepath.Dir(filePath)
217+ for {
218+ if !pathWithinRoot(backupRoot, dir) {
219+ return nil
220+ }
221+ if dir == domainRoot || dir == backupRoot {
222+ break
223+ }
224+
225+ err := os.Remove(dir)
226+ if err == nil {
227+ dir = filepath.Dir(dir)
228+ continue
229+ }
230+ if os.IsNotExist(err) {
231+ return nil
232+ }
233+ if errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EEXIST) {
234+ return nil
235+ }
236+ return err
237+ }
238+
239+ return nil
240+}
241+
242+func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int {
243+ switch schedule {
244+ case domain.ScheduleHourly:
245+ return policy.Hourly
246+ case domain.ScheduleDaily:
247+ return policy.Daily
248+ case domain.ScheduleWeekly:
249+ return policy.Weekly
250+ case domain.ScheduleMonthly:
251+ return policy.Monthly
252+ default:
253+ return -1
254+ }
255+}
256+
257+func sanitizeBackupPathComponent(input string) string {
258+ clean := strings.TrimSpace(input)
259+ if clean == "" {
260+ return "unknown"
261+ }
262+ if strings.Trim(clean, ".") == "" {
263+ return "unknown"
264+ }
265+ clean = strings.Trim(clean, ".")
266+ if clean == "" {
267+ return "unknown"
268+ }
269+ replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
270+ return replacer.Replace(clean)
271+}
272+
273+func pathWithinRoot(root, path string) bool {
274+ rootAbs, err := filepath.Abs(root)
275+ if err != nil {
276+ return false
277+ }
278+ pathAbs, err := filepath.Abs(path)
279+ if err != nil {
280+ return false
281+ }
282+
283+ rootClean := filepath.Clean(rootAbs)
284+ pathClean := filepath.Clean(pathAbs)
285+ rel, err := filepath.Rel(rootClean, pathClean)
286+ if err != nil {
287+ return false
288+ }
289+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
290+}
A internal/adapters/out/filesystem/backup_storage_test.go +111 -0
@@ -0,0 +1,111 @@
1+package filesystem
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "path/filepath"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+func TestBackupStorage_StoreAndGet(t *testing.T) {
17+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
18+ require.NoError(t, err)
19+
20+ now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC)
21+ payload := []byte("backup-content")
22+ path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(payload))
23+ require.NoError(t, err)
24+
25+ rc, err := storage.Get(context.Background(), path)
26+ require.NoError(t, err)
27+ defer rc.Close()
28+
29+ data, err := io.ReadAll(rc)
30+ require.NoError(t, err)
31+ assert.Equal(t, payload, data)
32+}
33+
34+func TestBackupStorage_ListWithScheduleFilter(t *testing.T) {
35+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
36+ require.NoError(t, err)
37+
38+ base := time.Date(2026, 2, 7, 10, 0, 0, 0, time.UTC)
39+ _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base, bytes.NewReader([]byte("d1")))
40+ require.NoError(t, err)
41+ _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Hour), bytes.NewReader([]byte("d2")))
42+ require.NoError(t, err)
43+ _, err = storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleWeekly, base.Add(2*time.Hour), bytes.NewReader([]byte("w1")))
44+ require.NoError(t, err)
45+
46+ schedule := domain.ScheduleDaily
47+ jobs, err := storage.List(context.Background(), "app.example.com", &schedule)
48+ require.NoError(t, err)
49+ require.Len(t, jobs, 2)
50+ assert.Equal(t, domain.ScheduleDaily, jobs[0].Schedule)
51+ assert.Equal(t, domain.ScheduleDaily, jobs[1].Schedule)
52+ assert.True(t, jobs[0].StartedAt.After(jobs[1].StartedAt))
53+}
54+
55+func TestBackupStorage_Delete(t *testing.T) {
56+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
57+ require.NoError(t, err)
58+
59+ path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data")))
60+ require.NoError(t, err)
61+
62+ err = storage.Delete(context.Background(), path)
63+ require.NoError(t, err)
64+
65+ _, err = storage.Get(context.Background(), path)
66+ assert.Error(t, err)
67+}
68+
69+func TestBackupStorage_ApplyRetention(t *testing.T) {
70+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
71+ require.NoError(t, err)
72+
73+ base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC)
74+ for i := 0; i < 4; i++ {
75+ _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data")))
76+ require.NoError(t, err)
77+ }
78+
79+ deleted, err := storage.ApplyRetention(context.Background(), "app.example.com", domain.RetentionPolicy{Daily: 2})
80+ require.NoError(t, err)
81+ assert.Equal(t, 2, deleted)
82+
83+ schedule := domain.ScheduleDaily
84+ jobs, err := storage.List(context.Background(), "app.example.com", &schedule)
85+ require.NoError(t, err)
86+ assert.Len(t, jobs, 2)
87+}
88+
89+func TestBackupStorage_StoreSanitizesDotOnlyPathComponents(t *testing.T) {
90+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
91+ require.NoError(t, err)
92+
93+ path, err := storage.Store(context.Background(), "..", "...", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data")))
94+ require.NoError(t, err)
95+ assert.NotContains(t, path, "..")
96+}
97+
98+func TestBackupStorage_StoreSanitizesSchedulePathComponent(t *testing.T) {
99+ rootDir := t.TempDir()
100+ storage, err := NewBackupStorage(rootDir, testLogger())
101+ require.NoError(t, err)
102+
103+ path, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.BackupSchedule("../../escape"), time.Now().UTC(), bytes.NewReader([]byte("data")))
104+ require.NoError(t, err)
105+
106+ rel, err := filepath.Rel(rootDir, path)
107+ require.NoError(t, err)
108+ assert.NotEqual(t, "..", rel)
109+ assert.NotContains(t, rel, "../")
110+ assert.NotContains(t, rel, "..\\")
111+}
M internal/app/run.go +84 -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,64 @@ 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+ retention, err := validateBackupRetention(cfg)
1099+ if err != nil {
1100+ return nil, nil, log.WrapErr(err, "invalid backup retention policy")
1101+ }
1102+
1103+ backupCfg := domain.BackupConfig{
1104+ Enabled: cfg.Backups.Enabled,
1105+ StorageDir: storageDir,
1106+ Retention: retention,
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+
1118+func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
1119+ if cfg.Backups.Retention.Hourly < 0 {
1120+ return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.hourly cannot be negative")
1121+ }
1122+ if cfg.Backups.Retention.Daily < 0 {
1123+ return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.daily cannot be negative")
1124+ }
1125+ if cfg.Backups.Retention.Weekly < 0 {
1126+ return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.weekly cannot be negative")
1127+ }
1128+ if cfg.Backups.Retention.Monthly < 0 {
1129+ return domain.RetentionPolicy{}, fmt.Errorf("backups.retention.monthly cannot be negative")
1130+ }
1131+
1132+ return domain.RetentionPolicy{
1133+ Hourly: cfg.Backups.Retention.Hourly,
1134+ Daily: cfg.Backups.Retention.Daily,
1135+ Weekly: cfg.Backups.Retention.Weekly,
1136+ Monthly: cfg.Backups.Retention.Monthly,
1137+ }, nil
1138+}
1139+
10631140 // registerEventHandlers registers all event handlers.
10641141 func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
10651142 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
@@ -1770,6 +1847,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
17701847 v.SetDefault("volumes.prefix", "gordon")
17711848 v.SetDefault("volumes.preserve", true)
17721849 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1850+ v.SetDefault("backups.enabled", false)
1851+ v.SetDefault("backups.storage_dir", "")
1852+ v.SetDefault("backups.retention.hourly", 0)
1853+ v.SetDefault("backups.retention.daily", 0)
1854+ v.SetDefault("backups.retention.weekly", 0)
1855+ v.SetDefault("backups.retention.monthly", 0)
17731856 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
17741857 v.SetDefault("server.registry_allowed_ips", []string{})
17751858 v.SetDefault("deploy.readiness_delay", "5s")
A internal/app/run_backup_test.go +88 -0
@@ -0,0 +1,88 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ set func(*Config)
14+ err string
15+ }{
16+ {
17+ name: "hourly",
18+ set: func(cfg *Config) {
19+ cfg.Backups.Retention.Hourly = -1
20+ },
21+ err: "backups.retention.hourly",
22+ },
23+ {
24+ name: "daily",
25+ set: func(cfg *Config) {
26+ cfg.Backups.Retention.Daily = -1
27+ },
28+ err: "backups.retention.daily",
29+ },
30+ {
31+ name: "weekly",
32+ set: func(cfg *Config) {
33+ cfg.Backups.Retention.Weekly = -1
34+ },
35+ err: "backups.retention.weekly",
36+ },
37+ {
38+ name: "monthly",
39+ set: func(cfg *Config) {
40+ cfg.Backups.Retention.Monthly = -1
41+ },
42+ err: "backups.retention.monthly",
43+ },
44+ }
45+
46+ for _, tt := range tests {
47+ t.Run(tt.name, func(t *testing.T) {
48+ var cfg Config
49+ tt.set(&cfg)
50+
51+ _, err := validateBackupRetention(cfg)
52+ require.Error(t, err)
53+ assert.Contains(t, err.Error(), tt.err)
54+ })
55+ }
56+}
57+
58+func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
59+ t.Run("positive values", func(t *testing.T) {
60+ var cfg Config
61+ cfg.Backups.Retention.Hourly = 1
62+ cfg.Backups.Retention.Daily = 7
63+ cfg.Backups.Retention.Weekly = 4
64+ cfg.Backups.Retention.Monthly = 12
65+
66+ retention, err := validateBackupRetention(cfg)
67+ require.NoError(t, err)
68+ assert.Equal(t, 1, retention.Hourly)
69+ assert.Equal(t, 7, retention.Daily)
70+ assert.Equal(t, 4, retention.Weekly)
71+ assert.Equal(t, 12, retention.Monthly)
72+ })
73+
74+ t.Run("zero values", func(t *testing.T) {
75+ var cfg Config
76+ cfg.Backups.Retention.Hourly = 0
77+ cfg.Backups.Retention.Daily = 0
78+ cfg.Backups.Retention.Weekly = 0
79+ cfg.Backups.Retention.Monthly = 0
80+
81+ retention, err := validateBackupRetention(cfg)
82+ require.NoError(t, err)
83+ assert.Equal(t, 0, retention.Hourly)
84+ assert.Equal(t, 0, retention.Daily)
85+ assert.Equal(t, 0, retention.Weekly)
86+ assert.Equal(t, 0, retention.Monthly)
87+ })
88+}
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+}
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+}
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+}
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+}
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) (io.ReadCloser, 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 io.ReadCloser
115+ var r1 error
116+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (io.ReadCloser, error)); ok {
117+ return returnFunc(ctx, containerID, srcPath)
118+ }
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) io.ReadCloser); ok {
120+ r0 = returnFunc(ctx, containerID, srcPath)
121+ } else {
122+ if ret.Get(0) != nil {
123+ r0 = ret.Get(0).(io.ReadCloser)
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(readCloser io.ReadCloser, err error) *MockContainerRuntime_CopyFromContainer_Call {
171+ _c.Call.Return(readCloser, err)
172+ return _c
173+}
174+
175+func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) (io.ReadCloser, 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)
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) (io.ReadCloser, 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+}
A internal/domain/backup.go +118 -0
@@ -0,0 +1,118 @@
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 contains sensitive values (passwords/tokens).
51+ // Never log or expose this map in API responses.
52+ Credentials map[string]string `json:"-"`
53+}
54+
55+// ClearCredentials clears sensitive credential values from DBInfo.
56+func (d *DBInfo) ClearCredentials() {
57+ if d == nil || d.Credentials == nil {
58+ return
59+ }
60+ for k := range d.Credentials {
61+ d.Credentials[k] = ""
62+ delete(d.Credentials, k)
63+ }
64+ d.Credentials = nil
65+}
66+
67+// BackupJob represents a scheduled or manual backup operation.
68+type BackupJob struct {
69+ ID string
70+ Domain string
71+ DBName string
72+ Schedule BackupSchedule
73+ Type BackupType
74+ Status BackupJobStatus
75+ StartedAt time.Time
76+ CompletedAt time.Time
77+ SizeBytes int64
78+ FilePath string
79+ Error string
80+ Metadata map[string]string
81+}
82+
83+// BackupResult is returned after a backup operation completes.
84+type BackupResult struct {
85+ Job BackupJob
86+ Duration time.Duration
87+}
88+
89+// RetentionPolicy defines how many backups to keep per schedule tier.
90+type RetentionPolicy struct {
91+ Hourly int
92+ Daily int
93+ Weekly int
94+ Monthly int
95+}
96+
97+// BackupOverride allows per-domain backup configuration.
98+type BackupOverride struct {
99+ Schedules []BackupSchedule
100+ Retention *RetentionPolicy
101+}
102+
103+// BackupConfig is the top-level backup configuration.
104+type BackupConfig struct {
105+ Enabled bool
106+ StorageDir string
107+ Retention RetentionPolicy
108+ Overrides map[string]BackupOverride
109+}
110+
111+// Backup labels for container metadata.
112+const (
113+ LabelBackupEnabled = "gordon.backup"
114+ LabelBackupType = "gordon.backup.type"
115+ LabelBackupVersion = "gordon.backup.version"
116+ LabelBackupSchedule = "gordon.backup.schedule"
117+ LabelBackupSidecar = "gordon.backup.sidecar"
118+)
A internal/domain/backup_test.go +24 -0
@@ -0,0 +1,24 @@
1+package domain
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDBInfoJSONOmitsCredentials(t *testing.T) {
12+ info := DBInfo{
13+ Type: DBTypePostgreSQL,
14+ Name: "postgres",
15+ Credentials: map[string]string{
16+ "password": "secret",
17+ },
18+ }
19+
20+ payload, err := json.Marshal(info)
21+ require.NoError(t, err)
22+ assert.NotContains(t, string(payload), "credentials")
23+ assert.NotContains(t, string(payload), "secret")
24+}
M internal/domain/container.go +1 -0
@@ -28,6 +28,7 @@ type Attachment struct {
2828 ContainerID string
2929 Status string
3030 Network string
31+ Ports []int
3132 }
3233
3334 // RouteInfo combines route configuration with runtime state.
A internal/domain/cron.go +22 -0
@@ -0,0 +1,22 @@
1+package domain
2+
3+import "time"
4+
5+// CronSchedule represents a recurring schedule.
6+//
7+// Preset schedules currently use fixed UTC times by design (MVP scope).
8+// Timezone-aware scheduling is deferred until a timezone field is added.
9+// When timezone support lands, host system timezone should be the default.
10+type CronSchedule struct {
11+ Preset BackupSchedule
12+}
13+
14+// CronEntry represents a registered cron job.
15+type CronEntry struct {
16+ ID string
17+ Name string
18+ Schedule CronSchedule
19+ LastRun time.Time
20+ NextRun time.Time
21+ Running bool
22+}
A internal/usecase/backup/detector.go +67 -0
@@ -0,0 +1,67 @@
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+ port := 5432
17+ hasPostgresPort := false
18+ firstPositive := 0
19+ for _, p := range a.Ports {
20+ if p == 5432 {
21+ port = 5432
22+ hasPostgresPort = true
23+ break
24+ }
25+ if p > 0 && firstPositive == 0 {
26+ firstPositive = p
27+ }
28+ }
29+ if !hasPostgresPort && firstPositive > 0 {
30+ port = firstPositive
31+ }
32+ return domain.DBInfo{
33+ Type: domain.DBTypePostgreSQL,
34+ Version: postgresVersionFromImage(a.Image),
35+ Domain: domainName,
36+ Name: a.Name,
37+ Host: a.Name,
38+ Port: port,
39+ ContainerID: a.ContainerID,
40+ ImageName: a.Image,
41+ }, true
42+ default:
43+ return domain.DBInfo{}, false
44+ }
45+}
46+
47+func postgresVersionFromImage(image string) string {
48+ lastColon := strings.LastIndex(image, ":")
49+ if lastColon == -1 {
50+ return ""
51+ }
52+ tag := strings.TrimSpace(image[lastColon+1:])
53+ if tag == "" {
54+ return ""
55+ }
56+
57+ for i := 0; i < len(tag); i++ {
58+ if tag[i] < '0' || tag[i] > '9' {
59+ if i == 0 {
60+ return ""
61+ }
62+ return tag[:i]
63+ }
64+ }
65+
66+ return tag
67+}
A internal/usecase/backup/detector_test.go +78 -0
@@ -0,0 +1,78 @@
1+package backup
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDetectDatabaseFromAttachment_ExtractsPostgresVersion(t *testing.T) {
12+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
13+ Name: "postgres",
14+ Image: "postgres:17.4-alpine",
15+ ContainerID: "abc123",
16+ Ports: []int{55432},
17+ })
18+
19+ require.True(t, ok)
20+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
21+ assert.Equal(t, "17", db.Version)
22+ assert.Equal(t, 55432, db.Port)
23+}
24+
25+func TestDetectDatabaseFromAttachment_DefaultPort(t *testing.T) {
26+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
27+ Name: "postgres",
28+ Image: "postgres:16",
29+ ContainerID: "xyz789",
30+ Ports: []int{},
31+ })
32+
33+ require.True(t, ok)
34+ assert.Equal(t, 5432, db.Port)
35+ assert.Equal(t, "16", db.Version)
36+}
37+
38+func TestDetectDatabaseFromAttachment_PrefersPostgresPort(t *testing.T) {
39+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
40+ Name: "postgres",
41+ Image: "postgres:16",
42+ ContainerID: "xyz789",
43+ Ports: []int{15432, 5432, 25432},
44+ })
45+
46+ require.True(t, ok)
47+ assert.Equal(t, 5432, db.Port)
48+}
49+
50+func TestDetectDatabaseFromAttachment_UsesFirstPositivePortWhen5432Missing(t *testing.T) {
51+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
52+ Name: "postgres",
53+ Image: "postgres:16",
54+ ContainerID: "xyz789",
55+ Ports: []int{0, -1, 15432, 25432},
56+ })
57+
58+ require.True(t, ok)
59+ assert.Equal(t, 15432, db.Port)
60+}
61+
62+func TestPostgresVersionFromImage(t *testing.T) {
63+ assert.Equal(t, "18", postgresVersionFromImage("postgres:18"))
64+ assert.Equal(t, "17", postgresVersionFromImage("postgres:17.4-alpine"))
65+ assert.Equal(t, "", postgresVersionFromImage("postgres:latest"))
66+ assert.Equal(t, "", postgresVersionFromImage("postgres:alpine"))
67+ assert.Equal(t, "", postgresVersionFromImage("postgres"))
68+}
69+
70+func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) {
71+ _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
72+ Name: "mysql",
73+ Image: "mysql:8.0",
74+ ContainerID: "def456",
75+ Ports: []int{3306},
76+ })
77+ assert.False(t, ok)
78+}
A internal/usecase/backup/integration_test.go +248 -0
@@ -0,0 +1,248 @@
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+ pullCtx, cancelPull := context.WithTimeout(ctx, 5*time.Minute)
46+ defer cancelPull()
47+ require.NoError(t, runtime.PullImage(pullCtx, image))
48+ netCtx, cancelNet := context.WithTimeout(ctx, 2*time.Minute)
49+ defer cancelNet()
50+ require.NoError(t, runtime.CreateNetwork(netCtx, networkName, map[string]string{"driver": "bridge"}))
51+ t.Cleanup(func() {
52+ cleanupCtx, cancelCleanup := context.WithTimeout(context.Background(), 20*time.Second)
53+ defer cancelCleanup()
54+ _ = runtime.RemoveNetwork(cleanupCtx, networkName)
55+ })
56+
57+ containerCfg := &domain.ContainerConfig{
58+ Image: image,
59+ Name: containerName,
60+ Hostname: "postgres",
61+ NetworkMode: networkName,
62+ Env: []string{
63+ "POSTGRES_USER=postgres",
64+ "POSTGRES_PASSWORD=postgres",
65+ "POSTGRES_DB=appdb",
66+ },
67+ Labels: map[string]string{
68+ domain.LabelManaged: "true",
69+ domain.LabelAttachment: "true",
70+ domain.LabelAttachedTo: domainName,
71+ domain.LabelImage: image,
72+ },
73+ }
74+
75+ contCtx, cancelCont := context.WithTimeout(ctx, 2*time.Minute)
76+ defer cancelCont()
77+ container, err := runtime.CreateContainer(contCtx, containerCfg)
78+ require.NoError(t, err)
79+ t.Cleanup(func() {
80+ stopCtx, cancelStop := context.WithTimeout(context.Background(), 20*time.Second)
81+ _ = runtime.StopContainer(stopCtx, container.ID)
82+ cancelStop()
83+
84+ removeCtx, cancelRemove := context.WithTimeout(context.Background(), 20*time.Second)
85+ _ = runtime.RemoveContainer(removeCtx, container.ID, true)
86+ cancelRemove()
87+ })
88+ startCtx, cancelStart := context.WithTimeout(ctx, 2*time.Minute)
89+ defer cancelStart()
90+ require.NoError(t, runtime.StartContainer(startCtx, container.ID))
91+
92+ require.NoError(t, waitForPostgresReady(ctx, runtime, container.ID, 60*time.Second))
93+ require.NoError(t, seedPostgresData(ctx, runtime, container.ID))
94+
95+ storage, err := filesystem.NewBackupStorage(t.TempDir(), zerowrap.Default())
96+ require.NoError(t, err)
97+
98+ containerSvc := &integrationContainerService{
99+ routes: map[string]*domain.Container{
100+ domainName: container,
101+ },
102+ attachments: map[string][]domain.Attachment{
103+ domainName: {
104+ {
105+ Name: "postgres",
106+ Image: image,
107+ ContainerID: container.ID,
108+ Status: container.Status,
109+ Network: networkName,
110+ Ports: []int{5432},
111+ },
112+ },
113+ },
114+ }
115+
116+ svc := backup.NewService(runtime, storage, containerSvc, domain.BackupConfig{Enabled: true}, zerowrap.Default())
117+
118+ detected, err := svc.DetectDatabases(ctx, domainName)
119+ require.NoError(t, err)
120+ require.Len(t, detected, 1)
121+ assert.Equal(t, domain.DBTypePostgreSQL, detected[0].Type)
122+
123+ result, err := svc.RunBackup(ctx, domainName, "postgres")
124+ require.NoError(t, err)
125+ require.NotNil(t, result)
126+ assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
127+
128+ backupBytes, err := os.ReadFile(result.Job.FilePath)
129+ require.NoError(t, err)
130+ assert.Greater(t, len(backupBytes), 32)
131+ assert.True(t, bytes.HasPrefix(backupBytes, []byte("PGDMP")), "expected pg_dump custom format header")
132+
133+ jobs, err := svc.ListBackups(ctx, domainName)
134+ require.NoError(t, err)
135+ assert.Len(t, jobs, 1)
136+}
137+
138+func requireDockerRuntime(t *testing.T) *docker.Runtime {
139+ t.Helper()
140+ runtime, err := docker.NewRuntime()
141+ if err != nil {
142+ t.Skipf("docker runtime unavailable: %v", err)
143+ }
144+ if err := runtime.Ping(context.Background()); err != nil {
145+ t.Skipf("docker daemon unreachable: %v", err)
146+ }
147+ return runtime
148+}
149+
150+func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error {
151+ deadline := time.Now().Add(timeout)
152+ for time.Now().Before(deadline) {
153+ select {
154+ case <-ctx.Done():
155+ return ctx.Err()
156+ default:
157+ }
158+
159+ execCtx, cancelExec := context.WithTimeout(ctx, 10*time.Second)
160+ res, err := runtime.ExecInContainer(execCtx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"})
161+ cancelExec()
162+ if err == nil && res.ExitCode == 0 {
163+ return nil
164+ }
165+ time.Sleep(1 * time.Second)
166+ }
167+ return fmt.Errorf("postgres did not become ready within %s", timeout)
168+}
169+
170+func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error {
171+ cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -c "SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'" | grep -q 1 || createdb -U "$POSTGRES_USER" "$POSTGRES_DB"; 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');"`
172+ execCtx, cancelExec := context.WithTimeout(ctx, 30*time.Second)
173+ defer cancelExec()
174+
175+ res, err := runtime.ExecInContainer(execCtx, containerID, []string{"sh", "-c", cmd})
176+ if err != nil {
177+ return err
178+ }
179+ if res.ExitCode != 0 {
180+ return fmt.Errorf("seed command failed: %s", strings.TrimSpace(string(res.Stderr)))
181+ }
182+ return nil
183+}
184+
185+type integrationContainerService struct {
186+ routes map[string]*domain.Container
187+ attachments map[string][]domain.Attachment
188+}
189+
190+func (s *integrationContainerService) Deploy(context.Context, domain.Route) (*domain.Container, error) {
191+ return nil, fmt.Errorf("not implemented")
192+}
193+
194+func (s *integrationContainerService) Stop(context.Context, string) error {
195+ return fmt.Errorf("not implemented")
196+}
197+
198+func (s *integrationContainerService) Remove(context.Context, string, bool) error {
199+ return fmt.Errorf("not implemented")
200+}
201+
202+func (s *integrationContainerService) Get(_ context.Context, domainName string) (*domain.Container, bool) {
203+ c, ok := s.routes[domainName]
204+ return c, ok
205+}
206+
207+func (s *integrationContainerService) Restart(context.Context, string, bool) error {
208+ return fmt.Errorf("not implemented")
209+}
210+
211+func (s *integrationContainerService) List(context.Context) map[string]*domain.Container {
212+ out := make(map[string]*domain.Container, len(s.routes))
213+ for k, v := range s.routes {
214+ out[k] = v
215+ }
216+ return out
217+}
218+
219+func (s *integrationContainerService) ListRoutesWithDetails(context.Context) []domain.RouteInfo {
220+ return nil
221+}
222+
223+func (s *integrationContainerService) ListAttachments(_ context.Context, domainName string) []domain.Attachment {
224+ attachments := s.attachments[domainName]
225+ out := make([]domain.Attachment, len(attachments))
226+ copy(out, attachments)
227+ return out
228+}
229+
230+func (s *integrationContainerService) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) {
231+ return nil, fmt.Errorf("not implemented")
232+}
233+
234+func (s *integrationContainerService) HealthCheck(context.Context) map[string]bool {
235+ return map[string]bool{}
236+}
237+
238+func (s *integrationContainerService) SyncContainers(context.Context) error {
239+ return nil
240+}
241+
242+func (s *integrationContainerService) AutoStart(context.Context, []domain.Route) error {
243+ return nil
244+}
245+
246+func (s *integrationContainerService) Shutdown(context.Context) error {
247+ return nil
248+}
A internal/usecase/backup/service.go +259 -0
@@ -0,0 +1,259 @@
1+package backup
2+
3+import (
4+ "context"
5+ "crypto/rand"
6+ "encoding/hex"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "sort"
11+ "strings"
12+ "sync"
13+ "time"
14+
15+ "github.com/bnema/zerowrap"
16+
17+ "github.com/bnema/gordon/internal/boundaries/in"
18+ "github.com/bnema/gordon/internal/boundaries/out"
19+ "github.com/bnema/gordon/internal/domain"
20+)
21+
22+const backupExecTimeout = 30 * time.Minute
23+
24+// Service orchestrates backup operations.
25+type Service struct {
26+ runtime out.ContainerRuntime
27+ storage out.BackupStorage
28+ containerSvc in.ContainerService
29+ config domain.BackupConfig
30+ log zerowrap.Logger
31+}
32+
33+// NewService creates a backup service.
34+func NewService(
35+ runtime out.ContainerRuntime,
36+ storage out.BackupStorage,
37+ containerSvc in.ContainerService,
38+ config domain.BackupConfig,
39+ log zerowrap.Logger,
40+) *Service {
41+ return &Service{
42+ runtime: runtime,
43+ storage: storage,
44+ containerSvc: containerSvc,
45+ config: config,
46+ log: log,
47+ }
48+}
49+
50+// ListBackups returns backups for a domain.
51+func (s *Service) ListBackups(ctx context.Context, domainName string) ([]domain.BackupJob, error) {
52+ return s.storage.List(ctx, domainName, nil)
53+}
54+
55+// DetectDatabases inspects attachments and returns detected DBs.
56+func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]domain.DBInfo, error) {
57+ attachments := s.containerSvc.ListAttachments(ctx, domainName)
58+ dbs := make([]domain.DBInfo, 0, len(attachments))
59+ for _, attachment := range attachments {
60+ if db, ok := detectDatabaseFromAttachment(domainName, attachment); ok {
61+ dbs = append(dbs, db)
62+ }
63+ }
64+ return dbs, nil
65+}
66+
67+// RunBackup triggers a logical PostgreSQL backup for a detected DB.
68+func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) {
69+ started := time.Now().UTC()
70+
71+ dbs, err := s.DetectDatabases(ctx, domainName)
72+ if err != nil {
73+ return nil, err
74+ }
75+
76+ db, err := selectDatabase(dbs, dbName)
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ if db.Type != domain.DBTypePostgreSQL {
82+ return nil, fmt.Errorf("unsupported database type: %s", db.Type)
83+ }
84+
85+ execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout)
86+ defer cancelExec()
87+ dumpPath := fmt.Sprintf("/tmp/gordon-backup-%d.bak", started.UnixNano())
88+ defer s.cleanupDumpFile(db.ContainerID, dumpPath)
89+
90+ execResult, err := s.runtime.ExecInContainer(execCtx, db.ContainerID, []string{"sh", "-c", pgDumpToPathCommand(dumpPath, db.Name)})
91+ if err != nil {
92+ if errors.Is(execCtx.Err(), context.DeadlineExceeded) {
93+ return nil, fmt.Errorf("pg_dump timed out after %s", backupExecTimeout)
94+ }
95+ return nil, err
96+ }
97+ if execResult.ExitCode != 0 {
98+ return nil, fmt.Errorf("pg_dump failed with exit code %d: %s", execResult.ExitCode, string(execResult.Stderr))
99+ }
100+
101+ dumpStream, err := s.runtime.CopyFromContainer(execCtx, db.ContainerID, dumpPath)
102+ if err != nil {
103+ if errors.Is(execCtx.Err(), context.DeadlineExceeded) {
104+ return nil, fmt.Errorf("backup copy timed out after %s", backupExecTimeout)
105+ }
106+ return nil, err
107+ }
108+ defer dumpStream.Close()
109+
110+ counter := &byteCounter{}
111+ path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, io.TeeReader(dumpStream, counter))
112+ if err != nil {
113+ return nil, err
114+ }
115+
116+ job := domain.BackupJob{
117+ ID: newBackupJobID(started),
118+ Domain: domainName,
119+ DBName: db.Name,
120+ Type: domain.BackupTypeLogical,
121+ Status: domain.BackupStatusCompleted,
122+ StartedAt: started,
123+ CompletedAt: time.Now().UTC(),
124+ SizeBytes: counter.n,
125+ FilePath: path,
126+ }
127+
128+ return &domain.BackupResult{
129+ Job: job,
130+ Duration: time.Since(started),
131+ }, nil
132+}
133+
134+// Restore restores a backup by ID.
135+func (s *Service) Restore(context.Context, string, string) error {
136+ return fmt.Errorf("backup restore not implemented yet")
137+}
138+
139+// RestorePITR restores to a point in time.
140+func (s *Service) RestorePITR(context.Context, string, time.Time) error {
141+ return fmt.Errorf("pitr restore not implemented yet")
142+}
143+
144+// Status returns aggregate backup status for all managed domains.
145+func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
146+ routes := s.containerSvc.List(ctx)
147+ domainNames := make([]string, 0, len(routes))
148+ for domainName := range routes {
149+ domainNames = append(domainNames, domainName)
150+ }
151+ sort.Strings(domainNames)
152+
153+ const maxWorkers = 4
154+ sem := make(chan struct{}, maxWorkers)
155+ results := make([][]domain.BackupJob, len(domainNames))
156+ errCh := make(chan error, 1)
157+ var wg sync.WaitGroup
158+
159+ for i, domainName := range domainNames {
160+ select {
161+ case <-ctx.Done():
162+ return nil, ctx.Err()
163+ default:
164+ }
165+
166+ select {
167+ case sem <- struct{}{}:
168+ case <-ctx.Done():
169+ return nil, ctx.Err()
170+ }
171+
172+ wg.Add(1)
173+ go func(idx int, name string) {
174+ defer wg.Done()
175+ defer func() { <-sem }()
176+
177+ domainJobs, err := s.ListBackups(ctx, name)
178+ if err != nil {
179+ select {
180+ case errCh <- err:
181+ default:
182+ }
183+ return
184+ }
185+ results[idx] = domainJobs
186+ }(i, domainName)
187+ }
188+
189+ wg.Wait()
190+ select {
191+ case err := <-errCh:
192+ return nil, err
193+ default:
194+ }
195+
196+ jobs := make([]domain.BackupJob, 0)
197+ for _, domainJobs := range results {
198+ jobs = append(jobs, domainJobs...)
199+ }
200+
201+ return jobs, nil
202+}
203+
204+func newBackupJobID(started time.Time) string {
205+ random := make([]byte, 4)
206+ if _, err := rand.Read(random); err != nil {
207+ return fmt.Sprintf("%s-%d", started.Format(time.RFC3339Nano), time.Now().UnixNano())
208+ }
209+ return fmt.Sprintf("%s-%s", started.Format(time.RFC3339Nano), hex.EncodeToString(random))
210+}
211+
212+func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) {
213+ if len(dbs) == 0 {
214+ return domain.DBInfo{}, fmt.Errorf("no supported database attachments detected")
215+ }
216+
217+ if requested == "" {
218+ if len(dbs) == 1 {
219+ return dbs[0], nil
220+ }
221+ return domain.DBInfo{}, fmt.Errorf("multiple database attachments detected; please specify one")
222+ }
223+
224+ for _, db := range dbs {
225+ if strings.EqualFold(db.Name, requested) {
226+ return db, nil
227+ }
228+ }
229+
230+ return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested)
231+}
232+
233+func postgresDumpCommand(dbName string) string {
234+ return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName))
235+}
236+
237+func pgDumpToPathCommand(path, dbName string) string {
238+ return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path)
239+}
240+
241+func shellQuote(value string) string {
242+ return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
243+}
244+
245+func (s *Service) cleanupDumpFile(containerID, dumpPath string) {
246+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
247+ defer cancel()
248+
249+ _, _ = s.runtime.ExecInContainer(cleanupCtx, containerID, []string{"sh", "-c", fmt.Sprintf("rm -f %q", dumpPath)})
250+}
251+
252+type byteCounter struct {
253+ n int64
254+}
255+
256+func (c *byteCounter) Write(p []byte) (int, error) {
257+ c.n += int64(len(p))
258+ return len(p), nil
259+}
A internal/usecase/backup/service_test.go +179 -0
@@ -0,0 +1,179 @@
1+package backup
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
12+ outiface "github.com/bnema/gordon/internal/boundaries/out"
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/zerowrap"
16+ "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
18+ "github.com/stretchr/testify/require"
19+)
20+
21+func TestService_DetectDatabases_PostgresAttachment(t *testing.T) {
22+ runtime := outmocks.NewMockContainerRuntime(t)
23+ storage := outmocks.NewMockBackupStorage(t)
24+ containerSvc := inmocks.NewMockContainerService(t)
25+
26+ containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
27+ {Name: "postgres", Image: "postgres:17", ContainerID: "db1", Status: "running"},
28+ })
29+
30+ svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
31+
32+ dbs, err := svc.DetectDatabases(context.Background(), "app.example.com")
33+ require.NoError(t, err)
34+ require.Len(t, dbs, 1)
35+ assert.Equal(t, domain.DBTypePostgreSQL, dbs[0].Type)
36+ assert.Equal(t, "db1", dbs[0].ContainerID)
37+}
38+
39+func TestService_RunBackup_Postgres(t *testing.T) {
40+ runtime := outmocks.NewMockContainerRuntime(t)
41+ storage := outmocks.NewMockBackupStorage(t)
42+ containerSvc := inmocks.NewMockContainerService(t)
43+
44+ containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
45+ {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
46+ })
47+
48+ runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
49+ if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
50+ return false
51+ }
52+ return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) &&
53+ bytes.Contains([]byte(cmd[2]), []byte("PGDATABASE='postgres'")) &&
54+ bytes.Contains([]byte(cmd[2]), []byte(" > "))
55+ })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
56+
57+ runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
58+ return path != ""
59+ })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
60+
61+ runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
62+ return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
63+ })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
64+
65+ storage.EXPECT().Store(
66+ mock.Anything,
67+ "app.example.com",
68+ "postgres",
69+ domain.BackupSchedule(""),
70+ mock.Anything,
71+ mock.MatchedBy(func(r io.Reader) bool {
72+ data, _ := io.ReadAll(r)
73+ return string(data) == "backup-data"
74+ }),
75+ ).Return("/tmp/backup.bak", nil)
76+
77+ svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
78+
79+ result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres")
80+ require.NoError(t, err)
81+ require.NotNil(t, result)
82+ assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
83+ assert.Equal(t, "/tmp/backup.bak", result.Job.FilePath)
84+ assert.Equal(t, int64(len("backup-data")), result.Job.SizeBytes)
85+}
86+
87+func TestService_RunBackup_CleansUpDumpWhenPgDumpFails(t *testing.T) {
88+ runtime := outmocks.NewMockContainerRuntime(t)
89+ storage := outmocks.NewMockBackupStorage(t)
90+ containerSvc := inmocks.NewMockContainerService(t)
91+
92+ containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
93+ {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
94+ })
95+
96+ runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
97+ return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
98+ })).Return(&outiface.ExecResult{ExitCode: 2, Stderr: []byte("dump failed")}, nil)
99+
100+ runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
101+ return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
102+ })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
103+
104+ svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
105+
106+ result, err := svc.RunBackup(context.Background(), "app.example.com", "postgres")
107+ require.Error(t, err)
108+ assert.Nil(t, result)
109+ assert.Contains(t, err.Error(), "pg_dump failed")
110+}
111+
112+func TestSelectDatabaseRequiresExplicitNameWhenMultipleDetected(t *testing.T) {
113+ db, err := selectDatabase([]domain.DBInfo{
114+ {Name: "postgres"},
115+ {Name: "analytics"},
116+ }, "")
117+ require.Error(t, err)
118+ assert.Contains(t, err.Error(), "multiple database attachments detected")
119+ assert.Equal(t, domain.DBInfo{}, db)
120+}
121+
122+func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) {
123+ db, err := selectDatabase([]domain.DBInfo{{Name: "postgres"}}, "")
124+ require.NoError(t, err)
125+ assert.Equal(t, "postgres", db.Name)
126+}
127+
128+func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) {
129+ cmd := postgresDumpCommand("customer data")
130+ assert.Contains(t, cmd, "PGDATABASE='customer data'")
131+}
132+
133+func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {
134+ runtime := outmocks.NewMockContainerRuntime(t)
135+ storage := outmocks.NewMockBackupStorage(t)
136+ containerSvc := inmocks.NewMockContainerService(t)
137+
138+ containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
139+ "a.example.com": {},
140+ "b.example.com": {},
141+ "c.example.com": {},
142+ "d.example.com": {},
143+ "e.example.com": {},
144+ })
145+
146+ unblock := make(chan struct{})
147+ defer close(unblock)
148+
149+ var started int32
150+ startedFour := make(chan struct{})
151+ storage.EXPECT().List(mock.Anything, mock.Anything, (*domain.BackupSchedule)(nil)).RunAndReturn(
152+ func(context.Context, string, *domain.BackupSchedule) ([]domain.BackupJob, error) {
153+ if atomic.AddInt32(&started, 1) == 4 {
154+ close(startedFour)
155+ }
156+ <-unblock
157+ return nil, nil
158+ },
159+ ).Times(4)
160+
161+ svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
162+
163+ ctx, cancel := context.WithCancel(context.Background())
164+ errCh := make(chan error, 1)
165+ go func() {
166+ _, err := svc.Status(ctx)
167+ errCh <- err
168+ }()
169+
170+ <-startedFour
171+ cancel()
172+
173+ select {
174+ case err := <-errCh:
175+ require.ErrorIs(t, err, context.Canceled)
176+ case <-time.After(250 * time.Millisecond):
177+ t.Fatal("Status did not return after context cancellation while waiting for semaphore")
178+ }
179+}
M internal/usecase/container/service.go +2 -0
@@ -1430,6 +1430,7 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
14301430 ContainerID: container.ID,
14311431 Status: container.Status,
14321432 Network: network,
1433+ Ports: append([]int(nil), container.Ports...),
14331434 }
14341435 result[ownerDomain] = append(result[ownerDomain], attachment)
14351436 }
@@ -1467,6 +1468,7 @@ func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Co
14671468 ContainerID: container.ID,
14681469 Status: container.Status,
14691470 Network: network,
1471+ Ports: append([]int(nil), container.Ports...),
14701472 }
14711473 attachments = append(attachments, attachment)
14721474 }
A internal/usecase/cron/scheduler.go +323 -0
@@ -0,0 +1,323 @@
1+package cron
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ "github.com/bnema/zerowrap"
13+)
14+
15+// Scheduler runs recurring jobs based on backup schedules.
16+type Scheduler struct {
17+ entries map[string]*entry
18+ mu sync.RWMutex
19+ stopCh chan struct{}
20+ log zerowrap.Logger
21+ nowFn func() time.Time
22+ maxJobs int
23+ started atomic.Bool
24+}
25+
26+// Option configures a Scheduler.
27+type Option func(*Scheduler)
28+
29+var ErrJobRunning = errors.New("schedule is currently running")
30+
31+// WithMaxJobs sets the maximum number of jobs run concurrently.
32+func WithMaxJobs(n int) Option {
33+ return func(s *Scheduler) {
34+ if n > 0 {
35+ s.maxJobs = n
36+ }
37+ }
38+}
39+
40+type entry struct {
41+ id string
42+ name string
43+ schedule domain.CronSchedule
44+ job func(ctx context.Context) error
45+ lastRun time.Time
46+ nextRun time.Time
47+ running atomic.Bool
48+}
49+
50+// NewScheduler creates a scheduler instance.
51+func NewScheduler(log zerowrap.Logger, opts ...Option) *Scheduler {
52+ s := &Scheduler{
53+ entries: make(map[string]*entry),
54+ stopCh: make(chan struct{}),
55+ log: log,
56+ maxJobs: 4,
57+ nowFn: func() time.Time {
58+ return time.Now().UTC()
59+ },
60+ }
61+
62+ for _, opt := range opts {
63+ if opt != nil {
64+ opt(s)
65+ }
66+ }
67+
68+ return s
69+}
70+
71+// Add registers a new scheduled job.
72+func (s *Scheduler) Add(id, name string, sched domain.CronSchedule, job func(ctx context.Context) error) error {
73+ if id == "" {
74+ return fmt.Errorf("id is required")
75+ }
76+ if job == nil {
77+ return fmt.Errorf("job is required")
78+ }
79+
80+ nextRun, err := calculateNextRun(s.nowFn(), sched)
81+ if err != nil {
82+ return err
83+ }
84+
85+ s.mu.Lock()
86+ defer s.mu.Unlock()
87+
88+ if _, exists := s.entries[id]; exists {
89+ return fmt.Errorf("schedule %q already exists", id)
90+ }
91+
92+ s.entries[id] = &entry{
93+ id: id,
94+ name: name,
95+ schedule: sched,
96+ job: job,
97+ nextRun: nextRun,
98+ }
99+
100+ return nil
101+}
102+
103+// Remove unregisters a scheduled job.
104+func (s *Scheduler) Remove(id string) error {
105+ s.mu.Lock()
106+ defer s.mu.Unlock()
107+ e := s.entries[id]
108+ if e == nil {
109+ return nil
110+ }
111+ if e.running.Load() {
112+ return fmt.Errorf("%w: %s", ErrJobRunning, id)
113+ }
114+ delete(s.entries, id)
115+ return nil
116+}
117+
118+// Start begins the scheduler loop.
119+func (s *Scheduler) Start(ctx context.Context) {
120+ if !s.started.CompareAndSwap(false, true) {
121+ return
122+ }
123+
124+ select {
125+ case <-s.stopCh:
126+ s.started.Store(false)
127+ return
128+ default:
129+ }
130+
131+ select {
132+ case <-ctx.Done():
133+ s.started.Store(false)
134+ return
135+ default:
136+ }
137+
138+ ticker := time.NewTicker(time.Minute)
139+ go func() {
140+ defer ticker.Stop()
141+ s.runDue(ctx)
142+ for {
143+ select {
144+ case <-ctx.Done():
145+ return
146+ case <-s.stopCh:
147+ return
148+ case <-ticker.C:
149+ s.runDue(ctx)
150+ }
151+ }
152+ }()
153+}
154+
155+// Stop stops Scheduler processing.
156+//
157+// Stop is idempotent and safe to call multiple times. Because Stop closes
158+// Scheduler.stopCh, the scheduler instance is not restartable after Stop;
159+// create a new Scheduler to start processing again.
160+func (s *Scheduler) Stop() {
161+ select {
162+ case <-s.stopCh:
163+ return
164+ default:
165+ close(s.stopCh)
166+ }
167+}
168+
169+// List returns current scheduler entries.
170+func (s *Scheduler) List() []domain.CronEntry {
171+ s.mu.RLock()
172+ defer s.mu.RUnlock()
173+
174+ entries := make([]domain.CronEntry, 0, len(s.entries))
175+ for _, e := range s.entries {
176+ entries = append(entries, domain.CronEntry{
177+ ID: e.id,
178+ Name: e.name,
179+ Schedule: e.schedule,
180+ LastRun: e.lastRun,
181+ NextRun: e.nextRun,
182+ Running: e.running.Load(),
183+ })
184+ }
185+
186+ return entries
187+}
188+
189+// RunNow triggers a registered job immediately with the provided context.
190+func (s *Scheduler) RunNow(ctx context.Context, id string) error {
191+ e := s.getEntry(id)
192+ if e == nil {
193+ return fmt.Errorf("schedule %q not found", id)
194+ }
195+
196+ return s.executeEntry(ctx, e)
197+}
198+
199+func (s *Scheduler) runDue(ctx context.Context) {
200+ now := s.nowFn()
201+ entries := s.dueEntries(now)
202+ sem := make(chan struct{}, s.maxJobs)
203+ var wg sync.WaitGroup
204+ for _, e := range entries {
205+ wg.Add(1)
206+ sem <- struct{}{}
207+
208+ e := e
209+ go func() {
210+ defer wg.Done()
211+ defer func() { <-sem }()
212+ if err := s.executeEntry(ctx, e); err != nil {
213+ s.log.Warn().Err(err).Str("schedule_id", e.id).Msg("scheduled job failed")
214+ }
215+ }()
216+ }
217+ wg.Wait()
218+}
219+
220+func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error {
221+ if !e.running.CompareAndSwap(false, true) {
222+ return fmt.Errorf("schedule %q is already running", e.id)
223+ }
224+ defer e.running.Store(false)
225+
226+ now := s.nowFn()
227+ var jobErr error
228+ func() {
229+ defer func() {
230+ if rec := recover(); rec != nil {
231+ jobErr = fmt.Errorf("job panic: %v", rec)
232+ }
233+ }()
234+ jobErr = e.job(ctx)
235+ }()
236+
237+ completedAt := s.nowFn()
238+ if completedAt.Before(now) {
239+ completedAt = now
240+ }
241+
242+ nextRun, nextErr := calculateNextRun(completedAt, e.schedule)
243+ if nextErr != nil {
244+ if jobErr != nil {
245+ return fmt.Errorf("job failed: %w; also failed to calculate next run: %v", jobErr, nextErr)
246+ }
247+ return nextErr
248+ }
249+
250+ s.mu.Lock()
251+ e.lastRun = now
252+ e.nextRun = nextRun
253+ s.mu.Unlock()
254+
255+ return jobErr
256+}
257+
258+func (s *Scheduler) getEntry(id string) *entry {
259+ s.mu.RLock()
260+ defer s.mu.RUnlock()
261+ return s.entries[id]
262+}
263+
264+func (s *Scheduler) dueEntries(now time.Time) []*entry {
265+ s.mu.RLock()
266+ defer s.mu.RUnlock()
267+
268+ entries := make([]*entry, 0, len(s.entries))
269+ for _, e := range s.entries {
270+ if now.Before(e.nextRun) {
271+ continue
272+ }
273+ entries = append(entries, e)
274+ }
275+ return entries
276+}
277+
278+// calculateNextRun computes the next execution time using UTC-only preset rules.
279+//
280+// Current MVP behavior is intentionally fixed to UTC because domain.CronSchedule
281+// has no timezone field yet:
282+// - daily: 02:00 UTC
283+// - weekly: Sunday 03:00 UTC
284+// - monthly: first day of month 04:00 UTC
285+//
286+// This means users in non-UTC timezones should account for UTC offsets.
287+//
288+// TODO: add timezone support on domain.CronSchedule once timezone-aware
289+// scheduling is introduced. Default timezone should be the host system timezone
290+// when no explicit timezone is configured.
291+func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
292+ now = now.UTC()
293+
294+ switch schedule.Preset {
295+ case domain.ScheduleHourly:
296+ next := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, time.UTC)
297+ if !next.After(now) {
298+ next = next.Add(time.Hour)
299+ }
300+ return next, nil
301+ case domain.ScheduleDaily:
302+ next := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, time.UTC)
303+ if !next.After(now) {
304+ next = next.AddDate(0, 0, 1)
305+ }
306+ return next, nil
307+ case domain.ScheduleWeekly:
308+ daysUntilSunday := (7 - int(now.Weekday())) % 7
309+ next := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, time.UTC).AddDate(0, 0, daysUntilSunday)
310+ if !next.After(now) {
311+ next = next.AddDate(0, 0, 7)
312+ }
313+ return next, nil
314+ case domain.ScheduleMonthly:
315+ next := time.Date(now.Year(), now.Month(), 1, 4, 0, 0, 0, time.UTC)
316+ if !next.After(now) {
317+ next = next.AddDate(0, 1, 0)
318+ }
319+ return next, nil
320+ default:
321+ return time.Time{}, fmt.Errorf("unsupported schedule preset: %q", schedule.Preset)
322+ }
323+}
A internal/usecase/cron/scheduler_test.go +227 -0
@@ -0,0 +1,227 @@
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 TestCalculateNextRunPresets(t *testing.T) {
16+ now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
17+
18+ tests := []struct {
19+ name string
20+ schedule domain.BackupSchedule
21+ expected time.Time
22+ }{
23+ {
24+ name: "hourly",
25+ schedule: domain.ScheduleHourly,
26+ expected: time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC),
27+ },
28+ {
29+ name: "daily",
30+ schedule: domain.ScheduleDaily,
31+ expected: time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC),
32+ },
33+ {
34+ name: "weekly",
35+ schedule: domain.ScheduleWeekly,
36+ expected: time.Date(2026, 2, 8, 3, 0, 0, 0, time.UTC),
37+ },
38+ {
39+ name: "monthly",
40+ schedule: domain.ScheduleMonthly,
41+ expected: time.Date(2026, 3, 1, 4, 0, 0, 0, time.UTC),
42+ },
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ next, err := calculateNextRun(now, domain.CronSchedule{Preset: tt.schedule})
48+ require.NoError(t, err)
49+ assert.Equal(t, tt.expected, next)
50+ })
51+ }
52+}
53+
54+func TestSchedulerAddListAndRunNow(t *testing.T) {
55+ s := NewScheduler(zerowrap.Default())
56+ now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
57+ s.nowFn = func() time.Time { return now }
58+
59+ runs := 0
60+ err := s.Add("backup-daily", "daily backup", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
61+ runs++
62+ return nil
63+ })
64+ require.NoError(t, err)
65+
66+ entries := s.List()
67+ require.Len(t, entries, 1)
68+ assert.Equal(t, "backup-daily", entries[0].ID)
69+ assert.Equal(t, "daily backup", entries[0].Name)
70+
71+ err = s.RunNow(context.Background(), "backup-daily")
72+ require.NoError(t, err)
73+ assert.Equal(t, 1, runs)
74+
75+ entries = s.List()
76+ require.Len(t, entries, 1)
77+ assert.Equal(t, now, entries[0].LastRun)
78+ assert.Equal(t, time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC), entries[0].NextRun)
79+}
80+
81+func TestSchedulerWithMaxJobsOption(t *testing.T) {
82+ s := NewScheduler(zerowrap.Default(), WithMaxJobs(9))
83+ assert.Equal(t, 9, s.maxJobs)
84+}
85+
86+func TestSchedulerRemoveRejectsRunningJob(t *testing.T) {
87+ s := NewScheduler(zerowrap.Default())
88+
89+ started := make(chan struct{})
90+ release := make(chan struct{})
91+ err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
92+ close(started)
93+ <-release
94+ return nil
95+ })
96+ require.NoError(t, err)
97+
98+ runErrCh := make(chan error, 1)
99+ go func() {
100+ runErrCh <- s.RunNow(context.Background(), "backup-hourly")
101+ }()
102+
103+ <-started
104+ err = s.Remove("backup-hourly")
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, ErrJobRunning)
107+
108+ close(release)
109+ require.NoError(t, <-runErrCh)
110+}
111+
112+func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) {
113+ s := NewScheduler(zerowrap.Default())
114+
115+ started := make(chan struct{})
116+ release := make(chan struct{})
117+ err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
118+ close(started)
119+ <-release
120+ return nil
121+ })
122+ require.NoError(t, err)
123+
124+ var wg sync.WaitGroup
125+ firstErrCh := make(chan error, 1)
126+ wg.Add(1)
127+ go func() {
128+ defer wg.Done()
129+ firstErrCh <- s.RunNow(context.Background(), "backup-hourly")
130+ }()
131+
132+ <-started
133+ err2 := s.RunNow(context.Background(), "backup-hourly")
134+ assert.Error(t, err2)
135+
136+ close(release)
137+ wg.Wait()
138+ err1 := <-firstErrCh
139+ require.NoError(t, err1)
140+}
141+
142+func TestSchedulerRunNowRecoversFromPanics(t *testing.T) {
143+ s := NewScheduler(zerowrap.Default())
144+ err := s.Add("panic-job", "panic job", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
145+ panic("boom")
146+ })
147+ require.NoError(t, err)
148+
149+ err = s.RunNow(context.Background(), "panic-job")
150+ require.Error(t, err)
151+ assert.Contains(t, err.Error(), "panic")
152+
153+ entries := s.List()
154+ require.Len(t, entries, 1)
155+ assert.False(t, entries[0].Running)
156+ assert.False(t, entries[0].LastRun.IsZero())
157+ assert.False(t, entries[0].NextRun.IsZero())
158+}
159+
160+func TestSchedulerStartNoOpWhenStopped(t *testing.T) {
161+ s := NewScheduler(zerowrap.Default())
162+ now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
163+ s.nowFn = func() time.Time { return now }
164+
165+ runs := 0
166+ err := s.Add("stopped-job", "stopped job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
167+ runs++
168+ return nil
169+ })
170+ require.NoError(t, err)
171+
172+ s.mu.Lock()
173+ s.entries["stopped-job"].nextRun = now.Add(-time.Minute)
174+ s.mu.Unlock()
175+
176+ s.Stop()
177+ s.Start(context.Background())
178+
179+ time.Sleep(50 * time.Millisecond)
180+ assert.Equal(t, 0, runs)
181+ assert.False(t, s.started.Load())
182+}
183+
184+func TestSchedulerRunNowComputesNextRunFromJobFinishTime(t *testing.T) {
185+ s := NewScheduler(zerowrap.Default())
186+ current := time.Date(2026, 2, 7, 12, 59, 0, 0, time.UTC)
187+ s.nowFn = func() time.Time { return current }
188+
189+ err := s.Add("backup-hourly", "hourly backup", domain.CronSchedule{Preset: domain.ScheduleHourly}, func(context.Context) error {
190+ current = time.Date(2026, 2, 7, 13, 1, 0, 0, time.UTC)
191+ return nil
192+ })
193+ require.NoError(t, err)
194+
195+ err = s.RunNow(context.Background(), "backup-hourly")
196+ require.NoError(t, err)
197+
198+ entries := s.List()
199+ require.Len(t, entries, 1)
200+ assert.Equal(t, time.Date(2026, 2, 7, 14, 0, 0, 0, time.UTC), entries[0].NextRun)
201+}
202+
203+func TestSchedulerStartNoOpWhenContextAlreadyCanceled(t *testing.T) {
204+ s := NewScheduler(zerowrap.Default())
205+ now := time.Date(2026, 2, 7, 12, 0, 0, 0, time.UTC)
206+ s.nowFn = func() time.Time { return now }
207+
208+ runs := 0
209+ err := s.Add("canceled-job", "canceled job", domain.CronSchedule{Preset: domain.ScheduleDaily}, func(context.Context) error {
210+ runs++
211+ return nil
212+ })
213+ require.NoError(t, err)
214+
215+ s.mu.Lock()
216+ s.entries["canceled-job"].nextRun = now.Add(-time.Minute)
217+ s.mu.Unlock()
218+
219+ ctx, cancel := context.WithCancel(context.Background())
220+ cancel()
221+
222+ s.Start(ctx)
223+ time.Sleep(50 * time.Millisecond)
224+
225+ assert.Equal(t, 0, runs)
226+ assert.False(t, s.started.Load())
227+}

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