merge main into sec/auth-proxy-critical-fixes
55 files · +4650 -268
A docs/cli/images.md +72 -0
| @@ -0,0 +1,72 @@ | |||||
| 1 | + | # Images Command | |||
| 2 | + | ||||
| 3 | + | List and prune runtime/registry images. | |||
| 4 | + | ||||
| 5 | + | ## gordon images | |||
| 6 | + | ||||
| 7 | + | ```bash | |||
| 8 | + | gordon images <subcommand> | |||
| 9 | + | ``` | |||
| 10 | + | ||||
| 11 | + | Subcommands: | |||
| 12 | + | ||||
| 13 | + | - `list` - List runtime images. | |||
| 14 | + | - `prune` - Prune dangling runtime images and optionally registry data. | |||
| 15 | + | ||||
| 16 | + | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | |||
| 17 | + | ||||
| 18 | + | ## gordon images list | |||
| 19 | + | ||||
| 20 | + | ```bash | |||
| 21 | + | gordon images list | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | |||
| 25 | + | ||||
| 26 | + | ## gordon images prune | |||
| 27 | + | ||||
| 28 | + | ```bash | |||
| 29 | + | gordon images prune [--dry-run] [--keep <n>] [--runtime-only] | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | Flags: | |||
| 33 | + | ||||
| 34 | + | | Flag | Default | Description | | |||
| 35 | + | |------|---------|-------------| | |||
| 36 | + | | `--dry-run` | `false` | Show prune behavior without applying changes | | |||
| 37 | + | | `--keep` | `0` | Number of latest tags to keep per repository (`0` disables registry cleanup) | | |||
| 38 | + | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | |||
| 39 | + | ||||
| 40 | + | `--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used. | |||
| 41 | + | ||||
| 42 | + | Behavior notes: | |||
| 43 | + | ||||
| 44 | + | - `--runtime-only` forces `keep_last = 0`. | |||
| 45 | + | - `--keep` must be `>= 0`. | |||
| 46 | + | - `--dry-run` reports what would be pruned. | |||
| 47 | + | ||||
| 48 | + | ## Examples | |||
| 49 | + | ||||
| 50 | + | ```bash | |||
| 51 | + | # List images | |||
| 52 | + | gordon images list | |||
| 53 | + | ||||
| 54 | + | # Prune dangling runtime images only | |||
| 55 | + | gordon images prune --runtime-only | |||
| 56 | + | ||||
| 57 | + | # Prune runtime images and keep 3 newest tags per repository | |||
| 58 | + | gordon images prune --keep 3 | |||
| 59 | + | ||||
| 60 | + | # Preview cleanup without applying | |||
| 61 | + | gordon images prune --dry-run --keep 5 | |||
| 62 | + | ``` | |||
| 63 | + | ||||
| 64 | + | ## Required Permissions | |||
| 65 | + | ||||
| 66 | + | - `list` requires `admin:status:read`. | |||
| 67 | + | - `prune` requires `admin:config:write`. | |||
| 68 | + | ||||
| 69 | + | ## Related | |||
| 70 | + | ||||
| 71 | + | - [CLI Commands](./index.md) | |||
| 72 | + | - [Images Configuration](../config/images.md) | |||
M docs/cli/index.md +6 -0
| @@ -32,6 +32,7 @@ Management commands run locally through in-process services by default. Add `--r | |||||
| 32 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | 32 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | ||
| 33 | | `gordon status` | Show Gordon server status | [status](./status.md) | | 33 | | `gordon status` | Show Gordon server status | [status](./status.md) | | ||
| 34 | | `gordon backups` | Manage database backups | [backup](./backup.md) | | 34 | | `gordon backups` | Manage database backups | [backup](./backup.md) | | ||
| 35 | + | | `gordon images` | List and prune images | [images](./images.md) | | |||
| 35 | 36 | ||||
| 36 | ## Client Commands | 37 | ## Client Commands | ||
| 37 | 38 | ||||
| @@ -82,6 +83,11 @@ gordon backups run app.example.com | |||||
| 82 | gordon backups detect app.example.com | 83 | gordon backups detect app.example.com | ||
| 83 | gordon backups status | 84 | gordon backups status | ||
| 84 | 85 | ||||
| 86 | + | # Images | |||
| 87 | + | gordon images list | |||
| 88 | + | gordon images prune --runtime-only | |||
| 89 | + | gordon images prune --keep 3 | |||
| 90 | + | ||||
| 85 | # Authentication | 91 | # Authentication | ||
| 86 | gordon auth token generate --subject ci-bot --expiry 0 | 92 | gordon auth token generate --subject ci-bot --expiry 0 | ||
| 87 | gordon auth token list | 93 | gordon auth token list | ||
M docs/config/backups.md +3 -0
| @@ -21,6 +21,7 @@ Current scope: | |||||
| 21 | ```toml | 21 | ```toml | ||
| 22 | [backups] | 22 | [backups] | ||
| 23 | enabled = true | 23 | enabled = true | ||
| 24 | + | schedule = "daily" | |||
| 24 | storage_dir = "~/.gordon/backups" | 25 | storage_dir = "~/.gordon/backups" | ||
| 25 | 26 | ||||
| 26 | [backups.retention] | 27 | [backups.retention] | ||
| @@ -35,6 +36,7 @@ monthly = 12 | |||||
| 35 | | Key | Type | Default | Description | | 36 | | Key | Type | Default | Description | | ||
| 36 | |-----|------|---------|-------------| | 37 | |-----|------|---------|-------------| | ||
| 37 | | `backups.enabled` | bool | `false` | Enables backup service wiring | | 38 | | `backups.enabled` | bool | `false` | Enables backup service wiring | | ||
| 39 | + | | `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` | | |||
| 38 | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | 40 | | `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 | | 41 | | `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 | | 42 | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | ||
| @@ -58,6 +60,7 @@ Example: | |||||
| 58 | ## Notes | 60 | ## Notes | ||
| 59 | 61 | ||||
| 60 | - Backups require PostgreSQL attachment containers to be running. | 62 | - Backups require PostgreSQL attachment containers to be running. | ||
| 63 | + | - Scheduled backups run only when `backups.enabled = true`. | |||
| 61 | - The backup command executes inside the database container using Gordon runtime APIs. | 64 | - The backup command executes inside the database container using Gordon runtime APIs. | ||
| 62 | - Retention applies per database and per schedule. | 65 | - 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`. | 66 | - `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`. | ||
A docs/config/images.md +41 -0
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | # Images Configuration | |||
| 2 | + | ||||
| 3 | + | Configure automatic image cleanup for Docker runtime images and local registry storage. | |||
| 4 | + | ||||
| 5 | + | ## Overview | |||
| 6 | + | ||||
| 7 | + | When enabled, Gordon runs a scheduled image prune job that: | |||
| 8 | + | ||||
| 9 | + | - Prunes dangling runtime images. | |||
| 10 | + | - Applies tag retention to registry repositories. | |||
| 11 | + | - Preserves the `latest` tag. | |||
| 12 | + | - Removes unreferenced blobs after tag cleanup. | |||
| 13 | + | ||||
| 14 | + | ## Configuration | |||
| 15 | + | ||||
| 16 | + | ```toml | |||
| 17 | + | [images.prune] | |||
| 18 | + | enabled = false | |||
| 19 | + | schedule = "daily" | |||
| 20 | + | keep_last = 3 | |||
| 21 | + | ``` | |||
| 22 | + | ||||
| 23 | + | ## Settings | |||
| 24 | + | ||||
| 25 | + | | Key | Type | Default | Description | | |||
| 26 | + | |-----|------|---------|-------------| | |||
| 27 | + | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | |||
| 28 | + | | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` | | |||
| 29 | + | | `images.prune.keep_last` | int | `3` | Number of newest tags kept per repository during registry cleanup | | |||
| 30 | + | ||||
| 31 | + | ## Retention Behavior | |||
| 32 | + | ||||
| 33 | + | - `latest` is always preserved. | |||
| 34 | + | - `keep_last` applies per repository. | |||
| 35 | + | - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs). | |||
| 36 | + | - Negative `keep_last` values are invalid. | |||
| 37 | + | ||||
| 38 | + | ## Related | |||
| 39 | + | ||||
| 40 | + | - [CLI Images Command](../cli/images.md) | |||
| 41 | + | - [Configuration Reference](./reference.md) | |||
M docs/config/index.md +13 -0
| @@ -119,6 +119,7 @@ enabled = false # Auto-create routes from image names | |||||
| 119 | # Backups | 119 | # Backups | ||
| 120 | [backups] | 120 | [backups] | ||
| 121 | enabled = true | 121 | enabled = true | ||
| 122 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |||
| 122 | storage_dir = "~/.gordon/backups" | 123 | storage_dir = "~/.gordon/backups" | ||
| 123 | 124 | ||||
| 124 | [backups.retention] | 125 | [backups.retention] | ||
| @@ -126,6 +127,12 @@ hourly = 24 | |||||
| 126 | daily = 7 | 127 | daily = 7 | ||
| 127 | weekly = 4 | 128 | weekly = 4 | ||
| 128 | monthly = 12 | 129 | monthly = 12 | ||
| 130 | + | ||||
| 131 | + | # Images | |||
| 132 | + | [images.prune] | |||
| 133 | + | enabled = false | |||
| 134 | + | schedule = "daily" | |||
| 135 | + | keep_last = 3 | |||
| 129 | ``` | 136 | ``` | ||
| 130 | 137 | ||||
| 131 | ## Configuration Sections | 138 | ## Configuration Sections | ||
| @@ -146,6 +153,7 @@ monthly = 12 | |||||
| 146 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | 153 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | ||
| 147 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | 154 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | ||
| 148 | | `[backups]` | Database backups | [Backups](./backups.md) | | 155 | | `[backups]` | Database backups | [Backups](./backups.md) | | ||
| 156 | + | | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) | | |||
| 149 | 157 | ||||
| 150 | ## Default Values | 158 | ## Default Values | ||
| 151 | 159 | ||||
| @@ -182,6 +190,10 @@ monthly = 12 | |||||
| 182 | | `network_isolation.enabled` | `false` | | 190 | | `network_isolation.enabled` | `false` | | ||
| 183 | | `auto_route.enabled` | `false` | | 191 | | `auto_route.enabled` | `false` | | ||
| 184 | | `backups.enabled` | `false` | | 192 | | `backups.enabled` | `false` | | ||
| 193 | + | | `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) | | |||
| 194 | + | | `images.prune.enabled` | `false` | | |||
| 195 | + | | `images.prune.schedule` | `"daily"` | | |||
| 196 | + | | `images.prune.keep_last` | `3` | | |||
| 185 | 197 | ||||
| 186 | When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only. | 198 | When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only. | ||
| 187 | 199 | ||||
| @@ -225,3 +237,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||||
| 225 | - [External Routes](./external-routes.md) | 237 | - [External Routes](./external-routes.md) | ||
| 226 | - [Authentication](./auth.md) | 238 | - [Authentication](./auth.md) | ||
| 227 | - [Backups](./backups.md) | 239 | - [Backups](./backups.md) | ||
| 240 | + | - [Images](./images.md) | |||
M docs/config/reference.md +14 -0
| @@ -133,6 +133,7 @@ preserve = true # Keep volumes when containers are | |||||
| 133 | # ============================================================================= | 133 | # ============================================================================= | ||
| 134 | [backups] | 134 | [backups] | ||
| 135 | enabled = false # Enable backup service | 135 | enabled = false # Enable backup service | ||
| 136 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |||
| 136 | storage_dir = "" # Backup root (default: {data_dir}/backups) | 137 | storage_dir = "" # Backup root (default: {data_dir}/backups) | ||
| 137 | 138 | ||||
| 138 | [backups.retention] | 139 | [backups.retention] | ||
| @@ -141,6 +142,14 @@ daily = 0 # Keep N daily backups per DB | |||||
| 141 | weekly = 0 # Keep N weekly backups per DB | 142 | weekly = 0 # Keep N weekly backups per DB | ||
| 142 | monthly = 0 # Keep N monthly backups per DB | 143 | monthly = 0 # Keep N monthly backups per DB | ||
| 143 | 144 | ||||
| 145 | + | # ============================================================================= | |||
| 146 | + | # IMAGES | |||
| 147 | + | # ============================================================================= | |||
| 148 | + | [images.prune] | |||
| 149 | + | enabled = false # Enable scheduled image cleanup | |||
| 150 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |||
| 151 | + | keep_last = 3 # Keep N newest tags per repository | |||
| 152 | + | ||||
| 144 | # Note: retention values set to 0 keep no backups for that tier. | 153 | # Note: retention values set to 0 keep no backups for that tier. | ||
| 145 | # For practical defaults, consider setting daily = 7. | 154 | # For practical defaults, consider setting daily = 7. | ||
| 146 | ``` | 155 | ``` | ||
| @@ -192,11 +201,15 @@ monthly = 0 # Keep N monthly backups per DB | |||||
| 192 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 201 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
| 193 | | `volumes.preserve` | `true` | Keep volumes | | 202 | | `volumes.preserve` | `true` | Keep volumes | | ||
| 194 | | `backups.enabled` | `false` | Backup service disabled | | 203 | | `backups.enabled` | `false` | Backup service disabled | | ||
| 204 | + | | `backups.schedule` | `"daily"` | Backup scheduler preset | | |||
| 195 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | 205 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | ||
| 196 | | `backups.retention.hourly` | `0` | Keep no hourly backups by default | | 206 | | `backups.retention.hourly` | `0` | Keep no hourly backups by default | | ||
| 197 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | 207 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | ||
| 198 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | 208 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | ||
| 199 | | `backups.retention.monthly` | `0` | Keep no monthly backups by default | | 209 | | `backups.retention.monthly` | `0` | Keep no monthly backups by default | | ||
| 210 | + | | `images.prune.enabled` | `false` | Scheduled image cleanup disabled | | |||
| 211 | + | | `images.prune.schedule` | `"daily"` | Cleanup schedule preset | | |||
| 212 | + | | `images.prune.keep_last` | `3` | Number of recent tags kept per repository | | |||
| 200 | 213 | ||||
| 201 | Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier. | 214 | Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier. | ||
| 202 | 215 | ||||
| @@ -269,3 +282,4 @@ gordon serve | |||||
| 269 | - [Authentication](./auth.md) | 282 | - [Authentication](./auth.md) | ||
| 270 | - [Network Isolation](./network-isolation.md) | 283 | - [Network Isolation](./network-isolation.md) | ||
| 271 | - [Volumes](./volumes.md) | 284 | - [Volumes](./volumes.md) | ||
| 285 | + | - [Images](./images.md) | |||
M docs/config/server.md +24 -0
| @@ -69,6 +69,30 @@ With TLS enabled: | |||||
| 69 | - `port` remains available for plain HTTP unless you restrict it externally | 69 | - `port` remains available for plain HTTP unless you restrict it externally | ||
| 70 | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | 70 | - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/` | ||
| 71 | 71 | ||||
| 72 | + | For rootless user services, do not bind directly to `443`. Use an unprivileged TLS port (for example `8443`) and forward `443` to it at the firewall or edge proxy. | |||
| 73 | + | ||||
| 74 | + | ```toml | |||
| 75 | + | [server] | |||
| 76 | + | tls_enabled = true | |||
| 77 | + | tls_port = 8443 | |||
| 78 | + | ``` | |||
| 79 | + | ||||
| 80 | + | With firewalld and Tailscale: | |||
| 81 | + | ||||
| 82 | + | ```bash | |||
| 83 | + | # Verify tailscale0 is in trusted zone | |||
| 84 | + | sudo firewall-cmd --get-zone-of-interface=tailscale0 | |||
| 85 | + | ||||
| 86 | + | # If needed, place tailscale0 in trusted | |||
| 87 | + | sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0 | |||
| 88 | + | sudo firewall-cmd --reload | |||
| 89 | + | ||||
| 90 | + | # Forward 443 -> 8443 permanently (IPv4 + IPv6) | |||
| 91 | + | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv4 forward-port port=443 protocol=tcp to-port=8443' | |||
| 92 | + | sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv6 forward-port port=443 protocol=tcp to-port=8443' | |||
| 93 | + | sudo firewall-cmd --reload | |||
| 94 | + | ``` | |||
| 95 | + | ||||
| 72 | ### Registry Port | 96 | ### Registry Port | ||
| 73 | 97 | ||||
| 74 | The `registry_port` setting controls where Gordon accepts image pushes: | 98 | The `registry_port` setting controls where Gordon accepts image pushes: | ||
M go.mod +10 -7
| @@ -4,7 +4,7 @@ go 1.25.6 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/zerowrap v1.3.0 | 6 | github.com/bnema/zerowrap v1.3.0 | ||
| 7 | - | github.com/charmbracelet/bubbles v0.21.0 | 7 | + | github.com/charmbracelet/bubbles v0.21.1 |
| 8 | github.com/charmbracelet/bubbletea v1.3.10 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 9 | github.com/charmbracelet/lipgloss v1.1.0 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| 10 | github.com/docker/docker v28.5.2+incompatible | 10 | github.com/docker/docker v28.5.2+incompatible | ||
| @@ -24,14 +24,17 @@ require ( | |||||
| 24 | github.com/atotto/clipboard v0.1.4 // indirect | 24 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 25 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 25 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
| 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| 27 | - | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect | 27 | + | github.com/charmbracelet/colorprofile v0.4.1 // indirect |
| 28 | - | github.com/charmbracelet/x/ansi v0.10.1 // indirect | 28 | + | github.com/charmbracelet/x/ansi v0.11.5 // indirect |
| 29 | - | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect | 29 | + | github.com/charmbracelet/x/cellbuf v0.0.15 // indirect |
| 30 | - | github.com/charmbracelet/x/term v0.2.1 // indirect | 30 | + | github.com/charmbracelet/x/term v0.2.2 // indirect |
| 31 | + | github.com/clipperhouse/displaywidth v0.9.0 // indirect | |||
| 32 | + | github.com/clipperhouse/stringish v0.1.1 // indirect | |||
| 33 | + | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | |||
| 31 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 34 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 32 | - | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | 35 | + | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect |
| 33 | github.com/mattn/go-localereader v0.0.1 // indirect | 36 | github.com/mattn/go-localereader v0.0.1 // indirect | ||
| 34 | - | github.com/mattn/go-runewidth v0.0.16 // indirect | 37 | + | github.com/mattn/go-runewidth v0.0.19 // indirect |
| 35 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 38 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 36 | github.com/muesli/cancelreader v0.2.2 // indirect | 39 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 37 | github.com/muesli/termenv v0.16.0 // indirect | 40 | github.com/muesli/termenv v0.16.0 // indirect | ||
M go.sum +24 -19 generated file: collapsed
A internal/adapters/dto/admin_images.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // Image represents a runtime image in admin API responses. | |||
| 6 | + | type Image struct { | |||
| 7 | + | Repository string `json:"repository"` | |||
| 8 | + | Tag string `json:"tag"` | |||
| 9 | + | Size int64 `json:"size"` | |||
| 10 | + | Created time.Time `json:"created"` | |||
| 11 | + | ID string `json:"id"` | |||
| 12 | + | Dangling bool `json:"dangling"` | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // ImagesResponse is returned by image list endpoints. | |||
| 16 | + | type ImagesResponse struct { | |||
| 17 | + | Images []Image `json:"images"` | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // ImagePruneRequest triggers image pruning. | |||
| 21 | + | type ImagePruneRequest struct { | |||
| 22 | + | KeepLast *int `json:"keep_last,omitempty"` | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // RuntimePruneResult represents runtime prune results. | |||
| 26 | + | type RuntimePruneResult struct { | |||
| 27 | + | DeletedCount int `json:"deleted_count"` | |||
| 28 | + | SpaceReclaimed int64 `json:"space_reclaimed"` | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // RegistryPruneResult represents registry prune results. | |||
| 32 | + | type RegistryPruneResult struct { | |||
| 33 | + | TagsRemoved int `json:"tags_removed"` | |||
| 34 | + | BlobsRemoved int `json:"blobs_removed"` | |||
| 35 | + | SpaceReclaimed int64 `json:"space_reclaimed"` | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // ImagePruneResponse is returned by image prune endpoints. | |||
| 39 | + | type ImagePruneResponse struct { | |||
| 40 | + | Runtime RuntimePruneResult `json:"runtime"` | |||
| 41 | + | Registry RegistryPruneResult `json:"registry"` | |||
| 42 | + | } | |||
A internal/adapters/in/cli/images.go +200 -0
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "text/tabwriter" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type imagesClient interface { | |||
| 16 | + | ListImages(ctx context.Context) ([]dto.Image, error) | |||
| 17 | + | PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type imagesPruneOptions struct { | |||
| 21 | + | DryRun bool | |||
| 22 | + | KeepLast int | |||
| 23 | + | RuntimeOnly bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func newImagesCmd() *cobra.Command { | |||
| 27 | + | cmd := &cobra.Command{ | |||
| 28 | + | Use: "images", | |||
| 29 | + | Short: "List and prune images", | |||
| 30 | + | Long: `Inspect and clean up runtime and registry images. | |||
| 31 | + | ||||
| 32 | + | These commands currently require remote mode with a configured target.`, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | cmd.AddCommand(newImagesListCmd()) | |||
| 36 | + | cmd.AddCommand(newImagesPruneCmd()) | |||
| 37 | + | ||||
| 38 | + | return cmd | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func newImagesListCmd() *cobra.Command { | |||
| 42 | + | return &cobra.Command{ | |||
| 43 | + | Use: "list", | |||
| 44 | + | Short: "List runtime images", | |||
| 45 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 46 | + | client, isRemote := GetRemoteClient() | |||
| 47 | + | if !isRemote { | |||
| 48 | + | return fmt.Errorf("images commands require a configured remote target") | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | return runImagesList(cmd.Context(), client, cmd.OutOrStdout()) | |||
| 52 | + | }, | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func newImagesPruneCmd() *cobra.Command { | |||
| 57 | + | var opts imagesPruneOptions | |||
| 58 | + | ||||
| 59 | + | cmd := &cobra.Command{ | |||
| 60 | + | Use: "prune", | |||
| 61 | + | Short: "Prune unused images", | |||
| 62 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 63 | + | client, isRemote := GetRemoteClient() | |||
| 64 | + | if !isRemote { | |||
| 65 | + | return fmt.Errorf("images commands require a configured remote target") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout()) | |||
| 69 | + | }, | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | |||
| 73 | + | cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of latest tags to keep per repository (0 disables registry cleanup)") | |||
| 74 | + | cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)") | |||
| 75 | + | ||||
| 76 | + | return cmd | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error { | |||
| 80 | + | images, err := client.ListImages(ctx) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return fmt.Errorf("failed to list images: %w", err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | if len(images) == 0 { | |||
| 86 | + | if _, err := fmt.Fprintln(out, "No images found"); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)") | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |||
| 94 | + | if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil { | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | danglingCount := 0 | |||
| 99 | + | for _, img := range images { | |||
| 100 | + | dangling := "no" | |||
| 101 | + | if img.Dangling { | |||
| 102 | + | dangling = "yes" | |||
| 103 | + | danglingCount++ | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), img.ID, dangling); err != nil { | |||
| 107 | + | return err | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | if err := w.Flush(); err != nil { | |||
| 112 | + | return err | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount) | |||
| 116 | + | return err | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error { | |||
| 120 | + | if opts.KeepLast < 0 { | |||
| 121 | + | return fmt.Errorf("--keep must be >= 0") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | keepLast := opts.KeepLast | |||
| 125 | + | if opts.RuntimeOnly { | |||
| 126 | + | keepLast = 0 | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | if opts.DryRun { | |||
| 130 | + | images, err := client.ListImages(ctx) | |||
| 131 | + | if err != nil { | |||
| 132 | + | return fmt.Errorf("failed to list images: %w", err) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | danglingCount := 0 | |||
| 136 | + | for _, img := range images { | |||
| 137 | + | if img.Dangling { | |||
| 138 | + | danglingCount++ | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil { | |||
| 143 | + | return err | |||
| 144 | + | } | |||
| 145 | + | if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil { | |||
| 146 | + | return err | |||
| 147 | + | } | |||
| 148 | + | if opts.RuntimeOnly { | |||
| 149 | + | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 152 | + | if keepLast == 0 { | |||
| 153 | + | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)") | |||
| 154 | + | return err | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | _, err = fmt.Fprintf(out, "Registry: would keep last %d tags per repository\n", keepLast) | |||
| 158 | + | return err | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | resp, err := client.PruneImages(ctx, keepLast) | |||
| 162 | + | if err != nil { | |||
| 163 | + | return fmt.Errorf("failed to prune images: %w", err) | |||
| 164 | + | } | |||
| 165 | + | if resp == nil { | |||
| 166 | + | return fmt.Errorf("failed to prune images: empty response") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil { | |||
| 170 | + | return err | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | if opts.RuntimeOnly { | |||
| 174 | + | _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)") | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed) | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func formatImageCreatedAt(t time.Time) string { | |||
| 183 | + | if t.IsZero() { | |||
| 184 | + | return "-" | |||
| 185 | + | } | |||
| 186 | + | return t.UTC().Format("2006-01-02T15:04:05Z") | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func formatImageSize(size int64) string { | |||
| 190 | + | const unit = 1024 | |||
| 191 | + | if size < unit { | |||
| 192 | + | return fmt.Sprintf("%d B", size) | |||
| 193 | + | } | |||
| 194 | + | div, exp := int64(unit), 0 | |||
| 195 | + | for n := size / unit; n >= unit; n /= unit { | |||
| 196 | + | div *= unit | |||
| 197 | + | exp++ | |||
| 198 | + | } | |||
| 199 | + | return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) | |||
| 200 | + | } | |||
A internal/adapters/in/cli/images_test.go +158 -0
| @@ -0,0 +1,158 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type fakeImagesClient struct { | |||
| 17 | + | listImagesResp []dto.Image | |||
| 18 | + | listImagesErr error | |||
| 19 | + | pruneResp *dto.ImagePruneResponse | |||
| 20 | + | pruneErr error | |||
| 21 | + | ||||
| 22 | + | listImagesCalls int | |||
| 23 | + | pruneCalls int | |||
| 24 | + | lastPruneKeep int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) { | |||
| 28 | + | f.listImagesCalls++ | |||
| 29 | + | if f.listImagesErr != nil { | |||
| 30 | + | return nil, f.listImagesErr | |||
| 31 | + | } | |||
| 32 | + | return f.listImagesResp, nil | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | |||
| 36 | + | f.pruneCalls++ | |||
| 37 | + | f.lastPruneKeep = keepLast | |||
| 38 | + | if f.pruneErr != nil { | |||
| 39 | + | return nil, f.pruneErr | |||
| 40 | + | } | |||
| 41 | + | return f.pruneResp, nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) { | |||
| 45 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 46 | + | client := &fakeImagesClient{ | |||
| 47 | + | listImagesResp: []dto.Image{ | |||
| 48 | + | {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false}, | |||
| 49 | + | {Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true}, | |||
| 50 | + | }, | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | var out bytes.Buffer | |||
| 54 | + | err := runImagesList(context.Background(), client, &out) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | ||||
| 57 | + | text := out.String() | |||
| 58 | + | assert.Contains(t, text, "REPOSITORY") | |||
| 59 | + | assert.Contains(t, text, "registry.example.com/app") | |||
| 60 | + | assert.Contains(t, text, "latest") | |||
| 61 | + | assert.Contains(t, text, "Total images: 2 (dangling: 1)") | |||
| 62 | + | assert.Equal(t, 1, client.listImagesCalls) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestRunImagesList_EmptyOutput(t *testing.T) { | |||
| 66 | + | client := &fakeImagesClient{listImagesResp: []dto.Image{}} | |||
| 67 | + | ||||
| 68 | + | var out bytes.Buffer | |||
| 69 | + | err := runImagesList(context.Background(), client, &out) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | ||||
| 72 | + | text := out.String() | |||
| 73 | + | assert.Contains(t, text, "No images found") | |||
| 74 | + | assert.Contains(t, text, "Total images: 0") | |||
| 75 | + | assert.Equal(t, 1, client.listImagesCalls) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||
| 79 | + | client := &fakeImagesClient{ | |||
| 80 | + | listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}}, | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | var out bytes.Buffer | |||
| 84 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | ||||
| 87 | + | text := out.String() | |||
| 88 | + | assert.Contains(t, text, "Dry run") | |||
| 89 | + | assert.Contains(t, text, "would prune 2 dangling runtime images") | |||
| 90 | + | assert.Contains(t, text, "would keep last 3 tags") | |||
| 91 | + | assert.Equal(t, 1, client.listImagesCalls) | |||
| 92 | + | assert.Equal(t, 0, client.pruneCalls) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) { | |||
| 96 | + | client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}} | |||
| 97 | + | ||||
| 98 | + | var out bytes.Buffer | |||
| 99 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out) | |||
| 100 | + | require.NoError(t, err) | |||
| 101 | + | ||||
| 102 | + | text := out.String() | |||
| 103 | + | assert.Contains(t, text, "Registry cleanup skipped (--keep=0)") | |||
| 104 | + | assert.Equal(t, 1, client.listImagesCalls) | |||
| 105 | + | assert.Equal(t, 0, client.pruneCalls) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) { | |||
| 109 | + | client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}} | |||
| 110 | + | ||||
| 111 | + | var out bytes.Buffer | |||
| 112 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out) | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | ||||
| 115 | + | assert.Equal(t, 1, client.pruneCalls) | |||
| 116 | + | assert.Equal(t, 0, client.lastPruneKeep) | |||
| 117 | + | assert.Contains(t, out.String(), "Registry cleanup skipped") | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) { | |||
| 121 | + | client := &fakeImagesClient{ | |||
| 122 | + | pruneResp: &dto.ImagePruneResponse{ | |||
| 123 | + | Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | |||
| 124 | + | Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192}, | |||
| 125 | + | }, | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | var out bytes.Buffer | |||
| 129 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | ||||
| 132 | + | text := out.String() | |||
| 133 | + | assert.Equal(t, 1, client.pruneCalls) | |||
| 134 | + | assert.Equal(t, 5, client.lastPruneKeep) | |||
| 135 | + | assert.Contains(t, text, "Runtime: deleted=2") | |||
| 136 | + | assert.Contains(t, text, "Registry: tags_removed=3") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) { | |||
| 140 | + | client := &fakeImagesClient{pruneErr: errors.New("request failed")} | |||
| 141 | + | ||||
| 142 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{}) | |||
| 143 | + | require.Error(t, err) | |||
| 144 | + | assert.Contains(t, err.Error(), "failed to prune images") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) { | |||
| 148 | + | err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{}) | |||
| 149 | + | require.Error(t, err) | |||
| 150 | + | assert.Contains(t, err.Error(), "--keep must be >= 0") | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) { | |||
| 154 | + | client := &fakeImagesClient{pruneResp: nil} | |||
| 155 | + | err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{}) | |||
| 156 | + | require.Error(t, err) | |||
| 157 | + | assert.Contains(t, err.Error(), "empty response") | |||
| 158 | + | } | |||
M internal/adapters/in/cli/push.go +107 -1
| @@ -8,8 +8,10 @@ import ( | |||||
| 8 | "regexp" | 8 | "regexp" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | 10 | ||||
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 11 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 12 | 13 | ||||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 13 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 15 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 14 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 15 | "github.com/bnema/gordon/pkg/validation" | 17 | "github.com/bnema/gordon/pkg/validation" | ||
| @@ -236,7 +238,15 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no | |||||
| 236 | } | 238 | } | ||
| 237 | } | 239 | } | ||
| 238 | 240 | ||||
| 239 | - | result, err := cp.Deploy(ctx, pushDomain) | 241 | + | var ( |
| 242 | + | result *remote.DeployResult | |||
| 243 | + | err error | |||
| 244 | + | ) | |||
| 245 | + | if remoteCP, ok := cp.(*remoteControlPlane); ok { | |||
| 246 | + | result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain) | |||
| 247 | + | } else { | |||
| 248 | + | result, err = cp.Deploy(ctx, pushDomain) | |||
| 249 | + | } | |||
| 240 | if err != nil { | 250 | if err != nil { | ||
| 241 | return fmt.Errorf("failed to deploy: %w", err) | 251 | return fmt.Errorf("failed to deploy: %w", err) | ||
| 242 | } | 252 | } | ||
| @@ -248,6 +258,102 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no | |||||
| 248 | return nil | 258 | return nil | ||
| 249 | } | 259 | } | ||
| 250 | 260 | ||||
| 261 | + | func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) { | |||
| 262 | + | if !isInteractiveTerminal() { | |||
| 263 | + | fmt.Printf("Deploying %s...\n", pushDomain) | |||
| 264 | + | return client.Deploy(ctx, pushDomain) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | done := make(chan deployOutcome, 1) | |||
| 268 | + | go func() { | |||
| 269 | + | result, err := client.Deploy(ctx, pushDomain) | |||
| 270 | + | done <- deployOutcome{result: result, err: err} | |||
| 271 | + | }() | |||
| 272 | + | ||||
| 273 | + | model := newDeploySpinnerModel(pushDomain, done) | |||
| 274 | + | final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run() | |||
| 275 | + | fmt.Print("\r\033[K") | |||
| 276 | + | if err != nil { | |||
| 277 | + | return nil, err | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | deployModel, ok := final.(deploySpinnerModel) | |||
| 281 | + | if !ok { | |||
| 282 | + | return nil, fmt.Errorf("spinner exited with unexpected model type %T", final) | |||
| 283 | + | } | |||
| 284 | + | if !deployModel.finished { | |||
| 285 | + | return nil, fmt.Errorf("deploy spinner exited before deploy result was received") | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | return deployModel.outcome.result, deployModel.outcome.err | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | type deployOutcome struct { | |||
| 292 | + | result *remote.DeployResult | |||
| 293 | + | err error | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | type deployDoneMsg deployOutcome | |||
| 297 | + | ||||
| 298 | + | type deploySpinnerModel struct { | |||
| 299 | + | spinner components.SpinnerModel | |||
| 300 | + | done <-chan deployOutcome | |||
| 301 | + | outcome deployOutcome | |||
| 302 | + | finished bool | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel { | |||
| 306 | + | return deploySpinnerModel{ | |||
| 307 | + | spinner: components.NewSpinner( | |||
| 308 | + | components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)), | |||
| 309 | + | components.WithSpinnerType(components.SpinnerMiniDot), | |||
| 310 | + | ), | |||
| 311 | + | done: done, | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func (m deploySpinnerModel) Init() tea.Cmd { | |||
| 316 | + | return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done)) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 320 | + | switch msg := msg.(type) { | |||
| 321 | + | case deployDoneMsg: | |||
| 322 | + | m.outcome = deployOutcome(msg) | |||
| 323 | + | m.finished = true | |||
| 324 | + | return m, tea.Quit | |||
| 325 | + | default: | |||
| 326 | + | updated, cmd := m.spinner.Update(msg) | |||
| 327 | + | spinnerModel, ok := updated.(components.SpinnerModel) | |||
| 328 | + | if ok { | |||
| 329 | + | m.spinner = spinnerModel | |||
| 330 | + | } | |||
| 331 | + | return m, cmd | |||
| 332 | + | } | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func (m deploySpinnerModel) View() string { | |||
| 336 | + | return m.spinner.View() | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | func waitForDeployDone(done <-chan deployOutcome) tea.Cmd { | |||
| 340 | + | return func() tea.Msg { | |||
| 341 | + | return deployDoneMsg(<-done) | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | func isInteractiveTerminal() bool { | |||
| 346 | + | term := os.Getenv("TERM") | |||
| 347 | + | if term == "" || term == "dumb" { | |||
| 348 | + | return false | |||
| 349 | + | } | |||
| 350 | + | info, err := os.Stdout.Stat() | |||
| 351 | + | if err != nil { | |||
| 352 | + | return false | |||
| 353 | + | } | |||
| 354 | + | return (info.Mode() & os.ModeCharDevice) != 0 | |||
| 355 | + | } | |||
| 356 | + | ||||
| 251 | // parseImageRef splits "registry/name:tag" into components. | 357 | // parseImageRef splits "registry/name:tag" into components. | ||
| 252 | func parseImageRef(image string) (registry, name, tag string) { | 358 | func parseImageRef(image string) (registry, name, tag string) { | ||
| 253 | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | 359 | // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot" | ||
M internal/adapters/in/cli/remote/client.go +36 -0
| @@ -465,6 +465,42 @@ type RouteHealth struct { | |||||
| 465 | 465 | ||||
| 466 | // Backups API | 466 | // Backups API | ||
| 467 | 467 | ||||
| 468 | + | // Images API | |||
| 469 | + | ||||
| 470 | + | // ListImages returns runtime images from the admin API. | |||
| 471 | + | func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | |||
| 472 | + | resp, err := c.request(ctx, http.MethodGet, "/images", nil) | |||
| 473 | + | if err != nil { | |||
| 474 | + | return nil, err | |||
| 475 | + | } | |||
| 476 | + | ||||
| 477 | + | var result dto.ImagesResponse | |||
| 478 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 479 | + | return nil, err | |||
| 480 | + | } | |||
| 481 | + | ||||
| 482 | + | return result.Images, nil | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | // PruneImages prunes runtime and registry images. | |||
| 486 | + | func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) { | |||
| 487 | + | if keepLast < 0 { | |||
| 488 | + | return nil, fmt.Errorf("keepLast must be >= 0") | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast}) | |||
| 492 | + | if err != nil { | |||
| 493 | + | return nil, err | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | var result dto.ImagePruneResponse | |||
| 497 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 498 | + | return nil, err | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | return &result, nil | |||
| 502 | + | } | |||
| 503 | + | ||||
| 468 | // ListBackups returns backups globally or for a domain. | 504 | // ListBackups returns backups globally or for a domain. | ||
| 469 | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | 505 | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | ||
| 470 | path := "/backups" | 506 | path := "/backups" | ||
M internal/adapters/in/cli/remote/client_test.go +43 -0
| @@ -2,6 +2,7 @@ package remote | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 7 | "sync/atomic" | 8 | "sync/atomic" | ||
| @@ -10,6 +11,8 @@ import ( | |||||
| 10 | 11 | ||||
| 11 | "github.com/stretchr/testify/assert" | 12 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 13 | ) | 16 | ) | ||
| 14 | 17 | ||||
| 15 | func withFastRetry(t *testing.T) { | 18 | func withFastRetry(t *testing.T) { | ||
| @@ -108,3 +111,43 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||||
| 108 | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | 111 | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | ||
| 109 | require.NoError(t, err) | 112 | require.NoError(t, err) | ||
| 110 | } | 113 | } | ||
| 114 | + | ||||
| 115 | + | func TestClientListImages(t *testing.T) { | |||
| 116 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 117 | + | require.Equal(t, "/admin/images", r.URL.Path) | |||
| 118 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 119 | + | w.Header().Set("Content-Type", "application/json") | |||
| 120 | + | _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`)) | |||
| 121 | + | })) | |||
| 122 | + | defer srv.Close() | |||
| 123 | + | ||||
| 124 | + | client := NewClient(srv.URL) | |||
| 125 | + | images, err := client.ListImages(context.Background()) | |||
| 126 | + | require.NoError(t, err) | |||
| 127 | + | require.Len(t, images, 1) | |||
| 128 | + | assert.Equal(t, "registry.example.com/app", images[0].Repository) | |||
| 129 | + | assert.Equal(t, "latest", images[0].Tag) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func TestClientPruneImages(t *testing.T) { | |||
| 133 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 134 | + | require.Equal(t, "/admin/images/prune", r.URL.Path) | |||
| 135 | + | require.Equal(t, http.MethodPost, r.Method) | |||
| 136 | + | ||||
| 137 | + | var req dto.ImagePruneRequest | |||
| 138 | + | require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) | |||
| 139 | + | require.NotNil(t, req.KeepLast) | |||
| 140 | + | assert.Equal(t, 4, *req.KeepLast) | |||
| 141 | + | ||||
| 142 | + | w.Header().Set("Content-Type", "application/json") | |||
| 143 | + | _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`)) | |||
| 144 | + | })) | |||
| 145 | + | defer srv.Close() | |||
| 146 | + | ||||
| 147 | + | client := NewClient(srv.URL) | |||
| 148 | + | resp, err := client.PruneImages(context.Background(), 4) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | require.NotNil(t, resp) | |||
| 151 | + | assert.Equal(t, 2, resp.Runtime.DeletedCount) | |||
| 152 | + | assert.Equal(t, 3, resp.Registry.TagsRemoved) | |||
| 153 | + | } | |||
M internal/adapters/in/cli/root.go +4 -0
| @@ -121,6 +121,10 @@ Commands are organized by where they run: | |||||
| 121 | backupCmd.GroupID = groupManage | 121 | backupCmd.GroupID = groupManage | ||
| 122 | rootCmd.AddCommand(backupCmd) | 122 | rootCmd.AddCommand(backupCmd) | ||
| 123 | 123 | ||||
| 124 | + | imagesCmd := newImagesCmd() | |||
| 125 | + | imagesCmd.GroupID = groupManage | |||
| 126 | + | rootCmd.AddCommand(imagesCmd) | |||
| 127 | + | ||||
| 124 | // Client-only commands (no server needed) | 128 | // Client-only commands (no server needed) | ||
| 125 | remotesCmd := newRemotesCmd() | 129 | remotesCmd := newRemotesCmd() | ||
| 126 | remotesCmd.GroupID = groupClient | 130 | remotesCmd.GroupID = groupClient | ||
M internal/adapters/in/http/admin/handler.go +9 -0
| @@ -33,6 +33,7 @@ type Handler struct { | |||||
| 33 | authSvc in.AuthService | 33 | authSvc in.AuthService | ||
| 34 | containerSvc in.ContainerService | 34 | containerSvc in.ContainerService | ||
| 35 | backupSvc in.BackupService | 35 | backupSvc in.BackupService | ||
| 36 | + | imageSvc in.ImageService | |||
| 36 | healthSvc in.HealthService | 37 | healthSvc in.HealthService | ||
| 37 | secretSvc in.SecretService | 38 | secretSvc in.SecretService | ||
| 38 | logSvc in.LogService | 39 | logSvc in.LogService | ||
| @@ -132,12 +133,19 @@ func NewHandler( | |||||
| 132 | eventBus out.EventPublisher, | 133 | eventBus out.EventPublisher, | ||
| 133 | log zerowrap.Logger, | 134 | log zerowrap.Logger, | ||
| 134 | backupSvc in.BackupService, | 135 | backupSvc in.BackupService, | ||
| 136 | + | imageSvcs ...in.ImageService, | |||
| 135 | ) *Handler { | 137 | ) *Handler { | ||
| 138 | + | var imageSvc in.ImageService | |||
| 139 | + | if len(imageSvcs) > 0 { | |||
| 140 | + | imageSvc = imageSvcs[0] | |||
| 141 | + | } | |||
| 142 | + | ||||
| 136 | return &Handler{ | 143 | return &Handler{ | ||
| 137 | configSvc: configSvc, | 144 | configSvc: configSvc, | ||
| 138 | authSvc: authSvc, | 145 | authSvc: authSvc, | ||
| 139 | containerSvc: containerSvc, | 146 | containerSvc: containerSvc, | ||
| 140 | backupSvc: backupSvc, | 147 | backupSvc: backupSvc, | ||
| 148 | + | imageSvc: imageSvc, | |||
| 141 | healthSvc: healthSvc, | 149 | healthSvc: healthSvc, | ||
| 142 | secretSvc: secretSvc, | 150 | secretSvc: secretSvc, | ||
| 143 | logSvc: logSvc, | 151 | logSvc: logSvc, | ||
| @@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 207 | {"/deploy", h.handleDeploy}, | 215 | {"/deploy", h.handleDeploy}, | ||
| 208 | {"/restart", h.handleRestart}, | 216 | {"/restart", h.handleRestart}, | ||
| 209 | {"/tags", h.handleTags}, | 217 | {"/tags", h.handleTags}, | ||
| 218 | + | {"/images", h.handleImages}, | |||
| 210 | {"/logs", h.handleLogs}, | 219 | {"/logs", h.handleLogs}, | ||
| 211 | } | 220 | } | ||
| 212 | for _, route := range prefixRoutes { | 221 | for _, route := range prefixRoutes { | ||
M internal/adapters/in/http/admin/handler_test.go +249 -0
| @@ -4,10 +4,12 @@ import ( | |||||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "encoding/json" | 6 | "encoding/json" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 10 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 11 | 13 | ||||
| 12 | "github.com/bnema/zerowrap" | 14 | "github.com/bnema/zerowrap" | ||
| 13 | "github.com/stretchr/testify/assert" | 15 | "github.com/stretchr/testify/assert" | ||
| @@ -17,6 +19,25 @@ import ( | |||||
| 17 | "github.com/bnema/gordon/internal/domain" | 19 | "github.com/bnema/gordon/internal/domain" | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 22 | + | type stubImageService struct { | |||
| 23 | + | listImagesFunc func(context.Context) ([]domain.ImageInfo, error) | |||
| 24 | + | pruneFunc func(context.Context, int) (domain.ImagePruneReport, error) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||
| 28 | + | if s.listImagesFunc == nil { | |||
| 29 | + | return nil, nil | |||
| 30 | + | } | |||
| 31 | + | return s.listImagesFunc(ctx) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |||
| 35 | + | if s.pruneFunc == nil { | |||
| 36 | + | return domain.ImagePruneReport{}, nil | |||
| 37 | + | } | |||
| 38 | + | return s.pruneFunc(ctx, keepLast) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 20 | func testLogger() zerowrap.Logger { | 41 | func testLogger() zerowrap.Logger { | ||
| 21 | return zerowrap.Default() | 42 | return zerowrap.Default() | ||
| 22 | } | 43 | } | ||
| @@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) { | |||||
| 1290 | assert.Equal(t, http.StatusOK, rec.Code) | 1311 | assert.Equal(t, http.StatusOK, rec.Code) | ||
| 1291 | assert.Contains(t, rec.Body.String(), "postgres") | 1312 | assert.Contains(t, rec.Body.String(), "postgres") | ||
| 1292 | } | 1313 | } | ||
| 1314 | + | ||||
| 1315 | + | func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { | |||
| 1316 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1317 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1318 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1319 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1320 | + | ||||
| 1321 | + | createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC) | |||
| 1322 | + | imageSvc := &stubImageService{ | |||
| 1323 | + | listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { | |||
| 1324 | + | return []domain.ImageInfo{ | |||
| 1325 | + | { | |||
| 1326 | + | Repository: "myapp", | |||
| 1327 | + | Tag: "latest", | |||
| 1328 | + | Size: 1024, | |||
| 1329 | + | Created: createdAt, | |||
| 1330 | + | ID: "sha256:abc123", | |||
| 1331 | + | Dangling: false, | |||
| 1332 | + | }, | |||
| 1333 | + | { | |||
| 1334 | + | Repository: "", | |||
| 1335 | + | Tag: "", | |||
| 1336 | + | Size: 512, | |||
| 1337 | + | Created: createdAt, | |||
| 1338 | + | ID: "sha256:def456", | |||
| 1339 | + | Dangling: true, | |||
| 1340 | + | }, | |||
| 1341 | + | }, nil | |||
| 1342 | + | }, | |||
| 1343 | + | } | |||
| 1344 | + | ||||
| 1345 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |||
| 1346 | + | ||||
| 1347 | + | req := httptest.NewRequest("GET", "/admin/images", nil) | |||
| 1348 | + | req = req.WithContext(ctxWithScopes("admin:status:read")) | |||
| 1349 | + | rec := httptest.NewRecorder() | |||
| 1350 | + | ||||
| 1351 | + | handler.ServeHTTP(rec, req) | |||
| 1352 | + | ||||
| 1353 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 1354 | + | ||||
| 1355 | + | var response struct { | |||
| 1356 | + | Images []struct { | |||
| 1357 | + | Repository string `json:"repository"` | |||
| 1358 | + | Tag string `json:"tag"` | |||
| 1359 | + | Size int64 `json:"size"` | |||
| 1360 | + | Created time.Time `json:"created"` | |||
| 1361 | + | ID string `json:"id"` | |||
| 1362 | + | Dangling bool `json:"dangling"` | |||
| 1363 | + | } `json:"images"` | |||
| 1364 | + | } | |||
| 1365 | + | ||||
| 1366 | + | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |||
| 1367 | + | assert.Len(t, response.Images, 2) | |||
| 1368 | + | assert.Equal(t, "myapp", response.Images[0].Repository) | |||
| 1369 | + | assert.Equal(t, "latest", response.Images[0].Tag) | |||
| 1370 | + | assert.Equal(t, int64(1024), response.Images[0].Size) | |||
| 1371 | + | assert.Equal(t, createdAt, response.Images[0].Created) | |||
| 1372 | + | assert.Equal(t, "sha256:abc123", response.Images[0].ID) | |||
| 1373 | + | assert.False(t, response.Images[0].Dangling) | |||
| 1374 | + | assert.True(t, response.Images[1].Dangling) | |||
| 1375 | + | } | |||
| 1376 | + | ||||
| 1377 | + | func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { | |||
| 1378 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1379 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1380 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1381 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1382 | + | ||||
| 1383 | + | tests := []struct { | |||
| 1384 | + | name string | |||
| 1385 | + | body string | |||
| 1386 | + | expectedKeepLast int | |||
| 1387 | + | }{ | |||
| 1388 | + | {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast}, | |||
| 1389 | + | {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3}, | |||
| 1390 | + | } | |||
| 1391 | + | ||||
| 1392 | + | for _, tt := range tests { | |||
| 1393 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 1394 | + | called := false | |||
| 1395 | + | imageSvc := &stubImageService{ | |||
| 1396 | + | pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) { | |||
| 1397 | + | called = true | |||
| 1398 | + | assert.Equal(t, tt.expectedKeepLast, keepLast) | |||
| 1399 | + | return domain.ImagePruneReport{ | |||
| 1400 | + | Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, | |||
| 1401 | + | Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, | |||
| 1402 | + | }, nil | |||
| 1403 | + | }, | |||
| 1404 | + | } | |||
| 1405 | + | ||||
| 1406 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |||
| 1407 | + | ||||
| 1408 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) | |||
| 1409 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1410 | + | rec := httptest.NewRecorder() | |||
| 1411 | + | ||||
| 1412 | + | handler.ServeHTTP(rec, req) | |||
| 1413 | + | ||||
| 1414 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 1415 | + | assert.True(t, called) | |||
| 1416 | + | assert.Contains(t, rec.Body.String(), "deleted_count") | |||
| 1417 | + | assert.Contains(t, rec.Body.String(), "tags_removed") | |||
| 1418 | + | }) | |||
| 1419 | + | } | |||
| 1420 | + | } | |||
| 1421 | + | ||||
| 1422 | + | func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { | |||
| 1423 | + | t.Run("list failure maps to internal server error", func(t *testing.T) { | |||
| 1424 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1425 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1426 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1427 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1428 | + | ||||
| 1429 | + | imageSvc := &stubImageService{ | |||
| 1430 | + | listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { | |||
| 1431 | + | return nil, errors.New("runtime list failed: confidential details") | |||
| 1432 | + | }, | |||
| 1433 | + | } | |||
| 1434 | + | ||||
| 1435 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |||
| 1436 | + | ||||
| 1437 | + | req := httptest.NewRequest("GET", "/admin/images", nil) | |||
| 1438 | + | req = req.WithContext(ctxWithScopes("admin:status:read")) | |||
| 1439 | + | rec := httptest.NewRecorder() | |||
| 1440 | + | ||||
| 1441 | + | handler.ServeHTTP(rec, req) | |||
| 1442 | + | ||||
| 1443 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 1444 | + | assert.Contains(t, rec.Body.String(), "failed to list images") | |||
| 1445 | + | assert.NotContains(t, rec.Body.String(), "confidential details") | |||
| 1446 | + | }) | |||
| 1447 | + | ||||
| 1448 | + | t.Run("prune failure maps to internal server error", func(t *testing.T) { | |||
| 1449 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1450 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1451 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1452 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1453 | + | ||||
| 1454 | + | imageSvc := &stubImageService{ | |||
| 1455 | + | pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) { | |||
| 1456 | + | return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") | |||
| 1457 | + | }, | |||
| 1458 | + | } | |||
| 1459 | + | ||||
| 1460 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc) | |||
| 1461 | + | ||||
| 1462 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`)) | |||
| 1463 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1464 | + | rec := httptest.NewRecorder() | |||
| 1465 | + | ||||
| 1466 | + | handler.ServeHTTP(rec, req) | |||
| 1467 | + | ||||
| 1468 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 1469 | + | assert.Contains(t, rec.Body.String(), "failed to prune images") | |||
| 1470 | + | assert.NotContains(t, rec.Body.String(), "confidential details") | |||
| 1471 | + | }) | |||
| 1472 | + | } | |||
| 1473 | + | ||||
| 1474 | + | func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { | |||
| 1475 | + | t.Run("rejects negative keep_last", func(t *testing.T) { | |||
| 1476 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1477 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1478 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1479 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1480 | + | ||||
| 1481 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{}) | |||
| 1482 | + | ||||
| 1483 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`)) | |||
| 1484 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1485 | + | rec := httptest.NewRecorder() | |||
| 1486 | + | ||||
| 1487 | + | handler.ServeHTTP(rec, req) | |||
| 1488 | + | ||||
| 1489 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 1490 | + | assert.Contains(t, rec.Body.String(), "keep_last must be") | |||
| 1491 | + | }) | |||
| 1492 | + | ||||
| 1493 | + | t.Run("returns service unavailable when image service missing", func(t *testing.T) { | |||
| 1494 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1495 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1496 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1497 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1498 | + | ||||
| 1499 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |||
| 1500 | + | ||||
| 1501 | + | req := httptest.NewRequest("GET", "/admin/images", nil) | |||
| 1502 | + | req = req.WithContext(ctxWithScopes("admin:status:read")) | |||
| 1503 | + | rec := httptest.NewRecorder() | |||
| 1504 | + | ||||
| 1505 | + | handler.ServeHTTP(rec, req) | |||
| 1506 | + | ||||
| 1507 | + | assert.Equal(t, http.StatusServiceUnavailable, rec.Code) | |||
| 1508 | + | assert.Contains(t, rec.Body.String(), "image service not available") | |||
| 1509 | + | }) | |||
| 1510 | + | } | |||
| 1511 | + | ||||
| 1512 | + | func TestHandler_Images_Authorization(t *testing.T) { | |||
| 1513 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1514 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1515 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1516 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1517 | + | ||||
| 1518 | + | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{}) | |||
| 1519 | + | ||||
| 1520 | + | t.Run("images list requires status:read", func(t *testing.T) { | |||
| 1521 | + | req := httptest.NewRequest("GET", "/admin/images", nil) | |||
| 1522 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1523 | + | rec := httptest.NewRecorder() | |||
| 1524 | + | ||||
| 1525 | + | handler.ServeHTTP(rec, req) | |||
| 1526 | + | ||||
| 1527 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 1528 | + | assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read") | |||
| 1529 | + | }) | |||
| 1530 | + | ||||
| 1531 | + | t.Run("images prune requires config:write", func(t *testing.T) { | |||
| 1532 | + | req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`)) | |||
| 1533 | + | req = req.WithContext(ctxWithScopes("admin:status:read")) | |||
| 1534 | + | rec := httptest.NewRecorder() | |||
| 1535 | + | ||||
| 1536 | + | handler.ServeHTTP(rec, req) | |||
| 1537 | + | ||||
| 1538 | + | assert.Equal(t, http.StatusForbidden, rec.Code) | |||
| 1539 | + | assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write") | |||
| 1540 | + | }) | |||
| 1541 | + | } | |||
A internal/adapters/in/http/admin/images.go +117 -0
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "net/http" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func toImageResponse(image domain.ImageInfo) dto.Image { | |||
| 16 | + | return dto.Image{ | |||
| 17 | + | Repository: image.Repository, | |||
| 18 | + | Tag: image.Tag, | |||
| 19 | + | Size: image.Size, | |||
| 20 | + | Created: image.Created, | |||
| 21 | + | ID: image.ID, | |||
| 22 | + | Dangling: image.Dangling, | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func toImagePruneResponse(report domain.ImagePruneReport) dto.ImagePruneResponse { | |||
| 27 | + | return dto.ImagePruneResponse{ | |||
| 28 | + | Runtime: dto.RuntimePruneResult{ | |||
| 29 | + | DeletedCount: report.Runtime.DeletedCount, | |||
| 30 | + | SpaceReclaimed: report.Runtime.SpaceReclaimed, | |||
| 31 | + | }, | |||
| 32 | + | Registry: dto.RegistryPruneResult{ | |||
| 33 | + | TagsRemoved: report.Registry.TagsRemoved, | |||
| 34 | + | BlobsRemoved: report.Registry.BlobsRemoved, | |||
| 35 | + | SpaceReclaimed: report.Registry.SpaceReclaimed, | |||
| 36 | + | }, | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) { | |||
| 41 | + | if path != "/images" && path != "/images/prune" { | |||
| 42 | + | h.sendError(w, http.StatusNotFound, "route not found") | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | if h.imageSvc == nil { | |||
| 47 | + | h.sendError(w, http.StatusServiceUnavailable, "image service not available") | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | switch { | |||
| 52 | + | case path == "/images" && r.Method == http.MethodGet: | |||
| 53 | + | h.handleImagesGet(w, r) | |||
| 54 | + | case path == "/images/prune" && r.Method == http.MethodPost: | |||
| 55 | + | h.handleImagesPrune(w, r) | |||
| 56 | + | default: | |||
| 57 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) { | |||
| 62 | + | ctx := r.Context() | |||
| 63 | + | ||||
| 64 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 65 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | images, err := h.imageSvc.ListImages(ctx) | |||
| 70 | + | if err != nil { | |||
| 71 | + | h.sendError(w, http.StatusInternalServerError, "failed to list images") | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | response := make([]dto.Image, 0, len(images)) | |||
| 76 | + | for _, image := range images { | |||
| 77 | + | response = append(response, toImageResponse(image)) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response}) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) { | |||
| 84 | + | ctx := r.Context() | |||
| 85 | + | log := zerowrap.FromCtx(ctx) | |||
| 86 | + | ||||
| 87 | + | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |||
| 88 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize) | |||
| 93 | + | var req dto.ImagePruneRequest | |||
| 94 | + | if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { | |||
| 95 | + | log.Warn().Err(err).Msg("invalid image prune JSON") | |||
| 96 | + | h.sendError(w, http.StatusBadRequest, "invalid JSON") | |||
| 97 | + | return | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | keepLast := domain.DefaultImagePruneKeepLast | |||
| 101 | + | if req.KeepLast != nil { | |||
| 102 | + | if *req.KeepLast < 0 { | |||
| 103 | + | h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0") | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | keepLast = *req.KeepLast | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | report, err := h.imageSvc.Prune(ctx, keepLast) | |||
| 110 | + | if err != nil { | |||
| 111 | + | log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed") | |||
| 112 | + | h.sendError(w, http.StatusInternalServerError, "failed to prune images") | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | h.sendJSON(w, http.StatusOK, toImagePruneResponse(report)) | |||
| 117 | + | } | |||
M internal/adapters/out/docker/runtime.go +102 -7
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "errors" | 10 | "errors" | ||
| 11 | "fmt" | 11 | "fmt" | ||
| 12 | "io" | 12 | "io" | ||
| 13 | + | "math" | |||
| 13 | "path/filepath" | 14 | "path/filepath" | ||
| 14 | "sort" | 15 | "sort" | ||
| 15 | "strconv" | 16 | "strconv" | ||
| @@ -20,6 +21,7 @@ import ( | |||||
| 20 | "github.com/bnema/zerowrap" | 21 | "github.com/bnema/zerowrap" | ||
| 21 | cerrdefs "github.com/containerd/errdefs" | 22 | cerrdefs "github.com/containerd/errdefs" | ||
| 22 | "github.com/docker/docker/api/types/container" | 23 | "github.com/docker/docker/api/types/container" | ||
| 24 | + | "github.com/docker/docker/api/types/filters" | |||
| 23 | "github.com/docker/docker/api/types/image" | 25 | "github.com/docker/docker/api/types/image" | ||
| 24 | "github.com/docker/docker/api/types/network" | 26 | "github.com/docker/docker/api/types/network" | ||
| 25 | "github.com/docker/docker/api/types/registry" | 27 | "github.com/docker/docker/api/types/registry" | ||
| @@ -30,6 +32,7 @@ import ( | |||||
| 30 | 32 | ||||
| 31 | "github.com/bnema/gordon/internal/boundaries/out" | 33 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 32 | "github.com/bnema/gordon/internal/domain" | 34 | "github.com/bnema/gordon/internal/domain" | ||
| 35 | + | runtimepkg "github.com/bnema/gordon/pkg/runtime" | |||
| 33 | ) | 36 | ) | ||
| 34 | 37 | ||||
| 35 | // Runtime implements the ContainerRuntime interface using Docker API. | 38 | // Runtime implements the ContainerRuntime interface using Docker API. | ||
| @@ -347,12 +350,13 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do | |||||
| 347 | name := strings.TrimPrefix(resp.Name, "/") | 350 | name := strings.TrimPrefix(resp.Name, "/") | ||
| 348 | 351 | ||||
| 349 | return &domain.Container{ | 352 | return &domain.Container{ | ||
| 350 | - | ID: resp.ID, | 353 | + | ID: resp.ID, |
| 351 | - | Image: resp.Config.Image, | 354 | + | Image: resp.Config.Image, |
| 352 | - | Name: name, | 355 | + | Name: name, |
| 353 | - | Status: resp.State.Status, | 356 | + | Status: resp.State.Status, |
| 354 | - | Ports: ports, | 357 | + | ExitCode: resp.State.ExitCode, |
| 355 | - | Labels: resp.Config.Labels, | 358 | + | Ports: ports, |
| 359 | + | Labels: resp.Config.Labels, | |||
| 356 | }, nil | 360 | }, nil | ||
| 357 | } | 361 | } | ||
| 358 | 362 | ||||
| @@ -555,6 +559,81 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) { | |||||
| 555 | return result, nil | 559 | return result, nil | ||
| 556 | } | 560 | } | ||
| 557 | 561 | ||||
| 562 | + | // ListImagesDetailed lists images with metadata. | |||
| 563 | + | func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) { | |||
| 564 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 565 | + | zerowrap.FieldLayer: "adapter", | |||
| 566 | + | zerowrap.FieldAdapter: "docker", | |||
| 567 | + | zerowrap.FieldAction: "ListImagesDetailed", | |||
| 568 | + | }) | |||
| 569 | + | log := zerowrap.FromCtx(ctx) | |||
| 570 | + | ||||
| 571 | + | images, err := r.client.ImageList(ctx, image.ListOptions{All: true}) | |||
| 572 | + | if err != nil { | |||
| 573 | + | return nil, log.WrapErr(err, "failed to list detailed images") | |||
| 574 | + | } | |||
| 575 | + | ||||
| 576 | + | result := make([]runtimepkg.ImageDetail, 0, len(images)) | |||
| 577 | + | for _, img := range images { | |||
| 578 | + | result = append(result, runtimepkg.ImageDetail{ | |||
| 579 | + | ID: img.ID, | |||
| 580 | + | RepoTags: img.RepoTags, | |||
| 581 | + | Size: img.Size, | |||
| 582 | + | Created: time.Unix(img.Created, 0), | |||
| 583 | + | }) | |||
| 584 | + | } | |||
| 585 | + | ||||
| 586 | + | return result, nil | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | // PruneImages prunes unused images and reports reclaimed space. | |||
| 590 | + | func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepkg.PruneReport, error) { | |||
| 591 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 592 | + | zerowrap.FieldLayer: "adapter", | |||
| 593 | + | zerowrap.FieldAdapter: "docker", | |||
| 594 | + | zerowrap.FieldAction: "PruneImages", | |||
| 595 | + | "dangling_only": danglingOnly, | |||
| 596 | + | }) | |||
| 597 | + | log := zerowrap.FromCtx(ctx) | |||
| 598 | + | ||||
| 599 | + | pruneFilters := filters.NewArgs() | |||
| 600 | + | if danglingOnly { | |||
| 601 | + | pruneFilters.Add("dangling", "true") | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters) | |||
| 605 | + | if err != nil { | |||
| 606 | + | return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images") | |||
| 607 | + | } | |||
| 608 | + | ||||
| 609 | + | deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted)) | |||
| 610 | + | for _, deleted := range pruneResult.ImagesDeleted { | |||
| 611 | + | if deleted.Deleted != "" { | |||
| 612 | + | deletedIDs = append(deletedIDs, deleted.Deleted) | |||
| 613 | + | } | |||
| 614 | + | if deleted.Untagged != "" { | |||
| 615 | + | deletedIDs = append(deletedIDs, deleted.Untagged) | |||
| 616 | + | } | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | spaceReclaimed := pruneResult.SpaceReclaimed | |||
| 620 | + | if spaceReclaimed > math.MaxInt64 { | |||
| 621 | + | log.Warn(). | |||
| 622 | + | Uint64("space_reclaimed_bytes", spaceReclaimed). | |||
| 623 | + | Int64("space_reclaimed_capped_bytes", math.MaxInt64). | |||
| 624 | + | Msg("space reclaimed exceeds int64 max; capping value") | |||
| 625 | + | spaceReclaimed = uint64(math.MaxInt64) | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | //nolint:gosec // spaceReclaimed is capped to MaxInt64 above | |||
| 629 | + | spaceReclaimedInt := int64(spaceReclaimed) | |||
| 630 | + | ||||
| 631 | + | return runtimepkg.PruneReport{ | |||
| 632 | + | DeletedIDs: deletedIDs, | |||
| 633 | + | SpaceReclaimed: spaceReclaimedInt, | |||
| 634 | + | }, nil | |||
| 635 | + | } | |||
| 636 | + | ||||
| 558 | // Ping checks if Docker is responsive. | 637 | // Ping checks if Docker is responsive. | ||
| 559 | func (r *Runtime) Ping(ctx context.Context) error { | 638 | func (r *Runtime) Ping(ctx context.Context) error { | ||
| 560 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 639 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| @@ -610,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri | |||||
| 610 | if err != nil { | 689 | if err != nil { | ||
| 611 | return "", false, log.WrapErr(err, "failed to inspect container") | 690 | return "", false, log.WrapErr(err, "failed to inspect container") | ||
| 612 | } | 691 | } | ||
| 613 | - | if resp.State == nil || resp.State.Health == nil { | 692 | + | |
| 693 | + | // Detect configured healthchecks from container config rather than relying on | |||
| 694 | + | // State.Health presence. Some runtimes can expose an empty State.Health struct | |||
| 695 | + | // even when no healthcheck is configured. | |||
| 696 | + | hasHealthcheck := hasConfiguredHealthcheck(resp.Config) | |||
| 697 | + | if !hasHealthcheck { | |||
| 614 | return "", false, nil | 698 | return "", false, nil | ||
| 615 | } | 699 | } | ||
| 700 | + | if resp.State == nil || resp.State.Health == nil { | |||
| 701 | + | return "", true, nil | |||
| 702 | + | } | |||
| 616 | 703 | ||||
| 617 | return resp.State.Health.Status, true, nil | 704 | return resp.State.Health.Status, true, nil | ||
| 618 | } | 705 | } | ||
| 619 | 706 | ||||
| 707 | + | func hasConfiguredHealthcheck(cfg *container.Config) bool { | |||
| 708 | + | if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 { | |||
| 709 | + | return false | |||
| 710 | + | } | |||
| 711 | + | // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images. | |||
| 712 | + | return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE") | |||
| 713 | + | } | |||
| 714 | + | ||||
| 620 | // GetContainerPort gets the host port for a container's internal port. | 715 | // GetContainerPort gets the host port for a container's internal port. | ||
| 621 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | 716 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | ||
| 622 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 717 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
A internal/adapters/out/docker/runtime_health_status_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package docker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net/http" | |||
| 6 | + | "net/http/httptest" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestRuntime_GetContainerHealthStatus_ConfiguredHealthcheck(t *testing.T) { | |||
| 14 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 15 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 16 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |||
| 17 | + | ||||
| 18 | + | w.Header().Set("Content-Type", "application/json") | |||
| 19 | + | _, _ = w.Write([]byte(`{ | |||
| 20 | + | "Id":"test-container", | |||
| 21 | + | "State":{"Status":"running","Health":{"Status":"healthy","FailingStreak":0,"Log":[]}}, | |||
| 22 | + | "Config":{"Healthcheck":{"Test":["CMD-SHELL","curl -f http://localhost/health || exit 1"]}} | |||
| 23 | + | }`)) | |||
| 24 | + | })) | |||
| 25 | + | defer server.Close() | |||
| 26 | + | ||||
| 27 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 28 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |||
| 29 | + | ||||
| 30 | + | require.NoError(t, err) | |||
| 31 | + | assert.True(t, hasHealthcheck) | |||
| 32 | + | assert.Equal(t, "healthy", status) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestRuntime_GetContainerHealthStatus_NoConfiguredHealthcheckWithStateHealthObject(t *testing.T) { | |||
| 36 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 37 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 38 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |||
| 39 | + | ||||
| 40 | + | w.Header().Set("Content-Type", "application/json") | |||
| 41 | + | _, _ = w.Write([]byte(`{ | |||
| 42 | + | "Id":"test-container", | |||
| 43 | + | "State":{"Status":"running","Health":{"Status":"","FailingStreak":0,"Log":null}}, | |||
| 44 | + | "Config":{"Healthcheck":null} | |||
| 45 | + | }`)) | |||
| 46 | + | })) | |||
| 47 | + | defer server.Close() | |||
| 48 | + | ||||
| 49 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 50 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |||
| 51 | + | ||||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | assert.False(t, hasHealthcheck) | |||
| 54 | + | assert.Equal(t, "", status) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) { | |||
| 58 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 59 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 60 | + | assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path) | |||
| 61 | + | ||||
| 62 | + | w.Header().Set("Content-Type", "application/json") | |||
| 63 | + | _, _ = w.Write([]byte(`{ | |||
| 64 | + | "Id":"test-container", | |||
| 65 | + | "State":{"Status":"running","Health":{"Status":"starting","FailingStreak":0,"Log":[]}}, | |||
| 66 | + | "Config":{"Healthcheck":{"Test":["NONE"]}} | |||
| 67 | + | }`)) | |||
| 68 | + | })) | |||
| 69 | + | defer server.Close() | |||
| 70 | + | ||||
| 71 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 72 | + | status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container") | |||
| 73 | + | ||||
| 74 | + | require.NoError(t, err) | |||
| 75 | + | assert.False(t, hasHealthcheck) | |||
| 76 | + | assert.Equal(t, "", status) | |||
| 77 | + | } | |||
A internal/adapters/out/docker/runtime_image_cleanup_test.go +190 -0
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package docker | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "math" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/docker/docker/api/types/filters" | |||
| 13 | + | "github.com/docker/docker/client" | |||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | func TestRuntime_PruneImages_DanglingOnlyFilter(t *testing.T) { | |||
| 19 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 20 | + | assert.Equal(t, http.MethodPost, r.Method) | |||
| 21 | + | assert.Equal(t, "/v1.41/images/prune", r.URL.Path) | |||
| 22 | + | ||||
| 23 | + | parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters")) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | assert.Equal(t, []string{"true"}, parsedFilters.Get("dangling")) | |||
| 26 | + | ||||
| 27 | + | w.Header().Set("Content-Type", "application/json") | |||
| 28 | + | _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`)) | |||
| 29 | + | })) | |||
| 30 | + | defer server.Close() | |||
| 31 | + | ||||
| 32 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 33 | + | report, err := runtime.PruneImages(context.Background(), true) | |||
| 34 | + | ||||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | assert.Equal(t, []string{"sha256:abc", "example:old"}, report.DeletedIDs) | |||
| 37 | + | assert.EqualValues(t, 1234, report.SpaceReclaimed) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestRuntime_PruneImages_FullUnusedHasNoDanglingFilter(t *testing.T) { | |||
| 41 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 42 | + | assert.Equal(t, http.MethodPost, r.Method) | |||
| 43 | + | assert.Equal(t, "/v1.41/images/prune", r.URL.Path) | |||
| 44 | + | ||||
| 45 | + | parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters")) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | assert.Empty(t, parsedFilters.Get("dangling")) | |||
| 48 | + | ||||
| 49 | + | w.Header().Set("Content-Type", "application/json") | |||
| 50 | + | _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`)) | |||
| 51 | + | })) | |||
| 52 | + | defer server.Close() | |||
| 53 | + | ||||
| 54 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 55 | + | report, err := runtime.PruneImages(context.Background(), false) | |||
| 56 | + | ||||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | assert.Equal(t, []string{"sha256:def"}, report.DeletedIDs) | |||
| 59 | + | assert.EqualValues(t, 5678, report.SpaceReclaimed) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func TestRuntime_PruneImages_ReturnsErrorOnNon2xxResponse(t *testing.T) { | |||
| 63 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 64 | + | assert.Equal(t, http.MethodPost, r.Method) | |||
| 65 | + | assert.Equal(t, "/v1.41/images/prune", r.URL.Path) | |||
| 66 | + | ||||
| 67 | + | w.WriteHeader(http.StatusInternalServerError) | |||
| 68 | + | _, _ = w.Write([]byte(`{"message":"internal error"}`)) | |||
| 69 | + | })) | |||
| 70 | + | defer server.Close() | |||
| 71 | + | ||||
| 72 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 73 | + | _, err := runtime.PruneImages(context.Background(), true) | |||
| 74 | + | ||||
| 75 | + | require.Error(t, err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestRuntime_PruneImages_ReturnsErrorOnInvalidPayload(t *testing.T) { | |||
| 79 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 80 | + | assert.Equal(t, http.MethodPost, r.Method) | |||
| 81 | + | assert.Equal(t, "/v1.41/images/prune", r.URL.Path) | |||
| 82 | + | ||||
| 83 | + | w.Header().Set("Content-Type", "application/json") | |||
| 84 | + | _, _ = w.Write([]byte(`not-json`)) | |||
| 85 | + | })) | |||
| 86 | + | defer server.Close() | |||
| 87 | + | ||||
| 88 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 89 | + | _, err := runtime.PruneImages(context.Background(), true) | |||
| 90 | + | ||||
| 91 | + | require.Error(t, err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestRuntime_PruneImages_CapsSpaceReclaimedOverflow(t *testing.T) { | |||
| 95 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 96 | + | assert.Equal(t, http.MethodPost, r.Method) | |||
| 97 | + | assert.Equal(t, "/v1.41/images/prune", r.URL.Path) | |||
| 98 | + | ||||
| 99 | + | w.Header().Set("Content-Type", "application/json") | |||
| 100 | + | _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775808}`)) | |||
| 101 | + | })) | |||
| 102 | + | defer server.Close() | |||
| 103 | + | ||||
| 104 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 105 | + | report, err := runtime.PruneImages(context.Background(), true) | |||
| 106 | + | ||||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | assert.EqualValues(t, math.MaxInt64, report.SpaceReclaimed) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestRuntime_ListImagesDetailed_MapsImageSummary(t *testing.T) { | |||
| 112 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 113 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 114 | + | assert.Equal(t, "/v1.41/images/json", r.URL.Path) | |||
| 115 | + | assert.Equal(t, "1", r.URL.Query().Get("all")) | |||
| 116 | + | ||||
| 117 | + | w.Header().Set("Content-Type", "application/json") | |||
| 118 | + | _, _ = w.Write([]byte(`[{"Id":"sha256:img1","RepoTags":["alpine:latest"],"Size":321,"Created":1700000000}]`)) | |||
| 119 | + | })) | |||
| 120 | + | defer server.Close() | |||
| 121 | + | ||||
| 122 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 123 | + | images, err := runtime.ListImagesDetailed(context.Background()) | |||
| 124 | + | ||||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | require.Len(t, images, 1) | |||
| 127 | + | assert.Equal(t, "sha256:img1", images[0].ID) | |||
| 128 | + | assert.Equal(t, []string{"alpine:latest"}, images[0].RepoTags) | |||
| 129 | + | assert.EqualValues(t, 321, images[0].Size) | |||
| 130 | + | assert.Equal(t, time.Unix(1700000000, 0), images[0].Created) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestRuntime_ListImagesDetailed_ReturnsErrorOnNon2xxResponse(t *testing.T) { | |||
| 134 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 135 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 136 | + | assert.Equal(t, "/v1.41/images/json", r.URL.Path) | |||
| 137 | + | ||||
| 138 | + | w.WriteHeader(http.StatusBadGateway) | |||
| 139 | + | _, _ = w.Write([]byte(`{"message":"upstream error"}`)) | |||
| 140 | + | })) | |||
| 141 | + | defer server.Close() | |||
| 142 | + | ||||
| 143 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 144 | + | _, err := runtime.ListImagesDetailed(context.Background()) | |||
| 145 | + | ||||
| 146 | + | require.Error(t, err) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestRuntime_ListImagesDetailed_ReturnsErrorOnInvalidPayload(t *testing.T) { | |||
| 150 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 151 | + | assert.Equal(t, http.MethodGet, r.Method) | |||
| 152 | + | assert.Equal(t, "/v1.41/images/json", r.URL.Path) | |||
| 153 | + | ||||
| 154 | + | w.Header().Set("Content-Type", "application/json") | |||
| 155 | + | _, _ = w.Write([]byte(`{"not":`)) | |||
| 156 | + | })) | |||
| 157 | + | defer server.Close() | |||
| 158 | + | ||||
| 159 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 160 | + | _, err := runtime.ListImagesDetailed(context.Background()) | |||
| 161 | + | ||||
| 162 | + | require.Error(t, err) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestRuntime_PruneImages_UsesMaxInt64Boundary(t *testing.T) { | |||
| 166 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 167 | + | assert.Equal(t, http.MethodPost, r.Method) | |||
| 168 | + | assert.Equal(t, "/v1.41/images/prune", r.URL.Path) | |||
| 169 | + | ||||
| 170 | + | w.Header().Set("Content-Type", "application/json") | |||
| 171 | + | _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775807}`)) | |||
| 172 | + | })) | |||
| 173 | + | defer server.Close() | |||
| 174 | + | ||||
| 175 | + | runtime := newRuntimeForHTTPServer(t, server) | |||
| 176 | + | report, err := runtime.PruneImages(context.Background(), true) | |||
| 177 | + | ||||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | assert.EqualValues(t, math.MaxInt64, report.SpaceReclaimed) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func newRuntimeForHTTPServer(t *testing.T, server *httptest.Server) *Runtime { | |||
| 183 | + | t.Helper() | |||
| 184 | + | ||||
| 185 | + | host := strings.TrimPrefix(server.URL, "http://") | |||
| 186 | + | cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client())) | |||
| 187 | + | require.NoError(t, err) | |||
| 188 | + | ||||
| 189 | + | return NewRuntimeWithClient(cli) | |||
| 190 | + | } | |||
M internal/adapters/out/filesystem/backup_storage.go +13 -0
| @@ -27,6 +27,8 @@ type BackupStorage struct { | |||||
| 27 | 27 | ||||
| 28 | // NewBackupStorage creates a new filesystem backup storage. | 28 | // NewBackupStorage creates a new filesystem backup storage. | ||
| 29 | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | 29 | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | ||
| 30 | + | rootDir = expandTilde(rootDir) | |||
| 31 | + | ||||
| 30 | if err := os.MkdirAll(rootDir, 0750); err != nil { | 32 | if err := os.MkdirAll(rootDir, 0750); err != nil { | ||
| 31 | return nil, fmt.Errorf("failed to create backup directory: %w", err) | 33 | return nil, fmt.Errorf("failed to create backup directory: %w", err) | ||
| 32 | } | 34 | } | ||
| @@ -34,6 +36,17 @@ func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, erro | |||||
| 34 | return &BackupStorage{rootDir: rootDir, log: log}, nil | 36 | return &BackupStorage{rootDir: rootDir, log: log}, nil | ||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| 39 | + | // expandTilde replaces a leading "~/" with the user's home directory. | |||
| 40 | + | func expandTilde(path string) string { | |||
| 41 | + | if !strings.HasPrefix(path, "~/") { | |||
| 42 | + | return path | |||
| 43 | + | } | |||
| 44 | + | if home, err := os.UserHomeDir(); err == nil { | |||
| 45 | + | return filepath.Join(home, path[2:]) | |||
| 46 | + | } | |||
| 47 | + | return path | |||
| 48 | + | } | |||
| 49 | + | ||||
| 37 | // Store saves backup data and returns the absolute storage path. | 50 | // 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) { | 51 | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | ||
| 39 | domainPart := sanitizeBackupPathComponent(domainName) | 52 | domainPart := sanitizeBackupPathComponent(domainName) | ||
M internal/adapters/out/filesystem/blob.go +46 -0
| @@ -165,6 +165,52 @@ func (s *BlobStorage) BlobExists(digest string) bool { | |||||
| 165 | return err == nil | 165 | return err == nil | ||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | + | // ListBlobs returns all blob digests. | |||
| 169 | + | func (s *BlobStorage) ListBlobs() ([]string, error) { | |||
| 170 | + | blobsDir := filepath.Join(s.rootDir, "blobs") | |||
| 171 | + | ||||
| 172 | + | if _, err := os.Stat(blobsDir); err != nil { | |||
| 173 | + | if os.IsNotExist(err) { | |||
| 174 | + | return []string{}, nil | |||
| 175 | + | } | |||
| 176 | + | return nil, fmt.Errorf("failed to access blobs directory: %w", err) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | digests := make([]string, 0) | |||
| 180 | + | err := filepath.Walk(blobsDir, func(path string, info os.FileInfo, err error) error { | |||
| 181 | + | if err != nil { | |||
| 182 | + | return err | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | if info.IsDir() { | |||
| 186 | + | return nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | relPath, err := filepath.Rel(blobsDir, path) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | parts := strings.Split(relPath, string(filepath.Separator)) | |||
| 195 | + | if len(parts) < 2 { | |||
| 196 | + | return nil | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | digest := parts[0] + ":" + parts[len(parts)-1] | |||
| 200 | + | if err := validation.ValidateDigest(digest); err != nil { | |||
| 201 | + | return nil | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | digests = append(digests, digest) | |||
| 205 | + | return nil | |||
| 206 | + | }) | |||
| 207 | + | if err != nil { | |||
| 208 | + | return nil, fmt.Errorf("failed to list blobs: %w", err) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | return digests, nil | |||
| 212 | + | } | |||
| 213 | + | ||||
| 168 | // StartBlobUpload starts a new blob upload and returns the upload UUID. | 214 | // StartBlobUpload starts a new blob upload and returns the upload UUID. | ||
| 169 | func (s *BlobStorage) StartBlobUpload(name string) (string, error) { | 215 | func (s *BlobStorage) StartBlobUpload(name string) (string, error) { | ||
| 170 | uploadID := uuid.New().String() | 216 | uploadID := uuid.New().String() | ||
M internal/adapters/out/filesystem/blob_test.go +33 -0
| @@ -211,6 +211,39 @@ func TestBlobStorage_BlobExists(t *testing.T) { | |||||
| 211 | assert.True(t, storage.BlobExists(testDigest2)) | 211 | assert.True(t, storage.BlobExists(testDigest2)) | ||
| 212 | } | 212 | } | ||
| 213 | 213 | ||||
| 214 | + | func TestBlobStorage_ListBlobs(t *testing.T) { | |||
| 215 | + | tmpDir := t.TempDir() | |||
| 216 | + | log := testLogger() | |||
| 217 | + | ||||
| 218 | + | storage, err := NewBlobStorage(tmpDir, log) | |||
| 219 | + | require.NoError(t, err) | |||
| 220 | + | ||||
| 221 | + | blobData := []byte("test blob content") | |||
| 222 | + | ||||
| 223 | + | err = storage.PutBlob(testDigest1, bytes.NewReader(blobData), int64(len(blobData))) | |||
| 224 | + | require.NoError(t, err) | |||
| 225 | + | ||||
| 226 | + | err = storage.PutBlob(testDigest2, bytes.NewReader(blobData), int64(len(blobData))) | |||
| 227 | + | require.NoError(t, err) | |||
| 228 | + | ||||
| 229 | + | digests, err := storage.ListBlobs() | |||
| 230 | + | require.NoError(t, err) | |||
| 231 | + | ||||
| 232 | + | assert.ElementsMatch(t, []string{testDigest1, testDigest2}, digests) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | func TestBlobStorage_ListBlobs_Empty(t *testing.T) { | |||
| 236 | + | tmpDir := t.TempDir() | |||
| 237 | + | log := testLogger() | |||
| 238 | + | ||||
| 239 | + | storage, err := NewBlobStorage(tmpDir, log) | |||
| 240 | + | require.NoError(t, err) | |||
| 241 | + | ||||
| 242 | + | digests, err := storage.ListBlobs() | |||
| 243 | + | require.NoError(t, err) | |||
| 244 | + | assert.Empty(t, digests) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 214 | func TestBlobStorage_StartBlobUpload(t *testing.T) { | 247 | func TestBlobStorage_StartBlobUpload(t *testing.T) { | ||
| 215 | tmpDir := t.TempDir() | 248 | tmpDir := t.TempDir() | ||
| 216 | log := testLogger() | 249 | log := testLogger() | ||
M internal/adapters/out/filesystem/manifest.go +19 -0
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| 10 | 11 | ||||
| @@ -222,6 +223,24 @@ func (s *ManifestStorage) ListRepositories() ([]string, error) { | |||||
| 222 | return repositories, nil | 223 | return repositories, nil | ||
| 223 | } | 224 | } | ||
| 224 | 225 | ||||
| 226 | + | // GetManifestModTime returns the manifest modification time. | |||
| 227 | + | func (s *ManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) { | |||
| 228 | + | manifestPath, err := s.getManifestPath(name, reference) | |||
| 229 | + | if err != nil { | |||
| 230 | + | return time.Time{}, fmt.Errorf("invalid path: %w", err) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | info, err := os.Stat(manifestPath) | |||
| 234 | + | if err != nil { | |||
| 235 | + | if os.IsNotExist(err) { | |||
| 236 | + | return time.Time{}, fmt.Errorf("manifest not found: %s/%s", name, reference) | |||
| 237 | + | } | |||
| 238 | + | return time.Time{}, fmt.Errorf("failed to stat manifest: %w", err) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | return info.ModTime(), nil | |||
| 242 | + | } | |||
| 243 | + | ||||
| 225 | // Helper methods for path generation with security validation | 244 | // Helper methods for path generation with security validation | ||
| 226 | 245 | ||||
| 227 | func (s *ManifestStorage) getManifestPath(name, reference string) (string, error) { | 246 | func (s *ManifestStorage) getManifestPath(name, reference string) (string, error) { | ||
M internal/adapters/out/filesystem/manifest_test.go +37 -0
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| @@ -67,6 +68,42 @@ func TestManifestStorage_GetManifest_NotFound(t *testing.T) { | |||||
| 67 | assert.Contains(t, err.Error(), "manifest not found") | 68 | assert.Contains(t, err.Error(), "manifest not found") | ||
| 68 | } | 69 | } | ||
| 69 | 70 | ||||
| 71 | + | func TestManifestStorage_GetManifestModTime(t *testing.T) { | |||
| 72 | + | tmpDir := t.TempDir() | |||
| 73 | + | log := testLogger() | |||
| 74 | + | ||||
| 75 | + | storage, err := NewManifestStorage(tmpDir, log) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | ||||
| 78 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |||
| 79 | + | contentType := "application/vnd.docker.distribution.manifest.v2+json" | |||
| 80 | + | ||||
| 81 | + | err = storage.PutManifest("myapp", "latest", contentType, manifestData) | |||
| 82 | + | require.NoError(t, err) | |||
| 83 | + | ||||
| 84 | + | modTime, err := storage.GetManifestModTime("myapp", "latest") | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | ||||
| 87 | + | manifestPath := filepath.Join(tmpDir, "repositories", "myapp", "manifests", "latest") | |||
| 88 | + | info, err := os.Stat(manifestPath) | |||
| 89 | + | require.NoError(t, err) | |||
| 90 | + | ||||
| 91 | + | assert.WithinDuration(t, info.ModTime(), modTime, time.Second) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestManifestStorage_GetManifestModTime_NotFound(t *testing.T) { | |||
| 95 | + | tmpDir := t.TempDir() | |||
| 96 | + | log := testLogger() | |||
| 97 | + | ||||
| 98 | + | storage, err := NewManifestStorage(tmpDir, log) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | ||||
| 101 | + | _, err = storage.GetManifestModTime("notexists", "latest") | |||
| 102 | + | ||||
| 103 | + | assert.Error(t, err) | |||
| 104 | + | assert.Contains(t, err.Error(), "manifest not found") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 70 | func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) { | 107 | func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) { | ||
| 71 | tmpDir := t.TempDir() | 108 | tmpDir := t.TempDir() | ||
| 72 | log := testLogger() | 109 | log := testLogger() | ||
M internal/app/run.go +209 -14
| @@ -58,7 +58,9 @@ import ( | |||||
| 58 | "github.com/bnema/gordon/internal/usecase/backup" | 58 | "github.com/bnema/gordon/internal/usecase/backup" | ||
| 59 | "github.com/bnema/gordon/internal/usecase/config" | 59 | "github.com/bnema/gordon/internal/usecase/config" | ||
| 60 | "github.com/bnema/gordon/internal/usecase/container" | 60 | "github.com/bnema/gordon/internal/usecase/container" | ||
| 61 | + | cronSvc "github.com/bnema/gordon/internal/usecase/cron" | |||
| 61 | "github.com/bnema/gordon/internal/usecase/health" | 62 | "github.com/bnema/gordon/internal/usecase/health" | ||
| 63 | + | "github.com/bnema/gordon/internal/usecase/images" | |||
| 62 | "github.com/bnema/gordon/internal/usecase/logs" | 64 | "github.com/bnema/gordon/internal/usecase/logs" | ||
| 63 | "github.com/bnema/gordon/internal/usecase/proxy" | 65 | "github.com/bnema/gordon/internal/usecase/proxy" | ||
| 64 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | 66 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | ||
| @@ -134,6 +136,7 @@ type Config struct { | |||||
| 134 | 136 | ||||
| 135 | Backups struct { | 137 | Backups struct { | ||
| 136 | Enabled bool `mapstructure:"enabled"` | 138 | Enabled bool `mapstructure:"enabled"` | ||
| 139 | + | Schedule string `mapstructure:"schedule"` | |||
| 137 | StorageDir string `mapstructure:"storage_dir"` | 140 | StorageDir string `mapstructure:"storage_dir"` | ||
| 138 | Retention struct { | 141 | Retention struct { | ||
| 139 | Hourly int `mapstructure:"hourly"` | 142 | Hourly int `mapstructure:"hourly"` | ||
| @@ -142,6 +145,14 @@ type Config struct { | |||||
| 142 | Monthly int `mapstructure:"monthly"` | 145 | Monthly int `mapstructure:"monthly"` | ||
| 143 | } `mapstructure:"retention"` | 146 | } `mapstructure:"retention"` | ||
| 144 | } `mapstructure:"backups"` | 147 | } `mapstructure:"backups"` | ||
| 148 | + | ||||
| 149 | + | Images struct { | |||
| 150 | + | Prune struct { | |||
| 151 | + | Enabled bool `mapstructure:"enabled"` | |||
| 152 | + | Schedule string `mapstructure:"schedule"` | |||
| 153 | + | KeepLast int `mapstructure:"keep_last"` | |||
| 154 | + | } `mapstructure:"prune"` | |||
| 155 | + | } `mapstructure:"images"` | |||
| 145 | } | 156 | } | ||
| 146 | 157 | ||||
| 147 | // services holds all the services used by the application. | 158 | // services holds all the services used by the application. | ||
| @@ -161,6 +172,7 @@ type services struct { | |||||
| 161 | registrySvc *registrySvc.Service | 172 | registrySvc *registrySvc.Service | ||
| 162 | healthSvc *health.Service | 173 | healthSvc *health.Service | ||
| 163 | logSvc *logs.Service | 174 | logSvc *logs.Service | ||
| 175 | + | imageSvc *images.Service | |||
| 164 | proxySvc *proxy.Service | 176 | proxySvc *proxy.Service | ||
| 165 | authSvc *auth.Service | 177 | authSvc *auth.Service | ||
| 166 | authHandler *authhandler.Handler | 178 | authHandler *authhandler.Handler | ||
| @@ -225,7 +237,7 @@ func Run(ctx context.Context, configPath string) error { | |||||
| 225 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | 237 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | ||
| 226 | 238 | ||||
| 227 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | 239 | // Start servers, wait for listeners to bind, then sync/auto-start containers. | ||
| 228 | - | return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log) | 240 | + | return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log) |
| 229 | } | 241 | } | ||
| 230 | 242 | ||||
| 231 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | 243 | func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error { | ||
| @@ -456,6 +468,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 456 | } | 468 | } | ||
| 457 | 469 | ||||
| 458 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | 470 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | ||
| 471 | + | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | |||
| 459 | 472 | ||||
| 460 | proxyCfg, err := buildProxyConfig(cfg, log) | 473 | proxyCfg, err := buildProxyConfig(cfg, log) | ||
| 461 | if err != nil { | 474 | if err != nil { | ||
| @@ -483,10 +496,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 483 | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | 496 | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | ||
| 484 | 497 | ||||
| 485 | // Create log service for accessing logs via admin API | 498 | // Create log service for accessing logs via admin API | ||
| 486 | - | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log) | 499 | + | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) |
| 487 | 500 | ||||
| 488 | // Create admin handler for admin API | 501 | // Create admin handler for admin API | ||
| 489 | - | svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.healthSvc, svc.secretSvc, svc.logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc) | 502 | + | svc.adminHandler = admin.NewHandler( |
| 503 | + | svc.configSvc, | |||
| 504 | + | svc.authSvc, | |||
| 505 | + | svc.containerSvc, | |||
| 506 | + | svc.healthSvc, | |||
| 507 | + | svc.secretSvc, | |||
| 508 | + | svc.logSvc, | |||
| 509 | + | svc.registrySvc, | |||
| 510 | + | svc.eventBus, | |||
| 511 | + | log, | |||
| 512 | + | svc.backupSvc, | |||
| 513 | + | svc.imageSvc, | |||
| 514 | + | ) | |||
| 490 | 515 | ||||
| 491 | return svc, nil | 516 | return svc, nil | ||
| 492 | } | 517 | } | ||
| @@ -1247,6 +1272,9 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||||
| 1247 | log.Warn().Err(err).Msg("failed to auto-start containers") | 1272 | log.Warn().Err(err).Msg("failed to auto-start containers") | ||
| 1248 | } | 1273 | } | ||
| 1249 | } | 1274 | } | ||
| 1275 | + | ||||
| 1276 | + | // Start background monitor to restart crashed containers. | |||
| 1277 | + | svc.containerSvc.StartMonitor(ctx) | |||
| 1250 | } | 1278 | } | ||
| 1251 | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | 1279 | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | ||
| 1252 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 1280 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| @@ -1475,7 +1503,7 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf | |||||
| 1475 | // - SIGUSR2: Triggers manual deploy for a specific route | 1503 | // - SIGUSR2: Triggers manual deploy for a specific route | ||
| 1476 | // The deferred signal.Stop calls ensure signal handlers are properly | 1504 | // The deferred signal.Stop calls ensure signal handlers are properly | ||
| 1477 | // cleaned up before program exit, preventing signal handler leaks. | 1505 | // cleaned up before program exit, preventing signal handler leaks. | ||
| 1478 | - | func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error { | 1506 | + | func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error { |
| 1479 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | 1507 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | ||
| 1480 | defer cancel() | 1508 | defer cancel() | ||
| 1481 | 1509 | ||||
| @@ -1511,22 +1539,16 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||||
| 1511 | ) | 1539 | ) | ||
| 1512 | } | 1540 | } | ||
| 1513 | 1541 | ||||
| 1514 | - | // Wait for both servers to bind their ports before auto-starting containers. | 1542 | + | // Wait for all enabled servers to bind their ports before auto-starting containers. |
| 1515 | // This prevents the race where auto-start pulls from the registry before it's listening. | 1543 | // This prevents the race where auto-start pulls from the registry before it's listening. | ||
| 1516 | - | select { | 1544 | + | if err := waitForServerReady(registryReady, errChan); err != nil { |
| 1517 | - | case <-registryReady: | |||
| 1518 | - | case err := <-errChan: | |||
| 1519 | return err | 1545 | return err | ||
| 1520 | } | 1546 | } | ||
| 1521 | - | select { | 1547 | + | if err := waitForServerReady(proxyReady, errChan); err != nil { |
| 1522 | - | case <-proxyReady: | |||
| 1523 | - | case err := <-errChan: | |||
| 1524 | return err | 1548 | return err | ||
| 1525 | } | 1549 | } | ||
| 1526 | if cfg.Server.TLSEnabled { | 1550 | if cfg.Server.TLSEnabled { | ||
| 1527 | - | select { | 1551 | + | if err := waitForServerReady(tlsReady, errChan); err != nil { |
| 1528 | - | case <-tlsReady: | |||
| 1529 | - | case err := <-errChan: | |||
| 1530 | return err | 1552 | return err | ||
| 1531 | } | 1553 | } | ||
| 1532 | } | 1554 | } | ||
| @@ -1539,6 +1561,14 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||||
| 1539 | } | 1561 | } | ||
| 1540 | logEvent.Msg("Gordon is running") | 1562 | logEvent.Msg("Gordon is running") | ||
| 1541 | 1563 | ||||
| 1564 | + | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | |||
| 1565 | + | if err != nil { | |||
| 1566 | + | return err | |||
| 1567 | + | } | |||
| 1568 | + | if schedulerCleanup != nil { | |||
| 1569 | + | defer schedulerCleanup() | |||
| 1570 | + | } | |||
| 1571 | + | ||||
| 1542 | // Auto-start after servers are listening (registry port is now bound). | 1572 | // Auto-start after servers are listening (registry port is now bound). | ||
| 1543 | syncAndAutoStart(ctx, svc, log) | 1573 | syncAndAutoStart(ctx, svc, log) | ||
| 1544 | 1574 | ||||
| @@ -1547,6 +1577,165 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||||
| 1547 | return nil | 1577 | return nil | ||
| 1548 | } | 1578 | } | ||
| 1549 | 1579 | ||||
| 1580 | + | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | |||
| 1581 | + | select { | |||
| 1582 | + | case <-ready: | |||
| 1583 | + | return nil | |||
| 1584 | + | case err := <-errChan: | |||
| 1585 | + | return err | |||
| 1586 | + | } | |||
| 1587 | + | } | |||
| 1588 | + | ||||
| 1589 | + | func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) { | |||
| 1590 | + | schedulers := make([]*cronSvc.Scheduler, 0, 2) | |||
| 1591 | + | ||||
| 1592 | + | backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log) | |||
| 1593 | + | if err != nil { | |||
| 1594 | + | return nil, err | |||
| 1595 | + | } | |||
| 1596 | + | if backupScheduler != nil { | |||
| 1597 | + | schedulers = append(schedulers, backupScheduler) | |||
| 1598 | + | } | |||
| 1599 | + | ||||
| 1600 | + | imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int { | |||
| 1601 | + | return v.GetInt("images.prune.keep_last") | |||
| 1602 | + | }) | |||
| 1603 | + | if err != nil { | |||
| 1604 | + | return nil, err | |||
| 1605 | + | } | |||
| 1606 | + | if imageScheduler != nil { | |||
| 1607 | + | schedulers = append(schedulers, imageScheduler) | |||
| 1608 | + | } | |||
| 1609 | + | ||||
| 1610 | + | if len(schedulers) == 0 { | |||
| 1611 | + | return nil, nil | |||
| 1612 | + | } | |||
| 1613 | + | ||||
| 1614 | + | return func() { | |||
| 1615 | + | for i := len(schedulers) - 1; i >= 0; i-- { | |||
| 1616 | + | schedulers[i].Stop() | |||
| 1617 | + | } | |||
| 1618 | + | }, nil | |||
| 1619 | + | } | |||
| 1620 | + | ||||
| 1621 | + | func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) { | |||
| 1622 | + | if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil { | |||
| 1623 | + | return nil, nil | |||
| 1624 | + | } | |||
| 1625 | + | ||||
| 1626 | + | preset, err := resolveBackupSchedule(cfg.Backups.Schedule) | |||
| 1627 | + | if err != nil { | |||
| 1628 | + | return nil, err | |||
| 1629 | + | } | |||
| 1630 | + | ||||
| 1631 | + | scheduler := cronSvc.NewScheduler(log) | |||
| 1632 | + | err = scheduler.Add( | |||
| 1633 | + | "backup-scheduler", | |||
| 1634 | + | "Backups", | |||
| 1635 | + | domain.CronSchedule{Preset: preset}, | |||
| 1636 | + | func(jobCtx context.Context) error { | |||
| 1637 | + | if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil { | |||
| 1638 | + | return err | |||
| 1639 | + | } | |||
| 1640 | + | log.Info(). | |||
| 1641 | + | Str("schedule", string(preset)). | |||
| 1642 | + | Msg("scheduled backup run complete") | |||
| 1643 | + | return nil | |||
| 1644 | + | }, | |||
| 1645 | + | ) | |||
| 1646 | + | if err != nil { | |||
| 1647 | + | return nil, log.WrapErr(err, "failed to register backup schedule") | |||
| 1648 | + | } | |||
| 1649 | + | ||||
| 1650 | + | scheduler.Start(ctx) | |||
| 1651 | + | log.Info(). | |||
| 1652 | + | Str("schedule", string(preset)). | |||
| 1653 | + | Msg("backup scheduler enabled") | |||
| 1654 | + | ||||
| 1655 | + | return scheduler, nil | |||
| 1656 | + | } | |||
| 1657 | + | ||||
| 1658 | + | func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) { | |||
| 1659 | + | return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily) | |||
| 1660 | + | } | |||
| 1661 | + | ||||
| 1662 | + | func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) { | |||
| 1663 | + | if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil { | |||
| 1664 | + | return nil, nil | |||
| 1665 | + | } | |||
| 1666 | + | if keepLastGetter == nil { | |||
| 1667 | + | keepLastGetter = func() int { return cfg.Images.Prune.KeepLast } | |||
| 1668 | + | } | |||
| 1669 | + | if keepLastGetter() < 0 { | |||
| 1670 | + | return nil, fmt.Errorf("images.prune.keep_last must be >= 0") | |||
| 1671 | + | } | |||
| 1672 | + | ||||
| 1673 | + | preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule) | |||
| 1674 | + | if err != nil { | |||
| 1675 | + | return nil, err | |||
| 1676 | + | } | |||
| 1677 | + | ||||
| 1678 | + | scheduler := cronSvc.NewScheduler(log) | |||
| 1679 | + | err = scheduler.Add( | |||
| 1680 | + | "image-prune", | |||
| 1681 | + | "Image prune", | |||
| 1682 | + | domain.CronSchedule{Preset: preset}, | |||
| 1683 | + | func(jobCtx context.Context) error { | |||
| 1684 | + | keepLast := keepLastGetter() | |||
| 1685 | + | if keepLast < 0 { | |||
| 1686 | + | log.Warn(). | |||
| 1687 | + | Int("configured_keep_last", keepLast). | |||
| 1688 | + | Int("fallback_keep_last", domain.DefaultImagePruneKeepLast). | |||
| 1689 | + | Msg("invalid images.prune.keep_last; using default") | |||
| 1690 | + | keepLast = domain.DefaultImagePruneKeepLast | |||
| 1691 | + | } | |||
| 1692 | + | ||||
| 1693 | + | report, err := svc.imageSvc.Prune(jobCtx, keepLast) | |||
| 1694 | + | if err != nil { | |||
| 1695 | + | return err | |||
| 1696 | + | } | |||
| 1697 | + | ||||
| 1698 | + | log.Info(). | |||
| 1699 | + | Int("keep_last", keepLast). | |||
| 1700 | + | Int("runtime_deleted", report.Runtime.DeletedCount). | |||
| 1701 | + | Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed). | |||
| 1702 | + | Int("registry_tags_removed", report.Registry.TagsRemoved). | |||
| 1703 | + | Int("registry_blobs_removed", report.Registry.BlobsRemoved). | |||
| 1704 | + | Msg("scheduled image prune complete") | |||
| 1705 | + | return nil | |||
| 1706 | + | }, | |||
| 1707 | + | ) | |||
| 1708 | + | if err != nil { | |||
| 1709 | + | return nil, log.WrapErr(err, "failed to register image prune schedule") | |||
| 1710 | + | } | |||
| 1711 | + | ||||
| 1712 | + | scheduler.Start(ctx) | |||
| 1713 | + | log.Info(). | |||
| 1714 | + | Str("schedule", string(preset)). | |||
| 1715 | + | Int("keep_last", keepLastGetter()). | |||
| 1716 | + | Msg("image prune scheduler enabled") | |||
| 1717 | + | ||||
| 1718 | + | return scheduler, nil | |||
| 1719 | + | } | |||
| 1720 | + | ||||
| 1721 | + | func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) { | |||
| 1722 | + | return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily) | |||
| 1723 | + | } | |||
| 1724 | + | ||||
| 1725 | + | func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) { | |||
| 1726 | + | schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw))) | |||
| 1727 | + | if schedule == "" { | |||
| 1728 | + | schedule = defaultVal | |||
| 1729 | + | } | |||
| 1730 | + | ||||
| 1731 | + | switch schedule { | |||
| 1732 | + | case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly: | |||
| 1733 | + | return schedule, nil | |||
| 1734 | + | default: | |||
| 1735 | + | return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name) | |||
| 1736 | + | } | |||
| 1737 | + | } | |||
| 1738 | + | ||||
| 1550 | // waitForShutdown blocks on the event loop, handling server errors and | 1739 | // waitForShutdown blocks on the event loop, handling server errors and | ||
| 1551 | // Unix signals (reload, deploy, shutdown) until the context is cancelled. | 1740 | // Unix signals (reload, deploy, shutdown) until the context is cancelled. | ||
| 1552 | func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) { | 1741 | func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) { | ||
| @@ -1593,6 +1782,8 @@ func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *containe | |||||
| 1593 | log.Warn().Err(err).Msg("proxy server shutdown error") | 1782 | log.Warn().Err(err).Msg("proxy server shutdown error") | ||
| 1594 | } | 1783 | } | ||
| 1595 | 1784 | ||||
| 1785 | + | containerSvc.StopMonitor() | |||
| 1786 | + | ||||
| 1596 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | 1787 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | ||
| 1597 | log.Warn().Err(err).Msg("error during container shutdown") | 1788 | log.Warn().Err(err).Msg("error during container shutdown") | ||
| 1598 | } | 1789 | } | ||
| @@ -1961,11 +2152,15 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1961 | v.SetDefault("volumes.preserve", true) | 2152 | v.SetDefault("volumes.preserve", true) | ||
| 1962 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | 2153 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | ||
| 1963 | v.SetDefault("backups.enabled", false) | 2154 | v.SetDefault("backups.enabled", false) | ||
| 2155 | + | v.SetDefault("backups.schedule", string(domain.ScheduleDaily)) | |||
| 1964 | v.SetDefault("backups.storage_dir", "") | 2156 | v.SetDefault("backups.storage_dir", "") | ||
| 1965 | v.SetDefault("backups.retention.hourly", 0) | 2157 | v.SetDefault("backups.retention.hourly", 0) | ||
| 1966 | v.SetDefault("backups.retention.daily", 0) | 2158 | v.SetDefault("backups.retention.daily", 0) | ||
| 1967 | v.SetDefault("backups.retention.weekly", 0) | 2159 | v.SetDefault("backups.retention.weekly", 0) | ||
| 1968 | v.SetDefault("backups.retention.monthly", 0) | 2160 | v.SetDefault("backups.retention.monthly", 0) | ||
| 2161 | + | v.SetDefault("images.prune.enabled", false) | |||
| 2162 | + | v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily)) | |||
| 2163 | + | v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast) | |||
| 1969 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | 2164 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | ||
| 1970 | v.SetDefault("server.registry_allowed_ips", []string{}) | 2165 | v.SetDefault("server.registry_allowed_ips", []string{}) | ||
| 1971 | v.SetDefault("deploy.readiness_delay", "5s") | 2166 | v.SetDefault("deploy.readiness_delay", "5s") | ||
M internal/app/run_backup_test.go +40 -0
| @@ -3,8 +3,11 @@ package app | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/spf13/viper" | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) { | 13 | func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) { | ||
| @@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) { | |||||
| 86 | assert.Equal(t, 0, retention.Monthly) | 89 | assert.Equal(t, 0, retention.Monthly) | ||
| 87 | }) | 90 | }) | ||
| 88 | } | 91 | } | ||
| 92 | + | ||||
| 93 | + | func TestLoadConfigSetsBackupScheduleDefault(t *testing.T) { | |||
| 94 | + | v := viper.New() | |||
| 95 | + | ||||
| 96 | + | require.NoError(t, loadConfig(v, "")) | |||
| 97 | + | ||||
| 98 | + | assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule")) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestResolveBackupSchedule(t *testing.T) { | |||
| 102 | + | t.Run("valid values", func(t *testing.T) { | |||
| 103 | + | tests := []struct { | |||
| 104 | + | input string | |||
| 105 | + | want domain.BackupSchedule | |||
| 106 | + | }{ | |||
| 107 | + | {input: "hourly", want: domain.ScheduleHourly}, | |||
| 108 | + | {input: "daily", want: domain.ScheduleDaily}, | |||
| 109 | + | {input: "weekly", want: domain.ScheduleWeekly}, | |||
| 110 | + | {input: "monthly", want: domain.ScheduleMonthly}, | |||
| 111 | + | {input: " DAILY ", want: domain.ScheduleDaily}, | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | for _, tt := range tests { | |||
| 115 | + | t.Run(tt.input, func(t *testing.T) { | |||
| 116 | + | got, err := resolveBackupSchedule(tt.input) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | assert.Equal(t, tt.want, got) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | ||||
| 123 | + | t.Run("invalid value", func(t *testing.T) { | |||
| 124 | + | _, err := resolveBackupSchedule("every-minute") | |||
| 125 | + | require.Error(t, err) | |||
| 126 | + | assert.Contains(t, err.Error(), "backups.schedule") | |||
| 127 | + | }) | |||
| 128 | + | } | |||
A internal/app/run_images_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/viper" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestLoadConfigSetsImagePruneDefaults(t *testing.T) { | |||
| 14 | + | v := viper.New() | |||
| 15 | + | ||||
| 16 | + | require.NoError(t, loadConfig(v, "")) | |||
| 17 | + | ||||
| 18 | + | assert.False(t, v.GetBool("images.prune.enabled")) | |||
| 19 | + | assert.Equal(t, string(domain.ScheduleDaily), v.GetString("images.prune.schedule")) | |||
| 20 | + | assert.Equal(t, domain.DefaultImagePruneKeepLast, v.GetInt("images.prune.keep_last")) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestResolveImagePruneSchedule(t *testing.T) { | |||
| 24 | + | t.Run("valid values", func(t *testing.T) { | |||
| 25 | + | tests := []struct { | |||
| 26 | + | input string | |||
| 27 | + | want domain.BackupSchedule | |||
| 28 | + | }{ | |||
| 29 | + | {input: "hourly", want: domain.ScheduleHourly}, | |||
| 30 | + | {input: "daily", want: domain.ScheduleDaily}, | |||
| 31 | + | {input: "weekly", want: domain.ScheduleWeekly}, | |||
| 32 | + | {input: "monthly", want: domain.ScheduleMonthly}, | |||
| 33 | + | {input: " DAILY ", want: domain.ScheduleDaily}, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | for _, tt := range tests { | |||
| 37 | + | t.Run(tt.input, func(t *testing.T) { | |||
| 38 | + | got, err := resolveImagePruneSchedule(tt.input) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | assert.Equal(t, tt.want, got) | |||
| 41 | + | }) | |||
| 42 | + | } | |||
| 43 | + | }) | |||
| 44 | + | ||||
| 45 | + | t.Run("invalid value", func(t *testing.T) { | |||
| 46 | + | _, err := resolveImagePruneSchedule("every-minute") | |||
| 47 | + | require.Error(t, err) | |||
| 48 | + | assert.Contains(t, err.Error(), "images.prune.schedule") | |||
| 49 | + | }) | |||
| 50 | + | } | |||
A internal/boundaries/in/images.go +17 -0
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | // Package in defines input ports (interfaces) for use cases. | |||
| 2 | + | package in | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // ImageService defines image listing and prune operations. | |||
| 11 | + | type ImageService interface { | |||
| 12 | + | // ListImages returns runtime images. | |||
| 13 | + | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | |||
| 14 | + | ||||
| 15 | + | // Prune removes unused images and applies retention. | |||
| 16 | + | Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) | |||
| 17 | + | } | |||
M internal/boundaries/out/mocks/mock_blob_storage.go +55 -0
| @@ -503,6 +503,61 @@ func (_c *MockBlobStorage_GetBlobUpload_Call) RunAndReturn(run func(uuid string) | |||||
| 503 | return _c | 503 | return _c | ||
| 504 | } | 504 | } | ||
| 505 | 505 | ||||
| 506 | + | // ListBlobs provides a mock function for the type MockBlobStorage | |||
| 507 | + | func (_mock *MockBlobStorage) ListBlobs() ([]string, error) { | |||
| 508 | + | ret := _mock.Called() | |||
| 509 | + | ||||
| 510 | + | if len(ret) == 0 { | |||
| 511 | + | panic("no return value specified for ListBlobs") | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | var r0 []string | |||
| 515 | + | var r1 error | |||
| 516 | + | if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok { | |||
| 517 | + | return returnFunc() | |||
| 518 | + | } | |||
| 519 | + | if returnFunc, ok := ret.Get(0).(func() []string); ok { | |||
| 520 | + | r0 = returnFunc() | |||
| 521 | + | } else { | |||
| 522 | + | if ret.Get(0) != nil { | |||
| 523 | + | r0 = ret.Get(0).([]string) | |||
| 524 | + | } | |||
| 525 | + | } | |||
| 526 | + | if returnFunc, ok := ret.Get(1).(func() error); ok { | |||
| 527 | + | r1 = returnFunc() | |||
| 528 | + | } else { | |||
| 529 | + | r1 = ret.Error(1) | |||
| 530 | + | } | |||
| 531 | + | return r0, r1 | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | // MockBlobStorage_ListBlobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBlobs' | |||
| 535 | + | type MockBlobStorage_ListBlobs_Call struct { | |||
| 536 | + | *mock.Call | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | // ListBlobs is a helper method to define mock.On call | |||
| 540 | + | func (_e *MockBlobStorage_Expecter) ListBlobs() *MockBlobStorage_ListBlobs_Call { | |||
| 541 | + | return &MockBlobStorage_ListBlobs_Call{Call: _e.mock.On("ListBlobs")} | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | func (_c *MockBlobStorage_ListBlobs_Call) Run(run func()) *MockBlobStorage_ListBlobs_Call { | |||
| 545 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 546 | + | run() | |||
| 547 | + | }) | |||
| 548 | + | return _c | |||
| 549 | + | } | |||
| 550 | + | ||||
| 551 | + | func (_c *MockBlobStorage_ListBlobs_Call) Return(strings []string, err error) *MockBlobStorage_ListBlobs_Call { | |||
| 552 | + | _c.Call.Return(strings, err) | |||
| 553 | + | return _c | |||
| 554 | + | } | |||
| 555 | + | ||||
| 556 | + | func (_c *MockBlobStorage_ListBlobs_Call) RunAndReturn(run func() ([]string, error)) *MockBlobStorage_ListBlobs_Call { | |||
| 557 | + | _c.Call.Return(run) | |||
| 558 | + | return _c | |||
| 559 | + | } | |||
| 560 | + | ||||
| 506 | // PutBlob provides a mock function for the type MockBlobStorage | 561 | // PutBlob provides a mock function for the type MockBlobStorage | ||
| 507 | func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error { | 562 | func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error { | ||
| 508 | ret := _mock.Called(digest, data, size) | 563 | ret := _mock.Called(digest, data, size) | ||
M internal/boundaries/out/mocks/mock_manifest_storage.go +68 -0
| @@ -5,6 +5,8 @@ | |||||
| 5 | package mocks | 5 | package mocks | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | + | "time" | |||
| 9 | + | ||||
| 8 | mock "github.com/stretchr/testify/mock" | 10 | mock "github.com/stretchr/testify/mock" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -166,6 +168,72 @@ func (_c *MockManifestStorage_GetManifest_Call) RunAndReturn(run func(name strin | |||||
| 166 | return _c | 168 | return _c | ||
| 167 | } | 169 | } | ||
| 168 | 170 | ||||
| 171 | + | // GetManifestModTime provides a mock function for the type MockManifestStorage | |||
| 172 | + | func (_mock *MockManifestStorage) GetManifestModTime(name string, reference string) (time.Time, error) { | |||
| 173 | + | ret := _mock.Called(name, reference) | |||
| 174 | + | ||||
| 175 | + | if len(ret) == 0 { | |||
| 176 | + | panic("no return value specified for GetManifestModTime") | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | var r0 time.Time | |||
| 180 | + | var r1 error | |||
| 181 | + | if returnFunc, ok := ret.Get(0).(func(string, string) (time.Time, error)); ok { | |||
| 182 | + | return returnFunc(name, reference) | |||
| 183 | + | } | |||
| 184 | + | if returnFunc, ok := ret.Get(0).(func(string, string) time.Time); ok { | |||
| 185 | + | r0 = returnFunc(name, reference) | |||
| 186 | + | } else { | |||
| 187 | + | r0 = ret.Get(0).(time.Time) | |||
| 188 | + | } | |||
| 189 | + | if returnFunc, ok := ret.Get(1).(func(string, string) error); ok { | |||
| 190 | + | r1 = returnFunc(name, reference) | |||
| 191 | + | } else { | |||
| 192 | + | r1 = ret.Error(1) | |||
| 193 | + | } | |||
| 194 | + | return r0, r1 | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // MockManifestStorage_GetManifestModTime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetManifestModTime' | |||
| 198 | + | type MockManifestStorage_GetManifestModTime_Call struct { | |||
| 199 | + | *mock.Call | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | // GetManifestModTime is a helper method to define mock.On call | |||
| 203 | + | // - name string | |||
| 204 | + | // - reference string | |||
| 205 | + | func (_e *MockManifestStorage_Expecter) GetManifestModTime(name interface{}, reference interface{}) *MockManifestStorage_GetManifestModTime_Call { | |||
| 206 | + | return &MockManifestStorage_GetManifestModTime_Call{Call: _e.mock.On("GetManifestModTime", name, reference)} | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (_c *MockManifestStorage_GetManifestModTime_Call) Run(run func(name string, reference string)) *MockManifestStorage_GetManifestModTime_Call { | |||
| 210 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 211 | + | var arg0 string | |||
| 212 | + | if args[0] != nil { | |||
| 213 | + | arg0 = args[0].(string) | |||
| 214 | + | } | |||
| 215 | + | var arg1 string | |||
| 216 | + | if args[1] != nil { | |||
| 217 | + | arg1 = args[1].(string) | |||
| 218 | + | } | |||
| 219 | + | run( | |||
| 220 | + | arg0, | |||
| 221 | + | arg1, | |||
| 222 | + | ) | |||
| 223 | + | }) | |||
| 224 | + | return _c | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (_c *MockManifestStorage_GetManifestModTime_Call) Return(time1 time.Time, err error) *MockManifestStorage_GetManifestModTime_Call { | |||
| 228 | + | _c.Call.Return(time1, err) | |||
| 229 | + | return _c | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func (_c *MockManifestStorage_GetManifestModTime_Call) RunAndReturn(run func(name string, reference string) (time.Time, error)) *MockManifestStorage_GetManifestModTime_Call { | |||
| 233 | + | _c.Call.Return(run) | |||
| 234 | + | return _c | |||
| 235 | + | } | |||
| 236 | + | ||||
| 169 | // ListRepositories provides a mock function for the type MockManifestStorage | 237 | // ListRepositories provides a mock function for the type MockManifestStorage | ||
| 170 | func (_mock *MockManifestStorage) ListRepositories() ([]string, error) { | 238 | func (_mock *MockManifestStorage) ListRepositories() ([]string, error) { | ||
| 171 | ret := _mock.Called() | 239 | ret := _mock.Called() | ||
M internal/boundaries/out/storage.go +7 -0
| @@ -2,6 +2,7 @@ package out | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "io" | 4 | "io" | ||
| 5 | + | "time" | |||
| 5 | ) | 6 | ) | ||
| 6 | 7 | ||||
| 7 | // BlobStorage defines the contract for blob storage operations. | 8 | // BlobStorage defines the contract for blob storage operations. | ||
| @@ -21,6 +22,9 @@ type BlobStorage interface { | |||||
| 21 | // BlobExists checks if a blob exists. | 22 | // BlobExists checks if a blob exists. | ||
| 22 | BlobExists(digest string) bool | 23 | BlobExists(digest string) bool | ||
| 23 | 24 | ||||
| 25 | + | // ListBlobs returns all blob digests. | |||
| 26 | + | ListBlobs() ([]string, error) | |||
| 27 | + | ||||
| 24 | // StartBlobUpload starts a new blob upload and returns the upload UUID. | 28 | // StartBlobUpload starts a new blob upload and returns the upload UUID. | ||
| 25 | StartBlobUpload(name string) (string, error) | 29 | StartBlobUpload(name string) (string, error) | ||
| 26 | 30 | ||||
| @@ -54,4 +58,7 @@ type ManifestStorage interface { | |||||
| 54 | 58 | ||||
| 55 | // ListRepositories returns all repository names. | 59 | // ListRepositories returns all repository names. | ||
| 56 | ListRepositories() ([]string, error) | 60 | ListRepositories() ([]string, error) | ||
| 61 | + | ||||
| 62 | + | // GetManifestModTime returns the manifest modification time. | |||
| 63 | + | GetManifestModTime(name, reference string) (time.Time, error) | |||
| 57 | } | 64 | } | ||
M internal/domain/container.go +7 -6
| @@ -4,12 +4,13 @@ package domain | |||||
| 4 | 4 | ||||
| 5 | // Container represents a running container in the system. | 5 | // Container represents a running container in the system. | ||
| 6 | type Container struct { | 6 | type Container struct { | ||
| 7 | - | ID string | 7 | + | ID string |
| 8 | - | Image string | 8 | + | Image string |
| 9 | - | Name string | 9 | + | Name string |
| 10 | - | Status string | 10 | + | Status string |
| 11 | - | Ports []int | 11 | + | ExitCode int |
| 12 | - | Labels map[string]string | 12 | + | Ports []int |
| 13 | + | Labels map[string]string | |||
| 13 | } | 14 | } | ||
| 14 | 15 | ||||
| 15 | // NetworkInfo represents network configuration and state. | 16 | // NetworkInfo represents network configuration and state. | ||
M internal/domain/event.go +17 -0
| @@ -81,3 +81,20 @@ func IsInternalDeploy(ctx context.Context) bool { | |||||
| 81 | func WithInternalDeploy(ctx context.Context) context.Context { | 81 | func WithInternalDeploy(ctx context.Context) context.Context { | ||
| 82 | return context.WithValue(ctx, ContextKeyInternalDeploy, true) | 82 | return context.WithValue(ctx, ContextKeyInternalDeploy, true) | ||
| 83 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | const ( | |||
| 86 | + | // ContextKeySkipReadiness indicates that readiness checks should be skipped | |||
| 87 | + | // (e.g., during AutoStart where the background monitor handles crash recovery). | |||
| 88 | + | ContextKeySkipReadiness contextKey = "skip_readiness" | |||
| 89 | + | ) | |||
| 90 | + | ||||
| 91 | + | // WithSkipReadiness returns a context that skips readiness checks on deploy. | |||
| 92 | + | func WithSkipReadiness(ctx context.Context) context.Context { | |||
| 93 | + | return context.WithValue(ctx, ContextKeySkipReadiness, true) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // IsSkipReadiness checks if the context indicates readiness should be skipped. | |||
| 97 | + | func IsSkipReadiness(ctx context.Context) bool { | |||
| 98 | + | v, ok := ctx.Value(ContextKeySkipReadiness).(bool) | |||
| 99 | + | return ok && v | |||
| 100 | + | } | |||
A internal/domain/images.go +46 -0
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | const DefaultImagePruneKeepLast = 3 | |||
| 6 | + | ||||
| 7 | + | // ImagePruneConfig defines scheduled image cleanup behavior. | |||
| 8 | + | type ImagePruneConfig struct { | |||
| 9 | + | Enabled bool | |||
| 10 | + | Schedule BackupSchedule | |||
| 11 | + | // KeepLast is the number of most-recent tags to retain per repository. | |||
| 12 | + | KeepLast int | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | // RuntimePruneResult reports runtime image cleanup results. | |||
| 16 | + | type RuntimePruneResult struct { | |||
| 17 | + | DeletedCount int | |||
| 18 | + | // SpaceReclaimed is reported in bytes. | |||
| 19 | + | SpaceReclaimed int64 | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // RegistryPruneResult reports registry cleanup results. | |||
| 23 | + | type RegistryPruneResult struct { | |||
| 24 | + | TagsRemoved int | |||
| 25 | + | BlobsRemoved int | |||
| 26 | + | // SpaceReclaimed is reported in bytes. | |||
| 27 | + | SpaceReclaimed int64 | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // ImagePruneReport aggregates runtime and registry cleanup results. | |||
| 31 | + | type ImagePruneReport struct { | |||
| 32 | + | Runtime RuntimePruneResult | |||
| 33 | + | Registry RegistryPruneResult | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // ImageInfo describes a runtime image. | |||
| 37 | + | type ImageInfo struct { | |||
| 38 | + | Repository string | |||
| 39 | + | Tag string | |||
| 40 | + | // Size is reported in bytes. | |||
| 41 | + | Size int64 | |||
| 42 | + | Created time.Time | |||
| 43 | + | // ID is the runtime image identifier (for example, image ID/digest). | |||
| 44 | + | ID string | |||
| 45 | + | Dangling bool | |||
| 46 | + | } | |||
M internal/usecase/backup/detector.go +49 -27
| @@ -1,49 +1,71 @@ | |||||
| 1 | package backup | 1 | package backup | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "slices" | |||
| 4 | "strings" | 5 | "strings" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/gordon/internal/domain" | 7 | "github.com/bnema/gordon/internal/domain" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 10 | + | // postgresAliases contains substrings that identify a PostgreSQL image or service. | |||
| 11 | + | var postgresAliases = []string{"postgres", "postgresql", "pgsql", "postgis"} | |||
| 12 | + | ||||
| 9 | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | 13 | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | ||
| 10 | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | 14 | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | ||
| 11 | image := strings.ToLower(a.Image) | 15 | image := strings.ToLower(a.Image) | ||
| 12 | name := strings.ToLower(a.Name) | 16 | name := strings.ToLower(a.Name) | ||
| 13 | 17 | ||||
| 14 | switch { | 18 | switch { | ||
| 15 | - | case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"): | 19 | + | case looksLikePostgres(image) || looksLikePostgres(name): |
| 16 | - | port := 5432 | 20 | + | return buildPostgresInfo(domainName, a), true |
| 17 | - | hasPostgresPort := false | 21 | + | case hasPort(a.Ports, 5432): |
| 18 | - | firstPositive := 0 | 22 | + | // Port 5432 fallback: likely PostgreSQL even with non-standard naming. |
| 19 | - | for _, p := range a.Ports { | 23 | + | return buildPostgresInfo(domainName, a), true |
| 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: | 24 | default: | ||
| 43 | return domain.DBInfo{}, false | 25 | return domain.DBInfo{}, false | ||
| 44 | } | 26 | } | ||
| 45 | } | 27 | } | ||
| 46 | 28 | ||||
| 29 | + | // looksLikePostgres returns true if s contains any known PostgreSQL alias. | |||
| 30 | + | func looksLikePostgres(s string) bool { | |||
| 31 | + | for _, alias := range postgresAliases { | |||
| 32 | + | if strings.Contains(s, alias) { | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | return false | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // hasPort returns true if the port list contains p. | |||
| 40 | + | func hasPort(ports []int, p int) bool { | |||
| 41 | + | return slices.Contains(ports, p) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // buildPostgresInfo constructs a DBInfo for a PostgreSQL attachment. | |||
| 45 | + | func buildPostgresInfo(domainName string, a domain.Attachment) domain.DBInfo { | |||
| 46 | + | port := 5432 | |||
| 47 | + | for _, p := range a.Ports { | |||
| 48 | + | if p == 5432 { | |||
| 49 | + | port = 5432 | |||
| 50 | + | break | |||
| 51 | + | } | |||
| 52 | + | if p > 0 && port == 5432 { | |||
| 53 | + | port = p | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | return domain.DBInfo{ | |||
| 58 | + | Type: domain.DBTypePostgreSQL, | |||
| 59 | + | Version: postgresVersionFromImage(a.Image), | |||
| 60 | + | Domain: domainName, | |||
| 61 | + | Name: a.Name, | |||
| 62 | + | Host: a.Name, | |||
| 63 | + | Port: port, | |||
| 64 | + | ContainerID: a.ContainerID, | |||
| 65 | + | ImageName: a.Image, | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 47 | func postgresVersionFromImage(image string) string { | 69 | func postgresVersionFromImage(image string) string { | ||
| 48 | lastColon := strings.LastIndex(image, ":") | 70 | lastColon := strings.LastIndex(image, ":") | ||
| 49 | if lastColon == -1 { | 71 | if lastColon == -1 { | ||
M internal/usecase/backup/detector_test.go +102 -0
| @@ -76,3 +76,105 @@ func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) { | |||||
| 76 | }) | 76 | }) | ||
| 77 | assert.False(t, ok) | 77 | assert.False(t, ok) | ||
| 78 | } | 78 | } | ||
| 79 | + | ||||
| 80 | + | func TestDetectDatabaseFromAttachment_PgsqlImage(t *testing.T) { | |||
| 81 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 82 | + | Name: "pgsql", | |||
| 83 | + | Image: "pgsql:16", | |||
| 84 | + | ContainerID: "pgsql-1", | |||
| 85 | + | Ports: []int{5432}, | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 88 | + | require.True(t, ok) | |||
| 89 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 90 | + | assert.Equal(t, "16", db.Version) | |||
| 91 | + | assert.Equal(t, 5432, db.Port) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestDetectDatabaseFromAttachment_PgsqlInCompositeName(t *testing.T) { | |||
| 95 | + | db, ok := detectDatabaseFromAttachment("mon-fauteuil.fr", domain.Attachment{ | |||
| 96 | + | Name: "mon-fauteuil-prod-pgsql", | |||
| 97 | + | Image: "mon-fauteuil-prod-pgsql:v18", | |||
| 98 | + | ContainerID: "pgsql-2", | |||
| 99 | + | Ports: []int{5432}, | |||
| 100 | + | }) | |||
| 101 | + | ||||
| 102 | + | require.True(t, ok) | |||
| 103 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 104 | + | assert.Equal(t, "", db.Version) // v18 tag starts with non-digit, no version extracted | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestDetectDatabaseFromAttachment_PostgresqlFullName(t *testing.T) { | |||
| 108 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 109 | + | Name: "postgresql", | |||
| 110 | + | Image: "postgresql:15", | |||
| 111 | + | ContainerID: "pg-full-1", | |||
| 112 | + | Ports: []int{5432}, | |||
| 113 | + | }) | |||
| 114 | + | ||||
| 115 | + | require.True(t, ok) | |||
| 116 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 117 | + | assert.Equal(t, "15", db.Version) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestDetectDatabaseFromAttachment_PostgisImage(t *testing.T) { | |||
| 121 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 122 | + | Name: "postgis", | |||
| 123 | + | Image: "postgis/postgis:16-3.4", | |||
| 124 | + | ContainerID: "postgis-1", | |||
| 125 | + | Ports: []int{5432}, | |||
| 126 | + | }) | |||
| 127 | + | ||||
| 128 | + | require.True(t, ok) | |||
| 129 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 130 | + | assert.Equal(t, "16", db.Version) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestDetectDatabaseFromAttachment_Port5432Fallback(t *testing.T) { | |||
| 134 | + | db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 135 | + | Name: "custom-db", | |||
| 136 | + | Image: "custom-db:latest", | |||
| 137 | + | ContainerID: "fallback-1", | |||
| 138 | + | Ports: []int{5432}, | |||
| 139 | + | }) | |||
| 140 | + | ||||
| 141 | + | require.True(t, ok, "port 5432 should trigger PostgreSQL fallback detection") | |||
| 142 | + | assert.Equal(t, domain.DBTypePostgreSQL, db.Type) | |||
| 143 | + | assert.Equal(t, 5432, db.Port) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func TestDetectDatabaseFromAttachment_UnknownImageNoPostgresPort(t *testing.T) { | |||
| 147 | + | _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{ | |||
| 148 | + | Name: "custom-db", | |||
| 149 | + | Image: "custom-db:latest", | |||
| 150 | + | ContainerID: "unknown-1", | |||
| 151 | + | Ports: []int{3306}, | |||
| 152 | + | }) | |||
| 153 | + | assert.False(t, ok, "non-postgres port with unknown image should not match") | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestLooksLikePostgres(t *testing.T) { | |||
| 157 | + | tests := []struct { | |||
| 158 | + | name string | |||
| 159 | + | image string | |||
| 160 | + | want bool | |||
| 161 | + | }{ | |||
| 162 | + | {name: "postgres base image", image: "postgres", want: true}, | |||
| 163 | + | {name: "postgres with version tag", image: "postgres:16", want: true}, | |||
| 164 | + | {name: "name contains postgres", image: "my-postgres-db", want: true}, | |||
| 165 | + | {name: "postgresql alias", image: "postgresql", want: true}, | |||
| 166 | + | {name: "pgsql alias", image: "pgsql", want: true}, | |||
| 167 | + | {name: "project specific pgsql alias", image: "mon-fauteuil-prod-pgsql", want: true}, | |||
| 168 | + | {name: "postgis alias", image: "postgis/postgis", want: true}, | |||
| 169 | + | {name: "mysql negative", image: "mysql", want: false}, | |||
| 170 | + | {name: "redis negative", image: "redis", want: false}, | |||
| 171 | + | {name: "custom negative", image: "custom-db", want: false}, | |||
| 172 | + | {name: "empty negative", image: "", want: false}, | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | for _, tc := range tests { | |||
| 176 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 177 | + | assert.Equal(t, tc.want, looksLikePostgres(tc.image)) | |||
| 178 | + | }) | |||
| 179 | + | } | |||
| 180 | + | } | |||
M internal/usecase/backup/service.go +65 -8
| @@ -66,8 +66,54 @@ func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]dom | |||||
| 66 | 66 | ||||
| 67 | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | 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) { | 68 | func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) { | ||
| 69 | - | started := time.Now().UTC() | 69 | + | return s.runBackup(ctx, domainName, dbName, domain.BackupSchedule("")) |
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // RunForSchedule executes backups for all detected databases and applies retention. | |||
| 73 | + | func (s *Service) RunForSchedule(ctx context.Context, schedule domain.BackupSchedule) error { | |||
| 74 | + | if !isValidBackupSchedule(schedule) { | |||
| 75 | + | return fmt.Errorf("invalid backup schedule: %q", schedule) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | routes := s.containerSvc.List(ctx) | |||
| 79 | + | domainNames := make([]string, 0, len(routes)) | |||
| 80 | + | for domainName := range routes { | |||
| 81 | + | domainNames = append(domainNames, domainName) | |||
| 82 | + | } | |||
| 83 | + | sort.Strings(domainNames) | |||
| 84 | + | ||||
| 85 | + | var firstErr error | |||
| 86 | + | for _, domainName := range domainNames { | |||
| 87 | + | dbs, err := s.DetectDatabases(ctx, domainName) | |||
| 88 | + | if err != nil { | |||
| 89 | + | s.log.Error().Err(err).Str("domain", domainName).Msg("scheduled backup database detection failed") | |||
| 90 | + | if firstErr == nil { | |||
| 91 | + | firstErr = fmt.Errorf("detect databases for %s: %w", domainName, err) | |||
| 92 | + | } | |||
| 93 | + | continue | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | for _, db := range dbs { | |||
| 97 | + | if _, err := s.runBackupForDB(ctx, domainName, db, schedule); err != nil { | |||
| 98 | + | s.log.Error().Err(err).Str("domain", domainName).Str("db", db.Name).Msg("scheduled backup failed") | |||
| 99 | + | if firstErr == nil { | |||
| 100 | + | firstErr = fmt.Errorf("run backup for %s/%s: %w", domainName, db.Name, err) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | if _, err := s.storage.ApplyRetention(ctx, domainName, s.config.Retention); err != nil { | |||
| 106 | + | s.log.Error().Err(err).Str("domain", domainName).Msg("scheduled backup retention failed") | |||
| 107 | + | if firstErr == nil { | |||
| 108 | + | firstErr = fmt.Errorf("apply retention for %s: %w", domainName, err) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 70 | 112 | ||||
| 113 | + | return firstErr | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (s *Service) runBackup(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule) (*domain.BackupResult, error) { | |||
| 71 | dbs, err := s.DetectDatabases(ctx, domainName) | 117 | dbs, err := s.DetectDatabases(ctx, domainName) | ||
| 72 | if err != nil { | 118 | if err != nil { | ||
| 73 | return nil, err | 119 | return nil, err | ||
| @@ -78,6 +124,12 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||||
| 78 | return nil, err | 124 | return nil, err | ||
| 79 | } | 125 | } | ||
| 80 | 126 | ||||
| 127 | + | return s.runBackupForDB(ctx, domainName, db, schedule) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (s *Service) runBackupForDB(ctx context.Context, domainName string, db domain.DBInfo, schedule domain.BackupSchedule) (*domain.BackupResult, error) { | |||
| 131 | + | started := time.Now().UTC() | |||
| 132 | + | ||||
| 81 | if db.Type != domain.DBTypePostgreSQL { | 133 | if db.Type != domain.DBTypePostgreSQL { | ||
| 82 | return nil, fmt.Errorf("unsupported database type: %s", db.Type) | 134 | return nil, fmt.Errorf("unsupported database type: %s", db.Type) | ||
| 83 | } | 135 | } | ||
| @@ -108,7 +160,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||||
| 108 | defer dumpStream.Close() | 160 | defer dumpStream.Close() | ||
| 109 | 161 | ||||
| 110 | counter := &byteCounter{} | 162 | counter := &byteCounter{} | ||
| 111 | - | path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, io.TeeReader(dumpStream, counter)) | 163 | + | path, err := s.storage.Store(ctx, domainName, db.Name, schedule, started, io.TeeReader(dumpStream, counter)) |
| 112 | if err != nil { | 164 | if err != nil { | ||
| 113 | return nil, err | 165 | return nil, err | ||
| 114 | } | 166 | } | ||
| @@ -131,6 +183,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||||
| 131 | }, nil | 183 | }, nil | ||
| 132 | } | 184 | } | ||
| 133 | 185 | ||||
| 186 | + | func isValidBackupSchedule(schedule domain.BackupSchedule) bool { | |||
| 187 | + | switch schedule { | |||
| 188 | + | case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly: | |||
| 189 | + | return true | |||
| 190 | + | default: | |||
| 191 | + | return false | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 134 | // Restore restores a backup by ID. | 195 | // Restore restores a backup by ID. | ||
| 135 | func (s *Service) Restore(context.Context, string, string) error { | 196 | func (s *Service) Restore(context.Context, string, string) error { | ||
| 136 | return fmt.Errorf("backup restore not implemented yet") | 197 | return fmt.Errorf("backup restore not implemented yet") | ||
| @@ -230,18 +291,14 @@ func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error | |||||
| 230 | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | 291 | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | ||
| 231 | } | 292 | } | ||
| 232 | 293 | ||||
| 233 | - | func postgresDumpCommand(dbName string) string { | 294 | + | func postgresDumpCommand(_ string) string { |
| 234 | - | return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName)) | 295 | + | return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\"" |
| 235 | } | 296 | } | ||
| 236 | 297 | ||||
| 237 | func pgDumpToPathCommand(path, dbName string) string { | 298 | func pgDumpToPathCommand(path, dbName string) string { | ||
| 238 | return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path) | 299 | return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path) | ||
| 239 | } | 300 | } | ||
| 240 | 301 | ||||
| 241 | - | func shellQuote(value string) string { | |||
| 242 | - | return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | func (s *Service) cleanupDumpFile(containerID, dumpPath string) { | 302 | func (s *Service) cleanupDumpFile(containerID, dumpPath string) { | ||
| 246 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | 303 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| 247 | defer cancel() | 304 | defer cancel() | ||
M internal/usecase/backup/service_test.go +61 -4
| @@ -50,7 +50,7 @@ func TestService_RunBackup_Postgres(t *testing.T) { | |||||
| 50 | return false | 50 | return false | ||
| 51 | } | 51 | } | ||
| 52 | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) && | 52 | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) && | ||
| 53 | - | bytes.Contains([]byte(cmd[2]), []byte("PGDATABASE='postgres'")) && | 53 | + | bytes.Contains([]byte(cmd[2]), []byte("${POSTGRES_DB:-postgres}")) && |
| 54 | bytes.Contains([]byte(cmd[2]), []byte(" > ")) | 54 | bytes.Contains([]byte(cmd[2]), []byte(" > ")) | ||
| 55 | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | 55 | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | ||
| 56 | 56 | ||||
| @@ -125,9 +125,10 @@ func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) { | |||||
| 125 | assert.Equal(t, "postgres", db.Name) | 125 | assert.Equal(t, "postgres", db.Name) | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) { | 128 | + | func TestPostgresDumpCommandUsesEnvVar(t *testing.T) { |
| 129 | - | cmd := postgresDumpCommand("customer data") | 129 | + | cmd := postgresDumpCommand("ignored") |
| 130 | - | assert.Contains(t, cmd, "PGDATABASE='customer data'") | 130 | + | assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}") |
| 131 | + | assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}") | |||
| 131 | } | 132 | } | ||
| 132 | 133 | ||||
| 133 | func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) { | 134 | func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) { | ||
| @@ -177,3 +178,59 @@ func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testi | |||||
| 177 | t.Fatal("Status did not return after context cancellation while waiting for semaphore") | 178 | t.Fatal("Status did not return after context cancellation while waiting for semaphore") | ||
| 178 | } | 179 | } | ||
| 179 | } | 180 | } | ||
| 181 | + | ||||
| 182 | + | func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) { | |||
| 183 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 184 | + | storage := outmocks.NewMockBackupStorage(t) | |||
| 185 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 186 | + | ||||
| 187 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |||
| 188 | + | "app.example.com": {}, | |||
| 189 | + | }) | |||
| 190 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |||
| 191 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"}, | |||
| 192 | + | }) | |||
| 193 | + | ||||
| 194 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |||
| 195 | + | if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" { | |||
| 196 | + | return false | |||
| 197 | + | } | |||
| 198 | + | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) | |||
| 199 | + | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |||
| 200 | + | ||||
| 201 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool { | |||
| 202 | + | return path != "" | |||
| 203 | + | })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil) | |||
| 204 | + | ||||
| 205 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |||
| 206 | + | return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f")) | |||
| 207 | + | })).Return(&outiface.ExecResult{ExitCode: 0}, nil) | |||
| 208 | + | ||||
| 209 | + | storage.EXPECT().Store( | |||
| 210 | + | mock.Anything, | |||
| 211 | + | "app.example.com", | |||
| 212 | + | "postgres", | |||
| 213 | + | domain.ScheduleDaily, | |||
| 214 | + | mock.Anything, | |||
| 215 | + | mock.Anything, | |||
| 216 | + | ).Return("/tmp/daily-backup.bak", nil) | |||
| 217 | + | ||||
| 218 | + | storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil) | |||
| 219 | + | ||||
| 220 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default()) | |||
| 221 | + | ||||
| 222 | + | err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily) | |||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) { | |||
| 227 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 228 | + | storage := outmocks.NewMockBackupStorage(t) | |||
| 229 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 230 | + | ||||
| 231 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |||
| 232 | + | ||||
| 233 | + | err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute")) | |||
| 234 | + | require.Error(t, err) | |||
| 235 | + | assert.Contains(t, err.Error(), "invalid backup schedule") | |||
| 236 | + | } | |||
A internal/usecase/container/monitor.go +239 -0
| @@ -0,0 +1,239 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | monitorDefaultInterval = 15 * time.Second | |||
| 15 | + | crashLoopThreshold = 3 | |||
| 16 | + | crashLoopWindow = 5 * time.Minute | |||
| 17 | + | backoffCap = 15 * time.Minute | |||
| 18 | + | stableRunningDuration = 5 * time.Minute | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // restartRecord tracks restart attempts for crash loop detection. | |||
| 22 | + | type restartRecord struct { | |||
| 23 | + | attempts []time.Time | |||
| 24 | + | backoffEnd time.Time | |||
| 25 | + | consecutive int | |||
| 26 | + | lastSeen time.Time // last time the container was seen running | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Monitor watches tracked containers and restarts crashed ones. | |||
| 30 | + | type Monitor struct { | |||
| 31 | + | service *Service | |||
| 32 | + | stopCh chan struct{} | |||
| 33 | + | stopped chan struct{} | |||
| 34 | + | interval time.Duration | |||
| 35 | + | mu sync.Mutex | |||
| 36 | + | history map[string]*restartRecord // keyed by domain | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // newMonitor creates a new container monitor. | |||
| 40 | + | func newMonitor(service *Service) *Monitor { | |||
| 41 | + | return &Monitor{ | |||
| 42 | + | service: service, | |||
| 43 | + | stopCh: make(chan struct{}), | |||
| 44 | + | stopped: make(chan struct{}), | |||
| 45 | + | interval: monitorDefaultInterval, | |||
| 46 | + | history: make(map[string]*restartRecord), | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Start begins the background monitoring loop. | |||
| 51 | + | func (m *Monitor) Start(ctx context.Context) { | |||
| 52 | + | log := zerowrap.FromCtx(ctx) | |||
| 53 | + | log.Info().Dur("interval", m.interval).Msg("container monitor started") | |||
| 54 | + | ||||
| 55 | + | go m.run(ctx) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // Stop signals the monitor to stop and waits for it to finish. | |||
| 59 | + | func (m *Monitor) Stop() { | |||
| 60 | + | close(m.stopCh) | |||
| 61 | + | <-m.stopped | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (m *Monitor) run(ctx context.Context) { | |||
| 65 | + | defer close(m.stopped) | |||
| 66 | + | ||||
| 67 | + | ticker := time.NewTicker(m.interval) | |||
| 68 | + | defer ticker.Stop() | |||
| 69 | + | ||||
| 70 | + | for { | |||
| 71 | + | select { | |||
| 72 | + | case <-m.stopCh: | |||
| 73 | + | return | |||
| 74 | + | case <-ctx.Done(): | |||
| 75 | + | return | |||
| 76 | + | case <-ticker.C: | |||
| 77 | + | m.check(ctx) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (m *Monitor) check(ctx context.Context) { | |||
| 83 | + | log := zerowrap.FromCtx(ctx) | |||
| 84 | + | ||||
| 85 | + | // Snapshot tracked containers under lock. | |||
| 86 | + | m.service.mu.RLock() | |||
| 87 | + | snapshot := make(map[string]*domain.Container, len(m.service.containers)) | |||
| 88 | + | for d, c := range m.service.containers { | |||
| 89 | + | snapshot[d] = c | |||
| 90 | + | } | |||
| 91 | + | m.service.mu.RUnlock() | |||
| 92 | + | ||||
| 93 | + | now := time.Now() | |||
| 94 | + | ||||
| 95 | + | for domainName, tracked := range snapshot { | |||
| 96 | + | m.checkContainer(ctx, log, domainName, tracked, now) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (m *Monitor) checkContainer(ctx context.Context, log zerowrap.Logger, domainName string, tracked *domain.Container, now time.Time) { | |||
| 101 | + | if tracked == nil { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | inspected, err := m.service.runtime.InspectContainer(ctx, tracked.ID) | |||
| 106 | + | if err != nil { | |||
| 107 | + | log.Debug().Err(err).Str("domain", domainName).Str("container_id", tracked.ID). | |||
| 108 | + | Msg("monitor: failed to inspect container, may have been replaced by deploy") | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | switch { | |||
| 113 | + | case inspected.Status == string(domain.ContainerStatusRunning): | |||
| 114 | + | m.handleRunning(ctx, log, domainName, tracked.ID, now) | |||
| 115 | + | ||||
| 116 | + | case inspected.Status == string(domain.ContainerStatusExited): | |||
| 117 | + | if inspected.ExitCode == 0 { | |||
| 118 | + | // Graceful stop (exit 0): respect user intent, don't restart. | |||
| 119 | + | log.Debug().Str("domain", domainName).Msg("monitor: container exited gracefully (code 0), skipping") | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | // Crashed (non-zero exit): restart if not in backoff. | |||
| 123 | + | m.handleCrash(ctx, log, domainName, tracked.ID, inspected.ExitCode, now) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domainName, containerID string, now time.Time) { | |||
| 128 | + | // Check Docker health status for running containers. | |||
| 129 | + | healthStatus, hasHealthcheck, err := m.service.runtime.GetContainerHealthStatus(ctx, containerID) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | if hasHealthcheck && healthStatus == "unhealthy" { | |||
| 135 | + | log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting") | |||
| 136 | + | if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil { | |||
| 137 | + | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container") | |||
| 138 | + | } | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Container is running and healthy — clear backoff if stable long enough. | |||
| 143 | + | m.mu.Lock() | |||
| 144 | + | rec, exists := m.history[domainName] | |||
| 145 | + | if exists { | |||
| 146 | + | if rec.lastSeen.IsZero() { | |||
| 147 | + | rec.lastSeen = now | |||
| 148 | + | } | |||
| 149 | + | if now.Sub(rec.lastSeen) >= stableRunningDuration { | |||
| 150 | + | // Container has been running for 5+ min — clear crash history. | |||
| 151 | + | delete(m.history, domainName) | |||
| 152 | + | m.mu.Unlock() | |||
| 153 | + | log.Info().Str("domain", domainName).Msg("monitor: container stable, cleared crash history") | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | m.mu.Unlock() | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) { | |||
| 161 | + | m.mu.Lock() | |||
| 162 | + | rec := m.history[domainName] | |||
| 163 | + | if rec == nil { | |||
| 164 | + | rec = &restartRecord{} | |||
| 165 | + | m.history[domainName] = rec | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Check backoff. | |||
| 169 | + | if now.Before(rec.backoffEnd) { | |||
| 170 | + | m.mu.Unlock() | |||
| 171 | + | log.Debug().Str("domain", domainName). | |||
| 172 | + | Time("backoff_until", rec.backoffEnd). | |||
| 173 | + | Msg("monitor: in crash loop backoff, skipping restart") | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Record this crash attempt. | |||
| 178 | + | rec.attempts = append(rec.attempts, now) | |||
| 179 | + | rec.consecutive++ | |||
| 180 | + | rec.lastSeen = time.Time{} // reset stable timer | |||
| 181 | + | ||||
| 182 | + | // Prune old attempts outside the crash loop window. | |||
| 183 | + | cutoff := now.Add(-crashLoopWindow) | |||
| 184 | + | pruned := rec.attempts[:0] | |||
| 185 | + | for _, t := range rec.attempts { | |||
| 186 | + | if t.After(cutoff) { | |||
| 187 | + | pruned = append(pruned, t) | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | rec.attempts = pruned | |||
| 191 | + | ||||
| 192 | + | // Check for crash loop. | |||
| 193 | + | if len(rec.attempts) >= crashLoopThreshold { | |||
| 194 | + | backoff := m.computeBackoff(rec.consecutive) | |||
| 195 | + | rec.backoffEnd = now.Add(backoff) | |||
| 196 | + | m.mu.Unlock() | |||
| 197 | + | ||||
| 198 | + | log.Error().Str("domain", domainName). | |||
| 199 | + | Int("consecutive", rec.consecutive). | |||
| 200 | + | Dur("backoff", backoff). | |||
| 201 | + | Msg("monitor: crash loop detected, backing off") | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | m.mu.Unlock() | |||
| 205 | + | ||||
| 206 | + | // Verify container ID still matches tracked state (deploy may have replaced it). | |||
| 207 | + | m.service.mu.RLock() | |||
| 208 | + | current := m.service.containers[domainName] | |||
| 209 | + | m.service.mu.RUnlock() | |||
| 210 | + | if current == nil || current.ID != containerID { | |||
| 211 | + | log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart") | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | log.Warn().Str("domain", domainName).Str("container_id", containerID). | |||
| 216 | + | Int("exit_code", exitCode). | |||
| 217 | + | Msg("monitor: restarting crashed container") | |||
| 218 | + | ||||
| 219 | + | if err := m.service.runtime.StartContainer(ctx, containerID); err != nil { | |||
| 220 | + | log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container") | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func (m *Monitor) computeBackoff(consecutive int) time.Duration { | |||
| 225 | + | // Exponential backoff: 1min, 2min, 4min, 8min, cap 15min. | |||
| 226 | + | base := time.Minute | |||
| 227 | + | shift := consecutive - crashLoopThreshold | |||
| 228 | + | if shift < 0 { | |||
| 229 | + | shift = 0 | |||
| 230 | + | } | |||
| 231 | + | if shift > 4 { | |||
| 232 | + | shift = 4 // cap shift to avoid overflow (max 16min, then capped to backoffCap) | |||
| 233 | + | } | |||
| 234 | + | backoff := base << uint(shift) // #nosec G115 -- shift is bounded [0,4] | |||
| 235 | + | if backoff > backoffCap { | |||
| 236 | + | backoff = backoffCap | |||
| 237 | + | } | |||
| 238 | + | return backoff | |||
| 239 | + | } | |||
A internal/usecase/container/monitor_test.go +324 -0
| @@ -0,0 +1,324 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func newTestService(runtime *mocks.MockContainerRuntime) *Service { | |||
| 17 | + | return &Service{ | |||
| 18 | + | runtime: runtime, | |||
| 19 | + | config: Config{}, | |||
| 20 | + | containers: make(map[string]*domain.Container), | |||
| 21 | + | attachments: make(map[string][]string), | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func monitorTestContext() context.Context { | |||
| 26 | + | return zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestMonitor_RestartsCrashedContainer(t *testing.T) { | |||
| 30 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 31 | + | svc := newTestService(runtime) | |||
| 32 | + | ||||
| 33 | + | // Track a container. | |||
| 34 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 35 | + | ID: "ctr-1", | |||
| 36 | + | Image: "myapp:latest", | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // Inspect returns exited with non-zero exit code. | |||
| 40 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 41 | + | ID: "ctr-1", | |||
| 42 | + | Status: string(domain.ContainerStatusExited), | |||
| 43 | + | ExitCode: 1, | |||
| 44 | + | }, nil) | |||
| 45 | + | ||||
| 46 | + | // Monitor should restart it. | |||
| 47 | + | runtime.EXPECT().StartContainer(mock.Anything, "ctr-1").Return(nil) | |||
| 48 | + | ||||
| 49 | + | m := newMonitor(svc) | |||
| 50 | + | m.check(monitorTestContext()) | |||
| 51 | + | ||||
| 52 | + | runtime.AssertExpectations(t) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestMonitor_SkipsGracefulExit(t *testing.T) { | |||
| 56 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 57 | + | svc := newTestService(runtime) | |||
| 58 | + | ||||
| 59 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 60 | + | ID: "ctr-1", | |||
| 61 | + | Image: "myapp:latest", | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Exited with code 0 — graceful stop. | |||
| 65 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 66 | + | ID: "ctr-1", | |||
| 67 | + | Status: string(domain.ContainerStatusExited), | |||
| 68 | + | ExitCode: 0, | |||
| 69 | + | }, nil) | |||
| 70 | + | ||||
| 71 | + | // Should NOT call StartContainer. | |||
| 72 | + | ||||
| 73 | + | m := newMonitor(svc) | |||
| 74 | + | m.check(monitorTestContext()) | |||
| 75 | + | ||||
| 76 | + | runtime.AssertExpectations(t) | |||
| 77 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TestMonitor_SkipsRunningContainer(t *testing.T) { | |||
| 81 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 82 | + | svc := newTestService(runtime) | |||
| 83 | + | ||||
| 84 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 85 | + | ID: "ctr-1", | |||
| 86 | + | Image: "myapp:latest", | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 90 | + | ID: "ctr-1", | |||
| 91 | + | Status: string(domain.ContainerStatusRunning), | |||
| 92 | + | }, nil) | |||
| 93 | + | ||||
| 94 | + | // Running container — check health status. | |||
| 95 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil) | |||
| 96 | + | ||||
| 97 | + | m := newMonitor(svc) | |||
| 98 | + | m.check(monitorTestContext()) | |||
| 99 | + | ||||
| 100 | + | runtime.AssertExpectations(t) | |||
| 101 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestMonitor_RestartsUnhealthyContainer(t *testing.T) { | |||
| 105 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 106 | + | svc := newTestService(runtime) | |||
| 107 | + | ||||
| 108 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 109 | + | ID: "ctr-1", | |||
| 110 | + | Image: "myapp:latest", | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 114 | + | ID: "ctr-1", | |||
| 115 | + | Status: string(domain.ContainerStatusRunning), | |||
| 116 | + | }, nil) | |||
| 117 | + | ||||
| 118 | + | // Running but Docker health says unhealthy. | |||
| 119 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("unhealthy", true, nil) | |||
| 120 | + | ||||
| 121 | + | // Should restart the unhealthy container. | |||
| 122 | + | runtime.EXPECT().RestartContainer(mock.Anything, "ctr-1").Return(nil) | |||
| 123 | + | ||||
| 124 | + | m := newMonitor(svc) | |||
| 125 | + | m.check(monitorTestContext()) | |||
| 126 | + | ||||
| 127 | + | runtime.AssertExpectations(t) | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestMonitor_CrashLoopBackoff(t *testing.T) { | |||
| 131 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 132 | + | svc := newTestService(runtime) | |||
| 133 | + | ||||
| 134 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 135 | + | ID: "ctr-1", | |||
| 136 | + | Image: "myapp:latest", | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | m := newMonitor(svc) | |||
| 140 | + | ctx := monitorTestContext() | |||
| 141 | + | now := time.Now() | |||
| 142 | + | ||||
| 143 | + | // Simulate 3 crashes within the crash loop window. | |||
| 144 | + | m.mu.Lock() | |||
| 145 | + | m.history["app.example.com"] = &restartRecord{ | |||
| 146 | + | attempts: []time.Time{now.Add(-2 * time.Minute), now.Add(-1 * time.Minute)}, | |||
| 147 | + | consecutive: 2, | |||
| 148 | + | } | |||
| 149 | + | m.mu.Unlock() | |||
| 150 | + | ||||
| 151 | + | // Third crash — this should trigger backoff. | |||
| 152 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 153 | + | ID: "ctr-1", | |||
| 154 | + | Status: string(domain.ContainerStatusExited), | |||
| 155 | + | ExitCode: 137, | |||
| 156 | + | }, nil) | |||
| 157 | + | ||||
| 158 | + | // Should NOT call StartContainer because crash loop is detected. | |||
| 159 | + | m.check(ctx) | |||
| 160 | + | ||||
| 161 | + | runtime.AssertExpectations(t) | |||
| 162 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 163 | + | ||||
| 164 | + | // Verify backoff was set. | |||
| 165 | + | m.mu.Lock() | |||
| 166 | + | rec := m.history["app.example.com"] | |||
| 167 | + | assert.False(t, rec.backoffEnd.IsZero(), "backoff should be set after crash loop detection") | |||
| 168 | + | assert.True(t, rec.backoffEnd.After(time.Now()), "backoff end should be in the future") | |||
| 169 | + | m.mu.Unlock() | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestMonitor_BackoffPreventsRestart(t *testing.T) { | |||
| 173 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 174 | + | svc := newTestService(runtime) | |||
| 175 | + | ||||
| 176 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 177 | + | ID: "ctr-1", | |||
| 178 | + | Image: "myapp:latest", | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | m := newMonitor(svc) | |||
| 182 | + | ||||
| 183 | + | // Set backoff into the future. | |||
| 184 | + | m.mu.Lock() | |||
| 185 | + | m.history["app.example.com"] = &restartRecord{ | |||
| 186 | + | backoffEnd: time.Now().Add(5 * time.Minute), | |||
| 187 | + | consecutive: 3, | |||
| 188 | + | } | |||
| 189 | + | m.mu.Unlock() | |||
| 190 | + | ||||
| 191 | + | // Container is crashed. | |||
| 192 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 193 | + | ID: "ctr-1", | |||
| 194 | + | Status: string(domain.ContainerStatusExited), | |||
| 195 | + | ExitCode: 1, | |||
| 196 | + | }, nil) | |||
| 197 | + | ||||
| 198 | + | // Should NOT restart — in backoff. | |||
| 199 | + | m.check(monitorTestContext()) | |||
| 200 | + | ||||
| 201 | + | runtime.AssertExpectations(t) | |||
| 202 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestMonitor_ClearsCrashHistoryAfterStableRunning(t *testing.T) { | |||
| 206 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 207 | + | svc := newTestService(runtime) | |||
| 208 | + | ||||
| 209 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 210 | + | ID: "ctr-1", | |||
| 211 | + | Image: "myapp:latest", | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | m := newMonitor(svc) | |||
| 215 | + | ||||
| 216 | + | // Container has been running for longer than stableRunningDuration. | |||
| 217 | + | m.mu.Lock() | |||
| 218 | + | m.history["app.example.com"] = &restartRecord{ | |||
| 219 | + | attempts: []time.Time{time.Now().Add(-10 * time.Minute)}, | |||
| 220 | + | consecutive: 2, | |||
| 221 | + | lastSeen: time.Now().Add(-6 * time.Minute), // 6 min ago > stableRunningDuration (5 min) | |||
| 222 | + | } | |||
| 223 | + | m.mu.Unlock() | |||
| 224 | + | ||||
| 225 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{ | |||
| 226 | + | ID: "ctr-1", | |||
| 227 | + | Status: string(domain.ContainerStatusRunning), | |||
| 228 | + | }, nil) | |||
| 229 | + | ||||
| 230 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil) | |||
| 231 | + | ||||
| 232 | + | m.check(monitorTestContext()) | |||
| 233 | + | ||||
| 234 | + | // History should be cleared. | |||
| 235 | + | m.mu.Lock() | |||
| 236 | + | _, exists := m.history["app.example.com"] | |||
| 237 | + | m.mu.Unlock() | |||
| 238 | + | assert.False(t, exists, "crash history should be cleared after stable running") | |||
| 239 | + | ||||
| 240 | + | runtime.AssertExpectations(t) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func TestMonitor_SkipsReplacedContainer(t *testing.T) { | |||
| 244 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 245 | + | svc := newTestService(runtime) | |||
| 246 | + | ||||
| 247 | + | // Track the OLD container ID. | |||
| 248 | + | svc.containers["app.example.com"] = &domain.Container{ | |||
| 249 | + | ID: "ctr-NEW", | |||
| 250 | + | Image: "myapp:latest", | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | m := newMonitor(svc) | |||
| 254 | + | ||||
| 255 | + | // Monitor snapshot captured "ctr-OLD" but a deploy replaced it with "ctr-NEW". | |||
| 256 | + | // We simulate this by directly calling checkContainer with the old ID. | |||
| 257 | + | ctx := monitorTestContext() | |||
| 258 | + | log := zerowrap.FromCtx(ctx) | |||
| 259 | + | ||||
| 260 | + | // Inspect the old container — it's exited. | |||
| 261 | + | runtime.EXPECT().InspectContainer(mock.Anything, "ctr-OLD").Return(&domain.Container{ | |||
| 262 | + | ID: "ctr-OLD", | |||
| 263 | + | Status: string(domain.ContainerStatusExited), | |||
| 264 | + | ExitCode: 1, | |||
| 265 | + | }, nil) | |||
| 266 | + | ||||
| 267 | + | // Should NOT restart — tracked container has been replaced. | |||
| 268 | + | oldTracked := &domain.Container{ID: "ctr-OLD", Image: "myapp:latest"} | |||
| 269 | + | m.checkContainer(ctx, log, "app.example.com", oldTracked, time.Now()) | |||
| 270 | + | ||||
| 271 | + | runtime.AssertExpectations(t) | |||
| 272 | + | runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func TestMonitor_ComputeBackoff(t *testing.T) { | |||
| 276 | + | m := &Monitor{} | |||
| 277 | + | ||||
| 278 | + | tests := []struct { | |||
| 279 | + | consecutive int | |||
| 280 | + | expected time.Duration | |||
| 281 | + | }{ | |||
| 282 | + | {3, 1 * time.Minute}, // first backoff | |||
| 283 | + | {4, 2 * time.Minute}, // second | |||
| 284 | + | {5, 4 * time.Minute}, // third | |||
| 285 | + | {6, 8 * time.Minute}, // fourth | |||
| 286 | + | {7, 15 * time.Minute}, // capped | |||
| 287 | + | {10, 15 * time.Minute}, // still capped | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | for _, tt := range tests { | |||
| 291 | + | got := m.computeBackoff(tt.consecutive) | |||
| 292 | + | assert.Equal(t, tt.expected, got, "consecutive=%d", tt.consecutive) | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestMonitor_StartStop(t *testing.T) { | |||
| 297 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 298 | + | svc := newTestService(runtime) | |||
| 299 | + | ||||
| 300 | + | m := newMonitor(svc) | |||
| 301 | + | m.interval = 50 * time.Millisecond // fast ticking for test | |||
| 302 | + | ||||
| 303 | + | ctx, cancel := context.WithCancel(monitorTestContext()) | |||
| 304 | + | defer cancel() | |||
| 305 | + | ||||
| 306 | + | m.Start(ctx) | |||
| 307 | + | ||||
| 308 | + | // Let it tick a couple times. | |||
| 309 | + | time.Sleep(150 * time.Millisecond) | |||
| 310 | + | ||||
| 311 | + | // Stop should return promptly. | |||
| 312 | + | done := make(chan struct{}) | |||
| 313 | + | go func() { | |||
| 314 | + | m.Stop() | |||
| 315 | + | close(done) | |||
| 316 | + | }() | |||
| 317 | + | ||||
| 318 | + | select { | |||
| 319 | + | case <-done: | |||
| 320 | + | // OK | |||
| 321 | + | case <-time.After(2 * time.Second): | |||
| 322 | + | t.Fatal("monitor.Stop() did not return within 2 seconds") | |||
| 323 | + | } | |||
| 324 | + | } | |||
M internal/usecase/container/service.go +90 -66
| @@ -69,6 +69,7 @@ type Service struct { | |||||
| 69 | attachments map[string][]string // ownerDomain → []containerIDs | 69 | attachments map[string][]string // ownerDomain → []containerIDs | ||
| 70 | mu sync.RWMutex | 70 | mu sync.RWMutex | ||
| 71 | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | 71 | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | ||
| 72 | + | monitor *Monitor | |||
| 72 | } | 73 | } | ||
| 73 | 74 | ||||
| 74 | // domainDeployLock is a context-aware mutex using a buffered channel. | 75 | // domainDeployLock is a context-aware mutex using a buffered channel. | ||
| @@ -347,9 +348,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 347 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | 348 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | ||
| 348 | return nil, log.WrapErr(err, "failed to start container") | 349 | return nil, log.WrapErr(err, "failed to start container") | ||
| 349 | } | 350 | } | ||
| 350 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | 351 | + | if !domain.IsSkipReadiness(ctx) { |
| 351 | - | s.cleanupFailedContainer(ctx, newContainer.ID) | 352 | + | if err := s.waitForReady(ctx, newContainer.ID); err != nil { |
| 352 | - | return nil, log.WrapErr(err, "container failed readiness check") | 353 | + | s.cleanupFailedContainer(ctx, newContainer.ID) |
| 354 | + | return nil, log.WrapErr(err, "container failed readiness check") | |||
| 355 | + | } | |||
| 353 | } | 356 | } | ||
| 354 | 357 | ||||
| 355 | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) | 358 | inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID) | ||
| @@ -805,6 +808,8 @@ func (s *Service) SyncContainers(ctx context.Context) error { | |||||
| 805 | } | 808 | } | ||
| 806 | 809 | ||||
| 807 | // AutoStart starts containers for the provided routes that aren't running. | 810 | // AutoStart starts containers for the provided routes that aren't running. | ||
| 811 | + | // It skips readiness checks to avoid blocking boot; the background monitor | |||
| 812 | + | // handles crash recovery. | |||
| 808 | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | 813 | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | ||
| 809 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 814 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 810 | zerowrap.FieldLayer: "usecase", | 815 | zerowrap.FieldLayer: "usecase", | ||
| @@ -814,37 +819,67 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |||||
| 814 | 819 | ||||
| 815 | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | 820 | log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes") | ||
| 816 | 821 | ||||
| 817 | - | var started, skipped, errors int | 822 | + | // Filter out routes that already have a running container. |
| 823 | + | var pending []domain.Route | |||
| 824 | + | skipped := 0 | |||
| 818 | for _, route := range routes { | 825 | for _, route := range routes { | ||
| 819 | - | // Check if container already exists and is running | |||
| 820 | if _, exists := s.Get(ctx, route.Domain); exists { | 826 | if _, exists := s.Get(ctx, route.Domain); exists { | ||
| 821 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | 827 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | ||
| 822 | skipped++ | 828 | skipped++ | ||
| 823 | - | continue | 829 | + | } else { |
| 830 | + | pending = append(pending, route) | |||
| 824 | } | 831 | } | ||
| 832 | + | } | |||
| 825 | 833 | ||||
| 826 | - | log.Info(). | 834 | + | if len(pending) == 0 { |
| 827 | - | Str("domain", route.Domain). | 835 | + | log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running") |
| 828 | - | Str("image", route.Image). | 836 | + | return nil |
| 829 | - | Msg("auto-starting container for route") | 837 | + | } |
| 830 | 838 | ||||
| 831 | - | if _, err := s.Deploy(ctx, route); err != nil { | 839 | + | // Deploy all pending routes concurrently with readiness checks skipped. |
| 832 | - | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container") | 840 | + | deployCtx := domain.WithSkipReadiness(ctx) |
| 833 | - | errors++ | 841 | + | |
| 834 | - | continue | 842 | + | type result struct { |
| 835 | - | } | 843 | + | route domain.Route |
| 844 | + | err error | |||
| 845 | + | } | |||
| 846 | + | results := make(chan result, len(pending)) | |||
| 847 | + | sem := make(chan struct{}, 4) // limit concurrency | |||
| 836 | 848 | ||||
| 837 | - | started++ | 849 | + | for _, route := range pending { |
| 850 | + | sem <- struct{}{} | |||
| 851 | + | go func(r domain.Route) { | |||
| 852 | + | defer func() { <-sem }() | |||
| 853 | + | ||||
| 854 | + | log.Info().Str("domain", r.Domain).Str("image", r.Image).Msg("auto-starting container for route") | |||
| 855 | + | ||||
| 856 | + | if _, err := s.Deploy(deployCtx, r); err != nil { | |||
| 857 | + | results <- result{route: r, err: err} | |||
| 858 | + | return | |||
| 859 | + | } | |||
| 860 | + | results <- result{route: r} | |||
| 861 | + | }(route) | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | var started, errCount int | |||
| 865 | + | for i := 0; i < len(pending); i++ { | |||
| 866 | + | res := <-results | |||
| 867 | + | if res.err != nil { | |||
| 868 | + | log.Warn().Err(res.err).Str("domain", res.route.Domain).Msg("failed to auto-start container") | |||
| 869 | + | errCount++ | |||
| 870 | + | } else { | |||
| 871 | + | started++ | |||
| 872 | + | } | |||
| 838 | } | 873 | } | ||
| 839 | 874 | ||||
| 840 | log.Info(). | 875 | log.Info(). | ||
| 841 | Int("started", started). | 876 | Int("started", started). | ||
| 842 | Int("skipped", skipped). | 877 | Int("skipped", skipped). | ||
| 843 | - | Int("errors", errors). | 878 | + | Int("errors", errCount). |
| 844 | Msg("auto-start completed") | 879 | Msg("auto-start completed") | ||
| 845 | 880 | ||||
| 846 | - | if errors > 0 { | 881 | + | if errCount > 0 { |
| 847 | - | return fmt.Errorf("auto-start completed with %d errors", errors) | 882 | + | return fmt.Errorf("auto-start completed with %d errors", errCount) |
| 848 | } | 883 | } | ||
| 849 | return nil | 884 | return nil | ||
| 850 | } | 885 | } | ||
| @@ -858,9 +893,8 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||||
| 858 | log := zerowrap.FromCtx(ctx) | 893 | log := zerowrap.FromCtx(ctx) | ||
| 859 | log.Info().Msg("shutting down container manager...") | 894 | log.Info().Msg("shutting down container manager...") | ||
| 860 | 895 | ||||
| 861 | - | if err := s.stopAllManagedContainers(ctx); err != nil { | 896 | + | // Containers are left running across Gordon restarts. |
| 862 | - | log.WrapErr(err, "failed to stop all containers during shutdown") | 897 | + | // SyncContainers + AutoStart will pick them back up on next boot. |
| 863 | - | } | |||
| 864 | 898 | ||||
| 865 | // Close log writer to stop all log collection | 899 | // Close log writer to stop all log collection | ||
| 866 | if s.logWriter != nil { | 900 | if s.logWriter != nil { | ||
| @@ -873,6 +907,33 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||||
| 873 | return nil | 907 | return nil | ||
| 874 | } | 908 | } | ||
| 875 | 909 | ||||
| 910 | + | // StartMonitor begins background monitoring of tracked containers. | |||
| 911 | + | // The monitor restarts crashed containers and detects crash loops. | |||
| 912 | + | // Safe to call multiple times; subsequent calls are no-ops. | |||
| 913 | + | func (s *Service) StartMonitor(ctx context.Context) { | |||
| 914 | + | s.mu.Lock() | |||
| 915 | + | if s.monitor != nil { | |||
| 916 | + | s.mu.Unlock() | |||
| 917 | + | return | |||
| 918 | + | } | |||
| 919 | + | s.monitor = newMonitor(s) | |||
| 920 | + | m := s.monitor | |||
| 921 | + | m.Start(ctx) | |||
| 922 | + | s.mu.Unlock() | |||
| 923 | + | } | |||
| 924 | + | ||||
| 925 | + | // StopMonitor stops the background container monitor. | |||
| 926 | + | // Safe to call multiple times; subsequent calls are no-ops. | |||
| 927 | + | func (s *Service) StopMonitor() { | |||
| 928 | + | s.mu.Lock() | |||
| 929 | + | m := s.monitor | |||
| 930 | + | s.monitor = nil | |||
| 931 | + | s.mu.Unlock() | |||
| 932 | + | if m != nil { | |||
| 933 | + | m.Stop() | |||
| 934 | + | } | |||
| 935 | + | } | |||
| 936 | + | ||||
| 876 | // UpdateConfig updates the service configuration. | 937 | // UpdateConfig updates the service configuration. | ||
| 877 | func (s *Service) UpdateConfig(config Config) { | 938 | func (s *Service) UpdateConfig(config Config) { | ||
| 878 | s.mu.Lock() | 939 | s.mu.Lock() | ||
| @@ -1720,46 +1781,6 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 1720 | return nil | 1781 | return nil | ||
| 1721 | } | 1782 | } | ||
| 1722 | 1783 | ||||
| 1723 | - | func (s *Service) stopAllManagedContainers(ctx context.Context) error { | |||
| 1724 | - | log := zerowrap.FromCtx(ctx) | |||
| 1725 | - | ||||
| 1726 | - | s.mu.RLock() | |||
| 1727 | - | containers := make(map[string]*domain.Container, len(s.containers)) | |||
| 1728 | - | maps.Copy(containers, s.containers) | |||
| 1729 | - | s.mu.RUnlock() | |||
| 1730 | - | ||||
| 1731 | - | if len(containers) == 0 { | |||
| 1732 | - | log.Info().Msg("no managed containers to stop") | |||
| 1733 | - | return nil | |||
| 1734 | - | } | |||
| 1735 | - | ||||
| 1736 | - | log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...") | |||
| 1737 | - | ||||
| 1738 | - | errorCount := 0 | |||
| 1739 | - | for d, c := range containers { | |||
| 1740 | - | log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container") | |||
| 1741 | - | ||||
| 1742 | - | if err := s.runtime.StopContainer(ctx, c.ID); err != nil { | |||
| 1743 | - | log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID}) | |||
| 1744 | - | errorCount++ | |||
| 1745 | - | continue | |||
| 1746 | - | } | |||
| 1747 | - | ||||
| 1748 | - | s.mu.Lock() | |||
| 1749 | - | delete(s.containers, d) | |||
| 1750 | - | s.mu.Unlock() | |||
| 1751 | - | ||||
| 1752 | - | log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully") | |||
| 1753 | - | } | |||
| 1754 | - | ||||
| 1755 | - | if errorCount > 0 { | |||
| 1756 | - | return fmt.Errorf("failed to stop %d containers", errorCount) | |||
| 1757 | - | } | |||
| 1758 | - | ||||
| 1759 | - | log.Info().Msg("all managed containers stopped successfully") | |||
| 1760 | - | return nil | |||
| 1761 | - | } | |||
| 1762 | - | ||||
| 1763 | // Utility functions | 1784 | // Utility functions | ||
| 1764 | 1785 | ||||
| 1765 | func normalizeImageRef(image string) string { | 1786 | func normalizeImageRef(image string) string { | ||
| @@ -1969,7 +1990,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||||
| 1969 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | 1990 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | ||
| 1970 | defer cancel() | 1991 | defer cancel() | ||
| 1971 | 1992 | ||||
| 1972 | - | lastStatus := "starting" | 1993 | + | var lastStatus string |
| 1973 | for { | 1994 | for { | ||
| 1974 | status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID) | 1995 | status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID) | ||
| 1975 | if err != nil { | 1996 | if err != nil { | ||
| @@ -1978,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||||
| 1978 | if !hasHealthcheck { | 1999 | if !hasHealthcheck { | ||
| 1979 | return errors.New("no healthcheck detected") | 2000 | return errors.New("no healthcheck detected") | ||
| 1980 | } | 2001 | } | ||
| 2002 | + | // Treat empty health status as transitional startup. Some runtimes can | |||
| 2003 | + | // temporarily report an empty status before first probe results. | |||
| 2004 | + | if status == "" { | |||
| 2005 | + | status = "starting" | |||
| 2006 | + | } | |||
| 1981 | if status == "healthy" { | 2007 | if status == "healthy" { | ||
| 1982 | return nil | 2008 | return nil | ||
| 1983 | } | 2009 | } | ||
| 1984 | - | if status != "" { | 2010 | + | lastStatus = status |
| 1985 | - | lastStatus = status | |||
| 1986 | - | } | |||
| 1987 | 2011 | ||||
| 1988 | select { | 2012 | select { | ||
| 1989 | case <-time.After(time.Second): | 2013 | case <-time.After(time.Second): | ||
A internal/usecase/container/service_readiness_test.go +47 -0
| @@ -0,0 +1,47 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/mock" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) { | |||
| 15 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 16 | + | svc := NewService(runtime, nil, nil, nil, Config{ | |||
| 17 | + | ReadinessMode: "auto", | |||
| 18 | + | ReadinessDelay: time.Millisecond, | |||
| 19 | + | }) | |||
| 20 | + | ||||
| 21 | + | ctx := testContext() | |||
| 22 | + | containerID := "container-1" | |||
| 23 | + | ||||
| 24 | + | // Initial running poll | |||
| 25 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 26 | + | // Auto mode probes for healthcheck support first | |||
| 27 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once() | |||
| 28 | + | // Delay-mode fallback verification | |||
| 29 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once() | |||
| 30 | + | ||||
| 31 | + | err := svc.waitForReady(ctx, containerID) | |||
| 32 | + | assert.NoError(t, err) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) { | |||
| 36 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 37 | + | svc := NewService(runtime, nil, nil, nil, Config{}) | |||
| 38 | + | ||||
| 39 | + | ctx := testContext() | |||
| 40 | + | containerID := "container-1" | |||
| 41 | + | ||||
| 42 | + | runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once() | |||
| 43 | + | ||||
| 44 | + | err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond) | |||
| 45 | + | assert.Error(t, err) | |||
| 46 | + | assert.True(t, strings.Contains(err.Error(), "last status: starting")) | |||
| 47 | + | } | |||
M internal/usecase/container/service_test.go +9 -33
| @@ -630,35 +630,13 @@ func TestService_Shutdown(t *testing.T) { | |||||
| 630 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | 630 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | ||
| 631 | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | 631 | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | ||
| 632 | 632 | ||||
| 633 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | 633 | + | // Shutdown no longer stops containers — they are left running for |
| 634 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil) | 634 | + | // SyncContainers + AutoStart to pick back up on next boot. |
| 635 | - | ||||
| 636 | err := svc.Shutdown(ctx) | 635 | err := svc.Shutdown(ctx) | ||
| 637 | 636 | ||||
| 638 | assert.NoError(t, err) | 637 | assert.NoError(t, err) | ||
| 639 | - | assert.Empty(t, svc.containers) | 638 | + | // Containers remain tracked (not stopped). |
| 640 | - | } | 639 | + | assert.Len(t, svc.containers, 2) |
| 641 | - | ||||
| 642 | - | func TestService_Shutdown_PartialFailure(t *testing.T) { | |||
| 643 | - | runtime := mocks.NewMockContainerRuntime(t) | |||
| 644 | - | envLoader := mocks.NewMockEnvLoader(t) | |||
| 645 | - | eventBus := mocks.NewMockEventPublisher(t) | |||
| 646 | - | ||||
| 647 | - | svc := NewService(runtime, envLoader, eventBus, nil, Config{}) | |||
| 648 | - | ctx := testContext() | |||
| 649 | - | ||||
| 650 | - | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |||
| 651 | - | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | |||
| 652 | - | ||||
| 653 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | |||
| 654 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed")) | |||
| 655 | - | ||||
| 656 | - | err := svc.Shutdown(ctx) | |||
| 657 | - | ||||
| 658 | - | // Shutdown logs errors but always returns nil for graceful degradation | |||
| 659 | - | assert.NoError(t, err) | |||
| 660 | - | // One container should still be tracked (the one that failed to stop) | |||
| 661 | - | assert.Len(t, svc.containers, 1) | |||
| 662 | } | 640 | } | ||
| 663 | 641 | ||||
| 664 | func TestService_Restart_NotFound(t *testing.T) { | 642 | func TestService_Restart_NotFound(t *testing.T) { | ||
| @@ -1196,14 +1174,14 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1196 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | 1174 | {Domain: "app2.example.com", Image: "myapp2:latest"}, | ||
| 1197 | } | 1175 | } | ||
| 1198 | 1176 | ||||
| 1199 | - | // Setup mocks for first route deployment | 1177 | + | // Setup mocks for route deployments |
| 1200 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | 1178 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | ||
| 1201 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | 1179 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | ||
| 1202 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | 1180 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | ||
| 1203 | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | 1181 | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | ||
| 1204 | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | 1182 | runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | ||
| 1205 | 1183 | ||||
| 1206 | - | // Create and start containers | 1184 | + | // Create and start containers — readiness is skipped so no IsContainerRunning calls. |
| 1207 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | 1185 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | ||
| 1208 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | 1186 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | ||
| 1209 | }, nil).Once() | 1187 | }, nil).Once() | ||
| @@ -1211,7 +1189,6 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||||
| 1211 | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | 1189 | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | ||
| 1212 | }, nil).Once() | 1190 | }, nil).Once() | ||
| 1213 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | 1191 | runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2) | ||
| 1214 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4) | |||
| 1215 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | 1192 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | ||
| 1216 | Status: "running", | 1193 | Status: "running", | ||
| 1217 | }, nil).Times(2) | 1194 | }, nil).Times(2) | ||
| @@ -1245,7 +1222,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1245 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | 1222 | {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route | ||
| 1246 | } | 1223 | } | ||
| 1247 | 1224 | ||||
| 1248 | - | // Only deploy for app2 (app1 is skipped) | 1225 | + | // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning. |
| 1249 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | 1226 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | ||
| 1250 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | 1227 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | ||
| 1251 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | 1228 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once() | ||
| @@ -1255,7 +1232,6 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||||
| 1255 | ID: "container-2", Status: "created", | 1232 | ID: "container-2", Status: "created", | ||
| 1256 | }, nil).Once() | 1233 | }, nil).Once() | ||
| 1257 | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | 1234 | runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once() | ||
| 1258 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2) | |||
| 1259 | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | 1235 | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | ||
| 1260 | Status: "running", | 1236 | Status: "running", | ||
| 1261 | }, nil).Once() | 1237 | }, nil).Once() | ||
| @@ -1313,7 +1289,8 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1313 | } | 1289 | } | ||
| 1314 | 1290 | ||||
| 1315 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | 1291 | // Key assertion: PullImage should be called with localhost:5000 rewrite, | ||
| 1316 | - | // NOT the original reg.example.com/myapp:latest | 1292 | + | // NOT the original reg.example.com/myapp:latest. |
| 1293 | + | // Readiness is skipped — no IsContainerRunning calls. | |||
| 1317 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | 1294 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | ||
| 1318 | runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | 1295 | runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | ||
| 1319 | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil) | 1296 | runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil) | ||
| @@ -1325,7 +1302,6 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||||
| 1325 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | 1302 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | ||
| 1326 | }, nil) | 1303 | }, nil) | ||
| 1327 | runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil) | 1304 | runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil) | ||
| 1328 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2) | |||
| 1329 | runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{ | 1305 | runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{ | ||
| 1330 | Status: "running", | 1306 | Status: "running", | ||
| 1331 | }, nil) | 1307 | }, nil) | ||
A internal/usecase/images/service.go +403 -0
| @@ -0,0 +1,403 @@ | |||||
| 1 | + | // Package images implements the image management use case. | |||
| 2 | + | package images | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "sort" | |||
| 8 | + | "strings" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/zerowrap" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 14 | + | "github.com/bnema/gordon/internal/domain" | |||
| 15 | + | "github.com/bnema/gordon/pkg/runtime" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Service implements image list and prune operations. | |||
| 19 | + | type Service struct { | |||
| 20 | + | runtime imageRuntime | |||
| 21 | + | manifestStorage out.ManifestStorage | |||
| 22 | + | blobStorage out.BlobStorage | |||
| 23 | + | log zerowrap.Logger | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type imageRuntime interface { | |||
| 27 | + | ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error) | |||
| 28 | + | PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // NewService creates a new images service. | |||
| 32 | + | func NewService( | |||
| 33 | + | rt imageRuntime, | |||
| 34 | + | manifestStorage out.ManifestStorage, | |||
| 35 | + | blobStorage out.BlobStorage, | |||
| 36 | + | log zerowrap.Logger, | |||
| 37 | + | ) *Service { | |||
| 38 | + | return &Service{ | |||
| 39 | + | runtime: rt, | |||
| 40 | + | manifestStorage: manifestStorage, | |||
| 41 | + | blobStorage: blobStorage, | |||
| 42 | + | log: log, | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // ListImages returns images known by the runtime. | |||
| 47 | + | func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||
| 48 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 49 | + | zerowrap.FieldLayer: "usecase", | |||
| 50 | + | zerowrap.FieldUseCase: "ListImages", | |||
| 51 | + | }) | |||
| 52 | + | log := zerowrap.FromCtx(ctx) | |||
| 53 | + | ||||
| 54 | + | details, err := s.runtime.ListImagesDetailed(ctx) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, log.WrapErr(err, "failed to list images") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | images := make([]domain.ImageInfo, 0, len(details)) | |||
| 60 | + | for _, detail := range details { | |||
| 61 | + | if isDanglingImage(detail.RepoTags) { | |||
| 62 | + | images = append(images, domain.ImageInfo{ | |||
| 63 | + | Repository: "", | |||
| 64 | + | Tag: "", | |||
| 65 | + | Size: detail.Size, | |||
| 66 | + | Created: detail.Created, | |||
| 67 | + | ID: detail.ID, | |||
| 68 | + | Dangling: true, | |||
| 69 | + | }) | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | for _, repoTag := range detail.RepoTags { | |||
| 74 | + | if repoTag == "" || repoTag == "<none>:<none>" { | |||
| 75 | + | continue | |||
| 76 | + | } | |||
| 77 | + | repository, tag := splitRepoTag(repoTag) | |||
| 78 | + | images = append(images, domain.ImageInfo{ | |||
| 79 | + | Repository: repository, | |||
| 80 | + | Tag: tag, | |||
| 81 | + | Size: detail.Size, | |||
| 82 | + | Created: detail.Created, | |||
| 83 | + | ID: detail.ID, | |||
| 84 | + | Dangling: false, | |||
| 85 | + | }) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | return images, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // PruneRuntime prunes dangling images from the runtime. | |||
| 93 | + | func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) { | |||
| 94 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 95 | + | zerowrap.FieldLayer: "usecase", | |||
| 96 | + | zerowrap.FieldUseCase: "PruneRuntime", | |||
| 97 | + | }) | |||
| 98 | + | log := zerowrap.FromCtx(ctx) | |||
| 99 | + | ||||
| 100 | + | pruneReport, err := s.runtime.PruneImages(ctx, true) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | return domain.ImagePruneReport{ | |||
| 106 | + | Runtime: domain.RuntimePruneResult{ | |||
| 107 | + | DeletedCount: len(pruneReport.DeletedIDs), | |||
| 108 | + | SpaceReclaimed: pruneReport.SpaceReclaimed, | |||
| 109 | + | }, | |||
| 110 | + | }, nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // PruneRegistry applies tag retention and blob garbage collection. | |||
| 114 | + | // It keeps the most recent keepLast tags from tagInfos and always keeps | |||
| 115 | + | // the "latest" tag when present, so up to keepLast+1 tags can be retained. | |||
| 116 | + | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |||
| 117 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 118 | + | zerowrap.FieldLayer: "usecase", | |||
| 119 | + | zerowrap.FieldUseCase: "PruneRegistry", | |||
| 120 | + | "keepLast": keepLast, | |||
| 121 | + | }) | |||
| 122 | + | log := zerowrap.FromCtx(ctx) | |||
| 123 | + | ||||
| 124 | + | if keepLast <= 0 { | |||
| 125 | + | return domain.ImagePruneReport{}, nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | repositories, err := s.manifestStorage.ListRepositories() | |||
| 129 | + | if err != nil { | |||
| 130 | + | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | referencedDigests := make(map[string]struct{}) | |||
| 134 | + | report := domain.ImagePruneReport{} | |||
| 135 | + | ||||
| 136 | + | for _, repository := range repositories { | |||
| 137 | + | tagInfos, err := s.loadRepositoryTagInfos(repository) | |||
| 138 | + | if err != nil { | |||
| 139 | + | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to load repository tags") | |||
| 140 | + | } | |||
| 141 | + | if len(tagInfos) == 0 { | |||
| 142 | + | continue | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | keptTags := buildKeptTagSet(tagInfos, keepLast) | |||
| 146 | + | removed, err := s.deleteUnkeptManifests(repository, tagInfos, keptTags) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest") | |||
| 149 | + | } | |||
| 150 | + | report.Registry.TagsRemoved += removed | |||
| 151 | + | ||||
| 152 | + | if err := s.collectKeptTagDigests(log, repository, tagInfos, keptTags, referencedDigests); err != nil { | |||
| 153 | + | return domain.ImagePruneReport{}, err | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | blobs, err := s.blobStorage.ListBlobs() | |||
| 158 | + | if err != nil { | |||
| 159 | + | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs") | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | for _, digest := range blobs { | |||
| 163 | + | if _, referenced := referencedDigests[digest]; referenced { | |||
| 164 | + | continue | |||
| 165 | + | } | |||
| 166 | + | if err := s.blobStorage.DeleteBlob(digest); err != nil { | |||
| 167 | + | return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob") | |||
| 168 | + | } | |||
| 169 | + | report.Registry.BlobsRemoved++ | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | return report, nil | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) { | |||
| 176 | + | tags, err := s.manifestStorage.ListTags(repository) | |||
| 177 | + | if err != nil { | |||
| 178 | + | return nil, err | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | tagInfos := make([]registryTag, 0, len(tags)) | |||
| 182 | + | for _, tag := range tags { | |||
| 183 | + | modTime, err := s.manifestStorage.GetManifestModTime(repository, tag) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return nil, err | |||
| 186 | + | } | |||
| 187 | + | tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime}) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | sort.Slice(tagInfos, func(i, j int) bool { | |||
| 191 | + | if tagInfos[i].modTime.Equal(tagInfos[j].modTime) { | |||
| 192 | + | return tagInfos[i].name > tagInfos[j].name | |||
| 193 | + | } | |||
| 194 | + | return tagInfos[i].modTime.After(tagInfos[j].modTime) | |||
| 195 | + | }) | |||
| 196 | + | ||||
| 197 | + | return tagInfos, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} { | |||
| 201 | + | keptTags := make(map[string]struct{}) | |||
| 202 | + | if containsTag(tagInfos, "latest") { | |||
| 203 | + | keptTags["latest"] = struct{}{} | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | for i := 0; i < len(tagInfos) && i < keepLast; i++ { | |||
| 207 | + | keptTags[tagInfos[i].name] = struct{}{} | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | return keptTags | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) { | |||
| 214 | + | removed := 0 | |||
| 215 | + | for _, tag := range tagInfos { | |||
| 216 | + | if _, kept := keptTags[tag.name]; kept { | |||
| 217 | + | continue | |||
| 218 | + | } | |||
| 219 | + | if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil { | |||
| 220 | + | return 0, err | |||
| 221 | + | } | |||
| 222 | + | removed++ | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | return removed, nil | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func (s *Service) collectKeptTagDigests( | |||
| 229 | + | log zerowrap.Logger, | |||
| 230 | + | repository string, | |||
| 231 | + | tagInfos []registryTag, | |||
| 232 | + | keptTags map[string]struct{}, | |||
| 233 | + | referencedDigests map[string]struct{}, | |||
| 234 | + | ) error { | |||
| 235 | + | for _, tag := range tagInfos { | |||
| 236 | + | if _, kept := keptTags[tag.name]; !kept { | |||
| 237 | + | continue | |||
| 238 | + | } | |||
| 239 | + | if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil { | |||
| 240 | + | return err | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | return nil | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Prune runs runtime prune and registry prune and aggregates reports. | |||
| 248 | + | func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |||
| 249 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |||
| 250 | + | zerowrap.FieldLayer: "usecase", | |||
| 251 | + | zerowrap.FieldUseCase: "Prune", | |||
| 252 | + | "keepLast": keepLast, | |||
| 253 | + | }) | |||
| 254 | + | log := zerowrap.FromCtx(ctx) | |||
| 255 | + | ||||
| 256 | + | var report domain.ImagePruneReport | |||
| 257 | + | ||||
| 258 | + | runtimeReport, err := s.PruneRuntime(ctx) | |||
| 259 | + | if err != nil { | |||
| 260 | + | log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune") | |||
| 261 | + | } else { | |||
| 262 | + | report.Runtime = runtimeReport.Runtime | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | registryReport, err := s.PruneRegistry(ctx, keepLast) | |||
| 266 | + | if err != nil { | |||
| 267 | + | return report, err | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | report.Registry = registryReport.Registry | |||
| 271 | + | return report, nil | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | type registryTag struct { | |||
| 275 | + | name string | |||
| 276 | + | modTime time.Time | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | type manifestReferences struct { | |||
| 280 | + | blobs []string | |||
| 281 | + | childManifests []string | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func containsTag(tags []registryTag, name string) bool { | |||
| 285 | + | for _, tag := range tags { | |||
| 286 | + | if tag.name == name { | |||
| 287 | + | return true | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | return false | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (s *Service) collectReferencedDigests( | |||
| 295 | + | log zerowrap.Logger, | |||
| 296 | + | repository, reference string, | |||
| 297 | + | referencedDigests map[string]struct{}, | |||
| 298 | + | visited map[string]struct{}, | |||
| 299 | + | ) error { | |||
| 300 | + | visitKey := repository + "@" + reference | |||
| 301 | + | if _, seen := visited[visitKey]; seen { | |||
| 302 | + | return nil | |||
| 303 | + | } | |||
| 304 | + | visited[visitKey] = struct{}{} | |||
| 305 | + | ||||
| 306 | + | manifestData, _, err := s.manifestStorage.GetManifest(repository, reference) | |||
| 307 | + | if err != nil { | |||
| 308 | + | return log.WrapErr(err, "failed to read manifest") | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | refs, err := parseManifestReferences(manifestData) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return log.WrapErr(err, "failed to parse manifest") | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | for _, digest := range refs.blobs { | |||
| 317 | + | referencedDigests[digest] = struct{}{} | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | for _, childRef := range refs.childManifests { | |||
| 321 | + | referencedDigests[childRef] = struct{}{} | |||
| 322 | + | if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil { | |||
| 323 | + | return err | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | return nil | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func parseManifestReferences(data []byte) (manifestReferences, error) { | |||
| 331 | + | var manifest struct { | |||
| 332 | + | Config struct { | |||
| 333 | + | Digest string `json:"digest"` | |||
| 334 | + | } `json:"config"` | |||
| 335 | + | Layers []struct { | |||
| 336 | + | Digest string `json:"digest"` | |||
| 337 | + | } `json:"layers"` | |||
| 338 | + | Manifests []struct { | |||
| 339 | + | Digest string `json:"digest"` | |||
| 340 | + | } `json:"manifests"` | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |||
| 344 | + | return manifestReferences{}, err | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | refs := manifestReferences{ | |||
| 348 | + | blobs: make([]string, 0, len(manifest.Layers)+1), | |||
| 349 | + | childManifests: make([]string, 0, len(manifest.Manifests)), | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | if manifest.Config.Digest != "" { | |||
| 353 | + | refs.blobs = append(refs.blobs, manifest.Config.Digest) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | for _, layer := range manifest.Layers { | |||
| 357 | + | if layer.Digest == "" { | |||
| 358 | + | continue | |||
| 359 | + | } | |||
| 360 | + | refs.blobs = append(refs.blobs, layer.Digest) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | for _, child := range manifest.Manifests { | |||
| 364 | + | if child.Digest == "" { | |||
| 365 | + | continue | |||
| 366 | + | } | |||
| 367 | + | refs.childManifests = append(refs.childManifests, child.Digest) | |||
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | return refs, nil | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func isDanglingImage(repoTags []string) bool { | |||
| 374 | + | if len(repoTags) == 0 { | |||
| 375 | + | return true | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | for _, tag := range repoTags { | |||
| 379 | + | if tag == "<none>:<none>" || tag == "" { | |||
| 380 | + | continue | |||
| 381 | + | } | |||
| 382 | + | return false | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | return true | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func splitRepoTag(repoTag string) (string, string) { | |||
| 389 | + | if repoTag == "" { | |||
| 390 | + | return "", "" | |||
| 391 | + | } | |||
| 392 | + | if strings.Contains(repoTag, "@") { | |||
| 393 | + | return repoTag, "" | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | idx := strings.LastIndex(repoTag, ":") | |||
| 397 | + | slashIdx := strings.LastIndex(repoTag, "/") | |||
| 398 | + | if idx <= slashIdx || idx >= len(repoTag)-1 { | |||
| 399 | + | return repoTag, "" | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | return repoTag[:idx], repoTag[idx+1:] | |||
| 403 | + | } | |||
A internal/usecase/images/service_test.go +642 -0
| @@ -0,0 +1,642 @@ | |||||
| 1 | + | package images | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zerowrap" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 15 | + | "github.com/bnema/gordon/internal/domain" | |||
| 16 | + | pkgruntime "github.com/bnema/gordon/pkg/runtime" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | func TestService_ListImages_ReturnsAllImages(t *testing.T) { | |||
| 20 | + | manifestStorage := noopManifestStorage{} | |||
| 21 | + | blobStorage := noopBlobStorage{} | |||
| 22 | + | ||||
| 23 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 24 | + | rt := &fakeRuntime{ | |||
| 25 | + | listDetails: []pkgruntime.ImageDetail{ | |||
| 26 | + | { | |||
| 27 | + | ID: "sha256:111", | |||
| 28 | + | RepoTags: []string{"gordon/api:latest", "gordon/api:v1.2.3"}, | |||
| 29 | + | Size: 1234, | |||
| 30 | + | Created: createdAt, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | ID: "sha256:222", | |||
| 34 | + | RepoTags: []string{"<none>:<none>"}, | |||
| 35 | + | Size: 4321, | |||
| 36 | + | Created: createdAt.Add(-time.Hour), | |||
| 37 | + | }, | |||
| 38 | + | }, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 42 | + | ||||
| 43 | + | images, err := svc.ListImages(context.Background()) | |||
| 44 | + | ||||
| 45 | + | require.NoError(t, err) | |||
| 46 | + | require.Len(t, images, 3) | |||
| 47 | + | ||||
| 48 | + | assert.Equal(t, domain.ImageInfo{ | |||
| 49 | + | Repository: "gordon/api", | |||
| 50 | + | Tag: "latest", | |||
| 51 | + | Size: 1234, | |||
| 52 | + | Created: createdAt, | |||
| 53 | + | ID: "sha256:111", | |||
| 54 | + | Dangling: false, | |||
| 55 | + | }, images[0]) | |||
| 56 | + | ||||
| 57 | + | assert.Equal(t, domain.ImageInfo{ | |||
| 58 | + | Repository: "gordon/api", | |||
| 59 | + | Tag: "v1.2.3", | |||
| 60 | + | Size: 1234, | |||
| 61 | + | Created: createdAt, | |||
| 62 | + | ID: "sha256:111", | |||
| 63 | + | Dangling: false, | |||
| 64 | + | }, images[1]) | |||
| 65 | + | ||||
| 66 | + | assert.Equal(t, domain.ImageInfo{ | |||
| 67 | + | Repository: "", | |||
| 68 | + | Tag: "", | |||
| 69 | + | Size: 4321, | |||
| 70 | + | Created: createdAt.Add(-time.Hour), | |||
| 71 | + | ID: "sha256:222", | |||
| 72 | + | Dangling: true, | |||
| 73 | + | }, images[2]) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestService_ListImages_EmptyWhenNoImages(t *testing.T) { | |||
| 77 | + | manifestStorage := noopManifestStorage{} | |||
| 78 | + | blobStorage := noopBlobStorage{} | |||
| 79 | + | rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}} | |||
| 80 | + | ||||
| 81 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 82 | + | ||||
| 83 | + | images, err := svc.ListImages(context.Background()) | |||
| 84 | + | ||||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | assert.Empty(t, images) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) { | |||
| 90 | + | manifestStorage := noopManifestStorage{} | |||
| 91 | + | blobStorage := noopBlobStorage{} | |||
| 92 | + | createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC) | |||
| 93 | + | ||||
| 94 | + | rt := &fakeRuntime{ | |||
| 95 | + | listDetails: []pkgruntime.ImageDetail{{ | |||
| 96 | + | ID: "sha256:mixed", | |||
| 97 | + | RepoTags: []string{"<none>:<none>", "gordon/web:latest"}, | |||
| 98 | + | Size: 100, | |||
| 99 | + | Created: createdAt, | |||
| 100 | + | }}, | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 104 | + | ||||
| 105 | + | images, err := svc.ListImages(context.Background()) | |||
| 106 | + | ||||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | require.Len(t, images, 1) | |||
| 109 | + | assert.Equal(t, "gordon/web", images[0].Repository) | |||
| 110 | + | assert.Equal(t, "latest", images[0].Tag) | |||
| 111 | + | assert.False(t, images[0].Dangling) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) { | |||
| 115 | + | manifestStorage := noopManifestStorage{} | |||
| 116 | + | blobStorage := noopBlobStorage{} | |||
| 117 | + | rt := &fakeRuntime{listErr: errors.New("runtime list failed")} | |||
| 118 | + | ||||
| 119 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 120 | + | ||||
| 121 | + | _, err := svc.ListImages(context.Background()) | |||
| 122 | + | ||||
| 123 | + | require.Error(t, err) | |||
| 124 | + | assert.Contains(t, err.Error(), "failed to list images") | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) { | |||
| 128 | + | manifestStorage := noopManifestStorage{} | |||
| 129 | + | blobStorage := noopBlobStorage{} | |||
| 130 | + | rt := &fakeRuntime{ | |||
| 131 | + | pruneReport: pkgruntime.PruneReport{ | |||
| 132 | + | DeletedIDs: []string{"sha256:a", "sha256:b"}, | |||
| 133 | + | SpaceReclaimed: 2048, | |||
| 134 | + | }, | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 138 | + | ||||
| 139 | + | report, err := svc.PruneRuntime(context.Background()) | |||
| 140 | + | ||||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | assert.Equal(t, 2, report.Runtime.DeletedCount) | |||
| 143 | + | assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed) | |||
| 144 | + | assert.True(t, rt.pruneCalled) | |||
| 145 | + | assert.True(t, rt.pruneDanglingOnly) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestService_PruneRuntime_NoDanglingImages(t *testing.T) { | |||
| 149 | + | manifestStorage := noopManifestStorage{} | |||
| 150 | + | blobStorage := noopBlobStorage{} | |||
| 151 | + | rt := &fakeRuntime{ | |||
| 152 | + | pruneReport: pkgruntime.PruneReport{ | |||
| 153 | + | DeletedIDs: nil, | |||
| 154 | + | SpaceReclaimed: 0, | |||
| 155 | + | }, | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 159 | + | ||||
| 160 | + | report, err := svc.PruneRuntime(context.Background()) | |||
| 161 | + | ||||
| 162 | + | require.NoError(t, err) | |||
| 163 | + | assert.Equal(t, 0, report.Runtime.DeletedCount) | |||
| 164 | + | assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed) | |||
| 165 | + | assert.True(t, rt.pruneCalled) | |||
| 166 | + | assert.True(t, rt.pruneDanglingOnly) | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) { | |||
| 170 | + | manifestStorage := noopManifestStorage{} | |||
| 171 | + | blobStorage := noopBlobStorage{} | |||
| 172 | + | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | |||
| 173 | + | ||||
| 174 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 175 | + | ||||
| 176 | + | _, err := svc.PruneRuntime(context.Background()) | |||
| 177 | + | ||||
| 178 | + | require.Error(t, err) | |||
| 179 | + | assert.Contains(t, err.Error(), "failed to prune runtime images") | |||
| 180 | + | assert.True(t, rt.pruneCalled) | |||
| 181 | + | assert.True(t, rt.pruneDanglingOnly) | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) { | |||
| 185 | + | manifestStorage := newFakeManifestStorage() | |||
| 186 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 187 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"} | |||
| 188 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 189 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 190 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 191 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC) | |||
| 192 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest") | |||
| 193 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3") | |||
| 194 | + | ||||
| 195 | + | blobStorage := &fakeBlobStorage{} | |||
| 196 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 197 | + | ||||
| 198 | + | report, err := svc.PruneRegistry(context.Background(), 2) | |||
| 199 | + | ||||
| 200 | + | require.NoError(t, err) | |||
| 201 | + | assert.Equal(t, 2, report.Registry.TagsRemoved) | |||
| 202 | + | assert.ElementsMatch(t, []manifestRef{ | |||
| 203 | + | {name: "gordon/api", reference: "v2"}, | |||
| 204 | + | {name: "gordon/api", reference: "v1"}, | |||
| 205 | + | }, manifestStorage.deletedManifests) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) { | |||
| 209 | + | manifestStorage := newFakeManifestStorage() | |||
| 210 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 211 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"} | |||
| 212 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC) | |||
| 213 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 214 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 215 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared") | |||
| 216 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared") | |||
| 217 | + | ||||
| 218 | + | blobStorage := &fakeBlobStorage{} | |||
| 219 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 220 | + | ||||
| 221 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 222 | + | ||||
| 223 | + | require.NoError(t, err) | |||
| 224 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |||
| 225 | + | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests) | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) { | |||
| 229 | + | manifestStorage := newFakeManifestStorage() | |||
| 230 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 231 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |||
| 232 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 233 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 234 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest") | |||
| 235 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1") | |||
| 236 | + | ||||
| 237 | + | blobStorage := &fakeBlobStorage{} | |||
| 238 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 239 | + | ||||
| 240 | + | report, err := svc.PruneRegistry(context.Background(), 5) | |||
| 241 | + | ||||
| 242 | + | require.NoError(t, err) | |||
| 243 | + | assert.Equal(t, 0, report.Registry.TagsRemoved) | |||
| 244 | + | assert.Empty(t, manifestStorage.deletedManifests) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) { | |||
| 248 | + | manifestStorage := newFakeManifestStorage() | |||
| 249 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 250 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}} | |||
| 251 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 252 | + | ||||
| 253 | + | report, err := svc.PruneRegistry(context.Background(), 0) | |||
| 254 | + | ||||
| 255 | + | require.NoError(t, err) | |||
| 256 | + | assert.Equal(t, domain.RegistryPruneResult{}, report.Registry) | |||
| 257 | + | assert.Equal(t, 0, manifestStorage.listRepositoriesCalls) | |||
| 258 | + | assert.Empty(t, manifestStorage.deletedManifests) | |||
| 259 | + | assert.Empty(t, blobStorage.deletedBlobs) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||
| 263 | + | manifestStorage := newFakeManifestStorage() | |||
| 264 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 265 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |||
| 266 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 267 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 268 | + | ||||
| 269 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |||
| 270 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 271 | + | ||||
| 272 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 273 | + | ||||
| 274 | + | require.NoError(t, err) | |||
| 275 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 276 | + | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | |||
| 280 | + | manifestStorage := newFakeManifestStorage() | |||
| 281 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 282 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |||
| 283 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 284 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 285 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared") | |||
| 286 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared") | |||
| 287 | + | ||||
| 288 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}} | |||
| 289 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 290 | + | ||||
| 291 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 292 | + | ||||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 295 | + | assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) { | |||
| 299 | + | manifestStorage := newFakeManifestStorage() | |||
| 300 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 301 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |||
| 302 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 303 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64") | |||
| 304 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64") | |||
| 305 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64") | |||
| 306 | + | ||||
| 307 | + | blobStorage := &fakeBlobStorage{blobs: []string{ | |||
| 308 | + | "sha256:child-amd64", | |||
| 309 | + | "sha256:child-arm64", | |||
| 310 | + | "sha256:cfg-amd64", | |||
| 311 | + | "sha256:layer-amd64", | |||
| 312 | + | "sha256:cfg-arm64", | |||
| 313 | + | "sha256:layer-arm64", | |||
| 314 | + | "sha256:orphan", | |||
| 315 | + | }} | |||
| 316 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 317 | + | ||||
| 318 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 319 | + | ||||
| 320 | + | require.NoError(t, err) | |||
| 321 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 322 | + | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) { | |||
| 326 | + | manifestStorage := newFakeManifestStorage() | |||
| 327 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 328 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"} | |||
| 329 | + | tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 330 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie | |||
| 331 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie | |||
| 332 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 333 | + | ||||
| 334 | + | blobStorage := &fakeBlobStorage{} | |||
| 335 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 336 | + | ||||
| 337 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |||
| 338 | + | ||||
| 339 | + | require.NoError(t, err) | |||
| 340 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |||
| 341 | + | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) { | |||
| 345 | + | manifestStorage := newFakeManifestStorage() | |||
| 346 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 347 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"} | |||
| 348 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 349 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing") | |||
| 350 | + | ||||
| 351 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}} | |||
| 352 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 353 | + | ||||
| 354 | + | _, err := svc.PruneRegistry(context.Background(), 1) | |||
| 355 | + | ||||
| 356 | + | require.Error(t, err) | |||
| 357 | + | assert.Contains(t, err.Error(), "failed to read manifest") | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func TestService_PruneRegistry_MultipleRepositories(t *testing.T) { | |||
| 361 | + | manifestStorage := newFakeManifestStorage() | |||
| 362 | + | manifestStorage.repositories = []string{"gordon/api", "gordon/web"} | |||
| 363 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |||
| 364 | + | manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"} | |||
| 365 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 366 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 367 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 368 | + | manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 369 | + | manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 370 | + | manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 371 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest") | |||
| 372 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2") | |||
| 373 | + | manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest") | |||
| 374 | + | manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3") | |||
| 375 | + | ||||
| 376 | + | blobStorage := &fakeBlobStorage{} | |||
| 377 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 378 | + | ||||
| 379 | + | report, err := svc.PruneRegistry(context.Background(), 2) | |||
| 380 | + | ||||
| 381 | + | require.NoError(t, err) | |||
| 382 | + | assert.Equal(t, 2, report.Registry.TagsRemoved) | |||
| 383 | + | assert.ElementsMatch(t, []manifestRef{ | |||
| 384 | + | {name: "gordon/api", reference: "v1"}, | |||
| 385 | + | {name: "gordon/web", reference: "v2"}, | |||
| 386 | + | }, manifestStorage.deletedManifests) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | func TestService_PruneRegistry_EmptyRepository(t *testing.T) { | |||
| 390 | + | manifestStorage := newFakeManifestStorage() | |||
| 391 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 392 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{} | |||
| 393 | + | blobStorage := &fakeBlobStorage{} | |||
| 394 | + | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 395 | + | ||||
| 396 | + | report, err := svc.PruneRegistry(context.Background(), 2) | |||
| 397 | + | ||||
| 398 | + | require.NoError(t, err) | |||
| 399 | + | assert.Equal(t, domain.RegistryPruneResult{}, report.Registry) | |||
| 400 | + | assert.Empty(t, manifestStorage.deletedManifests) | |||
| 401 | + | assert.Empty(t, blobStorage.deletedBlobs) | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |||
| 405 | + | manifestStorage := newFakeManifestStorage() | |||
| 406 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 407 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |||
| 408 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 409 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 410 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 411 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |||
| 412 | + | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | |||
| 413 | + | ||||
| 414 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 415 | + | ||||
| 416 | + | report, err := svc.Prune(context.Background(), 1) | |||
| 417 | + | ||||
| 418 | + | require.NoError(t, err) | |||
| 419 | + | assert.Equal(t, 1, report.Runtime.DeletedCount) | |||
| 420 | + | assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed) | |||
| 421 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |||
| 422 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 423 | + | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |||
| 424 | + | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |||
| 428 | + | manifestStorage := newFakeManifestStorage() | |||
| 429 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 430 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |||
| 431 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 432 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 433 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |||
| 434 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |||
| 435 | + | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | |||
| 436 | + | ||||
| 437 | + | svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default()) | |||
| 438 | + | ||||
| 439 | + | report, err := svc.Prune(context.Background(), 1) | |||
| 440 | + | ||||
| 441 | + | require.NoError(t, err) | |||
| 442 | + | assert.Equal(t, 0, report.Runtime.DeletedCount) | |||
| 443 | + | assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed) | |||
| 444 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |||
| 445 | + | assert.Equal(t, 1, report.Registry.BlobsRemoved) | |||
| 446 | + | assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |||
| 447 | + | assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs) | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | type fakeRuntime struct { | |||
| 451 | + | pkgruntime.Runtime | |||
| 452 | + | ||||
| 453 | + | listDetails []pkgruntime.ImageDetail | |||
| 454 | + | listErr error | |||
| 455 | + | ||||
| 456 | + | pruneReport pkgruntime.PruneReport | |||
| 457 | + | pruneErr error | |||
| 458 | + | pruneCalled bool | |||
| 459 | + | pruneDanglingOnly bool | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | type noopManifestStorage struct { | |||
| 463 | + | out.ManifestStorage | |||
| 464 | + | } | |||
| 465 | + | ||||
| 466 | + | type noopBlobStorage struct { | |||
| 467 | + | out.BlobStorage | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | type manifestRef struct { | |||
| 471 | + | name string | |||
| 472 | + | reference string | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | type fakeManifestStorage struct { | |||
| 476 | + | out.ManifestStorage | |||
| 477 | + | ||||
| 478 | + | repositories []string | |||
| 479 | + | tagsByRepo map[string][]string | |||
| 480 | + | modTimes map[string]time.Time | |||
| 481 | + | manifests map[string][]byte | |||
| 482 | + | ||||
| 483 | + | listRepositoriesCalls int | |||
| 484 | + | deletedManifests []manifestRef | |||
| 485 | + | } | |||
| 486 | + | ||||
| 487 | + | type fakeBlobStorage struct { | |||
| 488 | + | out.BlobStorage | |||
| 489 | + | ||||
| 490 | + | blobs []string | |||
| 491 | + | deletedBlobs []string | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) { | |||
| 495 | + | return f.listDetails, f.listErr | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) { | |||
| 499 | + | f.pruneCalled = true | |||
| 500 | + | f.pruneDanglingOnly = danglingOnly | |||
| 501 | + | return f.pruneReport, f.pruneErr | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func newFakeManifestStorage() *fakeManifestStorage { | |||
| 505 | + | return &fakeManifestStorage{ | |||
| 506 | + | tagsByRepo: make(map[string][]string), | |||
| 507 | + | modTimes: make(map[string]time.Time), | |||
| 508 | + | manifests: make(map[string][]byte), | |||
| 509 | + | } | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func manifestRefKey(name, reference string) string { | |||
| 513 | + | return name + "@" + reference | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte { | |||
| 517 | + | t.Helper() | |||
| 518 | + | ||||
| 519 | + | type descriptor struct { | |||
| 520 | + | Digest string `json:"digest"` | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | payload := struct { | |||
| 524 | + | Config descriptor `json:"config"` | |||
| 525 | + | Layers []descriptor `json:"layers"` | |||
| 526 | + | }{ | |||
| 527 | + | Config: descriptor{Digest: configDigest}, | |||
| 528 | + | Layers: make([]descriptor, 0, len(layerDigests)), | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | for _, digest := range layerDigests { | |||
| 532 | + | payload.Layers = append(payload.Layers, descriptor{Digest: digest}) | |||
| 533 | + | } | |||
| 534 | + | ||||
| 535 | + | data, err := json.Marshal(payload) | |||
| 536 | + | require.NoError(t, err) | |||
| 537 | + | return data | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func mustManifestIndexJSON(t *testing.T, digests ...string) []byte { | |||
| 541 | + | t.Helper() | |||
| 542 | + | ||||
| 543 | + | type descriptor struct { | |||
| 544 | + | Digest string `json:"digest"` | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | payload := struct { | |||
| 548 | + | Manifests []descriptor `json:"manifests"` | |||
| 549 | + | }{ | |||
| 550 | + | Manifests: make([]descriptor, 0, len(digests)), | |||
| 551 | + | } | |||
| 552 | + | ||||
| 553 | + | for _, digest := range digests { | |||
| 554 | + | payload.Manifests = append(payload.Manifests, descriptor{Digest: digest}) | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | data, err := json.Marshal(payload) | |||
| 558 | + | require.NoError(t, err) | |||
| 559 | + | return data | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | func (f *fakeManifestStorage) ListRepositories() ([]string, error) { | |||
| 563 | + | f.listRepositoriesCalls++ | |||
| 564 | + | return append([]string(nil), f.repositories...), nil | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | func (f *fakeManifestStorage) ListTags(name string) ([]string, error) { | |||
| 568 | + | return append([]string(nil), f.tagsByRepo[name]...), nil | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) { | |||
| 572 | + | modTime, ok := f.modTimes[manifestRefKey(name, reference)] | |||
| 573 | + | if !ok { | |||
| 574 | + | return time.Time{}, errors.New("manifest modtime not found") | |||
| 575 | + | } | |||
| 576 | + | return modTime, nil | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | func (f *fakeManifestStorage) DeleteManifest(name, reference string) error { | |||
| 580 | + | f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference}) | |||
| 581 | + | tags := f.tagsByRepo[name] | |||
| 582 | + | filtered := tags[:0] | |||
| 583 | + | for _, tag := range tags { | |||
| 584 | + | if tag != reference { | |||
| 585 | + | filtered = append(filtered, tag) | |||
| 586 | + | } | |||
| 587 | + | } | |||
| 588 | + | f.tagsByRepo[name] = filtered | |||
| 589 | + | delete(f.manifests, manifestRefKey(name, reference)) | |||
| 590 | + | return nil | |||
| 591 | + | } | |||
| 592 | + | ||||
| 593 | + | func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) { | |||
| 594 | + | manifest, ok := f.manifests[manifestRefKey(name, reference)] | |||
| 595 | + | if !ok { | |||
| 596 | + | return nil, "", errors.New("manifest not found") | |||
| 597 | + | } | |||
| 598 | + | return manifest, "application/vnd.oci.image.manifest.v1+json", nil | |||
| 599 | + | } | |||
| 600 | + | ||||
| 601 | + | func (f *fakeBlobStorage) ListBlobs() ([]string, error) { | |||
| 602 | + | return append([]string(nil), f.blobs...), nil | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func (f *fakeBlobStorage) DeleteBlob(digest string) error { | |||
| 606 | + | f.deletedBlobs = append(f.deletedBlobs, digest) | |||
| 607 | + | for i, existing := range f.blobs { | |||
| 608 | + | if existing != digest { | |||
| 609 | + | continue | |||
| 610 | + | } | |||
| 611 | + | f.blobs = append(f.blobs[:i], f.blobs[i+1:]...) | |||
| 612 | + | break | |||
| 613 | + | } | |||
| 614 | + | return nil | |||
| 615 | + | } | |||
| 616 | + | ||||
| 617 | + | func TestSplitRepoTag(t *testing.T) { | |||
| 618 | + | tests := []struct { | |||
| 619 | + | name string | |||
| 620 | + | input string | |||
| 621 | + | wantRepo string | |||
| 622 | + | wantTag string | |||
| 623 | + | }{ | |||
| 624 | + | {name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"}, | |||
| 625 | + | {name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"}, | |||
| 626 | + | {name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"}, | |||
| 627 | + | {name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""}, | |||
| 628 | + | {name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""}, | |||
| 629 | + | {name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""}, | |||
| 630 | + | {name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""}, | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | for _, tt := range tests { | |||
| 634 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 635 | + | repo, tag := splitRepoTag(tt.input) | |||
| 636 | + | assert.Equal(t, tt.wantRepo, repo) | |||
| 637 | + | assert.Equal(t, tt.wantTag, tag) | |||
| 638 | + | }) | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | ||||
| 642 | + | var _ pkgruntime.Runtime = (*fakeRuntime)(nil) | |||
M internal/usecase/logs/service.go +243 -57
| @@ -3,11 +3,14 @@ package logs | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bufio" | 5 | "bufio" | ||
| 6 | + | "bytes" | |||
| 6 | "context" | 7 | "context" | ||
| 7 | "encoding/binary" | 8 | "encoding/binary" | ||
| 8 | "fmt" | 9 | "fmt" | ||
| 9 | "io" | 10 | "io" | ||
| 10 | "os" | 11 | "os" | ||
| 12 | + | "os/exec" | |||
| 13 | + | "strconv" | |||
| 11 | "strings" | 14 | "strings" | ||
| 12 | "time" | 15 | "time" | ||
| 13 | 16 | ||||
| @@ -19,24 +22,29 @@ import ( | |||||
| 19 | 22 | ||||
| 20 | // Service implements the LogService interface. | 23 | // Service implements the LogService interface. | ||
| 21 | type Service struct { | 24 | type Service struct { | ||
| 22 | - | logFilePath string | 25 | + | logFilePath string |
| 23 | - | containerSvc in.ContainerService | 26 | + | fileLoggingEnabled bool |
| 24 | - | runtime out.ContainerRuntime | 27 | + | containerSvc in.ContainerService |
| 25 | - | log zerowrap.Logger | 28 | + | runtime out.ContainerRuntime |
| 29 | + | log zerowrap.Logger | |||
| 26 | } | 30 | } | ||
| 27 | 31 | ||||
| 32 | + | var execCommandContext = exec.CommandContext | |||
| 33 | + | ||||
| 28 | // NewService creates a new log service. | 34 | // NewService creates a new log service. | ||
| 29 | func NewService( | 35 | func NewService( | ||
| 30 | logFilePath string, | 36 | logFilePath string, | ||
| 37 | + | fileLoggingEnabled bool, | |||
| 31 | containerSvc in.ContainerService, | 38 | containerSvc in.ContainerService, | ||
| 32 | runtime out.ContainerRuntime, | 39 | runtime out.ContainerRuntime, | ||
| 33 | log zerowrap.Logger, | 40 | log zerowrap.Logger, | ||
| 34 | ) *Service { | 41 | ) *Service { | ||
| 35 | return &Service{ | 42 | return &Service{ | ||
| 36 | - | logFilePath: logFilePath, | 43 | + | logFilePath: logFilePath, |
| 37 | - | containerSvc: containerSvc, | 44 | + | fileLoggingEnabled: fileLoggingEnabled, |
| 38 | - | runtime: runtime, | 45 | + | containerSvc: containerSvc, |
| 39 | - | log: log, | 46 | + | runtime: runtime, |
| 47 | + | log: log, | |||
| 40 | } | 48 | } | ||
| 41 | } | 49 | } | ||
| 42 | 50 | ||||
| @@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro | |||||
| 49 | }) | 57 | }) | ||
| 50 | log := zerowrap.FromCtx(ctx) | 58 | log := zerowrap.FromCtx(ctx) | ||
| 51 | 59 | ||||
| 52 | - | if s.logFilePath == "" { | 60 | + | if s.useFileProcessLogs() { |
| 53 | - | return nil, fmt.Errorf("log file path not configured") | 61 | + | if s.logFilePath == "" { |
| 54 | - | } | 62 | + | return nil, fmt.Errorf("log file path not configured") |
| 63 | + | } | |||
| 55 | 64 | ||||
| 56 | - | file, err := os.Open(s.logFilePath) | 65 | + | file, err := os.Open(s.logFilePath) |
| 57 | - | if err != nil { | 66 | + | if err != nil { |
| 58 | - | if os.IsNotExist(err) { | 67 | + | if os.IsNotExist(err) { |
| 59 | - | return []string{}, nil | 68 | + | log.Warn().Str("log_path", s.logFilePath). |
| 69 | + | Msg("process log file missing, falling back to journalctl") | |||
| 70 | + | return s.getProcessLogsFromJournal(ctx, lines) | |||
| 71 | + | } | |||
| 72 | + | return nil, log.WrapErr(err, "failed to open log file") | |||
| 60 | } | 73 | } | ||
| 61 | - | return nil, log.WrapErr(err, "failed to open log file") | 74 | + | defer file.Close() |
| 75 | + | ||||
| 76 | + | return tailLines(file, lines) | |||
| 62 | } | 77 | } | ||
| 63 | - | defer file.Close() | |||
| 64 | 78 | ||||
| 65 | - | return tailLines(file, lines) | 79 | + | return s.getProcessLogsFromJournal(ctx, lines) |
| 66 | } | 80 | } | ||
| 67 | 81 | ||||
| 68 | // FollowProcessLogs returns a channel that streams Gordon process log lines. | 82 | // FollowProcessLogs returns a channel that streams Gordon process log lines. | ||
| @@ -72,67 +86,198 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||||
| 72 | zerowrap.FieldUseCase: "FollowProcessLogs", | 86 | zerowrap.FieldUseCase: "FollowProcessLogs", | ||
| 73 | "initial_lines": initialLines, | 87 | "initial_lines": initialLines, | ||
| 74 | }) | 88 | }) | ||
| 89 | + | if !s.useFileProcessLogs() { | |||
| 90 | + | return s.followProcessLogsFromJournal(ctx, initialLines) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | return s.followProcessLogsFromFileOrJournal(ctx, initialLines) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func (s *Service) useFileProcessLogs() bool { | |||
| 97 | + | return s.fileLoggingEnabled | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (s *Service) followProcessLogsFromFileOrJournal( | |||
| 101 | + | ctx context.Context, | |||
| 102 | + | initialLines int, | |||
| 103 | + | ) (<-chan string, error) { | |||
| 75 | log := zerowrap.FromCtx(ctx) | 104 | log := zerowrap.FromCtx(ctx) | ||
| 76 | 105 | ||||
| 106 | + | file, err := s.openProcessLogFile() | |||
| 107 | + | if err != nil { | |||
| 108 | + | if os.IsNotExist(err) { | |||
| 109 | + | log.Warn().Str("log_path", s.logFilePath). | |||
| 110 | + | Msg("process log file missing, falling back to journalctl follow") | |||
| 111 | + | return s.followProcessLogsFromJournal(ctx, initialLines) | |||
| 112 | + | } | |||
| 113 | + | return nil, log.WrapErr(err, "failed to open log file") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | return s.followProcessLogsFromFile(ctx, file, initialLines), nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (s *Service) openProcessLogFile() (*os.File, error) { | |||
| 77 | if s.logFilePath == "" { | 120 | if s.logFilePath == "" { | ||
| 78 | return nil, fmt.Errorf("log file path not configured") | 121 | return nil, fmt.Errorf("log file path not configured") | ||
| 79 | } | 122 | } | ||
| 80 | 123 | ||||
| 81 | - | file, err := os.Open(s.logFilePath) | 124 | + | return os.Open(s.logFilePath) |
| 82 | - | if err != nil { | 125 | + | } |
| 83 | - | return nil, log.WrapErr(err, "failed to open log file") | |||
| 84 | - | } | |||
| 85 | 126 | ||||
| 127 | + | func (s *Service) followProcessLogsFromFile( | |||
| 128 | + | ctx context.Context, | |||
| 129 | + | file *os.File, | |||
| 130 | + | initialLines int, | |||
| 131 | + | ) <-chan string { | |||
| 132 | + | log := zerowrap.FromCtx(ctx) | |||
| 86 | ch := make(chan string, 100) | 133 | ch := make(chan string, 100) | ||
| 87 | 134 | ||||
| 88 | go func() { | 135 | go func() { | ||
| 89 | defer close(ch) | 136 | defer close(ch) | ||
| 90 | defer file.Close() | 137 | defer file.Close() | ||
| 91 | 138 | ||||
| 92 | - | // First, get initial lines | 139 | + | if !s.emitInitialFileLogs(ctx, file, initialLines, ch) { |
| 93 | - | if initialLines > 0 { | 140 | + | return |
| 94 | - | lines, err := tailLines(file, initialLines) | 141 | + | } |
| 95 | - | if err != nil { | 142 | + | if err := s.seekToEndForFileFollow(file); err != nil { |
| 96 | - | log.Warn().Err(err).Msg("failed to read initial lines") | 143 | + | log.Warn().Err(err).Msg("failed to seek to end") |
| 97 | - | } else { | 144 | + | return |
| 98 | - | for _, line := range lines { | 145 | + | } |
| 99 | - | select { | 146 | + | |
| 100 | - | case ch <- line: | 147 | + | s.streamFileFollowLogs(ctx, file, ch) |
| 101 | - | case <-ctx.Done(): | 148 | + | }() |
| 102 | - | return | 149 | + | |
| 103 | - | } | 150 | + | return ch |
| 104 | - | } | 151 | + | } |
| 152 | + | ||||
| 153 | + | func (s *Service) emitInitialFileLogs( | |||
| 154 | + | ctx context.Context, | |||
| 155 | + | file *os.File, | |||
| 156 | + | initialLines int, | |||
| 157 | + | ch chan<- string, | |||
| 158 | + | ) bool { | |||
| 159 | + | if initialLines <= 0 { | |||
| 160 | + | return true | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | log := zerowrap.FromCtx(ctx) | |||
| 164 | + | lines, err := tailLines(file, initialLines) | |||
| 165 | + | if err != nil { | |||
| 166 | + | log.Warn().Err(err).Msg("failed to read initial lines") | |||
| 167 | + | return true | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | for _, line := range lines { | |||
| 171 | + | if !sendLogLineWithChannel(ctx, ch, line) { | |||
| 172 | + | return false | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | return true | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (s *Service) seekToEndForFileFollow(file *os.File) error { | |||
| 180 | + | _, err := file.Seek(0, io.SeekEnd) | |||
| 181 | + | return err | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func (s *Service) streamFileFollowLogs(ctx context.Context, file *os.File, ch chan<- string) { | |||
| 185 | + | log := zerowrap.FromCtx(ctx) | |||
| 186 | + | reader := bufio.NewReader(file) | |||
| 187 | + | ||||
| 188 | + | for { | |||
| 189 | + | if ctx.Err() != nil { | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | line, err := reader.ReadString('\n') | |||
| 194 | + | if err != nil { | |||
| 195 | + | if err == io.EOF { | |||
| 196 | + | time.Sleep(100 * time.Millisecond) | |||
| 197 | + | continue | |||
| 105 | } | 198 | } | ||
| 199 | + | log.Warn().Err(err).Msg("error reading log file") | |||
| 200 | + | return | |||
| 106 | } | 201 | } | ||
| 107 | 202 | ||||
| 108 | - | // Seek to end for following | 203 | + | line = strings.TrimRight(line, "\n\r") |
| 109 | - | if _, err := file.Seek(0, io.SeekEnd); err != nil { | 204 | + | if !sendLogLineWithChannel(ctx, ch, line) { |
| 110 | - | log.Warn().Err(err).Msg("failed to seek to end") | |||
| 111 | return | 205 | return | ||
| 112 | } | 206 | } | ||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func sendLogLineWithChannel(ctx context.Context, ch chan<- string, line string) bool { | |||
| 211 | + | select { | |||
| 212 | + | case ch <- line: | |||
| 213 | + | return true | |||
| 214 | + | case <-ctx.Done(): | |||
| 215 | + | return false | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) { | |||
| 220 | + | userScope, err := resolveJournalctlScope(ctx) | |||
| 221 | + | if err != nil { | |||
| 222 | + | return nil, err | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"} | |||
| 226 | + | if userScope { | |||
| 227 | + | args = append([]string{"--user"}, args...) | |||
| 228 | + | } | |||
| 113 | 229 | ||||
| 114 | - | // Follow new lines | 230 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() |
| 115 | - | reader := bufio.NewReader(file) | 231 | + | if err != nil { |
| 116 | - | for { | 232 | + | return nil, fmt.Errorf("failed to read journalctl logs: %w", err) |
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | return outputLines(out), nil | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) { | |||
| 239 | + | userScope, err := resolveJournalctlScope(ctx) | |||
| 240 | + | if err != nil { | |||
| 241 | + | return nil, err | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | args := []string{ | |||
| 245 | + | "-u", "gordon", | |||
| 246 | + | "-n", strconv.Itoa(initialLines), | |||
| 247 | + | "--no-pager", | |||
| 248 | + | "-o", "cat", | |||
| 249 | + | "-f", | |||
| 250 | + | } | |||
| 251 | + | if userScope { | |||
| 252 | + | args = append([]string{"--user"}, args...) | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only | |||
| 256 | + | stdout, err := cmd.StdoutPipe() | |||
| 257 | + | if err != nil { | |||
| 258 | + | return nil, fmt.Errorf("failed to open journalctl stdout: %w", err) | |||
| 259 | + | } | |||
| 260 | + | cmd.Stderr = io.Discard | |||
| 261 | + | ||||
| 262 | + | if err := cmd.Start(); err != nil { | |||
| 263 | + | return nil, fmt.Errorf("failed to start journalctl follow: %w", err) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | ch := make(chan string, 100) | |||
| 267 | + | ||||
| 268 | + | go func() { | |||
| 269 | + | defer close(ch) | |||
| 270 | + | defer stdout.Close() | |||
| 271 | + | defer cmd.Wait() //nolint:errcheck // best-effort cleanup | |||
| 272 | + | ||||
| 273 | + | scanner := bufio.NewScanner(stdout) | |||
| 274 | + | scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) | |||
| 275 | + | ||||
| 276 | + | for scanner.Scan() { | |||
| 117 | select { | 277 | select { | ||
| 278 | + | case ch <- scanner.Text(): | |||
| 118 | case <-ctx.Done(): | 279 | case <-ctx.Done(): | ||
| 119 | return | 280 | return | ||
| 120 | - | default: | |||
| 121 | - | line, err := reader.ReadString('\n') | |||
| 122 | - | if err != nil { | |||
| 123 | - | if err == io.EOF { | |||
| 124 | - | time.Sleep(100 * time.Millisecond) | |||
| 125 | - | continue | |||
| 126 | - | } | |||
| 127 | - | log.Warn().Err(err).Msg("error reading log file") | |||
| 128 | - | return | |||
| 129 | - | } | |||
| 130 | - | line = strings.TrimRight(line, "\n\r") | |||
| 131 | - | select { | |||
| 132 | - | case ch <- line: | |||
| 133 | - | case <-ctx.Done(): | |||
| 134 | - | return | |||
| 135 | - | } | |||
| 136 | } | 281 | } | ||
| 137 | } | 282 | } | ||
| 138 | }() | 283 | }() | ||
| @@ -140,6 +285,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch | |||||
| 140 | return ch, nil | 285 | return ch, nil | ||
| 141 | } | 286 | } | ||
| 142 | 287 | ||||
| 288 | + | func resolveJournalctlScope(ctx context.Context) (bool, error) { | |||
| 289 | + | userLogs, userErr := hasJournalctlLogs(ctx, true) | |||
| 290 | + | if userErr == nil && userLogs { | |||
| 291 | + | return true, nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | systemLogs, systemErr := hasJournalctlLogs(ctx, false) | |||
| 295 | + | if systemErr == nil && systemLogs { | |||
| 296 | + | return false, nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | if userErr != nil && systemErr != nil { | |||
| 300 | + | return false, fmt.Errorf("journalctl unavailable for user and system services") | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // Default to user scope when logs are currently empty; this matches rootless setups. | |||
| 304 | + | return true, nil | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) { | |||
| 308 | + | args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"} | |||
| 309 | + | if userService { | |||
| 310 | + | args = append([]string{"--user"}, args...) | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() | |||
| 314 | + | if err != nil { | |||
| 315 | + | return false, err | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | return len(bytes.TrimSpace(out)) > 0, nil | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | func outputLines(out []byte) []string { | |||
| 322 | + | trimmed := strings.TrimRight(string(out), "\n\r") | |||
| 323 | + | if trimmed == "" { | |||
| 324 | + | return []string{} | |||
| 325 | + | } | |||
| 326 | + | return strings.Split(trimmed, "\n") | |||
| 327 | + | } | |||
| 328 | + | ||||
| 143 | // validateDomain checks that domain is safe for use in logs and error messages. | 329 | // validateDomain checks that domain is safe for use in logs and error messages. | ||
| 144 | func validateDomain(domain string) error { | 330 | func validateDomain(domain string) error { | ||
| 145 | if domain == "" { | 331 | if domain == "" { | ||
M internal/usecase/logs/service_test.go +28 -8
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | + | "os/exec" | |||
| 7 | "path/filepath" | 8 | "path/filepath" | ||
| 8 | "testing" | 9 | "testing" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 32 | containerSvc := mocks.NewMockContainerService(t) | 33 | containerSvc := mocks.NewMockContainerService(t) | ||
| 33 | runtime := outMocks.NewMockContainerRuntime(t) | 34 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 34 | 35 | ||||
| 35 | - | svc := NewService(logPath, containerSvc, runtime, log) | 36 | + | svc := NewService(logPath, true, containerSvc, runtime, log) |
| 36 | 37 | ||||
| 37 | lines, err := svc.GetProcessLogs(context.Background(), 3) | 38 | lines, err := svc.GetProcessLogs(context.Background(), 3) | ||
| 38 | require.NoError(t, err) | 39 | require.NoError(t, err) | ||
| @@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 44 | containerSvc := mocks.NewMockContainerService(t) | 45 | containerSvc := mocks.NewMockContainerService(t) | ||
| 45 | runtime := outMocks.NewMockContainerRuntime(t) | 46 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 46 | 47 | ||||
| 47 | - | svc := NewService("/nonexistent/file.log", containerSvc, runtime, log) | 48 | + | svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log) |
| 48 | 49 | ||||
| 49 | lines, err := svc.GetProcessLogs(context.Background(), 10) | 50 | lines, err := svc.GetProcessLogs(context.Background(), 10) | ||
| 50 | require.NoError(t, err) | 51 | require.NoError(t, err) | ||
| @@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 55 | containerSvc := mocks.NewMockContainerService(t) | 56 | containerSvc := mocks.NewMockContainerService(t) | ||
| 56 | runtime := outMocks.NewMockContainerRuntime(t) | 57 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 57 | 58 | ||||
| 58 | - | svc := NewService("", containerSvc, runtime, log) | 59 | + | svc := NewService("", true, containerSvc, runtime, log) |
| 59 | 60 | ||||
| 60 | _, err := svc.GetProcessLogs(context.Background(), 10) | 61 | _, err := svc.GetProcessLogs(context.Background(), 10) | ||
| 61 | assert.Error(t, err) | 62 | assert.Error(t, err) | ||
| @@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) { | |||||
| 72 | containerSvc := mocks.NewMockContainerService(t) | 73 | containerSvc := mocks.NewMockContainerService(t) | ||
| 73 | runtime := outMocks.NewMockContainerRuntime(t) | 74 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 74 | 75 | ||||
| 75 | - | svc := NewService(logPath, containerSvc, runtime, log) | 76 | + | svc := NewService(logPath, true, containerSvc, runtime, log) |
| 76 | 77 | ||||
| 77 | lines, err := svc.GetProcessLogs(context.Background(), 10) | 78 | lines, err := svc.GetProcessLogs(context.Background(), 10) | ||
| 78 | require.NoError(t, err) | 79 | require.NoError(t, err) | ||
| 79 | assert.Equal(t, []string{"line1", "line2"}, lines) | 80 | assert.Equal(t, []string{"line1", "line2"}, lines) | ||
| 80 | }) | 81 | }) | ||
| 82 | + | ||||
| 83 | + | t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) { | |||
| 84 | + | origExec := execCommandContext | |||
| 85 | + | execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd { | |||
| 86 | + | return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204 | |||
| 87 | + | } | |||
| 88 | + | defer func() { | |||
| 89 | + | execCommandContext = origExec | |||
| 90 | + | }() | |||
| 91 | + | ||||
| 92 | + | containerSvc := mocks.NewMockContainerService(t) | |||
| 93 | + | runtime := outMocks.NewMockContainerRuntime(t) | |||
| 94 | + | ||||
| 95 | + | svc := NewService("/tmp/unused.log", false, containerSvc, runtime, log) | |||
| 96 | + | ||||
| 97 | + | lines, err := svc.GetProcessLogs(context.Background(), 10) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines) | |||
| 100 | + | }) | |||
| 81 | } | 101 | } | ||
| 82 | 102 | ||||
| 83 | func TestService_FollowProcessLogs(t *testing.T) { | 103 | func TestService_FollowProcessLogs(t *testing.T) { | ||
| @@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) { | |||||
| 93 | containerSvc := mocks.NewMockContainerService(t) | 113 | containerSvc := mocks.NewMockContainerService(t) | ||
| 94 | runtime := outMocks.NewMockContainerRuntime(t) | 114 | runtime := outMocks.NewMockContainerRuntime(t) | ||
| 95 | 115 | ||||
| 96 | - | svc := NewService(logPath, containerSvc, runtime, log) | 116 | + | svc := NewService(logPath, true, containerSvc, runtime, log) |
| 97 | 117 | ||||
| 98 | ctx, cancel := context.WithCancel(context.Background()) | 118 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 99 | defer cancel() | 119 | defer cancel() | ||
| @@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||||
| 135 | 155 | ||||
| 136 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | 156 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | ||
| 137 | 157 | ||||
| 138 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | 158 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) |
| 139 | 159 | ||||
| 140 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | 160 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | ||
| 141 | assert.Error(t, err) | 161 | assert.Error(t, err) | ||
| @@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||||
| 156 | // Create a mock reader that returns empty content | 176 | // Create a mock reader that returns empty content | ||
| 157 | runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil) | 177 | runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil) | ||
| 158 | 178 | ||||
| 159 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | 179 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) |
| 160 | 180 | ||||
| 161 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | 181 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | ||
| 162 | require.NoError(t, err) | 182 | require.NoError(t, err) | ||
| @@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) { | |||||
| 173 | 193 | ||||
| 174 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | 194 | containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false) | ||
| 175 | 195 | ||||
| 176 | - | svc := NewService("/tmp/test.log", containerSvc, runtime, log) | 196 | + | svc := NewService("/tmp/test.log", true, containerSvc, runtime, log) |
| 177 | 197 | ||||
| 178 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | 198 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | ||
| 179 | assert.Error(t, err) | 199 | assert.Error(t, err) | ||
M internal/usecase/proxy/service.go +27 -8
| @@ -24,8 +24,8 @@ import ( | |||||
| 24 | "github.com/bnema/gordon/internal/domain" | 24 | "github.com/bnema/gordon/internal/domain" | ||
| 25 | ) | 25 | ) | ||
| 26 | 26 | ||||
| 27 | - | // proxyTransport is a shared HTTP transport with proper timeouts. | 27 | + | // proxyTransport is a shared HTTP transport for proxying to application containers. |
| 28 | - | // This prevents resource exhaustion from slow backends or network issues. | 28 | + | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. |
| 29 | var proxyTransport = &http.Transport{ | 29 | var proxyTransport = &http.Transport{ | ||
| 30 | DialContext: (&net.Dialer{ | 30 | DialContext: (&net.Dialer{ | ||
| 31 | Timeout: 10 * time.Second, | 31 | Timeout: 10 * time.Second, | ||
| @@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{ | |||||
| 38 | IdleConnTimeout: 90 * time.Second, | 38 | IdleConnTimeout: 90 * time.Second, | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | // registryTransport is used for the registry reverse proxy (localhost loopback). | |||
| 42 | + | // It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy | |||
| 43 | + | // perform blocking operations (image pull, container start, readiness checks) that | |||
| 44 | + | // can take well over 30 seconds. | |||
| 45 | + | var registryTransport = &http.Transport{ | |||
| 46 | + | DialContext: (&net.Dialer{ | |||
| 47 | + | Timeout: 10 * time.Second, | |||
| 48 | + | KeepAlive: 30 * time.Second, | |||
| 49 | + | }).DialContext, | |||
| 50 | + | ResponseHeaderTimeout: 3 * time.Minute, | |||
| 51 | + | MaxIdleConns: 20, | |||
| 52 | + | MaxIdleConnsPerHost: 10, | |||
| 53 | + | IdleConnTimeout: 90 * time.Second, | |||
| 54 | + | } | |||
| 55 | + | ||||
| 41 | // Config holds configuration needed by the proxy service. | 56 | // Config holds configuration needed by the proxy service. | ||
| 42 | type Config struct { | 57 | type Config struct { | ||
| 43 | RegistryDomain string | 58 | RegistryDomain string | ||
| @@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 558 | return | 573 | return | ||
| 559 | } | 574 | } | ||
| 560 | 575 | ||||
| 561 | - | proxy := newReverseProxy(targetURL, "", | 576 | + | proxy := &httputil.ReverseProxy{ |
| 562 | - | func(w http.ResponseWriter, _ *http.Request, err error) { | 577 | + | Rewrite: func(pr *httputil.ProxyRequest) { |
| 578 | + | pr.SetURL(targetURL) | |||
| 579 | + | pr.SetXForwarded() | |||
| 580 | + | pr.Out.Host = targetURL.Host | |||
| 581 | + | }, | |||
| 582 | + | Transport: registryTransport, | |||
| 583 | + | ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { | |||
| 563 | var maxBytesErr *http.MaxBytesError | 584 | var maxBytesErr *http.MaxBytesError | ||
| 564 | if errors.As(err, &maxBytesErr) { | 585 | if errors.As(err, &maxBytesErr) { | ||
| 565 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | 586 | log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large") | ||
| @@ -569,13 +590,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 569 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | 590 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | ||
| 570 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | 591 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | ||
| 571 | }, | 592 | }, | ||
| 572 | - | func(resp *http.Response) error { | 593 | + | ModifyResponse: func(resp *http.Response) error { |
| 573 | resp.Header.Set("X-Proxied-By", "Gordon") | 594 | resp.Header.Set("X-Proxied-By", "Gordon") | ||
| 574 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | 595 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | ||
| 575 | 596 | ||||
| 576 | // Remove security headers from registry response to prevent duplicates. | 597 | // Remove security headers from registry response to prevent duplicates. | ||
| 577 | - | // The proxy middleware already adds these headers, so the registry's | |||
| 578 | - | // copies would create duplicates in the final response. | |||
| 579 | resp.Header.Del("X-Content-Type-Options") | 598 | resp.Header.Del("X-Content-Type-Options") | ||
| 580 | resp.Header.Del("X-Frame-Options") | 599 | resp.Header.Del("X-Frame-Options") | ||
| 581 | resp.Header.Del("X-XSS-Protection") | 600 | resp.Header.Del("X-XSS-Protection") | ||
| @@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||||
| 586 | 605 | ||||
| 587 | return nil | 606 | return nil | ||
| 588 | }, | 607 | }, | ||
| 589 | - | ) | 608 | + | } |
| 590 | 609 | ||||
| 591 | log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") | 610 | log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry") | ||
| 592 | 611 | ||||
M internal/usecase/registry/service.go +5 -2
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "crypto/sha256" | 6 | "crypto/sha256" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | + | "strings" | |||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| 11 | 12 | ||||
| @@ -74,8 +75,10 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||||
| 74 | return "", log.WrapErr(err, "failed to store manifest") | 75 | return "", log.WrapErr(err, "failed to store manifest") | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 77 | - | // Publish image pushed event | 78 | + | // Publish image pushed event only for tag references (not digests). |
| 78 | - | if s.eventBus != nil { | 79 | + | // A docker push sends manifests by both digest and tag; firing only on |
| 80 | + | // tag prevents duplicate deploy triggers for the same push. | |||
| 81 | + | if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") { | |||
| 79 | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | 82 | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | ||
| 80 | Name: manifest.Name, | 83 | Name: manifest.Name, | ||
| 81 | Reference: manifest.Reference, | 84 | Reference: manifest.Reference, | ||
M pkg/runtime/interface.go +26 -0
| @@ -3,6 +3,7 @@ package runtime | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | + | "time" | |||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | // Container represents a running container | 9 | // Container represents a running container | ||
| @@ -40,6 +41,26 @@ type ContainerConfig struct { | |||||
| 40 | Aliases []string // Additional network aliases | 41 | Aliases []string // Additional network aliases | ||
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 44 | + | // PruneReport represents the result of an image prune operation. | |||
| 45 | + | type PruneReport struct { | |||
| 46 | + | // DeletedIDs contains runtime-provided image identifiers removed by prune. | |||
| 47 | + | DeletedIDs []string | |||
| 48 | + | // SpaceReclaimed is the number of bytes reclaimed by prune. | |||
| 49 | + | SpaceReclaimed int64 | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // ImageDetail represents detailed metadata for an image. | |||
| 53 | + | type ImageDetail struct { | |||
| 54 | + | // ID is the runtime image identifier (for example, image ID/digest). | |||
| 55 | + | ID string | |||
| 56 | + | // RepoTags contains repository:tag references known for the image. | |||
| 57 | + | RepoTags []string | |||
| 58 | + | // Size is the image size in bytes. | |||
| 59 | + | Size int64 | |||
| 60 | + | // Created is the image creation timestamp as reported by the runtime. | |||
| 61 | + | Created time.Time | |||
| 62 | + | } | |||
| 63 | + | ||||
| 43 | // Runtime interface defines the contract for container runtime implementations | 64 | // Runtime interface defines the contract for container runtime implementations | ||
| 44 | type Runtime interface { | 65 | type Runtime interface { | ||
| 45 | // Container lifecycle | 66 | // Container lifecycle | ||
| @@ -59,6 +80,11 @@ type Runtime interface { | |||||
| 59 | PullImageWithAuth(ctx context.Context, image, username, password string) error | 80 | PullImageWithAuth(ctx context.Context, image, username, password string) error | ||
| 60 | RemoveImage(ctx context.Context, image string, force bool) error | 81 | RemoveImage(ctx context.Context, image string, force bool) error | ||
| 61 | ListImages(ctx context.Context) ([]string, error) | 82 | ListImages(ctx context.Context) ([]string, error) | ||
| 83 | + | // ListImagesDetailed returns metadata for all images visible to the runtime. | |||
| 84 | + | ListImagesDetailed(ctx context.Context) ([]ImageDetail, error) | |||
| 85 | + | // PruneImages removes images eligible for prune; when danglingOnly is true, | |||
| 86 | + | // only dangling images are pruned. | |||
| 87 | + | PruneImages(ctx context.Context, danglingOnly bool) (PruneReport, error) | |||
| 62 | 88 | ||||
| 63 | // Runtime information | 89 | // Runtime information | ||
| 64 | Ping(ctx context.Context) error | 90 | Ping(ctx context.Context) error | ||
M wiki/guides/remote-cli.md +36 -1
| @@ -20,7 +20,7 @@ By default (without `--remote`), management commands execute locally through in- | |||||
| 20 | 20 | ||||
| 21 | ## Understanding the Remote URL | 21 | ## Understanding the Remote URL | ||
| 22 | 22 | ||||
| 23 | - | The `--remote` URL must be the `gordon_domain` configured on the remote Gordon instance. This domain serves both the container registry and the Admin API. | 23 | + | Default: use the Gordon domain configured on the remote instance. |
| 24 | 24 | ||||
| 25 | ```toml | 25 | ```toml | ||
| 26 | # On the remote Gordon server | 26 | # On the remote Gordon server | ||
| @@ -30,6 +30,40 @@ gordon_domain = "gordon.example.com" # Use this as --remote URL | |||||
| 30 | 30 | ||||
| 31 | The CLI connects to `https://gordon.example.com/admin/*` endpoints. | 31 | The CLI connects to `https://gordon.example.com/admin/*` endpoints. | ||
| 32 | 32 | ||||
| 33 | + | Private tailnet setup (recommended for hardened VPS): keep using the HTTPS domain, point that DNS record to the VPS tailnet IP, and set insecure TLS for self-signed certs. | |||
| 34 | + | ||||
| 35 | + | > [!CAUTION] | |||
| 36 | + | > `--insecure` and `insecure_tls = true` disable TLS certificate verification. | |||
| 37 | + | > This removes server identity validation and increases man-in-the-middle risk. | |||
| 38 | + | > Use this only for private tailnet/VPN deployments with strict network access controls (restricted CIDRs, firewall allowlists, and no public ingress to admin endpoints). | |||
| 39 | + | > Safer alternatives: | |||
| 40 | + | > - Use a publicly trusted certificate (for example, Let's Encrypt). | |||
| 41 | + | > - Use an internal CA and trust that CA on operator machines. | |||
| 42 | + | > - Use Tailscale certificate generation (`tailscale cert`) and serve Gordon with that cert. | |||
| 43 | + | ||||
| 44 | + | ```bash | |||
| 45 | + | # Save remote once | |||
| 46 | + | gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure | |||
| 47 | + | gordon remotes use tailnet-reg | |||
| 48 | + | ||||
| 49 | + | # Use it for auth/admin commands | |||
| 50 | + | gordon auth login | |||
| 51 | + | gordon routes list | |||
| 52 | + | ``` | |||
| 53 | + | ||||
| 54 | + | Equivalent `remotes.toml` entry: | |||
| 55 | + | ||||
| 56 | + | ```toml | |||
| 57 | + | active = "tailnet-reg" | |||
| 58 | + | ||||
| 59 | + | [remotes.tailnet-reg] | |||
| 60 | + | url = "https://gordon.example.com" | |||
| 61 | + | token_env = "GORDON_TOKEN" | |||
| 62 | + | insecure_tls = true | |||
| 63 | + | ``` | |||
| 64 | + | ||||
| 65 | + | Use this mode when registry/auth/admin access is intentionally restricted to tailnet CIDRs. | |||
| 66 | + | ||||
| 33 | ## Quick Start | 67 | ## Quick Start | ||
| 34 | 68 | ||||
| 35 | ### One-off Remote Command | 69 | ### One-off Remote Command | ||
| @@ -67,6 +101,7 @@ These flags are available on all commands: | |||||
| 67 | |------|-------------| | 101 | |------|-------------| | ||
| 68 | | `--remote <URL>` | Remote Gordon URL | | 102 | | `--remote <URL>` | Remote Gordon URL | | ||
| 69 | | `--token <TOKEN>` | Authentication token | | 103 | | `--token <TOKEN>` | Authentication token | | ||
| 104 | + | | `--insecure` | Skip TLS verification (self-signed/private certs) | | |||
| 70 | 105 | ||||
| 71 | ```bash | 106 | ```bash | ||
| 72 | # List routes on remote | 107 | # List routes on remote | ||
M wiki/guides/secure-vps-setup.md +49 -0
| @@ -142,6 +142,55 @@ Verify public SSH is blocked: | |||||
| 142 | ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout | 142 | ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout | ||
| 143 | ``` | 143 | ``` | ||
| 144 | 144 | ||||
| 145 | + | ## Step 6.5: Restrict Registry and Auth to Tailnet CIDR | |||
| 146 | + | ||||
| 147 | + | If you want `/auth/*` and registry (`/v2/*`) reachable only from your tailnet, set Gordon's registry allowlist to the Tailscale CGNAT range. | |||
| 148 | + | ||||
| 149 | + | Edit `~/.config/gordon/gordon.toml`: | |||
| 150 | + | ||||
| 151 | + | ```toml | |||
| 152 | + | [server] | |||
| 153 | + | registry_allowed_ips = ["100.64.0.0/10"] | |||
| 154 | + | ``` | |||
| 155 | + | ||||
| 156 | + | Apply the change: | |||
| 157 | + | ||||
| 158 | + | ```bash | |||
| 159 | + | systemctl --user restart gordon | |||
| 160 | + | ``` | |||
| 161 | + | ||||
| 162 | + | Behavior: | |||
| 163 | + | ||||
| 164 | + | - Requests from tailnet IPs (`100.64.0.0/10`) are allowed. | |||
| 165 | + | - Requests outside that CIDR get `403 Forbidden` on registry/auth endpoints. | |||
| 166 | + | - App routes still use normal host-based routing through your proxy ports. | |||
| 167 | + | ||||
| 168 | + | For private operations (auth, push/pull, admin API), keep using the HTTPS registry domain, but resolve that domain to the VPS tailnet IP (for example, via Cloudflare DNS to the tailnet address). With self-signed certs, configure the CLI remote with insecure TLS enabled. | |||
| 169 | + | ||||
| 170 | + | ```bash | |||
| 171 | + | # Example: save remote once | |||
| 172 | + | gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure | |||
| 173 | + | gordon remotes use tailnet-reg | |||
| 174 | + | ||||
| 175 | + | # Then use commands normally (auth + admin API) | |||
| 176 | + | gordon auth login | |||
| 177 | + | gordon routes list | |||
| 178 | + | gordon backup status | |||
| 179 | + | ``` | |||
| 180 | + | ||||
| 181 | + | `~/.config/gordon/remotes.toml` should contain an entry like: | |||
| 182 | + | ||||
| 183 | + | ```toml | |||
| 184 | + | active = "tailnet-reg" | |||
| 185 | + | ||||
| 186 | + | [remotes.tailnet-reg] | |||
| 187 | + | url = "https://gordon.example.com" | |||
| 188 | + | token_env = "GORDON_TOKEN" | |||
| 189 | + | insecure_tls = true | |||
| 190 | + | ``` | |||
| 191 | + | ||||
| 192 | + | This keeps transport on HTTPS while allowing self-signed certificates in tailnet-only deployments. | |||
| 193 | + | ||||
| 145 | --- | 194 | --- | ||
| 146 | 195 | ||||
| 147 | ## Step 7: Install CrowdSec | 196 | ## Step 7: Install CrowdSec | ||