Merge pull request #65 from bnema/feat/backup-cron-services
feat: add postgres backup MVP with admin and CLI support
38 files · +4220 -51
M .mockery.yaml +2 -0
| @@ -22,9 +22,11 @@ packages: | |||
| 22 | 22 | TokenStore: | |
| 23 | 23 | DomainSecretStore: | |
| 24 | 24 | RateLimiter: | |
| 25 | + | BackupStorage: | |
| 25 | 26 | github.com/bnema/gordon/internal/boundaries/in: | |
| 26 | 27 | interfaces: | |
| 27 | 28 | ContainerService: | |
| 29 | + | BackupService: | |
| 28 | 30 | RegistryService: | |
| 29 | 31 | ProxyService: | |
| 30 | 32 | ConfigService: | |
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: | |||
| 29 | 29 | | `gordon reload` | Reload configuration and sync containers | [serve](./serve.md#gordon-reload) | | |
| 30 | 30 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 31 | 31 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 32 | + | | `gordon backup` | Manage database backups | [backup](./backup.md) | | |
| 32 | 33 | ||
| 33 | 34 | ## Client Commands | |
| 34 | 35 | ||
| @@ -73,6 +74,12 @@ gordon logs myapp.local -f # Follow container logs | |||
| 73 | 74 | # Check version | |
| 74 | 75 | gordon version | |
| 75 | 76 | ||
| 77 | + | # Backups | |
| 78 | + | gordon backup list | |
| 79 | + | gordon backup run app.example.com | |
| 80 | + | gordon backup detect app.example.com | |
| 81 | + | gordon backup status | |
| 82 | + | ||
| 76 | 83 | # Authentication | |
| 77 | 84 | gordon auth token generate --subject ci-bot --expiry 0 | |
| 78 | 85 | gordon auth token list | |
M docs/concepts.md +15 -0
| @@ -211,6 +211,21 @@ Gordon uses an internal event system for coordination: | |||
| 211 | 211 | | `manual.deploy` | `gordon deploy <domain>` command | Deploy specific route | | |
| 212 | 212 | | `container.deployed` | Container started | Update proxy cache | | |
| 213 | 213 | ||
| 214 | + | ## Backups and Recovery | |
| 215 | + | ||
| 216 | + | Gordon can run logical PostgreSQL backups for attachment containers. | |
| 217 | + | ||
| 218 | + | Current design: | |
| 219 | + | ||
| 220 | + | 1. Detect PostgreSQL attachments attached to a route | |
| 221 | + | 2. Execute `pg_dump -Fc` through Gordon runtime operations | |
| 222 | + | 3. Store backup artifacts on local filesystem storage | |
| 223 | + | 4. Expose backup actions through admin API and CLI | |
| 224 | + | ||
| 225 | + | This is intentionally scoped for operational safety and predictable behavior. | |
| 226 | + | Future extensions can add physical backups, PITR, and remote object storage adapters. | |
| 227 | + | 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 | + | ||
| 214 | 229 | ## Container Labels | |
| 215 | 230 | ||
| 216 | 231 | 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 | |||
| 109 | 109 | [attachments] | |
| 110 | 110 | "app.mydomain.com" = ["postgres:latest", "redis:latest"] | |
| 111 | 111 | "backend" = ["rabbitmq:latest"] | |
| 112 | + | ||
| 113 | + | # Backups | |
| 114 | + | [backups] | |
| 115 | + | enabled = true | |
| 116 | + | storage_dir = "~/.gordon/backups" | |
| 117 | + | ||
| 118 | + | [backups.retention] | |
| 119 | + | hourly = 24 | |
| 120 | + | daily = 7 | |
| 121 | + | weekly = 4 | |
| 122 | + | monthly = 12 | |
| 112 | 123 | ``` | |
| 113 | 124 | ||
| 114 | 125 | ## Configuration Sections | |
| @@ -128,6 +139,7 @@ enabled = false # Auto-create routes from image names | |||
| 128 | 139 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 129 | 140 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 130 | 141 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 142 | + | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| 131 | 143 | ||
| 132 | 144 | ## Default Values | |
| 133 | 145 | ||
| @@ -158,6 +170,7 @@ enabled = false # Auto-create routes from image names | |||
| 158 | 170 | | `volumes.preserve` | `true` | | |
| 159 | 171 | | `network_isolation.enabled` | `false` | | |
| 160 | 172 | | `auto_route.enabled` | `false` | | |
| 173 | + | | `backups.enabled` | `false` | | |
| 161 | 174 | ||
| 162 | 175 | ## Hot Reload | |
| 163 | 176 | ||
| @@ -193,3 +206,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 193 | 206 | - [Routes Configuration](./routes.md) | |
| 194 | 207 | - [External Routes](./external-routes.md) | |
| 195 | 208 | - [Authentication](./auth.md) | |
| 209 | + | - [Backups](./backups.md) | |
M docs/config/reference.md +24 -0
| @@ -121,6 +121,22 @@ preserve = true # Keep volumes when containers are | |||
| 121 | 121 | # ============================================================================= | |
| 122 | 122 | [attachments] | |
| 123 | 123 | # "domain-or-group" = ["image1:tag", "image2:tag"] | |
| 124 | + | ||
| 125 | + | # ============================================================================= | |
| 126 | + | # BACKUPS | |
| 127 | + | # ============================================================================= | |
| 128 | + | [backups] | |
| 129 | + | enabled = false # Enable backup service | |
| 130 | + | storage_dir = "" # Backup root (default: {data_dir}/backups) | |
| 131 | + | ||
| 132 | + | [backups.retention] | |
| 133 | + | hourly = 0 # Keep N hourly backups per DB | |
| 134 | + | daily = 0 # Keep N daily backups per DB | |
| 135 | + | weekly = 0 # Keep N weekly backups per DB | |
| 136 | + | monthly = 0 # Keep N monthly backups per DB | |
| 137 | + | ||
| 138 | + | # Note: retention values set to 0 keep no backups for that tier. | |
| 139 | + | # For practical defaults, consider setting daily = 7. | |
| 124 | 140 | ``` | |
| 125 | 141 | ||
| 126 | 142 | ## Default Values Summary | |
| @@ -164,6 +180,14 @@ preserve = true # Keep volumes when containers are | |||
| 164 | 180 | | `volumes.auto_create` | `true` | Auto-create volumes | | |
| 165 | 181 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 166 | 182 | | `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. | |
| 167 | 191 | ||
| 168 | 192 | ## Environment Variables | |
| 169 | 193 | ||
M docs/reference/docker-labels.md +15 -0
| @@ -23,6 +23,18 @@ Additional labels for attachment containers: | |||
| 23 | 23 | | `gordon.attachment` | `"true"` | Container is an attachment service | | |
| 24 | 24 | | `gordon.attached-to` | Domain/group | Route or network group this serves | | |
| 25 | 25 | ||
| 26 | + | ### Backup Labels | |
| 27 | + | ||
| 28 | + | Labels used by the backup subsystem: | |
| 29 | + | ||
| 30 | + | | Label | Value | Description | | |
| 31 | + | |-------|-------|-------------| | |
| 32 | + | | `gordon.backup` | `"true"` / `"false"` | Enables or disables backup behavior for a container | | |
| 33 | + | | `gordon.backup.type` | e.g. `"postgresql"` | Explicit database type override | | |
| 34 | + | | `gordon.backup.version` | e.g. `"17"` | Explicit database version override | | |
| 35 | + | | `gordon.backup.schedule` | e.g. `"hourly,daily"` | Schedule override hint | | |
| 36 | + | | `gordon.backup.sidecar` | `"true"` | Identifies backup sidecar containers | | |
| 37 | + | ||
| 26 | 38 | ## Image Labels | |
| 27 | 39 | ||
| 28 | 40 | Labels you can set in your Dockerfile: | |
| @@ -96,6 +108,9 @@ docker ps -f "label=gordon.attachment=true" | |||
| 96 | 108 | ||
| 97 | 109 | # Attachments for specific route | |
| 98 | 110 | docker ps -f "label=gordon.attached-to=app.mydomain.com" | |
| 111 | + | ||
| 112 | + | # Backup sidecars | |
| 113 | + | docker ps -f "label=gordon.backup.sidecar=true" | |
| 99 | 114 | ``` | |
| 100 | 115 | ||
| 101 | 116 | ## Examples | |
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 { | |||
| 463 | 463 | Error string `json:"error"` | |
| 464 | 464 | } | |
| 465 | 465 | ||
| 466 | + | // Backups API | |
| 467 | + | ||
| 468 | + | // ListBackups returns backups globally or for a domain. | |
| 469 | + | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 470 | + | path := "/backups" | |
| 471 | + | if backupDomain != "" { | |
| 472 | + | path += "/" + url.PathEscape(backupDomain) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | resp, err := c.request(ctx, http.MethodGet, path, nil) | |
| 476 | + | if err != nil { | |
| 477 | + | return nil, err | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | var result dto.BackupsResponse | |
| 481 | + | if err := parseResponse(resp, &result); err != nil { | |
| 482 | + | return nil, err | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | return result.Backups, nil | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | // BackupStatus returns aggregate backup status. | |
| 489 | + | func (c *Client) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) { | |
| 490 | + | resp, err := c.request(ctx, http.MethodGet, "/backups/status", nil) | |
| 491 | + | if err != nil { | |
| 492 | + | return nil, err | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | var result dto.BackupsResponse | |
| 496 | + | if err := parseResponse(resp, &result); err != nil { | |
| 497 | + | return nil, err | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | return result.Backups, nil | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // RunBackup triggers a backup for a domain. | |
| 504 | + | func (c *Client) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) { | |
| 505 | + | if backupDomain == "" { | |
| 506 | + | return nil, fmt.Errorf("domain cannot be empty") | |
| 507 | + | } | |
| 508 | + | ||
| 509 | + | resp, err := c.request(ctx, http.MethodPost, "/backups/"+url.PathEscape(backupDomain), dto.BackupRunRequest{DB: dbName}) | |
| 510 | + | if err != nil { | |
| 511 | + | return nil, err | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | var result dto.BackupRunResponse | |
| 515 | + | if err := parseResponse(resp, &result); err != nil { | |
| 516 | + | return nil, err | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | return &result, nil | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | // DetectDatabases detects supported databases for a domain. | |
| 523 | + | func (c *Client) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) { | |
| 524 | + | if backupDomain == "" { | |
| 525 | + | return nil, fmt.Errorf("domain cannot be empty") | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | resp, err := c.request(ctx, http.MethodGet, "/backups/"+url.PathEscape(backupDomain)+"/detect", nil) | |
| 529 | + | if err != nil { | |
| 530 | + | return nil, err | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | var result dto.BackupDetectResponse | |
| 534 | + | if err := parseResponse(resp, &result); err != nil { | |
| 535 | + | return nil, err | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | return result.Databases, nil | |
| 539 | + | } | |
| 540 | + | ||
| 466 | 541 | // GetHealth returns health status for all routes with HTTP probing. | |
| 467 | 542 | func (c *Client) GetHealth(ctx context.Context) (map[string]*RouteHealth, error) { | |
| 468 | 543 | resp, err := c.request(ctx, http.MethodGet, "/health", nil) | |
M internal/adapters/in/cli/root.go +4 -0
| @@ -117,6 +117,10 @@ Commands are organized by where they run: | |||
| 117 | 117 | statusCmd.GroupID = groupManage | |
| 118 | 118 | rootCmd.AddCommand(statusCmd) | |
| 119 | 119 | ||
| 120 | + | backupCmd := newBackupCmd() | |
| 121 | + | backupCmd.GroupID = groupManage | |
| 122 | + | rootCmd.AddCommand(backupCmd) | |
| 123 | + | ||
| 120 | 124 | // Client-only commands (no server needed) | |
| 121 | 125 | remotesCmd := newRemotesCmd() | |
| 122 | 126 | remotesCmd.GroupID = groupClient | |
M internal/adapters/in/http/admin/handler.go +189 -0
| @@ -5,9 +5,11 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "io" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "strconv" | |
| 10 | 11 | "strings" | |
| 12 | + | "time" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/zerowrap" | |
| 13 | 15 | ||
| @@ -30,6 +32,7 @@ type Handler struct { | |||
| 30 | 32 | configSvc in.ConfigService | |
| 31 | 33 | authSvc in.AuthService | |
| 32 | 34 | containerSvc in.ContainerService | |
| 35 | + | backupSvc in.BackupService | |
| 33 | 36 | healthSvc in.HealthService | |
| 34 | 37 | secretSvc in.SecretService | |
| 35 | 38 | logSvc in.LogService | |
| @@ -79,6 +82,44 @@ func toRouteResponse(r domain.Route) dto.Route { | |||
| 79 | 82 | } | |
| 80 | 83 | } | |
| 81 | 84 | ||
| 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 | + | ||
| 82 | 123 | // NewHandler creates a new admin HTTP handler. | |
| 83 | 124 | func NewHandler( | |
| 84 | 125 | configSvc in.ConfigService, | |
| @@ -90,11 +131,13 @@ func NewHandler( | |||
| 90 | 131 | registrySvc in.RegistryService, | |
| 91 | 132 | eventBus out.EventPublisher, | |
| 92 | 133 | log zerowrap.Logger, | |
| 134 | + | backupSvc in.BackupService, | |
| 93 | 135 | ) *Handler { | |
| 94 | 136 | return &Handler{ | |
| 95 | 137 | configSvc: configSvc, | |
| 96 | 138 | authSvc: authSvc, | |
| 97 | 139 | containerSvc: containerSvc, | |
| 140 | + | backupSvc: backupSvc, | |
| 98 | 141 | healthSvc: healthSvc, | |
| 99 | 142 | secretSvc: secretSvc, | |
| 100 | 143 | logSvc: logSvc, | |
| @@ -157,6 +200,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 157 | 200 | prefix string | |
| 158 | 201 | handler routeHandler | |
| 159 | 202 | }{ | |
| 203 | + | {"/backups", h.handleBackups}, | |
| 160 | 204 | {"/attachments", h.handleAttachmentsConfig}, | |
| 161 | 205 | {"/routes", h.handleRoutes}, | |
| 162 | 206 | {"/secrets", h.handleSecrets}, | |
| @@ -718,6 +762,151 @@ func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) { | |||
| 718 | 762 | h.sendJSON(w, http.StatusOK, status) | |
| 719 | 763 | } | |
| 720 | 764 | ||
| 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 | + | ||
| 721 | 910 | // handleReload handles /admin/reload endpoint. | |
| 722 | 911 | // This reloads configuration from file into memory and triggers container sync. | |
| 723 | 912 | 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) { | |||
| 34 | 34 | containerSvc := inmocks.NewMockContainerService(t) | |
| 35 | 35 | secretSvc := inmocks.NewMockSecretService(t) | |
| 36 | 36 | ||
| 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) | |
| 38 | 38 | ||
| 39 | 39 | tests := []struct { | |
| 40 | 40 | name string | |
| @@ -97,7 +97,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 97 | 97 | secretSvc := inmocks.NewMockSecretService(t) | |
| 98 | 98 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 99 | 99 | ||
| 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) | |
| 101 | 101 | ||
| 102 | 102 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |
| 103 | 103 | ||
| @@ -158,7 +158,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 158 | 158 | secretSvc := inmocks.NewMockSecretService(t) | |
| 159 | 159 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 160 | 160 | ||
| 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) | |
| 162 | 162 | ||
| 163 | 163 | routeJSON := `{"image": "myapp:v2"}` | |
| 164 | 164 | ||
| @@ -203,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 203 | 203 | containerSvc := inmocks.NewMockContainerService(t) | |
| 204 | 204 | secretSvc := inmocks.NewMockSecretService(t) | |
| 205 | 205 | ||
| 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) | |
| 207 | 207 | ||
| 208 | 208 | tests := []struct { | |
| 209 | 209 | name string | |
| @@ -247,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 247 | 247 | containerSvc := inmocks.NewMockContainerService(t) | |
| 248 | 248 | secretSvc := inmocks.NewMockSecretService(t) | |
| 249 | 249 | ||
| 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) | |
| 251 | 251 | ||
| 252 | 252 | tests := []struct { | |
| 253 | 253 | name string | |
| @@ -304,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 304 | 304 | containerSvc := inmocks.NewMockContainerService(t) | |
| 305 | 305 | secretSvc := inmocks.NewMockSecretService(t) | |
| 306 | 306 | ||
| 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) | |
| 308 | 308 | ||
| 309 | 309 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 310 | 310 | ||
| @@ -353,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 353 | 353 | containerSvc := inmocks.NewMockContainerService(t) | |
| 354 | 354 | secretSvc := inmocks.NewMockSecretService(t) | |
| 355 | 355 | ||
| 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) | |
| 357 | 357 | ||
| 358 | 358 | tests := []struct { | |
| 359 | 359 | name string | |
| @@ -397,7 +397,7 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) { | |||
| 397 | 397 | containerSvc := inmocks.NewMockContainerService(t) | |
| 398 | 398 | secretSvc := inmocks.NewMockSecretService(t) | |
| 399 | 399 | ||
| 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) | |
| 401 | 401 | ||
| 402 | 402 | secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil) | |
| 403 | 403 | ||
| @@ -423,7 +423,7 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) { | |||
| 423 | 423 | containerSvc := inmocks.NewMockContainerService(t) | |
| 424 | 424 | secretSvc := inmocks.NewMockSecretService(t) | |
| 425 | 425 | ||
| 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) | |
| 427 | 427 | ||
| 428 | 428 | secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil) | |
| 429 | 429 | ||
| @@ -450,7 +450,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) { | |||
| 450 | 450 | containerSvc := inmocks.NewMockContainerService(t) | |
| 451 | 451 | secretSvc := inmocks.NewMockSecretService(t) | |
| 452 | 452 | ||
| 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) | |
| 454 | 454 | ||
| 455 | 455 | tests := []struct { | |
| 456 | 456 | name string | |
| @@ -504,7 +504,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 504 | 504 | containerSvc := inmocks.NewMockContainerService(t) | |
| 505 | 505 | secretSvc := inmocks.NewMockSecretService(t) | |
| 506 | 506 | ||
| 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) | |
| 508 | 508 | ||
| 509 | 509 | tests := []struct { | |
| 510 | 510 | name string | |
| @@ -561,7 +561,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 561 | 561 | containerSvc := inmocks.NewMockContainerService(t) | |
| 562 | 562 | secretSvc := inmocks.NewMockSecretService(t) | |
| 563 | 563 | ||
| 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) | |
| 565 | 565 | ||
| 566 | 566 | tests := []struct { | |
| 567 | 567 | name string | |
| @@ -615,7 +615,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 615 | 615 | containerSvc := inmocks.NewMockContainerService(t) | |
| 616 | 616 | secretSvc := inmocks.NewMockSecretService(t) | |
| 617 | 617 | ||
| 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) | |
| 619 | 619 | ||
| 620 | 620 | expectedRoutes := []domain.Route{ | |
| 621 | 621 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -650,7 +650,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 650 | 650 | containerSvc := inmocks.NewMockContainerService(t) | |
| 651 | 651 | secretSvc := inmocks.NewMockSecretService(t) | |
| 652 | 652 | ||
| 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) | |
| 654 | 654 | ||
| 655 | 655 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | |
| 656 | 656 | Domain: "app.example.com", Image: "app:latest", | |
| @@ -681,7 +681,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 681 | 681 | containerSvc := inmocks.NewMockContainerService(t) | |
| 682 | 682 | secretSvc := inmocks.NewMockSecretService(t) | |
| 683 | 683 | ||
| 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) | |
| 685 | 685 | ||
| 686 | 686 | configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) | |
| 687 | 687 | ||
| @@ -725,7 +725,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) { | |||
| 725 | 725 | secretSvc := inmocks.NewMockSecretService(t) | |
| 726 | 726 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 727 | 727 | ||
| 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) | |
| 729 | 729 | ||
| 730 | 730 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | |
| 731 | 731 | registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe() | |
| @@ -748,7 +748,7 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) { | |||
| 748 | 748 | secretSvc := inmocks.NewMockSecretService(t) | |
| 749 | 749 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 750 | 750 | ||
| 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) | |
| 752 | 752 | ||
| 753 | 753 | tests := []struct { | |
| 754 | 754 | name string | |
| @@ -802,7 +802,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||
| 802 | 802 | containerSvc := inmocks.NewMockContainerService(t) | |
| 803 | 803 | secretSvc := inmocks.NewMockSecretService(t) | |
| 804 | 804 | ||
| 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) | |
| 806 | 806 | ||
| 807 | 807 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 808 | 808 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -819,7 +819,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 819 | 819 | containerSvc := inmocks.NewMockContainerService(t) | |
| 820 | 820 | secretSvc := inmocks.NewMockSecretService(t) | |
| 821 | 821 | ||
| 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) | |
| 823 | 823 | ||
| 824 | 824 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 825 | 825 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -836,7 +836,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 836 | 836 | containerSvc := inmocks.NewMockContainerService(t) | |
| 837 | 837 | secretSvc := inmocks.NewMockSecretService(t) | |
| 838 | 838 | ||
| 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) | |
| 840 | 840 | ||
| 841 | 841 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 842 | 842 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -853,7 +853,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 853 | 853 | containerSvc := inmocks.NewMockContainerService(t) | |
| 854 | 854 | secretSvc := inmocks.NewMockSecretService(t) | |
| 855 | 855 | ||
| 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) | |
| 857 | 857 | ||
| 858 | 858 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 859 | 859 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -870,7 +870,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 870 | 870 | containerSvc := inmocks.NewMockContainerService(t) | |
| 871 | 871 | secretSvc := inmocks.NewMockSecretService(t) | |
| 872 | 872 | ||
| 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) | |
| 874 | 874 | ||
| 875 | 875 | tests := []struct { | |
| 876 | 876 | method string | |
| @@ -903,7 +903,7 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||
| 903 | 903 | containerSvc := inmocks.NewMockContainerService(t) | |
| 904 | 904 | secretSvc := inmocks.NewMockSecretService(t) | |
| 905 | 905 | ||
| 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) | |
| 907 | 907 | ||
| 908 | 908 | tests := []struct { | |
| 909 | 909 | name string | |
| @@ -933,7 +933,7 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) { | |||
| 933 | 933 | containerSvc := inmocks.NewMockContainerService(t) | |
| 934 | 934 | secretSvc := inmocks.NewMockSecretService(t) | |
| 935 | 935 | ||
| 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) | |
| 937 | 937 | ||
| 938 | 938 | containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound) | |
| 939 | 939 | ||
| @@ -952,7 +952,7 @@ func TestHandler_Restart_GenericError(t *testing.T) { | |||
| 952 | 952 | containerSvc := inmocks.NewMockContainerService(t) | |
| 953 | 953 | secretSvc := inmocks.NewMockSecretService(t) | |
| 954 | 954 | ||
| 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) | |
| 956 | 956 | ||
| 957 | 957 | containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details")) | |
| 958 | 958 | ||
| @@ -974,7 +974,7 @@ func TestHandler_Restart_Success(t *testing.T) { | |||
| 974 | 974 | containerSvc := inmocks.NewMockContainerService(t) | |
| 975 | 975 | secretSvc := inmocks.NewMockSecretService(t) | |
| 976 | 976 | ||
| 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) | |
| 978 | 978 | ||
| 979 | 979 | tests := []struct { | |
| 980 | 980 | name string | |
| @@ -1021,7 +1021,7 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) { | |||
| 1021 | 1021 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1022 | 1022 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1023 | 1023 | ||
| 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) | |
| 1025 | 1025 | ||
| 1026 | 1026 | tests := []struct { | |
| 1027 | 1027 | name string | |
| @@ -1052,7 +1052,7 @@ func TestHandler_Tags_Success(t *testing.T) { | |||
| 1052 | 1052 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1053 | 1053 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 1054 | 1054 | ||
| 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) | |
| 1056 | 1056 | ||
| 1057 | 1057 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil) | |
| 1058 | 1058 | ||
| @@ -1079,7 +1079,7 @@ func TestHandler_Tags_Error(t *testing.T) { | |||
| 1079 | 1079 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1080 | 1080 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 1081 | 1081 | ||
| 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) | |
| 1083 | 1083 | ||
| 1084 | 1084 | registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error")) | |
| 1085 | 1085 | ||
| @@ -1099,7 +1099,7 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 1099 | 1099 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1100 | 1100 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1101 | 1101 | ||
| 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) | |
| 1103 | 1103 | ||
| 1104 | 1104 | body := `{"REDIS_URL": "redis://localhost:6379"}` | |
| 1105 | 1105 | 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) { | |||
| 1117 | 1117 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1118 | 1118 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1119 | 1119 | ||
| 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) | |
| 1121 | 1121 | ||
| 1122 | 1122 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |
| 1123 | 1123 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -1134,7 +1134,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { | |||
| 1134 | 1134 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1135 | 1135 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1136 | 1136 | ||
| 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) | |
| 1138 | 1138 | ||
| 1139 | 1139 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | |
| 1140 | 1140 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -1152,7 +1152,7 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { | |||
| 1152 | 1152 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1153 | 1153 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1154 | 1154 | ||
| 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) | |
| 1156 | 1156 | ||
| 1157 | 1157 | body := `{not valid json` | |
| 1158 | 1158 | req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) | |
| @@ -1171,7 +1171,7 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 1171 | 1171 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1172 | 1172 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1173 | 1173 | ||
| 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) | |
| 1175 | 1175 | ||
| 1176 | 1176 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 1177 | 1177 | req = req.WithContext(ctxWithScopes("admin:*:*")) | |
| @@ -1181,3 +1181,112 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 1181 | 1181 | ||
| 1182 | 1182 | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 1183 | 1183 | } | |
| 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 | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "archive/tar" | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "encoding/base64" | |
| 8 | 9 | "encoding/json" | |
| 10 | + | "errors" | |
| 9 | 11 | "fmt" | |
| 10 | 12 | "io" | |
| 11 | 13 | "path/filepath" | |
| 12 | 14 | "sort" | |
| 13 | 15 | "strconv" | |
| 14 | 16 | "strings" | |
| 17 | + | "sync" | |
| 18 | + | "time" | |
| 15 | 19 | ||
| 16 | 20 | "github.com/bnema/zerowrap" | |
| 17 | 21 | cerrdefs "github.com/containerd/errdefs" | |
| @@ -21,8 +25,10 @@ import ( | |||
| 21 | 25 | "github.com/docker/docker/api/types/registry" | |
| 22 | 26 | "github.com/docker/docker/api/types/volume" | |
| 23 | 27 | "github.com/docker/docker/client" | |
| 28 | + | "github.com/docker/docker/pkg/stdcopy" | |
| 24 | 29 | "github.com/docker/go-connections/nat" | |
| 25 | 30 | ||
| 31 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 26 | 32 | "github.com/bnema/gordon/internal/domain" | |
| 27 | 33 | ) | |
| 28 | 34 | ||
| @@ -31,6 +37,35 @@ type Runtime struct { | |||
| 31 | 37 | client *client.Client | |
| 32 | 38 | } | |
| 33 | 39 | ||
| 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 | + | ||
| 34 | 69 | // NewRuntime creates a new Docker runtime instance. | |
| 35 | 70 | func NewRuntime() (*Runtime, error) { | |
| 36 | 71 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| @@ -1107,9 +1142,77 @@ func (r *Runtime) DisconnectContainerFromNetwork(ctx context.Context, containerN | |||
| 1107 | 1142 | return nil | |
| 1108 | 1143 | } | |
| 1109 | 1144 | ||
| 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) { | |
| 1113 | 1216 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1114 | 1217 | zerowrap.FieldLayer: "adapter", | |
| 1115 | 1218 | zerowrap.FieldAdapter: "docker", | |
| @@ -1119,24 +1222,23 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st | |||
| 1119 | 1222 | }) | |
| 1120 | 1223 | log := zerowrap.FromCtx(ctx) | |
| 1121 | 1224 | ||
| 1122 | - | reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath) | |
| 1225 | + | tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath) | |
| 1123 | 1226 | if err != nil { | |
| 1124 | 1227 | return nil, log.WrapErr(err, "failed to copy from container") | |
| 1125 | 1228 | } | |
| 1126 | - | defer reader.Close() | |
| 1127 | 1229 | ||
| 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) | |
| 1130 | 1232 | if err != nil { | |
| 1233 | + | _ = tarReader.Close() | |
| 1131 | 1234 | return nil, log.WrapErr(err, "failed to extract file from tar") | |
| 1132 | 1235 | } | |
| 1133 | 1236 | ||
| 1134 | - | log.Debug().Int("size", len(data)).Msg("file copied from container") | |
| 1135 | - | return data, nil | |
| 1237 | + | return reader, nil | |
| 1136 | 1238 | } | |
| 1137 | 1239 | ||
| 1138 | 1240 | // 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) { | |
| 1140 | 1242 | tr := tar.NewReader(reader) | |
| 1141 | 1243 | ||
| 1142 | 1244 | // The path in the tar may be relative or absolute | |
| @@ -1156,15 +1258,23 @@ func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) { | |||
| 1156 | 1258 | if header.Typeflag == tar.TypeReg { | |
| 1157 | 1259 | headerName := filepath.Base(header.Name) | |
| 1158 | 1260 | 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 | |
| 1164 | 1273 | } | |
| 1165 | 1274 | } | |
| 1166 | 1275 | } | |
| 1167 | 1276 | ||
| 1277 | + | _ = reader.Close() | |
| 1168 | 1278 | return nil, fmt.Errorf("file not found in container: %s", targetPath) | |
| 1169 | 1279 | } | |
| 1170 | 1280 | ||
| @@ -1204,10 +1314,16 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile | |||
| 1204 | 1314 | }() | |
| 1205 | 1315 | ||
| 1206 | 1316 | // Copy the env file from the container | |
| 1207 | - | data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) | |
| 1317 | + | reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) | |
| 1208 | 1318 | if err != nil { | |
| 1209 | 1319 | return nil, err | |
| 1210 | 1320 | } | |
| 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 | + | } | |
| 1211 | 1327 | ||
| 1212 | 1328 | log.Info().Int("size", len(data)).Msg("env file extracted from image") | |
| 1213 | 1329 | 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 ( | |||
| 54 | 54 | ||
| 55 | 55 | // Use cases | |
| 56 | 56 | "github.com/bnema/gordon/internal/usecase/auth" | |
| 57 | + | "github.com/bnema/gordon/internal/usecase/backup" | |
| 57 | 58 | "github.com/bnema/gordon/internal/usecase/config" | |
| 58 | 59 | "github.com/bnema/gordon/internal/usecase/container" | |
| 59 | 60 | "github.com/bnema/gordon/internal/usecase/health" | |
| @@ -129,6 +130,17 @@ type Config struct { | |||
| 129 | 130 | TrustedProxies []string `mapstructure:"trusted_proxies"` | |
| 130 | 131 | } `mapstructure:"rate_limit"` | |
| 131 | 132 | } `mapstructure:"api"` | |
| 133 | + | ||
| 134 | + | Backups struct { | |
| 135 | + | Enabled bool `mapstructure:"enabled"` | |
| 136 | + | StorageDir string `mapstructure:"storage_dir"` | |
| 137 | + | Retention struct { | |
| 138 | + | Hourly int `mapstructure:"hourly"` | |
| 139 | + | Daily int `mapstructure:"daily"` | |
| 140 | + | Weekly int `mapstructure:"weekly"` | |
| 141 | + | Monthly int `mapstructure:"monthly"` | |
| 142 | + | } `mapstructure:"retention"` | |
| 143 | + | } `mapstructure:"backups"` | |
| 132 | 144 | } | |
| 133 | 145 | ||
| 134 | 146 | // services holds all the services used by the application. | |
| @@ -137,11 +149,13 @@ type services struct { | |||
| 137 | 149 | eventBus *eventbus.InMemory | |
| 138 | 150 | blobStorage *filesystem.BlobStorage | |
| 139 | 151 | manifestStorage *filesystem.ManifestStorage | |
| 152 | + | backupStorage *filesystem.BackupStorage | |
| 140 | 153 | envLoader out.EnvLoader | |
| 141 | 154 | logWriter *logwriter.LogWriter | |
| 142 | 155 | tokenStore out.TokenStore | |
| 143 | 156 | configSvc *config.Service | |
| 144 | 157 | containerSvc *container.Service | |
| 158 | + | backupSvc *backup.Service | |
| 145 | 159 | registrySvc *registrySvc.Service | |
| 146 | 160 | proxySvc *proxy.Service | |
| 147 | 161 | authSvc *auth.Service | |
| @@ -432,6 +446,11 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 432 | 446 | if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil { | |
| 433 | 447 | return nil, err | |
| 434 | 448 | } | |
| 449 | + | ||
| 450 | + | if svc.backupStorage, svc.backupSvc, err = createBackupService(cfg, svc, log); err != nil { | |
| 451 | + | return nil, err | |
| 452 | + | } | |
| 453 | + | ||
| 435 | 454 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 436 | 455 | ||
| 437 | 456 | proxyCfg, err := buildProxyConfig(cfg, log) | |
| @@ -458,7 +477,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 458 | 477 | logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | |
| 459 | 478 | ||
| 460 | 479 | // Create admin handler for admin API | |
| 461 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log) | |
| 480 | + | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) | |
| 462 | 481 | ||
| 463 | 482 | return svc, nil | |
| 464 | 483 | } | |
| @@ -1060,6 +1079,64 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc | |||
| 1060 | 1079 | return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil | |
| 1061 | 1080 | } | |
| 1062 | 1081 | ||
| 1082 | + | func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) { | |
| 1083 | + | if !cfg.Backups.Enabled { | |
| 1084 | + | return nil, nil, nil | |
| 1085 | + | } | |
| 1086 | + | ||
| 1087 | + | storageDir := cfg.Backups.StorageDir | |
| 1088 | + | if storageDir == "" { | |
| 1089 | + | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 1090 | + | storageDir = filepath.Join(dataDir, "backups") | |
| 1091 | + | } | |
| 1092 | + | ||
| 1093 | + | backupStorage, err := filesystem.NewBackupStorage(storageDir, log) | |
| 1094 | + | if err != nil { | |
| 1095 | + | return nil, nil, log.WrapErr(err, "failed to create backup storage") | |
| 1096 | + | } | |
| 1097 | + | ||
| 1098 | + | 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 | + | ||
| 1063 | 1140 | // registerEventHandlers registers all event handlers. | |
| 1064 | 1141 | func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error { | |
| 1065 | 1142 | imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc) | |
| @@ -1770,6 +1847,12 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1770 | 1847 | v.SetDefault("volumes.prefix", "gordon") | |
| 1771 | 1848 | v.SetDefault("volumes.preserve", true) | |
| 1772 | 1849 | 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) | |
| 1773 | 1856 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 1774 | 1857 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 1775 | 1858 | 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 ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | "io" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | 12 | "github.com/bnema/gordon/internal/domain" | |
| 12 | 13 | mock "github.com/stretchr/testify/mock" | |
| 13 | 14 | ) | |
| @@ -102,6 +103,80 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run | |||
| 102 | 103 | return _c | |
| 103 | 104 | } | |
| 104 | 105 | ||
| 106 | + | // CopyFromContainer provides a mock function for the type MockContainerRuntime | |
| 107 | + | func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) (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 | + | ||
| 105 | 180 | // CreateContainer provides a mock function for the type MockContainerRuntime | |
| 106 | 181 | func (_mock *MockContainerRuntime) CreateContainer(ctx context.Context, config *domain.ContainerConfig) (*domain.Container, error) { | |
| 107 | 182 | ret := _mock.Called(ctx, config) | |
| @@ -353,6 +428,80 @@ func (_c *MockContainerRuntime_DisconnectContainerFromNetwork_Call) RunAndReturn | |||
| 353 | 428 | return _c | |
| 354 | 429 | } | |
| 355 | 430 | ||
| 431 | + | // ExecInContainer provides a mock function for the type MockContainerRuntime | |
| 432 | + | func (_mock *MockContainerRuntime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) { | |
| 433 | + | ret := _mock.Called(ctx, containerID, cmd) | |
| 434 | + | ||
| 435 | + | if len(ret) == 0 { | |
| 436 | + | panic("no return value specified for ExecInContainer") | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | var r0 *out.ExecResult | |
| 440 | + | var r1 error | |
| 441 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) (*out.ExecResult, error)); ok { | |
| 442 | + | return returnFunc(ctx, containerID, cmd) | |
| 443 | + | } | |
| 444 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string) *out.ExecResult); ok { | |
| 445 | + | r0 = returnFunc(ctx, containerID, cmd) | |
| 446 | + | } else { | |
| 447 | + | if ret.Get(0) != nil { | |
| 448 | + | r0 = ret.Get(0).(*out.ExecResult) | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string) error); ok { | |
| 452 | + | r1 = returnFunc(ctx, containerID, cmd) | |
| 453 | + | } else { | |
| 454 | + | r1 = ret.Error(1) | |
| 455 | + | } | |
| 456 | + | return r0, r1 | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | // MockContainerRuntime_ExecInContainer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecInContainer' | |
| 460 | + | type MockContainerRuntime_ExecInContainer_Call struct { | |
| 461 | + | *mock.Call | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | // ExecInContainer is a helper method to define mock.On call | |
| 465 | + | // - ctx context.Context | |
| 466 | + | // - containerID string | |
| 467 | + | // - cmd []string | |
| 468 | + | func (_e *MockContainerRuntime_Expecter) ExecInContainer(ctx interface{}, containerID interface{}, cmd interface{}) *MockContainerRuntime_ExecInContainer_Call { | |
| 469 | + | return &MockContainerRuntime_ExecInContainer_Call{Call: _e.mock.On("ExecInContainer", ctx, containerID, cmd)} | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) Run(run func(ctx context.Context, containerID string, cmd []string)) *MockContainerRuntime_ExecInContainer_Call { | |
| 473 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 474 | + | var arg0 context.Context | |
| 475 | + | if args[0] != nil { | |
| 476 | + | arg0 = args[0].(context.Context) | |
| 477 | + | } | |
| 478 | + | var arg1 string | |
| 479 | + | if args[1] != nil { | |
| 480 | + | arg1 = args[1].(string) | |
| 481 | + | } | |
| 482 | + | var arg2 []string | |
| 483 | + | if args[2] != nil { | |
| 484 | + | arg2 = args[2].([]string) | |
| 485 | + | } | |
| 486 | + | run( | |
| 487 | + | arg0, | |
| 488 | + | arg1, | |
| 489 | + | arg2, | |
| 490 | + | ) | |
| 491 | + | }) | |
| 492 | + | return _c | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) Return(execResult *out.ExecResult, err error) *MockContainerRuntime_ExecInContainer_Call { | |
| 496 | + | _c.Call.Return(execResult, err) | |
| 497 | + | return _c | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | func (_c *MockContainerRuntime_ExecInContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error)) *MockContainerRuntime_ExecInContainer_Call { | |
| 501 | + | _c.Call.Return(run) | |
| 502 | + | return _c | |
| 503 | + | } | |
| 504 | + | ||
| 356 | 505 | // GetContainerExposedPorts provides a mock function for the type MockContainerRuntime | |
| 357 | 506 | func (_mock *MockContainerRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) { | |
| 358 | 507 | ret := _mock.Called(ctx, containerID) | |
M internal/boundaries/out/runtime.go +11 -0
| @@ -60,6 +60,10 @@ type ContainerRuntime interface { | |||
| 60 | 60 | // Label inspection | |
| 61 | 61 | GetImageLabels(ctx context.Context, imageRef string) (map[string]string, error) | |
| 62 | 62 | ||
| 63 | + | // In-container operations | |
| 64 | + | ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error) | |
| 65 | + | CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) | |
| 66 | + | ||
| 63 | 67 | // Network management | |
| 64 | 68 | CreateNetwork(ctx context.Context, name string, options map[string]string) error | |
| 65 | 69 | RemoveNetwork(ctx context.Context, name string) error | |
| @@ -68,3 +72,10 @@ type ContainerRuntime interface { | |||
| 68 | 72 | ConnectContainerToNetwork(ctx context.Context, containerName, networkName string) error | |
| 69 | 73 | DisconnectContainerFromNetwork(ctx context.Context, containerName, networkName string) error | |
| 70 | 74 | } | |
| 75 | + | ||
| 76 | + | // ExecResult holds the result of executing a command in a container. | |
| 77 | + | type ExecResult struct { | |
| 78 | + | ExitCode int | |
| 79 | + | Stdout []byte | |
| 80 | + | Stderr []byte | |
| 81 | + | } | |
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 { | |||
| 28 | 28 | ContainerID string | |
| 29 | 29 | Status string | |
| 30 | 30 | Network string | |
| 31 | + | Ports []int | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | // 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 | |||
| 1430 | 1430 | ContainerID: container.ID, | |
| 1431 | 1431 | Status: container.Status, | |
| 1432 | 1432 | Network: network, | |
| 1433 | + | Ports: append([]int(nil), container.Ports...), | |
| 1433 | 1434 | } | |
| 1434 | 1435 | result[ownerDomain] = append(result[ownerDomain], attachment) | |
| 1435 | 1436 | } | |
| @@ -1467,6 +1468,7 @@ func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Co | |||
| 1467 | 1468 | ContainerID: container.ID, | |
| 1468 | 1469 | Status: container.Status, | |
| 1469 | 1470 | Network: network, | |
| 1471 | + | Ports: append([]int(nil), container.Ports...), | |
| 1470 | 1472 | } | |
| 1471 | 1473 | attachments = append(attachments, attachment) | |
| 1472 | 1474 | } | |
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 | + | } | |