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 | 32 | | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) | | |
| 33 | 33 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 34 | 34 | | `gordon backups` | Manage database backups | [backup](./backup.md) | | |
| 35 | + | | `gordon images` | List and prune images | [images](./images.md) | | |
| 35 | 36 | ||
| 36 | 37 | ## Client Commands | |
| 37 | 38 | ||
| @@ -82,6 +83,11 @@ gordon backups run app.example.com | |||
| 82 | 83 | gordon backups detect app.example.com | |
| 83 | 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 | 91 | # Authentication | |
| 86 | 92 | gordon auth token generate --subject ci-bot --expiry 0 | |
| 87 | 93 | gordon auth token list | |
M docs/config/backups.md +3 -0
| @@ -21,6 +21,7 @@ Current scope: | |||
| 21 | 21 | ```toml | |
| 22 | 22 | [backups] | |
| 23 | 23 | enabled = true | |
| 24 | + | schedule = "daily" | |
| 24 | 25 | storage_dir = "~/.gordon/backups" | |
| 25 | 26 | ||
| 26 | 27 | [backups.retention] | |
| @@ -35,6 +36,7 @@ monthly = 12 | |||
| 35 | 36 | | Key | Type | Default | Description | | |
| 36 | 37 | |-----|------|---------|-------------| | |
| 37 | 38 | | `backups.enabled` | bool | `false` | Enables backup service wiring | | |
| 39 | + | | `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` | | |
| 38 | 40 | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | |
| 39 | 41 | | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB | | |
| 40 | 42 | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | |
| @@ -58,6 +60,7 @@ Example: | |||
| 58 | 60 | ## Notes | |
| 59 | 61 | ||
| 60 | 62 | - Backups require PostgreSQL attachment containers to be running. | |
| 63 | + | - Scheduled backups run only when `backups.enabled = true`. | |
| 61 | 64 | - The backup command executes inside the database container using Gordon runtime APIs. | |
| 62 | 65 | - Retention applies per database and per schedule. | |
| 63 | 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 | 119 | # Backups | |
| 120 | 120 | [backups] | |
| 121 | 121 | enabled = true | |
| 122 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |
| 122 | 123 | storage_dir = "~/.gordon/backups" | |
| 123 | 124 | ||
| 124 | 125 | [backups.retention] | |
| @@ -126,6 +127,12 @@ hourly = 24 | |||
| 126 | 127 | daily = 7 | |
| 127 | 128 | weekly = 4 | |
| 128 | 129 | monthly = 12 | |
| 130 | + | ||
| 131 | + | # Images | |
| 132 | + | [images.prune] | |
| 133 | + | enabled = false | |
| 134 | + | schedule = "daily" | |
| 135 | + | keep_last = 3 | |
| 129 | 136 | ``` | |
| 130 | 137 | ||
| 131 | 138 | ## Configuration Sections | |
| @@ -146,6 +153,7 @@ monthly = 12 | |||
| 146 | 153 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 147 | 154 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 148 | 155 | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| 156 | + | | `[images.prune]` | Scheduled image cleanup | [Images](./images.md) | | |
| 149 | 157 | ||
| 150 | 158 | ## Default Values | |
| 151 | 159 | ||
| @@ -182,6 +190,10 @@ monthly = 12 | |||
| 182 | 190 | | `network_isolation.enabled` | `false` | | |
| 183 | 191 | | `auto_route.enabled` | `false` | | |
| 184 | 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 | 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 | 237 | - [External Routes](./external-routes.md) | |
| 226 | 238 | - [Authentication](./auth.md) | |
| 227 | 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 | 134 | [backups] | |
| 135 | 135 | enabled = false # Enable backup service | |
| 136 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |
| 136 | 137 | storage_dir = "" # Backup root (default: {data_dir}/backups) | |
| 137 | 138 | ||
| 138 | 139 | [backups.retention] | |
| @@ -141,6 +142,14 @@ daily = 0 # Keep N daily backups per DB | |||
| 141 | 142 | weekly = 0 # Keep N weekly backups per DB | |
| 142 | 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 | 153 | # Note: retention values set to 0 keep no backups for that tier. | |
| 145 | 154 | # For practical defaults, consider setting daily = 7. | |
| 146 | 155 | ``` | |
| @@ -192,11 +201,15 @@ monthly = 0 # Keep N monthly backups per DB | |||
| 192 | 201 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 193 | 202 | | `volumes.preserve` | `true` | Keep volumes | | |
| 194 | 203 | | `backups.enabled` | `false` | Backup service disabled | | |
| 204 | + | | `backups.schedule` | `"daily"` | Backup scheduler preset | | |
| 195 | 205 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | |
| 196 | 206 | | `backups.retention.hourly` | `0` | Keep no hourly backups by default | | |
| 197 | 207 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | |
| 198 | 208 | | `backups.retention.weekly` | `0` | Keep no weekly backups by default | | |
| 199 | 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 | 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 | 282 | - [Authentication](./auth.md) | |
| 270 | 283 | - [Network Isolation](./network-isolation.md) | |
| 271 | 284 | - [Volumes](./volumes.md) | |
| 285 | + | - [Images](./images.md) | |
M docs/config/server.md +24 -0
| @@ -69,6 +69,30 @@ With TLS enabled: | |||
| 69 | 69 | - `port` remains available for plain HTTP unless you restrict it externally | |
| 70 | 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 | 96 | ### Registry Port | |
| 73 | 97 | ||
| 74 | 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 | 5 | require ( | |
| 6 | 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 | 8 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 9 | 9 | github.com/charmbracelet/lipgloss v1.1.0 | |
| 10 | 10 | github.com/docker/docker v28.5.2+incompatible | |
| @@ -24,14 +24,17 @@ require ( | |||
| 24 | 24 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 25 | 25 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 26 | 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 27 | - | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect | |
| 28 | - | github.com/charmbracelet/x/ansi v0.10.1 // indirect | |
| 29 | - | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect | |
| 30 | - | github.com/charmbracelet/x/term v0.2.1 // indirect | |
| 27 | + | github.com/charmbracelet/colorprofile v0.4.1 // indirect | |
| 28 | + | github.com/charmbracelet/x/ansi v0.11.5 // indirect | |
| 29 | + | github.com/charmbracelet/x/cellbuf v0.0.15 // 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 | 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 | 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 | 38 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 36 | 39 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 37 | 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 | 8 | "regexp" | |
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | + | tea "github.com/charmbracelet/bubbletea" | |
| 11 | 12 | "github.com/spf13/cobra" | |
| 12 | 13 | ||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 13 | 15 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 14 | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 15 | 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 | 250 | if err != nil { | |
| 241 | 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 | 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 | 357 | // parseImageRef splits "registry/name:tag" into components. | |
| 252 | 358 | func parseImageRef(image string) (registry, name, tag string) { | |
| 253 | 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 | 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 | 504 | // ListBackups returns backups globally or for a domain. | |
| 469 | 505 | func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) { | |
| 470 | 506 | path := "/backups" | |
M internal/adapters/in/cli/remote/client_test.go +43 -0
| @@ -2,6 +2,7 @@ package remote | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 7 | 8 | "sync/atomic" | |
| @@ -10,6 +11,8 @@ import ( | |||
| 10 | 11 | ||
| 11 | 12 | "github.com/stretchr/testify/assert" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | 16 | ) | |
| 14 | 17 | ||
| 15 | 18 | func withFastRetry(t *testing.T) { | |
| @@ -108,3 +111,43 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||
| 108 | 111 | _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background()) | |
| 109 | 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 | 121 | backupCmd.GroupID = groupManage | |
| 122 | 122 | rootCmd.AddCommand(backupCmd) | |
| 123 | 123 | ||
| 124 | + | imagesCmd := newImagesCmd() | |
| 125 | + | imagesCmd.GroupID = groupManage | |
| 126 | + | rootCmd.AddCommand(imagesCmd) | |
| 127 | + | ||
| 124 | 128 | // Client-only commands (no server needed) | |
| 125 | 129 | remotesCmd := newRemotesCmd() | |
| 126 | 130 | remotesCmd.GroupID = groupClient | |
M internal/adapters/in/http/admin/handler.go +9 -0
| @@ -33,6 +33,7 @@ type Handler struct { | |||
| 33 | 33 | authSvc in.AuthService | |
| 34 | 34 | containerSvc in.ContainerService | |
| 35 | 35 | backupSvc in.BackupService | |
| 36 | + | imageSvc in.ImageService | |
| 36 | 37 | healthSvc in.HealthService | |
| 37 | 38 | secretSvc in.SecretService | |
| 38 | 39 | logSvc in.LogService | |
| @@ -132,12 +133,19 @@ func NewHandler( | |||
| 132 | 133 | eventBus out.EventPublisher, | |
| 133 | 134 | log zerowrap.Logger, | |
| 134 | 135 | backupSvc in.BackupService, | |
| 136 | + | imageSvcs ...in.ImageService, | |
| 135 | 137 | ) *Handler { | |
| 138 | + | var imageSvc in.ImageService | |
| 139 | + | if len(imageSvcs) > 0 { | |
| 140 | + | imageSvc = imageSvcs[0] | |
| 141 | + | } | |
| 142 | + | ||
| 136 | 143 | return &Handler{ | |
| 137 | 144 | configSvc: configSvc, | |
| 138 | 145 | authSvc: authSvc, | |
| 139 | 146 | containerSvc: containerSvc, | |
| 140 | 147 | backupSvc: backupSvc, | |
| 148 | + | imageSvc: imageSvc, | |
| 141 | 149 | healthSvc: healthSvc, | |
| 142 | 150 | secretSvc: secretSvc, | |
| 143 | 151 | logSvc: logSvc, | |
| @@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 207 | 215 | {"/deploy", h.handleDeploy}, | |
| 208 | 216 | {"/restart", h.handleRestart}, | |
| 209 | 217 | {"/tags", h.handleTags}, | |
| 218 | + | {"/images", h.handleImages}, | |
| 210 | 219 | {"/logs", h.handleLogs}, | |
| 211 | 220 | } | |
| 212 | 221 | for _, route := range prefixRoutes { | |
M internal/adapters/in/http/admin/handler_test.go +249 -0
| @@ -4,10 +4,12 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| 10 | 11 | "testing" | |
| 12 | + | "time" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/zerowrap" | |
| 13 | 15 | "github.com/stretchr/testify/assert" | |
| @@ -17,6 +19,25 @@ import ( | |||
| 17 | 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 | 41 | func testLogger() zerowrap.Logger { | |
| 21 | 42 | return zerowrap.Default() | |
| 22 | 43 | } | |
| @@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) { | |||
| 1290 | 1311 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 1291 | 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 | 10 | "errors" | |
| 11 | 11 | "fmt" | |
| 12 | 12 | "io" | |
| 13 | + | "math" | |
| 13 | 14 | "path/filepath" | |
| 14 | 15 | "sort" | |
| 15 | 16 | "strconv" | |
| @@ -20,6 +21,7 @@ import ( | |||
| 20 | 21 | "github.com/bnema/zerowrap" | |
| 21 | 22 | cerrdefs "github.com/containerd/errdefs" | |
| 22 | 23 | "github.com/docker/docker/api/types/container" | |
| 24 | + | "github.com/docker/docker/api/types/filters" | |
| 23 | 25 | "github.com/docker/docker/api/types/image" | |
| 24 | 26 | "github.com/docker/docker/api/types/network" | |
| 25 | 27 | "github.com/docker/docker/api/types/registry" | |
| @@ -30,6 +32,7 @@ import ( | |||
| 30 | 32 | ||
| 31 | 33 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 32 | 34 | "github.com/bnema/gordon/internal/domain" | |
| 35 | + | runtimepkg "github.com/bnema/gordon/pkg/runtime" | |
| 33 | 36 | ) | |
| 34 | 37 | ||
| 35 | 38 | // Runtime implements the ContainerRuntime interface using Docker API. | |
| @@ -347,12 +350,13 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do | |||
| 347 | 350 | name := strings.TrimPrefix(resp.Name, "/") | |
| 348 | 351 | ||
| 349 | 352 | return &domain.Container{ | |
| 350 | - | ID: resp.ID, | |
| 351 | - | Image: resp.Config.Image, | |
| 352 | - | Name: name, | |
| 353 | - | Status: resp.State.Status, | |
| 354 | - | Ports: ports, | |
| 355 | - | Labels: resp.Config.Labels, | |
| 353 | + | ID: resp.ID, | |
| 354 | + | Image: resp.Config.Image, | |
| 355 | + | Name: name, | |
| 356 | + | Status: resp.State.Status, | |
| 357 | + | ExitCode: resp.State.ExitCode, | |
| 358 | + | Ports: ports, | |
| 359 | + | Labels: resp.Config.Labels, | |
| 356 | 360 | }, nil | |
| 357 | 361 | } | |
| 358 | 362 | ||
| @@ -555,6 +559,81 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) { | |||
| 555 | 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 | 637 | // Ping checks if Docker is responsive. | |
| 559 | 638 | func (r *Runtime) Ping(ctx context.Context) error { | |
| 560 | 639 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -610,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri | |||
| 610 | 689 | if err != nil { | |
| 611 | 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 | 698 | return "", false, nil | |
| 615 | 699 | } | |
| 700 | + | if resp.State == nil || resp.State.Health == nil { | |
| 701 | + | return "", true, nil | |
| 702 | + | } | |
| 616 | 703 | ||
| 617 | 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 | 715 | // GetContainerPort gets the host port for a container's internal port. | |
| 621 | 716 | func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | |
| 622 | 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 | 28 | // NewBackupStorage creates a new filesystem backup storage. | |
| 29 | 29 | func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) { | |
| 30 | + | rootDir = expandTilde(rootDir) | |
| 31 | + | ||
| 30 | 32 | if err := os.MkdirAll(rootDir, 0750); err != nil { | |
| 31 | 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 | 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 | 50 | // Store saves backup data and returns the absolute storage path. | |
| 38 | 51 | func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) { | |
| 39 | 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 | 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 | 214 | // StartBlobUpload starts a new blob upload and returns the upload UUID. | |
| 169 | 215 | func (s *BlobStorage) StartBlobUpload(name string) (string, error) { | |
| 170 | 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 | 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 | 247 | func TestBlobStorage_StartBlobUpload(t *testing.T) { | |
| 215 | 248 | tmpDir := t.TempDir() | |
| 216 | 249 | log := testLogger() | |
M internal/adapters/out/filesystem/manifest.go +19 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| 10 | 11 | ||
| @@ -222,6 +223,24 @@ func (s *ManifestStorage) ListRepositories() ([]string, error) { | |||
| 222 | 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 | 244 | // Helper methods for path generation with security validation | |
| 226 | 245 | ||
| 227 | 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 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "testing" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| @@ -67,6 +68,42 @@ func TestManifestStorage_GetManifest_NotFound(t *testing.T) { | |||
| 67 | 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 | 107 | func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) { | |
| 71 | 108 | tmpDir := t.TempDir() | |
| 72 | 109 | log := testLogger() | |
M internal/app/run.go +209 -14
| @@ -58,7 +58,9 @@ import ( | |||
| 58 | 58 | "github.com/bnema/gordon/internal/usecase/backup" | |
| 59 | 59 | "github.com/bnema/gordon/internal/usecase/config" | |
| 60 | 60 | "github.com/bnema/gordon/internal/usecase/container" | |
| 61 | + | cronSvc "github.com/bnema/gordon/internal/usecase/cron" | |
| 61 | 62 | "github.com/bnema/gordon/internal/usecase/health" | |
| 63 | + | "github.com/bnema/gordon/internal/usecase/images" | |
| 62 | 64 | "github.com/bnema/gordon/internal/usecase/logs" | |
| 63 | 65 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 64 | 66 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| @@ -134,6 +136,7 @@ type Config struct { | |||
| 134 | 136 | ||
| 135 | 137 | Backups struct { | |
| 136 | 138 | Enabled bool `mapstructure:"enabled"` | |
| 139 | + | Schedule string `mapstructure:"schedule"` | |
| 137 | 140 | StorageDir string `mapstructure:"storage_dir"` | |
| 138 | 141 | Retention struct { | |
| 139 | 142 | Hourly int `mapstructure:"hourly"` | |
| @@ -142,6 +145,14 @@ type Config struct { | |||
| 142 | 145 | Monthly int `mapstructure:"monthly"` | |
| 143 | 146 | } `mapstructure:"retention"` | |
| 144 | 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 | 158 | // services holds all the services used by the application. | |
| @@ -161,6 +172,7 @@ type services struct { | |||
| 161 | 172 | registrySvc *registrySvc.Service | |
| 162 | 173 | healthSvc *health.Service | |
| 163 | 174 | logSvc *logs.Service | |
| 175 | + | imageSvc *images.Service | |
| 164 | 176 | proxySvc *proxy.Service | |
| 165 | 177 | authSvc *auth.Service | |
| 166 | 178 | authHandler *authhandler.Handler | |
| @@ -225,7 +237,7 @@ func Run(ctx context.Context, configPath string) error { | |||
| 225 | 237 | registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log) | |
| 226 | 238 | ||
| 227 | 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 | 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 | 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 | 473 | proxyCfg, err := buildProxyConfig(cfg, log) | |
| 461 | 474 | if err != nil { | |
| @@ -483,10 +496,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 483 | 496 | svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log) | |
| 484 | 497 | ||
| 485 | 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 | 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 | 516 | return svc, nil | |
| 492 | 517 | } | |
| @@ -1247,6 +1272,9 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) { | |||
| 1247 | 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 | 1279 | func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler { | |
| 1252 | 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 | 1503 | // - SIGUSR2: Triggers manual deploy for a specific route | |
| 1476 | 1504 | // The deferred signal.Stop calls ensure signal handlers are properly | |
| 1477 | 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 | 1507 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 1480 | 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 | 1543 | // This prevents the race where auto-start pulls from the registry before it's listening. | |
| 1516 | - | select { | |
| 1517 | - | case <-registryReady: | |
| 1518 | - | case err := <-errChan: | |
| 1544 | + | if err := waitForServerReady(registryReady, errChan); err != nil { | |
| 1519 | 1545 | return err | |
| 1520 | 1546 | } | |
| 1521 | - | select { | |
| 1522 | - | case <-proxyReady: | |
| 1523 | - | case err := <-errChan: | |
| 1547 | + | if err := waitForServerReady(proxyReady, errChan); err != nil { | |
| 1524 | 1548 | return err | |
| 1525 | 1549 | } | |
| 1526 | 1550 | if cfg.Server.TLSEnabled { | |
| 1527 | - | select { | |
| 1528 | - | case <-tlsReady: | |
| 1529 | - | case err := <-errChan: | |
| 1551 | + | if err := waitForServerReady(tlsReady, errChan); err != nil { | |
| 1530 | 1552 | return err | |
| 1531 | 1553 | } | |
| 1532 | 1554 | } | |
| @@ -1539,6 +1561,14 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||
| 1539 | 1561 | } | |
| 1540 | 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 | 1572 | // Auto-start after servers are listening (registry port is now bound). | |
| 1543 | 1573 | syncAndAutoStart(ctx, svc, log) | |
| 1544 | 1574 | ||
| @@ -1547,6 +1577,165 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h | |||
| 1547 | 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 | 1739 | // waitForShutdown blocks on the event loop, handling server errors and | |
| 1551 | 1740 | // Unix signals (reload, deploy, shutdown) until the context is cancelled. | |
| 1552 | 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 | 1782 | log.Warn().Err(err).Msg("proxy server shutdown error") | |
| 1594 | 1783 | } | |
| 1595 | 1784 | ||
| 1785 | + | containerSvc.StopMonitor() | |
| 1786 | + | ||
| 1596 | 1787 | if err := containerSvc.Shutdown(shutdownCtx); err != nil { | |
| 1597 | 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 | 2152 | v.SetDefault("volumes.preserve", true) | |
| 1962 | 2153 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |
| 1963 | 2154 | v.SetDefault("backups.enabled", false) | |
| 2155 | + | v.SetDefault("backups.schedule", string(domain.ScheduleDaily)) | |
| 1964 | 2156 | v.SetDefault("backups.storage_dir", "") | |
| 1965 | 2157 | v.SetDefault("backups.retention.hourly", 0) | |
| 1966 | 2158 | v.SetDefault("backups.retention.daily", 0) | |
| 1967 | 2159 | v.SetDefault("backups.retention.weekly", 0) | |
| 1968 | 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 | 2164 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 1970 | 2165 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 1971 | 2166 | v.SetDefault("deploy.readiness_delay", "5s") | |
M internal/app/run_backup_test.go +40 -0
| @@ -3,8 +3,11 @@ package app | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/spf13/viper" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) { | |
| @@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) { | |||
| 86 | 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 | 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 | 561 | // PutBlob provides a mock function for the type MockBlobStorage | |
| 507 | 562 | func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error { | |
| 508 | 563 | ret := _mock.Called(digest, data, size) | |
M internal/boundaries/out/mocks/mock_manifest_storage.go +68 -0
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | package mocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "time" | |
| 9 | + | ||
| 8 | 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 | 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 | 237 | // ListRepositories provides a mock function for the type MockManifestStorage | |
| 170 | 238 | func (_mock *MockManifestStorage) ListRepositories() ([]string, error) { | |
| 171 | 239 | ret := _mock.Called() | |
M internal/boundaries/out/storage.go +7 -0
| @@ -2,6 +2,7 @@ package out | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "io" | |
| 5 | + | "time" | |
| 5 | 6 | ) | |
| 6 | 7 | ||
| 7 | 8 | // BlobStorage defines the contract for blob storage operations. | |
| @@ -21,6 +22,9 @@ type BlobStorage interface { | |||
| 21 | 22 | // BlobExists checks if a blob exists. | |
| 22 | 23 | BlobExists(digest string) bool | |
| 23 | 24 | ||
| 25 | + | // ListBlobs returns all blob digests. | |
| 26 | + | ListBlobs() ([]string, error) | |
| 27 | + | ||
| 24 | 28 | // StartBlobUpload starts a new blob upload and returns the upload UUID. | |
| 25 | 29 | StartBlobUpload(name string) (string, error) | |
| 26 | 30 | ||
| @@ -54,4 +58,7 @@ type ManifestStorage interface { | |||
| 54 | 58 | ||
| 55 | 59 | // ListRepositories returns all repository names. | |
| 56 | 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 | 5 | // Container represents a running container in the system. | |
| 6 | 6 | type Container struct { | |
| 7 | - | ID string | |
| 8 | - | Image string | |
| 9 | - | Name string | |
| 10 | - | Status string | |
| 11 | - | Ports []int | |
| 12 | - | Labels map[string]string | |
| 7 | + | ID string | |
| 8 | + | Image string | |
| 9 | + | Name string | |
| 10 | + | Status string | |
| 11 | + | ExitCode int | |
| 12 | + | Ports []int | |
| 13 | + | Labels map[string]string | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 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 | 81 | func WithInternalDeploy(ctx context.Context) context.Context { | |
| 82 | 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 | 1 | package backup | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "slices" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 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 | 13 | // detectDatabaseFromAttachment maps a container attachment to DB metadata. | |
| 10 | 14 | func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) { | |
| 11 | 15 | image := strings.ToLower(a.Image) | |
| 12 | 16 | name := strings.ToLower(a.Name) | |
| 13 | 17 | ||
| 14 | 18 | switch { | |
| 15 | - | case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"): | |
| 16 | - | port := 5432 | |
| 17 | - | hasPostgresPort := false | |
| 18 | - | firstPositive := 0 | |
| 19 | - | for _, p := range a.Ports { | |
| 20 | - | if p == 5432 { | |
| 21 | - | port = 5432 | |
| 22 | - | hasPostgresPort = true | |
| 23 | - | break | |
| 24 | - | } | |
| 25 | - | if p > 0 && firstPositive == 0 { | |
| 26 | - | firstPositive = p | |
| 27 | - | } | |
| 28 | - | } | |
| 29 | - | if !hasPostgresPort && firstPositive > 0 { | |
| 30 | - | port = firstPositive | |
| 31 | - | } | |
| 32 | - | return domain.DBInfo{ | |
| 33 | - | Type: domain.DBTypePostgreSQL, | |
| 34 | - | Version: postgresVersionFromImage(a.Image), | |
| 35 | - | Domain: domainName, | |
| 36 | - | Name: a.Name, | |
| 37 | - | Host: a.Name, | |
| 38 | - | Port: port, | |
| 39 | - | ContainerID: a.ContainerID, | |
| 40 | - | ImageName: a.Image, | |
| 41 | - | }, true | |
| 19 | + | case looksLikePostgres(image) || looksLikePostgres(name): | |
| 20 | + | return buildPostgresInfo(domainName, a), true | |
| 21 | + | case hasPort(a.Ports, 5432): | |
| 22 | + | // Port 5432 fallback: likely PostgreSQL even with non-standard naming. | |
| 23 | + | return buildPostgresInfo(domainName, a), true | |
| 42 | 24 | default: | |
| 43 | 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 | 69 | func postgresVersionFromImage(image string) string { | |
| 48 | 70 | lastColon := strings.LastIndex(image, ":") | |
| 49 | 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 | 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 | 67 | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | |
| 68 | 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 | 117 | dbs, err := s.DetectDatabases(ctx, domainName) | |
| 72 | 118 | if err != nil { | |
| 73 | 119 | return nil, err | |
| @@ -78,6 +124,12 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||
| 78 | 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 | 133 | if db.Type != domain.DBTypePostgreSQL { | |
| 82 | 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 | 160 | defer dumpStream.Close() | |
| 109 | 161 | ||
| 110 | 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 | 164 | if err != nil { | |
| 113 | 165 | return nil, err | |
| 114 | 166 | } | |
| @@ -131,6 +183,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||
| 131 | 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 | 195 | // Restore restores a backup by ID. | |
| 135 | 196 | func (s *Service) Restore(context.Context, string, string) error { | |
| 136 | 197 | return fmt.Errorf("backup restore not implemented yet") | |
| @@ -230,18 +291,14 @@ func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error | |||
| 230 | 291 | return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested) | |
| 231 | 292 | } | |
| 232 | 293 | ||
| 233 | - | func postgresDumpCommand(dbName string) string { | |
| 234 | - | return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName)) | |
| 294 | + | func postgresDumpCommand(_ string) string { | |
| 295 | + | return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\"" | |
| 235 | 296 | } | |
| 236 | 297 | ||
| 237 | 298 | func pgDumpToPathCommand(path, dbName string) string { | |
| 238 | 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 | 302 | func (s *Service) cleanupDumpFile(containerID, dumpPath string) { | |
| 246 | 303 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| 247 | 304 | defer cancel() | |
M internal/usecase/backup/service_test.go +61 -4
| @@ -50,7 +50,7 @@ func TestService_RunBackup_Postgres(t *testing.T) { | |||
| 50 | 50 | return false | |
| 51 | 51 | } | |
| 52 | 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 | 54 | bytes.Contains([]byte(cmd[2]), []byte(" > ")) | |
| 55 | 55 | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |
| 56 | 56 | ||
| @@ -125,9 +125,10 @@ func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) { | |||
| 125 | 125 | assert.Equal(t, "postgres", db.Name) | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | - | func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) { | |
| 129 | - | cmd := postgresDumpCommand("customer data") | |
| 130 | - | assert.Contains(t, cmd, "PGDATABASE='customer data'") | |
| 128 | + | func TestPostgresDumpCommandUsesEnvVar(t *testing.T) { | |
| 129 | + | cmd := postgresDumpCommand("ignored") | |
| 130 | + | assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}") | |
| 131 | + | assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}") | |
| 131 | 132 | } | |
| 132 | 133 | ||
| 133 | 134 | func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) { | |
| @@ -177,3 +178,59 @@ func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testi | |||
| 177 | 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 | 69 | attachments map[string][]string // ownerDomain → []containerIDs | |
| 70 | 70 | mu sync.RWMutex | |
| 71 | 71 | deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock) | |
| 72 | + | monitor *Monitor | |
| 72 | 73 | } | |
| 73 | 74 | ||
| 74 | 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 | 348 | s.runtime.RemoveContainer(ctx, newContainer.ID, true) | |
| 348 | 349 | return nil, log.WrapErr(err, "failed to start container") | |
| 349 | 350 | } | |
| 350 | - | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 351 | - | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 352 | - | return nil, log.WrapErr(err, "container failed readiness check") | |
| 351 | + | if !domain.IsSkipReadiness(ctx) { | |
| 352 | + | if err := s.waitForReady(ctx, newContainer.ID); err != nil { | |
| 353 | + | s.cleanupFailedContainer(ctx, newContainer.ID) | |
| 354 | + | return nil, log.WrapErr(err, "container failed readiness check") | |
| 355 | + | } | |
| 353 | 356 | } | |
| 354 | 357 | ||
| 355 | 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 | 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 | 813 | func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |
| 809 | 814 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 810 | 815 | zerowrap.FieldLayer: "usecase", | |
| @@ -814,37 +819,67 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error { | |||
| 814 | 819 | ||
| 815 | 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 | 825 | for _, route := range routes { | |
| 819 | - | // Check if container already exists and is running | |
| 820 | 826 | if _, exists := s.Get(ctx, route.Domain); exists { | |
| 821 | 827 | log.Debug().Str("domain", route.Domain).Msg("container already running, skipping") | |
| 822 | 828 | skipped++ | |
| 823 | - | continue | |
| 829 | + | } else { | |
| 830 | + | pending = append(pending, route) | |
| 824 | 831 | } | |
| 832 | + | } | |
| 825 | 833 | ||
| 826 | - | log.Info(). | |
| 827 | - | Str("domain", route.Domain). | |
| 828 | - | Str("image", route.Image). | |
| 829 | - | Msg("auto-starting container for route") | |
| 834 | + | if len(pending) == 0 { | |
| 835 | + | log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running") | |
| 836 | + | return nil | |
| 837 | + | } | |
| 830 | 838 | ||
| 831 | - | if _, err := s.Deploy(ctx, route); err != nil { | |
| 832 | - | log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container") | |
| 833 | - | errors++ | |
| 834 | - | continue | |
| 835 | - | } | |
| 839 | + | // Deploy all pending routes concurrently with readiness checks skipped. | |
| 840 | + | deployCtx := domain.WithSkipReadiness(ctx) | |
| 841 | + | ||
| 842 | + | type result struct { | |
| 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 | 875 | log.Info(). | |
| 841 | 876 | Int("started", started). | |
| 842 | 877 | Int("skipped", skipped). | |
| 843 | - | Int("errors", errors). | |
| 878 | + | Int("errors", errCount). | |
| 844 | 879 | Msg("auto-start completed") | |
| 845 | 880 | ||
| 846 | - | if errors > 0 { | |
| 847 | - | return fmt.Errorf("auto-start completed with %d errors", errors) | |
| 881 | + | if errCount > 0 { | |
| 882 | + | return fmt.Errorf("auto-start completed with %d errors", errCount) | |
| 848 | 883 | } | |
| 849 | 884 | return nil | |
| 850 | 885 | } | |
| @@ -858,9 +893,8 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||
| 858 | 893 | log := zerowrap.FromCtx(ctx) | |
| 859 | 894 | log.Info().Msg("shutting down container manager...") | |
| 860 | 895 | ||
| 861 | - | if err := s.stopAllManagedContainers(ctx); err != nil { | |
| 862 | - | log.WrapErr(err, "failed to stop all containers during shutdown") | |
| 863 | - | } | |
| 896 | + | // Containers are left running across Gordon restarts. | |
| 897 | + | // SyncContainers + AutoStart will pick them back up on next boot. | |
| 864 | 898 | ||
| 865 | 899 | // Close log writer to stop all log collection | |
| 866 | 900 | if s.logWriter != nil { | |
| @@ -873,6 +907,33 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||
| 873 | 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 | 937 | // UpdateConfig updates the service configuration. | |
| 877 | 938 | func (s *Service) UpdateConfig(config Config) { | |
| 878 | 939 | s.mu.Lock() | |
| @@ -1720,46 +1781,6 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 1720 | 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 | 1784 | // Utility functions | |
| 1764 | 1785 | ||
| 1765 | 1786 | func normalizeImageRef(image string) string { | |
| @@ -1969,7 +1990,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 1969 | 1990 | waitCtx, cancel := context.WithTimeout(ctx, timeout) | |
| 1970 | 1991 | defer cancel() | |
| 1971 | 1992 | ||
| 1972 | - | lastStatus := "starting" | |
| 1993 | + | var lastStatus string | |
| 1973 | 1994 | for { | |
| 1974 | 1995 | status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID) | |
| 1975 | 1996 | if err != nil { | |
| @@ -1978,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou | |||
| 1978 | 1999 | if !hasHealthcheck { | |
| 1979 | 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 | 2007 | if status == "healthy" { | |
| 1982 | 2008 | return nil | |
| 1983 | 2009 | } | |
| 1984 | - | if status != "" { | |
| 1985 | - | lastStatus = status | |
| 1986 | - | } | |
| 2010 | + | lastStatus = status | |
| 1987 | 2011 | ||
| 1988 | 2012 | select { | |
| 1989 | 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 | 630 | svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"} | |
| 631 | 631 | svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"} | |
| 632 | 632 | ||
| 633 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil) | |
| 634 | - | runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil) | |
| 635 | - | ||
| 633 | + | // Shutdown no longer stops containers — they are left running for | |
| 634 | + | // SyncContainers + AutoStart to pick back up on next boot. | |
| 636 | 635 | err := svc.Shutdown(ctx) | |
| 637 | 636 | ||
| 638 | 637 | assert.NoError(t, err) | |
| 639 | - | assert.Empty(t, svc.containers) | |
| 640 | - | } | |
| 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) | |
| 638 | + | // Containers remain tracked (not stopped). | |
| 639 | + | assert.Len(t, svc.containers, 2) | |
| 662 | 640 | } | |
| 663 | 641 | ||
| 664 | 642 | func TestService_Restart_NotFound(t *testing.T) { | |
| @@ -1196,14 +1174,14 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1196 | 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 | 1178 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2) | |
| 1201 | 1179 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2) | |
| 1202 | 1180 | runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2) | |
| 1203 | 1181 | envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2) | |
| 1204 | 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 | 1185 | runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ | |
| 1208 | 1186 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | |
| 1209 | 1187 | }, nil).Once() | |
| @@ -1211,7 +1189,6 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) { | |||
| 1211 | 1189 | ID: "container-2", Name: "gordon-app2.example.com", Status: "created", | |
| 1212 | 1190 | }, nil).Once() | |
| 1213 | 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 | 1192 | runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{ | |
| 1216 | 1193 | Status: "running", | |
| 1217 | 1194 | }, nil).Times(2) | |
| @@ -1245,7 +1222,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) { | |||
| 1245 | 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 | 1226 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once() | |
| 1250 | 1227 | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once() | |
| 1251 | 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 | 1232 | ID: "container-2", Status: "created", | |
| 1256 | 1233 | }, nil).Once() | |
| 1257 | 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 | 1235 | runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{ | |
| 1260 | 1236 | Status: "running", | |
| 1261 | 1237 | }, nil).Once() | |
| @@ -1313,7 +1289,8 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) { | |||
| 1313 | 1289 | } | |
| 1314 | 1290 | ||
| 1315 | 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 | 1294 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 1318 | 1295 | runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil) | |
| 1319 | 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 | 1302 | ID: "container-1", Name: "gordon-app1.example.com", Status: "created", | |
| 1326 | 1303 | }, nil) | |
| 1327 | 1304 | runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil) | |
| 1328 | - | runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2) | |
| 1329 | 1305 | runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{ | |
| 1330 | 1306 | Status: "running", | |
| 1331 | 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 | 4 | import ( | |
| 5 | 5 | "bufio" | |
| 6 | + | "bytes" | |
| 6 | 7 | "context" | |
| 7 | 8 | "encoding/binary" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 10 | 11 | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "strconv" | |
| 11 | 14 | "strings" | |
| 12 | 15 | "time" | |
| 13 | 16 | ||
| @@ -19,24 +22,29 @@ import ( | |||
| 19 | 22 | ||
| 20 | 23 | // Service implements the LogService interface. | |
| 21 | 24 | type Service struct { | |
| 22 | - | logFilePath string | |
| 23 | - | containerSvc in.ContainerService | |
| 24 | - | runtime out.ContainerRuntime | |
| 25 | - | log zerowrap.Logger | |
| 25 | + | logFilePath string | |
| 26 | + | fileLoggingEnabled bool | |
| 27 | + | containerSvc in.ContainerService | |
| 28 | + | runtime out.ContainerRuntime | |
| 29 | + | log zerowrap.Logger | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 32 | + | var execCommandContext = exec.CommandContext | |
| 33 | + | ||
| 28 | 34 | // NewService creates a new log service. | |
| 29 | 35 | func NewService( | |
| 30 | 36 | logFilePath string, | |
| 37 | + | fileLoggingEnabled bool, | |
| 31 | 38 | containerSvc in.ContainerService, | |
| 32 | 39 | runtime out.ContainerRuntime, | |
| 33 | 40 | log zerowrap.Logger, | |
| 34 | 41 | ) *Service { | |
| 35 | 42 | return &Service{ | |
| 36 | - | logFilePath: logFilePath, | |
| 37 | - | containerSvc: containerSvc, | |
| 38 | - | runtime: runtime, | |
| 39 | - | log: log, | |
| 43 | + | logFilePath: logFilePath, | |
| 44 | + | fileLoggingEnabled: fileLoggingEnabled, | |
| 45 | + | containerSvc: containerSvc, | |
| 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 | 58 | log := zerowrap.FromCtx(ctx) | |
| 51 | 59 | ||
| 52 | - | if s.logFilePath == "" { | |
| 53 | - | return nil, fmt.Errorf("log file path not configured") | |
| 54 | - | } | |
| 60 | + | if s.useFileProcessLogs() { | |
| 61 | + | if s.logFilePath == "" { | |
| 62 | + | return nil, fmt.Errorf("log file path not configured") | |
| 63 | + | } | |
| 55 | 64 | ||
| 56 | - | file, err := os.Open(s.logFilePath) | |
| 57 | - | if err != nil { | |
| 58 | - | if os.IsNotExist(err) { | |
| 59 | - | return []string{}, nil | |
| 65 | + | file, err := os.Open(s.logFilePath) | |
| 66 | + | if err != nil { | |
| 67 | + | if os.IsNotExist(err) { | |
| 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 | 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 | 86 | zerowrap.FieldUseCase: "FollowProcessLogs", | |
| 73 | 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 | 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 | 120 | if s.logFilePath == "" { | |
| 78 | 121 | return nil, fmt.Errorf("log file path not configured") | |
| 79 | 122 | } | |
| 80 | 123 | ||
| 81 | - | file, err := os.Open(s.logFilePath) | |
| 82 | - | if err != nil { | |
| 83 | - | return nil, log.WrapErr(err, "failed to open log file") | |
| 84 | - | } | |
| 124 | + | return os.Open(s.logFilePath) | |
| 125 | + | } | |
| 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 | 133 | ch := make(chan string, 100) | |
| 87 | 134 | ||
| 88 | 135 | go func() { | |
| 89 | 136 | defer close(ch) | |
| 90 | 137 | defer file.Close() | |
| 91 | 138 | ||
| 92 | - | // First, get initial lines | |
| 93 | - | if initialLines > 0 { | |
| 94 | - | lines, err := tailLines(file, initialLines) | |
| 95 | - | if err != nil { | |
| 96 | - | log.Warn().Err(err).Msg("failed to read initial lines") | |
| 97 | - | } else { | |
| 98 | - | for _, line := range lines { | |
| 99 | - | select { | |
| 100 | - | case ch <- line: | |
| 101 | - | case <-ctx.Done(): | |
| 102 | - | return | |
| 103 | - | } | |
| 104 | - | } | |
| 139 | + | if !s.emitInitialFileLogs(ctx, file, initialLines, ch) { | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | if err := s.seekToEndForFileFollow(file); err != nil { | |
| 143 | + | log.Warn().Err(err).Msg("failed to seek to end") | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | s.streamFileFollowLogs(ctx, file, ch) | |
| 148 | + | }() | |
| 149 | + | ||
| 150 | + | return ch | |
| 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 | |
| 109 | - | if _, err := file.Seek(0, io.SeekEnd); err != nil { | |
| 110 | - | log.Warn().Err(err).Msg("failed to seek to end") | |
| 203 | + | line = strings.TrimRight(line, "\n\r") | |
| 204 | + | if !sendLogLineWithChannel(ctx, ch, line) { | |
| 111 | 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 | |
| 115 | - | reader := bufio.NewReader(file) | |
| 116 | - | for { | |
| 230 | + | out, err := execCommandContext(ctx, "journalctl", args...).Output() | |
| 231 | + | if err != nil { | |
| 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 | 277 | select { | |
| 278 | + | case ch <- scanner.Text(): | |
| 118 | 279 | case <-ctx.Done(): | |
| 119 | 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 | 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 | 329 | // validateDomain checks that domain is safe for use in logs and error messages. | |
| 144 | 330 | func validateDomain(domain string) error { | |
| 145 | 331 | if domain == "" { | |
M internal/usecase/logs/service_test.go +28 -8
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | 6 | "os" | |
| 7 | + | "os/exec" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "testing" | |
| 9 | 10 | "time" | |
| @@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 32 | 33 | containerSvc := mocks.NewMockContainerService(t) | |
| 33 | 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 | 38 | lines, err := svc.GetProcessLogs(context.Background(), 3) | |
| 38 | 39 | require.NoError(t, err) | |
| @@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 44 | 45 | containerSvc := mocks.NewMockContainerService(t) | |
| 45 | 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 | 50 | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 50 | 51 | require.NoError(t, err) | |
| @@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 55 | 56 | containerSvc := mocks.NewMockContainerService(t) | |
| 56 | 57 | runtime := outMocks.NewMockContainerRuntime(t) | |
| 57 | 58 | ||
| 58 | - | svc := NewService("", containerSvc, runtime, log) | |
| 59 | + | svc := NewService("", true, containerSvc, runtime, log) | |
| 59 | 60 | ||
| 60 | 61 | _, err := svc.GetProcessLogs(context.Background(), 10) | |
| 61 | 62 | assert.Error(t, err) | |
| @@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) { | |||
| 72 | 73 | containerSvc := mocks.NewMockContainerService(t) | |
| 73 | 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 | 78 | lines, err := svc.GetProcessLogs(context.Background(), 10) | |
| 78 | 79 | require.NoError(t, err) | |
| 79 | 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 | 103 | func TestService_FollowProcessLogs(t *testing.T) { | |
| @@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) { | |||
| 93 | 113 | containerSvc := mocks.NewMockContainerService(t) | |
| 94 | 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 | 118 | ctx, cancel := context.WithCancel(context.Background()) | |
| 99 | 119 | defer cancel() | |
| @@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||
| 135 | 155 | ||
| 136 | 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 | 160 | _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10) | |
| 141 | 161 | assert.Error(t, err) | |
| @@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) { | |||
| 156 | 176 | // Create a mock reader that returns empty content | |
| 157 | 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 | 181 | lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10) | |
| 162 | 182 | require.NoError(t, err) | |
| @@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) { | |||
| 173 | 193 | ||
| 174 | 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 | 198 | _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10) | |
| 179 | 199 | assert.Error(t, err) | |
M internal/usecase/proxy/service.go +27 -8
| @@ -24,8 +24,8 @@ import ( | |||
| 24 | 24 | "github.com/bnema/gordon/internal/domain" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | - | // proxyTransport is a shared HTTP transport with proper timeouts. | |
| 28 | - | // This prevents resource exhaustion from slow backends or network issues. | |
| 27 | + | // proxyTransport is a shared HTTP transport for proxying to application containers. | |
| 28 | + | // ResponseHeaderTimeout is kept short to detect unresponsive backends quickly. | |
| 29 | 29 | var proxyTransport = &http.Transport{ | |
| 30 | 30 | DialContext: (&net.Dialer{ | |
| 31 | 31 | Timeout: 10 * time.Second, | |
| @@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{ | |||
| 38 | 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 | 56 | // Config holds configuration needed by the proxy service. | |
| 42 | 57 | type Config struct { | |
| 43 | 58 | RegistryDomain string | |
| @@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 558 | 573 | return | |
| 559 | 574 | } | |
| 560 | 575 | ||
| 561 | - | proxy := newReverseProxy(targetURL, "", | |
| 562 | - | func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 576 | + | proxy := &httputil.ReverseProxy{ | |
| 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 | 584 | var maxBytesErr *http.MaxBytesError | |
| 564 | 585 | if errors.As(err, &maxBytesErr) { | |
| 565 | 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 | 590 | log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error") | |
| 570 | 591 | proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 571 | 592 | }, | |
| 572 | - | func(resp *http.Response) error { | |
| 593 | + | ModifyResponse: func(resp *http.Response) error { | |
| 573 | 594 | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 574 | 595 | resp.Header.Set("X-Registry-Backend", "gordon-registry") | |
| 575 | 596 | ||
| 576 | 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 | 598 | resp.Header.Del("X-Content-Type-Options") | |
| 580 | 599 | resp.Header.Del("X-Frame-Options") | |
| 581 | 600 | resp.Header.Del("X-XSS-Protection") | |
| @@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 586 | 605 | ||
| 587 | 606 | return nil | |
| 588 | 607 | }, | |
| 589 | - | ) | |
| 608 | + | } | |
| 590 | 609 | ||
| 591 | 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 | 6 | "crypto/sha256" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "io" | |
| 9 | + | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| 11 | 12 | ||
| @@ -74,8 +75,10 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s | |||
| 74 | 75 | return "", log.WrapErr(err, "failed to store manifest") | |
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | - | // Publish image pushed event | |
| 78 | - | if s.eventBus != nil { | |
| 78 | + | // Publish image pushed event only for tag references (not digests). | |
| 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 | 82 | if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{ | |
| 80 | 83 | Name: manifest.Name, | |
| 81 | 84 | Reference: manifest.Reference, | |
M pkg/runtime/interface.go +26 -0
| @@ -3,6 +3,7 @@ package runtime | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | + | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | // Container represents a running container | |
| @@ -40,6 +41,26 @@ type ContainerConfig struct { | |||
| 40 | 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 | 64 | // Runtime interface defines the contract for container runtime implementations | |
| 44 | 65 | type Runtime interface { | |
| 45 | 66 | // Container lifecycle | |
| @@ -59,6 +80,11 @@ type Runtime interface { | |||
| 59 | 80 | PullImageWithAuth(ctx context.Context, image, username, password string) error | |
| 60 | 81 | RemoveImage(ctx context.Context, image string, force bool) error | |
| 61 | 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 | 89 | // Runtime information | |
| 64 | 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 | 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 | 25 | ```toml | |
| 26 | 26 | # On the remote Gordon server | |
| @@ -30,6 +30,40 @@ gordon_domain = "gordon.example.com" # Use this as --remote URL | |||
| 30 | 30 | ||
| 31 | 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 | 67 | ## Quick Start | |
| 34 | 68 | ||
| 35 | 69 | ### One-off Remote Command | |
| @@ -67,6 +101,7 @@ These flags are available on all commands: | |||
| 67 | 101 | |------|-------------| | |
| 68 | 102 | | `--remote <URL>` | Remote Gordon URL | | |
| 69 | 103 | | `--token <TOKEN>` | Authentication token | | |
| 104 | + | | `--insecure` | Skip TLS verification (self-signed/private certs) | | |
| 70 | 105 | ||
| 71 | 106 | ```bash | |
| 72 | 107 | # List routes on remote | |
M wiki/guides/secure-vps-setup.md +49 -0
| @@ -142,6 +142,55 @@ Verify public SSH is blocked: | |||
| 142 | 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 | 196 | ## Step 7: Install CrowdSec | |