Merge pull request #74 from bnema/feat/image-versioning-rollback-list
feat(images): expose registry tags and improve rollback visibility
13 files · +337 -38
M docs/cli/images.md +5 -3
| @@ -10,7 +10,7 @@ gordon images <subcommand> | |||
| 10 | 10 | ||
| 11 | 11 | Subcommands: | |
| 12 | 12 | ||
| 13 | - | - `list` - List runtime images. | |
| 13 | + | - `list` - List runtime images and registry tags. | |
| 14 | 14 | - `prune` - Prune dangling runtime images and optionally registry data. | |
| 15 | 15 | ||
| 16 | 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | |
| @@ -22,6 +22,7 @@ gordon images list | |||
| 22 | 22 | ``` | |
| 23 | 23 | ||
| 24 | 24 | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | |
| 25 | + | Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`. | |
| 25 | 26 | ||
| 26 | 27 | ## gordon images prune | |
| 27 | 28 | ||
| @@ -34,7 +35,7 @@ Flags: | |||
| 34 | 35 | | Flag | Default | Description | | |
| 35 | 36 | |------|---------|-------------| | |
| 36 | 37 | | `--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 | + | | `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) | | |
| 38 | 39 | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | |
| 39 | 40 | ||
| 40 | 41 | `--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. | |
| @@ -44,6 +45,7 @@ Behavior notes: | |||
| 44 | 45 | - `--runtime-only` forces `keep_last = 0`. | |
| 45 | 46 | - `--keep` must be `>= 0`. | |
| 46 | 47 | - `--dry-run` reports what would be pruned. | |
| 48 | + | - When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags. | |
| 47 | 49 | ||
| 48 | 50 | ## Examples | |
| 49 | 51 | ||
| @@ -54,7 +56,7 @@ gordon images list | |||
| 54 | 56 | # Prune dangling runtime images only | |
| 55 | 57 | gordon images prune --runtime-only | |
| 56 | 58 | ||
| 57 | - | # Prune runtime images and keep 3 newest tags per repository | |
| 59 | + | # Prune runtime images and keep latest + 3 previous tags per repository | |
| 58 | 60 | gordon images prune --keep 3 | |
| 59 | 61 | ||
| 60 | 62 | # Preview cleanup without applying | |
| @@ -10,7 +10,7 @@ gordon images <subcommand> | |||||
| 10 | 10 | ||||
| 11 | Subcommands: | 11 | Subcommands: | ||
| 12 | 12 | ||||
| 13 | - | - `list` - List runtime images. | 13 | + | - `list` - List runtime images and registry tags. |
| 14 | - `prune` - Prune dangling runtime images and optionally registry data. | 14 | - `prune` - Prune dangling runtime images and optionally registry data. | ||
| 15 | 15 | ||||
| 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | 16 | > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes). | ||
| @@ -22,6 +22,7 @@ gordon images list | |||||
| 22 | ``` | 22 | ``` | ||
| 23 | 23 | ||||
| 24 | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | 24 | Shows image rows with repository, tag, size, creation time, image id, and dangling status. | ||
| 25 | + | Rows that exist only in the registry (not currently present in the runtime cache) are included with unavailable runtime fields shown as `-`. | |||
| 25 | 26 | ||||
| 26 | ## gordon images prune | 27 | ## gordon images prune | ||
| 27 | 28 | ||||
| @@ -34,7 +35,7 @@ Flags: | |||||
| 34 | | Flag | Default | Description | | 35 | | Flag | Default | Description | | ||
| 35 | |------|---------|-------------| | 36 | |------|---------|-------------| | ||
| 36 | | `--dry-run` | `false` | Show prune behavior without applying changes | | 37 | | `--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 | + | | `--keep` | `0` | Number of previous tags to keep per repository (`latest` is always preserved when present; `0` disables registry cleanup) | |
| 38 | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | 39 | | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off | | ||
| 39 | 40 | ||||
| 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 | `--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. | ||
| @@ -44,6 +45,7 @@ Behavior notes: | |||||
| 44 | - `--runtime-only` forces `keep_last = 0`. | 45 | - `--runtime-only` forces `keep_last = 0`. | ||
| 45 | - `--keep` must be `>= 0`. | 46 | - `--keep` must be `>= 0`. | ||
| 46 | - `--dry-run` reports what would be pruned. | 47 | - `--dry-run` reports what would be pruned. | ||
| 48 | + | - When `--keep` is greater than `0`, registry cleanup keeps `latest` (if present) plus the requested number of most-recent non-`latest` tags. | |||
| 47 | 49 | ||||
| 48 | ## Examples | 50 | ## Examples | ||
| 49 | 51 | ||||
| @@ -54,7 +56,7 @@ gordon images list | |||||
| 54 | # Prune dangling runtime images only | 56 | # Prune dangling runtime images only | ||
| 55 | gordon images prune --runtime-only | 57 | gordon images prune --runtime-only | ||
| 56 | 58 | ||||
| 57 | - | # Prune runtime images and keep 3 newest tags per repository | 59 | + | # Prune runtime images and keep latest + 3 previous tags per repository |
| 58 | gordon images prune --keep 3 | 60 | gordon images prune --keep 3 | ||
| 59 | 61 | ||||
| 60 | # Preview cleanup without applying | 62 | # Preview cleanup without applying | ||
M docs/cli/rollback.md +9 -3
| @@ -8,6 +8,7 @@ Roll back a route to a previous image tag. | |||
| 8 | 8 | ||
| 9 | 9 | ```bash | |
| 10 | 10 | gordon rollback <domain> [options] | |
| 11 | + | gordon rollback list <domain> | |
| 11 | 12 | ``` | |
| 12 | 13 | ||
| 13 | 14 | ### Arguments | |
| @@ -26,9 +27,11 @@ gordon rollback <domain> [options] | |||
| 26 | 27 | ||
| 27 | 28 | ### Description | |
| 28 | 29 | ||
| 29 | - | `gordon rollback` lists available tags in the Gordon registry for the route's | |
| 30 | - | repository, then deploys the selected version. Semver tags are sorted in | |
| 31 | - | descending order first, followed by non-semver tags. | |
| 30 | + | `gordon rollback` deploys a selected tag for the route's repository. | |
| 31 | + | Semver tags are sorted in descending order first, followed by non-semver tags. | |
| 32 | + | ||
| 33 | + | `gordon rollback list <domain>` only lists available tags and marks the current | |
| 34 | + | tag without deploying. | |
| 32 | 35 | ||
| 33 | 36 | If the selected tag matches the current running tag, no action is taken. | |
| 34 | 37 | ||
| @@ -40,6 +43,9 @@ gordon rollback myapp.example.com | |||
| 40 | 43 | ||
| 41 | 44 | # Roll back to a specific tag | |
| 42 | 45 | gordon rollback myapp.example.com --tag v1.2.0 | |
| 46 | + | ||
| 47 | + | # List available tags for a domain | |
| 48 | + | gordon rollback list myapp.example.com | |
| 43 | 49 | ``` | |
| 44 | 50 | ||
| 45 | 51 | ### Notes | |
| @@ -8,6 +8,7 @@ Roll back a route to a previous image tag. | |||||
| 8 | 8 | ||||
| 9 | ```bash | 9 | ```bash | ||
| 10 | gordon rollback <domain> [options] | 10 | gordon rollback <domain> [options] | ||
| 11 | + | gordon rollback list <domain> | |||
| 11 | ``` | 12 | ``` | ||
| 12 | 13 | ||||
| 13 | ### Arguments | 14 | ### Arguments | ||
| @@ -26,9 +27,11 @@ gordon rollback <domain> [options] | |||||
| 26 | 27 | ||||
| 27 | ### Description | 28 | ### Description | ||
| 28 | 29 | ||||
| 29 | - | `gordon rollback` lists available tags in the Gordon registry for the route's | 30 | + | `gordon rollback` deploys a selected tag for the route's repository. |
| 30 | - | repository, then deploys the selected version. Semver tags are sorted in | 31 | + | Semver tags are sorted in descending order first, followed by non-semver tags. |
| 31 | - | descending order first, followed by non-semver tags. | 32 | + | |
| 33 | + | `gordon rollback list <domain>` only lists available tags and marks the current | |||
| 34 | + | tag without deploying. | |||
| 32 | 35 | ||||
| 33 | If the selected tag matches the current running tag, no action is taken. | 36 | If the selected tag matches the current running tag, no action is taken. | ||
| 34 | 37 | ||||
| @@ -40,6 +43,9 @@ gordon rollback myapp.example.com | |||||
| 40 | 43 | ||||
| 41 | # Roll back to a specific tag | 44 | # Roll back to a specific tag | ||
| 42 | gordon rollback myapp.example.com --tag v1.2.0 | 45 | gordon rollback myapp.example.com --tag v1.2.0 | ||
| 46 | + | ||||
| 47 | + | # List available tags for a domain | |||
| 48 | + | gordon rollback list myapp.example.com | |||
| 43 | ``` | 49 | ``` | ||
| 44 | 50 | ||||
| 45 | ### Notes | 51 | ### Notes | ||
M docs/config/images.md +2 -2
| @@ -26,12 +26,12 @@ keep_last = 3 | |||
| 26 | 26 | |-----|------|---------|-------------| | |
| 27 | 27 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | |
| 28 | 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 | | |
| 29 | + | | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) | | |
| 30 | 30 | ||
| 31 | 31 | ## Retention Behavior | |
| 32 | 32 | ||
| 33 | 33 | - `latest` is always preserved. | |
| 34 | - | - `keep_last` applies per repository. | |
| 34 | + | - `keep_last` applies per repository and counts non-`latest` tags. | |
| 35 | 35 | - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs). | |
| 36 | 36 | - Negative `keep_last` values are invalid. | |
| 37 | 37 | ||
| @@ -26,12 +26,12 @@ keep_last = 3 | |||||
| 26 | |-----|------|---------|-------------| | 26 | |-----|------|---------|-------------| | ||
| 27 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | 27 | | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup | | ||
| 28 | | `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` | | 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 | | 29 | + | | `images.prune.keep_last` | int | `3` | Number of newest non-`latest` tags kept per repository during registry cleanup (`latest` is always kept when present) | |
| 30 | 30 | ||||
| 31 | ## Retention Behavior | 31 | ## Retention Behavior | ||
| 32 | 32 | ||||
| 33 | - `latest` is always preserved. | 33 | - `latest` is always preserved. | ||
| 34 | - | - `keep_last` applies per repository. | 34 | + | - `keep_last` applies per repository and counts non-`latest` tags. |
| 35 | - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs). | 35 | - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs). | ||
| 36 | - Negative `keep_last` values are invalid. | 36 | - Negative `keep_last` values are invalid. | ||
| 37 | 37 | ||||
M internal/adapters/dto/admin_images.go +1 -1
| @@ -2,7 +2,7 @@ package dto | |||
| 2 | 2 | ||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | - | // Image represents a runtime image in admin API responses. | |
| 5 | + | // Image represents an image/tag in admin API responses. | |
| 6 | 6 | type Image struct { | |
| 7 | 7 | Repository string `json:"repository"` | |
| 8 | 8 | Tag string `json:"tag"` | |
| @@ -2,7 +2,7 @@ package dto | |||||
| 2 | 2 | ||||
| 3 | import "time" | 3 | import "time" | ||
| 4 | 4 | ||||
| 5 | - | // Image represents a runtime image in admin API responses. | 5 | + | // Image represents an image/tag in admin API responses. |
| 6 | type Image struct { | 6 | type Image struct { | ||
| 7 | Repository string `json:"repository"` | 7 | Repository string `json:"repository"` | ||
| 8 | Tag string `json:"tag"` | 8 | Tag string `json:"tag"` | ||
M internal/adapters/in/cli/images.go +17 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "strings" | |
| 7 | 8 | "text/tabwriter" | |
| 8 | 9 | "time" | |
| 9 | 10 | ||
| @@ -41,7 +42,7 @@ These commands currently require remote mode with a configured target.`, | |||
| 41 | 42 | func newImagesListCmd() *cobra.Command { | |
| 42 | 43 | return &cobra.Command{ | |
| 43 | 44 | Use: "list", | |
| 44 | - | Short: "List runtime images", | |
| 45 | + | Short: "List runtime images and registry tags", | |
| 45 | 46 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 46 | 47 | client, isRemote := GetRemoteClient() | |
| 47 | 48 | if !isRemote { | |
| @@ -70,7 +71,7 @@ func newImagesPruneCmd() *cobra.Command { | |||
| 70 | 71 | } | |
| 71 | 72 | ||
| 72 | 73 | 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().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)") | |
| 74 | 75 | cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)") | |
| 75 | 76 | ||
| 76 | 77 | return cmd | |
| @@ -103,7 +104,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||
| 103 | 104 | danglingCount++ | |
| 104 | 105 | } | |
| 105 | 106 | ||
| 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 | + | 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), formatImageID(img.ID), dangling); err != nil { | |
| 107 | 108 | return err | |
| 108 | 109 | } | |
| 109 | 110 | } | |
| @@ -154,7 +155,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||
| 154 | 155 | return err | |
| 155 | 156 | } | |
| 156 | 157 | ||
| 157 | - | _, err = fmt.Fprintf(out, "Registry: would keep last %d tags per repository\n", keepLast) | |
| 158 | + | _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) | |
| 158 | 159 | return err | |
| 159 | 160 | } | |
| 160 | 161 | ||
| @@ -187,6 +188,10 @@ func formatImageCreatedAt(t time.Time) string { | |||
| 187 | 188 | } | |
| 188 | 189 | ||
| 189 | 190 | func formatImageSize(size int64) string { | |
| 191 | + | if size <= 0 { | |
| 192 | + | return "-" | |
| 193 | + | } | |
| 194 | + | ||
| 190 | 195 | const unit = 1024 | |
| 191 | 196 | if size < unit { | |
| 192 | 197 | return fmt.Sprintf("%d B", size) | |
| @@ -198,3 +203,11 @@ func formatImageSize(size int64) string { | |||
| 198 | 203 | } | |
| 199 | 204 | return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) | |
| 200 | 205 | } | |
| 206 | + | ||
| 207 | + | func formatImageID(id string) string { | |
| 208 | + | if strings.TrimSpace(id) == "" { | |
| 209 | + | return "-" | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | return id | |
| 213 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | + | "strings" | |||
| 7 | "text/tabwriter" | 8 | "text/tabwriter" | ||
| 8 | "time" | 9 | "time" | ||
| 9 | 10 | ||||
| @@ -41,7 +42,7 @@ These commands currently require remote mode with a configured target.`, | |||||
| 41 | func newImagesListCmd() *cobra.Command { | 42 | func newImagesListCmd() *cobra.Command { | ||
| 42 | return &cobra.Command{ | 43 | return &cobra.Command{ | ||
| 43 | Use: "list", | 44 | Use: "list", | ||
| 44 | - | Short: "List runtime images", | 45 | + | Short: "List runtime images and registry tags", |
| 45 | RunE: func(cmd *cobra.Command, _ []string) error { | 46 | RunE: func(cmd *cobra.Command, _ []string) error { | ||
| 46 | client, isRemote := GetRemoteClient() | 47 | client, isRemote := GetRemoteClient() | ||
| 47 | if !isRemote { | 48 | if !isRemote { | ||
| @@ -70,7 +71,7 @@ func newImagesPruneCmd() *cobra.Command { | |||||
| 70 | } | 71 | } | ||
| 71 | 72 | ||||
| 72 | cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes") | 73 | 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().IntVar(&opts.KeepLast, "keep", 0, "Number of previous version tags to keep per repository (latest is always kept when present; 0 disables registry cleanup)") |
| 74 | cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)") | 75 | cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)") | ||
| 75 | 76 | ||||
| 76 | return cmd | 77 | return cmd | ||
| @@ -103,7 +104,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro | |||||
| 103 | danglingCount++ | 104 | danglingCount++ | ||
| 104 | } | 105 | } | ||
| 105 | 106 | ||||
| 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 | + | 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), formatImageID(img.ID), dangling); err != nil { |
| 107 | return err | 108 | return err | ||
| 108 | } | 109 | } | ||
| 109 | } | 110 | } | ||
| @@ -154,7 +155,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp | |||||
| 154 | return err | 155 | return err | ||
| 155 | } | 156 | } | ||
| 156 | 157 | ||||
| 157 | - | _, err = fmt.Fprintf(out, "Registry: would keep last %d tags per repository\n", keepLast) | 158 | + | _, err = fmt.Fprintf(out, "Registry: would keep latest + %d previous tags per repository\n", keepLast) |
| 158 | return err | 159 | return err | ||
| 159 | } | 160 | } | ||
| 160 | 161 | ||||
| @@ -187,6 +188,10 @@ func formatImageCreatedAt(t time.Time) string { | |||||
| 187 | } | 188 | } | ||
| 188 | 189 | ||||
| 189 | func formatImageSize(size int64) string { | 190 | func formatImageSize(size int64) string { | ||
| 191 | + | if size <= 0 { | |||
| 192 | + | return "-" | |||
| 193 | + | } | |||
| 194 | + | ||||
| 190 | const unit = 1024 | 195 | const unit = 1024 | ||
| 191 | if size < unit { | 196 | if size < unit { | ||
| 192 | return fmt.Sprintf("%d B", size) | 197 | return fmt.Sprintf("%d B", size) | ||
| @@ -198,3 +203,11 @@ func formatImageSize(size int64) string { | |||||
| 198 | } | 203 | } | ||
| 199 | return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) | 204 | return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp]) | ||
| 200 | } | 205 | } | ||
| 206 | + | ||||
| 207 | + | func formatImageID(id string) string { | |||
| 208 | + | if strings.TrimSpace(id) == "" { | |||
| 209 | + | return "-" | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | return id | |||
| 213 | + | } | |||
M internal/adapters/in/cli/images_test.go +1 -1
| @@ -87,7 +87,7 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||
| 87 | 87 | text := out.String() | |
| 88 | 88 | assert.Contains(t, text, "Dry run") | |
| 89 | 89 | assert.Contains(t, text, "would prune 2 dangling runtime images") | |
| 90 | - | assert.Contains(t, text, "would keep last 3 tags") | |
| 90 | + | assert.Contains(t, text, "would keep latest + 3 previous tags") | |
| 91 | 91 | assert.Equal(t, 1, client.listImagesCalls) | |
| 92 | 92 | assert.Equal(t, 0, client.pruneCalls) | |
| 93 | 93 | } | |
| @@ -87,7 +87,7 @@ func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) { | |||||
| 87 | text := out.String() | 87 | text := out.String() | ||
| 88 | assert.Contains(t, text, "Dry run") | 88 | assert.Contains(t, text, "Dry run") | ||
| 89 | assert.Contains(t, text, "would prune 2 dangling runtime images") | 89 | assert.Contains(t, text, "would prune 2 dangling runtime images") | ||
| 90 | - | assert.Contains(t, text, "would keep last 3 tags") | 90 | + | assert.Contains(t, text, "would keep latest + 3 previous tags") |
| 91 | assert.Equal(t, 1, client.listImagesCalls) | 91 | assert.Equal(t, 1, client.listImagesCalls) | ||
| 92 | assert.Equal(t, 0, client.pruneCalls) | 92 | assert.Equal(t, 0, client.pruneCalls) | ||
| 93 | } | 93 | } | ||
M internal/adapters/in/cli/remote/client.go +1 -1
| @@ -485,7 +485,7 @@ type RouteHealth struct { | |||
| 485 | 485 | ||
| 486 | 486 | // Images API | |
| 487 | 487 | ||
| 488 | - | // ListImages returns runtime images from the admin API. | |
| 488 | + | // ListImages returns runtime images and registry tags from the admin API. | |
| 489 | 489 | func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | |
| 490 | 490 | resp, err := c.request(ctx, http.MethodGet, "/images", nil) | |
| 491 | 491 | if err != nil { | |
| @@ -485,7 +485,7 @@ type RouteHealth struct { | |||||
| 485 | 485 | ||||
| 486 | // Images API | 486 | // Images API | ||
| 487 | 487 | ||||
| 488 | - | // ListImages returns runtime images from the admin API. | 488 | + | // ListImages returns runtime images and registry tags from the admin API. |
| 489 | func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | 489 | func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) { | ||
| 490 | resp, err := c.request(ctx, http.MethodGet, "/images", nil) | 490 | resp, err := c.request(ctx, http.MethodGet, "/images", nil) | ||
| 491 | if err != nil { | 491 | if err != nil { | ||
M internal/adapters/in/cli/rollback.go +56 -1
| @@ -3,6 +3,7 @@ package cli | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "os" | |
| 7 | 8 | "sort" | |
| 8 | 9 | "strings" | |
| @@ -26,7 +27,8 @@ Tags are read from the Gordon registry. | |||
| 26 | 27 | ||
| 27 | 28 | Examples: | |
| 28 | 29 | gordon rollback myapp.example.com --remote ... | |
| 29 | - | gordon rollback myapp.example.com --tag v1.0.0 --remote ...`, | |
| 30 | + | gordon rollback myapp.example.com --tag v1.0.0 --remote ... | |
| 31 | + | gordon rollback list myapp.example.com --remote ...`, | |
| 30 | 32 | Args: cobra.ExactArgs(1), | |
| 31 | 33 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 32 | 34 | return runRollback(cmd.Context(), args[0], targetTag) | |
| @@ -34,10 +36,22 @@ Examples: | |||
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)") | |
| 39 | + | cmd.AddCommand(newRollbackListCmd()) | |
| 37 | 40 | ||
| 38 | 41 | return cmd | |
| 39 | 42 | } | |
| 40 | 43 | ||
| 44 | + | func newRollbackListCmd() *cobra.Command { | |
| 45 | + | return &cobra.Command{ | |
| 46 | + | Use: "list <domain>", | |
| 47 | + | Short: "List available rollback tags", | |
| 48 | + | Args: cobra.ExactArgs(1), | |
| 49 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 50 | + | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0]) | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 41 | 55 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |
| 42 | 56 | handle, err := resolveControlPlane(configPath) | |
| 43 | 57 | if err != nil { | |
| @@ -73,6 +87,47 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||
| 73 | 87 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | |
| 74 | 88 | } | |
| 75 | 89 | ||
| 90 | + | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error { | |
| 91 | + | handle, err := resolveControlPlane(configPath) | |
| 92 | + | if err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | defer handle.close() | |
| 96 | + | ||
| 97 | + | route, err := handle.plane.GetRoute(ctx, rollbackDomain) | |
| 98 | + | if err != nil { | |
| 99 | + | return fmt.Errorf("failed to get route: %w", err) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | _, imageName, currentTag := parseImageRef(route.Image) | |
| 103 | + | if imageName == "" { | |
| 104 | + | return fmt.Errorf("cannot parse image name from route: %s", route.Image) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | tags, err := fetchAndSortTags(ctx, handle.plane, imageName) | |
| 108 | + | if err != nil { | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | return printRollbackTags(w, rollbackDomain, currentTag, tags) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error { | |
| 116 | + | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | |
| 117 | + | return err | |
| 118 | + | } | |
| 119 | + | for _, tag := range tags { | |
| 120 | + | suffix := "" | |
| 121 | + | if tag == currentTag { | |
| 122 | + | suffix = " (current)" | |
| 123 | + | } | |
| 124 | + | if _, err := fmt.Fprintf(w, "- %s%s\n", tag, suffix); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | ||
| 76 | 131 | func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) { | |
| 77 | 132 | tags, err := cp.ListTags(ctx, imageName) | |
| 78 | 133 | if err != nil { | |
| @@ -3,6 +3,7 @@ package cli | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 6 | "os" | 7 | "os" | ||
| 7 | "sort" | 8 | "sort" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -26,7 +27,8 @@ Tags are read from the Gordon registry. | |||||
| 26 | 27 | ||||
| 27 | Examples: | 28 | Examples: | ||
| 28 | gordon rollback myapp.example.com --remote ... | 29 | gordon rollback myapp.example.com --remote ... | ||
| 29 | - | gordon rollback myapp.example.com --tag v1.0.0 --remote ...`, | 30 | + | gordon rollback myapp.example.com --tag v1.0.0 --remote ... |
| 31 | + | gordon rollback list myapp.example.com --remote ...`, | |||
| 30 | Args: cobra.ExactArgs(1), | 32 | Args: cobra.ExactArgs(1), | ||
| 31 | RunE: func(cmd *cobra.Command, args []string) error { | 33 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 32 | return runRollback(cmd.Context(), args[0], targetTag) | 34 | return runRollback(cmd.Context(), args[0], targetTag) | ||
| @@ -34,10 +36,22 @@ Examples: | |||||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)") | 38 | cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)") | ||
| 39 | + | cmd.AddCommand(newRollbackListCmd()) | |||
| 37 | 40 | ||||
| 38 | return cmd | 41 | return cmd | ||
| 39 | } | 42 | } | ||
| 40 | 43 | ||||
| 44 | + | func newRollbackListCmd() *cobra.Command { | |||
| 45 | + | return &cobra.Command{ | |||
| 46 | + | Use: "list <domain>", | |||
| 47 | + | Short: "List available rollback tags", | |||
| 48 | + | Args: cobra.ExactArgs(1), | |||
| 49 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 50 | + | return runRollbackList(cmd.Context(), cmd.OutOrStdout(), args[0]) | |||
| 51 | + | }, | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 41 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | 55 | func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | ||
| 42 | handle, err := resolveControlPlane(configPath) | 56 | handle, err := resolveControlPlane(configPath) | ||
| 43 | if err != nil { | 57 | if err != nil { | ||
| @@ -73,6 +87,47 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error { | |||||
| 73 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | 87 | return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag) | ||
| 74 | } | 88 | } | ||
| 75 | 89 | ||||
| 90 | + | func runRollbackList(ctx context.Context, w io.Writer, rollbackDomain string) error { | |||
| 91 | + | handle, err := resolveControlPlane(configPath) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return err | |||
| 94 | + | } | |||
| 95 | + | defer handle.close() | |||
| 96 | + | ||||
| 97 | + | route, err := handle.plane.GetRoute(ctx, rollbackDomain) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return fmt.Errorf("failed to get route: %w", err) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | _, imageName, currentTag := parseImageRef(route.Image) | |||
| 103 | + | if imageName == "" { | |||
| 104 | + | return fmt.Errorf("cannot parse image name from route: %s", route.Image) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | tags, err := fetchAndSortTags(ctx, handle.plane, imageName) | |||
| 108 | + | if err != nil { | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | return printRollbackTags(w, rollbackDomain, currentTag, tags) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func printRollbackTags(w io.Writer, rollbackDomain, currentTag string, tags []string) error { | |||
| 116 | + | if _, err := fmt.Fprintf(w, "Available tags for %s:\n", styles.Theme.Bold.Render(rollbackDomain)); err != nil { | |||
| 117 | + | return err | |||
| 118 | + | } | |||
| 119 | + | for _, tag := range tags { | |||
| 120 | + | suffix := "" | |||
| 121 | + | if tag == currentTag { | |||
| 122 | + | suffix = " (current)" | |||
| 123 | + | } | |||
| 124 | + | if _, err := fmt.Fprintf(w, "- %s%s\n", tag, suffix); err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 76 | func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) { | 131 | func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) { | ||
| 77 | tags, err := cp.ListTags(ctx, imageName) | 132 | tags, err := cp.ListTags(ctx, imageName) | ||
| 78 | if err != nil { | 133 | if err != nil { | ||
M internal/adapters/in/cli/rollback_test.go +27 -0
| @@ -1,11 +1,19 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/stretchr/testify/assert" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 11 | + | type failingWriter struct{} | |
| 12 | + | ||
| 13 | + | func (failingWriter) Write(_ []byte) (int, error) { | |
| 14 | + | return 0, errors.New("write failed") | |
| 15 | + | } | |
| 16 | + | ||
| 9 | 17 | func TestSortSemverTags(t *testing.T) { | |
| 10 | 18 | tests := []struct { | |
| 11 | 19 | name string | |
| @@ -51,3 +59,22 @@ func TestValidateTagExists(t *testing.T) { | |||
| 51 | 59 | assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"})) | |
| 52 | 60 | assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"})) | |
| 53 | 61 | } | |
| 62 | + | ||
| 63 | + | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |
| 64 | + | var buf bytes.Buffer | |
| 65 | + | ||
| 66 | + | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}) | |
| 67 | + | assert.NoError(t, err) | |
| 68 | + | ||
| 69 | + | output := buf.String() | |
| 70 | + | assert.Contains(t, output, "Available tags for") | |
| 71 | + | assert.Contains(t, output, "myapp.example.com") | |
| 72 | + | assert.Contains(t, output, "- v1.2.0 (current)") | |
| 73 | + | assert.Contains(t, output, "- v1.1.0") | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | |
| 77 | + | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}) | |
| 78 | + | assert.Error(t, err) | |
| 79 | + | assert.Contains(t, err.Error(), "write failed") | |
| 80 | + | } | |
| @@ -1,11 +1,19 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 11 | + | type failingWriter struct{} | |||
| 12 | + | ||||
| 13 | + | func (failingWriter) Write(_ []byte) (int, error) { | |||
| 14 | + | return 0, errors.New("write failed") | |||
| 15 | + | } | |||
| 16 | + | ||||
| 9 | func TestSortSemverTags(t *testing.T) { | 17 | func TestSortSemverTags(t *testing.T) { | ||
| 10 | tests := []struct { | 18 | tests := []struct { | ||
| 11 | name string | 19 | name string | ||
| @@ -51,3 +59,22 @@ func TestValidateTagExists(t *testing.T) { | |||||
| 51 | assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"})) | 59 | assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"})) | ||
| 52 | assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"})) | 60 | assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"})) | ||
| 53 | } | 61 | } | ||
| 62 | + | ||||
| 63 | + | func TestPrintRollbackTags_WritesToProvidedWriter(t *testing.T) { | |||
| 64 | + | var buf bytes.Buffer | |||
| 65 | + | ||||
| 66 | + | err := printRollbackTags(&buf, "myapp.example.com", "v1.2.0", []string{"v1.2.0", "v1.1.0"}) | |||
| 67 | + | assert.NoError(t, err) | |||
| 68 | + | ||||
| 69 | + | output := buf.String() | |||
| 70 | + | assert.Contains(t, output, "Available tags for") | |||
| 71 | + | assert.Contains(t, output, "myapp.example.com") | |||
| 72 | + | assert.Contains(t, output, "- v1.2.0 (current)") | |||
| 73 | + | assert.Contains(t, output, "- v1.1.0") | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestPrintRollbackTags_ReturnsWriteError(t *testing.T) { | |||
| 77 | + | err := printRollbackTags(failingWriter{}, "myapp.example.com", "v1.2.0", []string{"v1.2.0"}) | |||
| 78 | + | assert.Error(t, err) | |||
| 79 | + | assert.Contains(t, err.Error(), "write failed") | |||
| 80 | + | } | |||
M internal/boundaries/in/images.go +1 -1
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | // ImageService defines image listing and prune operations. | |
| 11 | 11 | type ImageService interface { | |
| 12 | - | // ListImages returns runtime images. | |
| 12 | + | // ListImages returns runtime images and known registry tags. | |
| 13 | 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | |
| 14 | 14 | ||
| 15 | 15 | // Prune removes unused images and applies retention. | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | // ImageService defines image listing and prune operations. | 10 | // ImageService defines image listing and prune operations. | ||
| 11 | type ImageService interface { | 11 | type ImageService interface { | ||
| 12 | - | // ListImages returns runtime images. | 12 | + | // ListImages returns runtime images and known registry tags. |
| 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | 13 | ListImages(ctx context.Context) ([]domain.ImageInfo, error) | ||
| 14 | 14 | ||||
| 15 | // Prune removes unused images and applies retention. | 15 | // Prune removes unused images and applies retention. | ||
M internal/domain/images.go +3 -2
| @@ -8,7 +8,8 @@ const DefaultImagePruneKeepLast = 3 | |||
| 8 | 8 | type ImagePruneConfig struct { | |
| 9 | 9 | Enabled bool | |
| 10 | 10 | Schedule BackupSchedule | |
| 11 | - | // KeepLast is the number of most-recent tags to retain per repository. | |
| 11 | + | // KeepLast is the number of most-recent non-latest tags to retain per repository. | |
| 12 | + | // The latest tag is retained separately when present. | |
| 12 | 13 | KeepLast int | |
| 13 | 14 | } | |
| 14 | 15 | ||
| @@ -33,7 +34,7 @@ type ImagePruneReport struct { | |||
| 33 | 34 | Registry RegistryPruneResult | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | - | // ImageInfo describes a runtime image. | |
| 37 | + | // ImageInfo describes an image/tag visible from runtime and registry data. | |
| 37 | 38 | type ImageInfo struct { | |
| 38 | 39 | Repository string | |
| 39 | 40 | Tag string | |
| @@ -8,7 +8,8 @@ const DefaultImagePruneKeepLast = 3 | |||||
| 8 | type ImagePruneConfig struct { | 8 | type ImagePruneConfig struct { | ||
| 9 | Enabled bool | 9 | Enabled bool | ||
| 10 | Schedule BackupSchedule | 10 | Schedule BackupSchedule | ||
| 11 | - | // KeepLast is the number of most-recent tags to retain per repository. | 11 | + | // KeepLast is the number of most-recent non-latest tags to retain per repository. |
| 12 | + | // The latest tag is retained separately when present. | |||
| 12 | KeepLast int | 13 | KeepLast int | ||
| 13 | } | 14 | } | ||
| 14 | 15 | ||||
| @@ -33,7 +34,7 @@ type ImagePruneReport struct { | |||||
| 33 | Registry RegistryPruneResult | 34 | Registry RegistryPruneResult | ||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 36 | - | // ImageInfo describes a runtime image. | 37 | + | // ImageInfo describes an image/tag visible from runtime and registry data. |
| 37 | type ImageInfo struct { | 38 | type ImageInfo struct { | ||
| 38 | Repository string | 39 | Repository string | ||
| 39 | Tag string | 40 | Tag string | ||
M internal/usecase/images/service.go +142 -5
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | 14 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 15 | "github.com/bnema/gordon/pkg/runtime" | |
| 16 | + | "github.com/bnema/gordon/pkg/validation" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | // Service implements image list and prune operations. | |
| @@ -43,7 +44,7 @@ func NewService( | |||
| 43 | 44 | } | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | - | // ListImages returns images known by the runtime. | |
| 47 | + | // ListImages returns images known by the runtime and registry tags. | |
| 47 | 48 | func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |
| 48 | 49 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 49 | 50 | zerowrap.FieldLayer: "usecase", | |
| @@ -56,7 +57,24 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||
| 56 | 57 | return nil, log.WrapErr(err, "failed to list images") | |
| 57 | 58 | } | |
| 58 | 59 | ||
| 60 | + | images, seenRepoTags, repoDisplayByNormalized := buildRuntimeImageIndex(details) | |
| 61 | + | images, err = s.appendRegistryImages(log, images, seenRepoTags, repoDisplayByNormalized) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | sort.SliceStable(images, func(i, j int) bool { | |
| 67 | + | return lessImageInfo(images[i], images[j]) | |
| 68 | + | }) | |
| 69 | + | ||
| 70 | + | return images, nil | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func buildRuntimeImageIndex(details []runtime.ImageDetail) ([]domain.ImageInfo, map[string]struct{}, map[string]string) { | |
| 59 | 74 | images := make([]domain.ImageInfo, 0, len(details)) | |
| 75 | + | seenRepoTags := make(map[string]struct{}, len(details)) | |
| 76 | + | repoDisplayByNormalized := make(map[string]string) | |
| 77 | + | ||
| 60 | 78 | for _, detail := range details { | |
| 61 | 79 | if isDanglingImage(detail.RepoTags) { | |
| 62 | 80 | images = append(images, domain.ImageInfo{ | |
| @@ -75,6 +93,11 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||
| 75 | 93 | continue | |
| 76 | 94 | } | |
| 77 | 95 | repository, tag := splitRepoTag(repoTag) | |
| 96 | + | normalizedRepository := normalizeRepository(repository) | |
| 97 | + | seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{} | |
| 98 | + | if normalizedRepository != "" && normalizedRepository != repository { | |
| 99 | + | repoDisplayByNormalized[normalizedRepository] = repository | |
| 100 | + | } | |
| 78 | 101 | images = append(images, domain.ImageInfo{ | |
| 79 | 102 | Repository: repository, | |
| 80 | 103 | Tag: tag, | |
| @@ -86,6 +109,62 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||
| 86 | 109 | } | |
| 87 | 110 | } | |
| 88 | 111 | ||
| 112 | + | return images, seenRepoTags, repoDisplayByNormalized | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (s *Service) appendRegistryImages( | |
| 116 | + | log zerowrap.Logger, | |
| 117 | + | images []domain.ImageInfo, | |
| 118 | + | seenRepoTags map[string]struct{}, | |
| 119 | + | repoDisplayByNormalized map[string]string, | |
| 120 | + | ) ([]domain.ImageInfo, error) { | |
| 121 | + | repositories, err := s.manifestStorage.ListRepositories() | |
| 122 | + | if err != nil { | |
| 123 | + | return nil, log.WrapErr(err, "failed to list repositories") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | sort.Strings(repositories) | |
| 127 | + | for _, repository := range repositories { | |
| 128 | + | tags, err := s.manifestStorage.ListTags(repository) | |
| 129 | + | if err != nil { | |
| 130 | + | return nil, log.WrapErr(err, "failed to list repository tags") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | displayRepository := repository | |
| 134 | + | normalizedRepository := normalizeRepository(repository) | |
| 135 | + | if mappedRepository, ok := repoDisplayByNormalized[normalizedRepository]; ok { | |
| 136 | + | displayRepository = mappedRepository | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | for _, tag := range tags { | |
| 140 | + | if isRegistryTagPlaceholder(tag) { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | key := repoTagKey(normalizedRepository, tag) | |
| 145 | + | if _, exists := seenRepoTags[key]; exists { | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | createdAt, err := s.manifestStorage.GetManifestModTime(repository, tag) | |
| 150 | + | if err != nil { | |
| 151 | + | log.Warn(). | |
| 152 | + | Err(err). | |
| 153 | + | Str("repository", repository). | |
| 154 | + | Str("tag", tag). | |
| 155 | + | Msg("failed to read manifest modification time") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | images = append(images, domain.ImageInfo{ | |
| 159 | + | Repository: displayRepository, | |
| 160 | + | Tag: tag, | |
| 161 | + | Created: createdAt, | |
| 162 | + | Dangling: false, | |
| 163 | + | }) | |
| 164 | + | seenRepoTags[key] = struct{}{} | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 89 | 168 | return images, nil | |
| 90 | 169 | } | |
| 91 | 170 | ||
| @@ -111,8 +190,8 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er | |||
| 111 | 190 | } | |
| 112 | 191 | ||
| 113 | 192 | // 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. | |
| 193 | + | // It keeps the "latest" tag when present and keeps keepLast most-recent | |
| 194 | + | // non-latest tags from tagInfos. | |
| 116 | 195 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | |
| 117 | 196 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 118 | 197 | zerowrap.FieldLayer: "usecase", | |
| @@ -203,8 +282,16 @@ func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} { | |||
| 203 | 282 | keptTags["latest"] = struct{}{} | |
| 204 | 283 | } | |
| 205 | 284 | ||
| 206 | - | for i := 0; i < len(tagInfos) && i < keepLast; i++ { | |
| 207 | - | keptTags[tagInfos[i].name] = struct{}{} | |
| 285 | + | kept := 0 | |
| 286 | + | for _, tagInfo := range tagInfos { | |
| 287 | + | if tagInfo.name == "latest" { | |
| 288 | + | continue | |
| 289 | + | } | |
| 290 | + | if kept >= keepLast { | |
| 291 | + | break | |
| 292 | + | } | |
| 293 | + | keptTags[tagInfo.name] = struct{}{} | |
| 294 | + | kept++ | |
| 208 | 295 | } | |
| 209 | 296 | ||
| 210 | 297 | return keptTags | |
| @@ -401,3 +488,53 @@ func splitRepoTag(repoTag string) (string, string) { | |||
| 401 | 488 | ||
| 402 | 489 | return repoTag[:idx], repoTag[idx+1:] | |
| 403 | 490 | } | |
| 491 | + | ||
| 492 | + | func normalizeRepository(repository string) string { | |
| 493 | + | repository = strings.TrimSpace(repository) | |
| 494 | + | if repository == "" { | |
| 495 | + | return "" | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | idx := strings.Index(repository, "/") | |
| 499 | + | if idx <= 0 { | |
| 500 | + | return repository | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | firstComponent := repository[:idx] | |
| 504 | + | if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" { | |
| 505 | + | return repository[idx+1:] | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | return repository | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | func repoTagKey(repository, tag string) string { | |
| 512 | + | return repository + "\x00" + tag | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | func isRegistryTagPlaceholder(tag string) bool { | |
| 516 | + | return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag) | |
| 517 | + | } | |
| 518 | + | ||
| 519 | + | func lessImageInfo(left, right domain.ImageInfo) bool { | |
| 520 | + | if left.Dangling != right.Dangling { | |
| 521 | + | return !left.Dangling | |
| 522 | + | } | |
| 523 | + | if left.Repository != right.Repository { | |
| 524 | + | return left.Repository < right.Repository | |
| 525 | + | } | |
| 526 | + | if left.Tag == "latest" && right.Tag != "latest" { | |
| 527 | + | return true | |
| 528 | + | } | |
| 529 | + | if right.Tag == "latest" && left.Tag != "latest" { | |
| 530 | + | return false | |
| 531 | + | } | |
| 532 | + | if !left.Created.Equal(right.Created) { | |
| 533 | + | return left.Created.After(right.Created) | |
| 534 | + | } | |
| 535 | + | if left.Tag != right.Tag { | |
| 536 | + | return left.Tag > right.Tag | |
| 537 | + | } | |
| 538 | + | ||
| 539 | + | return left.ID < right.ID | |
| 540 | + | } | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/gordon/internal/boundaries/out" | 13 | "github.com/bnema/gordon/internal/boundaries/out" | ||
| 14 | "github.com/bnema/gordon/internal/domain" | 14 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | "github.com/bnema/gordon/pkg/runtime" | 15 | "github.com/bnema/gordon/pkg/runtime" | ||
| 16 | + | "github.com/bnema/gordon/pkg/validation" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | // Service implements image list and prune operations. | 19 | // Service implements image list and prune operations. | ||
| @@ -43,7 +44,7 @@ func NewService( | |||||
| 43 | } | 44 | } | ||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 46 | - | // ListImages returns images known by the runtime. | 47 | + | // ListImages returns images known by the runtime and registry tags. |
| 47 | func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | 48 | func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | ||
| 48 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 49 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 49 | zerowrap.FieldLayer: "usecase", | 50 | zerowrap.FieldLayer: "usecase", | ||
| @@ -56,7 +57,24 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||||
| 56 | return nil, log.WrapErr(err, "failed to list images") | 57 | return nil, log.WrapErr(err, "failed to list images") | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 60 | + | images, seenRepoTags, repoDisplayByNormalized := buildRuntimeImageIndex(details) | |||
| 61 | + | images, err = s.appendRegistryImages(log, images, seenRepoTags, repoDisplayByNormalized) | |||
| 62 | + | if err != nil { | |||
| 63 | + | return nil, err | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | sort.SliceStable(images, func(i, j int) bool { | |||
| 67 | + | return lessImageInfo(images[i], images[j]) | |||
| 68 | + | }) | |||
| 69 | + | ||||
| 70 | + | return images, nil | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func buildRuntimeImageIndex(details []runtime.ImageDetail) ([]domain.ImageInfo, map[string]struct{}, map[string]string) { | |||
| 59 | images := make([]domain.ImageInfo, 0, len(details)) | 74 | images := make([]domain.ImageInfo, 0, len(details)) | ||
| 75 | + | seenRepoTags := make(map[string]struct{}, len(details)) | |||
| 76 | + | repoDisplayByNormalized := make(map[string]string) | |||
| 77 | + | ||||
| 60 | for _, detail := range details { | 78 | for _, detail := range details { | ||
| 61 | if isDanglingImage(detail.RepoTags) { | 79 | if isDanglingImage(detail.RepoTags) { | ||
| 62 | images = append(images, domain.ImageInfo{ | 80 | images = append(images, domain.ImageInfo{ | ||
| @@ -75,6 +93,11 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||||
| 75 | continue | 93 | continue | ||
| 76 | } | 94 | } | ||
| 77 | repository, tag := splitRepoTag(repoTag) | 95 | repository, tag := splitRepoTag(repoTag) | ||
| 96 | + | normalizedRepository := normalizeRepository(repository) | |||
| 97 | + | seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{} | |||
| 98 | + | if normalizedRepository != "" && normalizedRepository != repository { | |||
| 99 | + | repoDisplayByNormalized[normalizedRepository] = repository | |||
| 100 | + | } | |||
| 78 | images = append(images, domain.ImageInfo{ | 101 | images = append(images, domain.ImageInfo{ | ||
| 79 | Repository: repository, | 102 | Repository: repository, | ||
| 80 | Tag: tag, | 103 | Tag: tag, | ||
| @@ -86,6 +109,62 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { | |||||
| 86 | } | 109 | } | ||
| 87 | } | 110 | } | ||
| 88 | 111 | ||||
| 112 | + | return images, seenRepoTags, repoDisplayByNormalized | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func (s *Service) appendRegistryImages( | |||
| 116 | + | log zerowrap.Logger, | |||
| 117 | + | images []domain.ImageInfo, | |||
| 118 | + | seenRepoTags map[string]struct{}, | |||
| 119 | + | repoDisplayByNormalized map[string]string, | |||
| 120 | + | ) ([]domain.ImageInfo, error) { | |||
| 121 | + | repositories, err := s.manifestStorage.ListRepositories() | |||
| 122 | + | if err != nil { | |||
| 123 | + | return nil, log.WrapErr(err, "failed to list repositories") | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | sort.Strings(repositories) | |||
| 127 | + | for _, repository := range repositories { | |||
| 128 | + | tags, err := s.manifestStorage.ListTags(repository) | |||
| 129 | + | if err != nil { | |||
| 130 | + | return nil, log.WrapErr(err, "failed to list repository tags") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | displayRepository := repository | |||
| 134 | + | normalizedRepository := normalizeRepository(repository) | |||
| 135 | + | if mappedRepository, ok := repoDisplayByNormalized[normalizedRepository]; ok { | |||
| 136 | + | displayRepository = mappedRepository | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | for _, tag := range tags { | |||
| 140 | + | if isRegistryTagPlaceholder(tag) { | |||
| 141 | + | continue | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | key := repoTagKey(normalizedRepository, tag) | |||
| 145 | + | if _, exists := seenRepoTags[key]; exists { | |||
| 146 | + | continue | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | createdAt, err := s.manifestStorage.GetManifestModTime(repository, tag) | |||
| 150 | + | if err != nil { | |||
| 151 | + | log.Warn(). | |||
| 152 | + | Err(err). | |||
| 153 | + | Str("repository", repository). | |||
| 154 | + | Str("tag", tag). | |||
| 155 | + | Msg("failed to read manifest modification time") | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | images = append(images, domain.ImageInfo{ | |||
| 159 | + | Repository: displayRepository, | |||
| 160 | + | Tag: tag, | |||
| 161 | + | Created: createdAt, | |||
| 162 | + | Dangling: false, | |||
| 163 | + | }) | |||
| 164 | + | seenRepoTags[key] = struct{}{} | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | ||||
| 89 | return images, nil | 168 | return images, nil | ||
| 90 | } | 169 | } | ||
| 91 | 170 | ||||
| @@ -111,8 +190,8 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er | |||||
| 111 | } | 190 | } | ||
| 112 | 191 | ||||
| 113 | // PruneRegistry applies tag retention and blob garbage collection. | 192 | // PruneRegistry applies tag retention and blob garbage collection. | ||
| 114 | - | // It keeps the most recent keepLast tags from tagInfos and always keeps | 193 | + | // It keeps the "latest" tag when present and keeps keepLast most-recent |
| 115 | - | // the "latest" tag when present, so up to keepLast+1 tags can be retained. | 194 | + | // non-latest tags from tagInfos. |
| 116 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | 195 | func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) { | ||
| 117 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 196 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
| 118 | zerowrap.FieldLayer: "usecase", | 197 | zerowrap.FieldLayer: "usecase", | ||
| @@ -203,8 +282,16 @@ func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} { | |||||
| 203 | keptTags["latest"] = struct{}{} | 282 | keptTags["latest"] = struct{}{} | ||
| 204 | } | 283 | } | ||
| 205 | 284 | ||||
| 206 | - | for i := 0; i < len(tagInfos) && i < keepLast; i++ { | 285 | + | kept := 0 |
| 207 | - | keptTags[tagInfos[i].name] = struct{}{} | 286 | + | for _, tagInfo := range tagInfos { |
| 287 | + | if tagInfo.name == "latest" { | |||
| 288 | + | continue | |||
| 289 | + | } | |||
| 290 | + | if kept >= keepLast { | |||
| 291 | + | break | |||
| 292 | + | } | |||
| 293 | + | keptTags[tagInfo.name] = struct{}{} | |||
| 294 | + | kept++ | |||
| 208 | } | 295 | } | ||
| 209 | 296 | ||||
| 210 | return keptTags | 297 | return keptTags | ||
| @@ -401,3 +488,53 @@ func splitRepoTag(repoTag string) (string, string) { | |||||
| 401 | 488 | ||||
| 402 | return repoTag[:idx], repoTag[idx+1:] | 489 | return repoTag[:idx], repoTag[idx+1:] | ||
| 403 | } | 490 | } | ||
| 491 | + | ||||
| 492 | + | func normalizeRepository(repository string) string { | |||
| 493 | + | repository = strings.TrimSpace(repository) | |||
| 494 | + | if repository == "" { | |||
| 495 | + | return "" | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | idx := strings.Index(repository, "/") | |||
| 499 | + | if idx <= 0 { | |||
| 500 | + | return repository | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | firstComponent := repository[:idx] | |||
| 504 | + | if strings.Contains(firstComponent, ".") || strings.Contains(firstComponent, ":") || firstComponent == "localhost" { | |||
| 505 | + | return repository[idx+1:] | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | return repository | |||
| 509 | + | } | |||
| 510 | + | ||||
| 511 | + | func repoTagKey(repository, tag string) string { | |||
| 512 | + | return repository + "\x00" + tag | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | func isRegistryTagPlaceholder(tag string) bool { | |||
| 516 | + | return tag == "" || tag == "<none>" || tag == "<none>:<none>" || validation.IsDigest(tag) | |||
| 517 | + | } | |||
| 518 | + | ||||
| 519 | + | func lessImageInfo(left, right domain.ImageInfo) bool { | |||
| 520 | + | if left.Dangling != right.Dangling { | |||
| 521 | + | return !left.Dangling | |||
| 522 | + | } | |||
| 523 | + | if left.Repository != right.Repository { | |||
| 524 | + | return left.Repository < right.Repository | |||
| 525 | + | } | |||
| 526 | + | if left.Tag == "latest" && right.Tag != "latest" { | |||
| 527 | + | return true | |||
| 528 | + | } | |||
| 529 | + | if right.Tag == "latest" && left.Tag != "latest" { | |||
| 530 | + | return false | |||
| 531 | + | } | |||
| 532 | + | if !left.Created.Equal(right.Created) { | |||
| 533 | + | return left.Created.After(right.Created) | |||
| 534 | + | } | |||
| 535 | + | if left.Tag != right.Tag { | |||
| 536 | + | return left.Tag > right.Tag | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | return left.ID < right.ID | |||
| 540 | + | } | |||
M internal/usecase/images/service_test.go +72 -14
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | func TestService_ListImages_ReturnsAllImages(t *testing.T) { | |
| 20 | - | manifestStorage := noopManifestStorage{} | |
| 20 | + | manifestStorage := newFakeManifestStorage() | |
| 21 | 21 | blobStorage := noopBlobStorage{} | |
| 22 | 22 | ||
| 23 | 23 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| @@ -74,7 +74,7 @@ func TestService_ListImages_ReturnsAllImages(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | func TestService_ListImages_EmptyWhenNoImages(t *testing.T) { | |
| 77 | - | manifestStorage := noopManifestStorage{} | |
| 77 | + | manifestStorage := newFakeManifestStorage() | |
| 78 | 78 | blobStorage := noopBlobStorage{} | |
| 79 | 79 | rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}} | |
| 80 | 80 | ||
| @@ -87,7 +87,7 @@ func TestService_ListImages_EmptyWhenNoImages(t *testing.T) { | |||
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | 89 | func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) { | |
| 90 | - | manifestStorage := noopManifestStorage{} | |
| 90 | + | manifestStorage := newFakeManifestStorage() | |
| 91 | 91 | blobStorage := noopBlobStorage{} | |
| 92 | 92 | createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC) | |
| 93 | 93 | ||
| @@ -112,7 +112,7 @@ func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) { | |||
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | 114 | func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) { | |
| 115 | - | manifestStorage := noopManifestStorage{} | |
| 115 | + | manifestStorage := newFakeManifestStorage() | |
| 116 | 116 | blobStorage := noopBlobStorage{} | |
| 117 | 117 | rt := &fakeRuntime{listErr: errors.New("runtime list failed")} | |
| 118 | 118 | ||
| @@ -124,6 +124,60 @@ func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) { | |||
| 124 | 124 | assert.Contains(t, err.Error(), "failed to list images") | |
| 125 | 125 | } | |
| 126 | 126 | ||
| 127 | + | func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T) { | |
| 128 | + | manifestStorage := newFakeManifestStorage() | |
| 129 | + | manifestStorage.repositories = []string{"gordon/api"} | |
| 130 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"v1.2.3", "v1.2.2"} | |
| 131 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.2")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC) | |
| 132 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.3")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 133 | + | ||
| 134 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 135 | + | rt := &fakeRuntime{ | |
| 136 | + | listDetails: []pkgruntime.ImageDetail{ | |
| 137 | + | { | |
| 138 | + | ID: "sha256:runtime", | |
| 139 | + | RepoTags: []string{"reg.bnema.dev/gordon/api:latest"}, | |
| 140 | + | Size: 4096, | |
| 141 | + | Created: createdAt, | |
| 142 | + | }, | |
| 143 | + | }, | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | svc := NewService(rt, manifestStorage, noopBlobStorage{}, zerowrap.Default()) | |
| 147 | + | ||
| 148 | + | images, err := svc.ListImages(context.Background()) | |
| 149 | + | ||
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.Len(t, images, 3) | |
| 152 | + | ||
| 153 | + | assert.Equal(t, domain.ImageInfo{ | |
| 154 | + | Repository: "reg.bnema.dev/gordon/api", | |
| 155 | + | Tag: "latest", | |
| 156 | + | Size: 4096, | |
| 157 | + | Created: createdAt, | |
| 158 | + | ID: "sha256:runtime", | |
| 159 | + | Dangling: false, | |
| 160 | + | }, images[0]) | |
| 161 | + | ||
| 162 | + | assert.Equal(t, domain.ImageInfo{ | |
| 163 | + | Repository: "reg.bnema.dev/gordon/api", | |
| 164 | + | Tag: "v1.2.3", | |
| 165 | + | Size: 0, | |
| 166 | + | Created: time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC), | |
| 167 | + | ID: "", | |
| 168 | + | Dangling: false, | |
| 169 | + | }, images[1]) | |
| 170 | + | ||
| 171 | + | assert.Equal(t, domain.ImageInfo{ | |
| 172 | + | Repository: "reg.bnema.dev/gordon/api", | |
| 173 | + | Tag: "v1.2.2", | |
| 174 | + | Size: 0, | |
| 175 | + | Created: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC), | |
| 176 | + | ID: "", | |
| 177 | + | Dangling: false, | |
| 178 | + | }, images[2]) | |
| 179 | + | } | |
| 180 | + | ||
| 127 | 181 | func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) { | |
| 128 | 182 | manifestStorage := noopManifestStorage{} | |
| 129 | 183 | blobStorage := noopBlobStorage{} | |
| @@ -191,6 +245,7 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) { | |||
| 191 | 245 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC) | |
| 192 | 246 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest") | |
| 193 | 247 | manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3") | |
| 248 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 194 | 249 | ||
| 195 | 250 | blobStorage := &fakeBlobStorage{} | |
| 196 | 251 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| @@ -198,11 +253,8 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) { | |||
| 198 | 253 | report, err := svc.PruneRegistry(context.Background(), 2) | |
| 199 | 254 | ||
| 200 | 255 | 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) | |
| 256 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) | |
| 257 | + | assert.ElementsMatch(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) | |
| 206 | 258 | } | |
| 207 | 259 | ||
| 208 | 260 | func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) { | |
| @@ -279,13 +331,15 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||
| 279 | 331 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | |
| 280 | 332 | manifestStorage := newFakeManifestStorage() | |
| 281 | 333 | manifestStorage.repositories = []string{"gordon/api"} | |
| 282 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 334 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 283 | 335 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 336 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 284 | 337 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 285 | 338 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared") | |
| 339 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared") | |
| 286 | 340 | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared") | |
| 287 | 341 | ||
| 288 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}} | |
| 342 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}} | |
| 289 | 343 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 290 | 344 | ||
| 291 | 345 | report, err := svc.PruneRegistry(context.Background(), 1) | |
| @@ -376,7 +430,7 @@ func TestService_PruneRegistry_MultipleRepositories(t *testing.T) { | |||
| 376 | 430 | blobStorage := &fakeBlobStorage{} | |
| 377 | 431 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | |
| 378 | 432 | ||
| 379 | - | report, err := svc.PruneRegistry(context.Background(), 2) | |
| 433 | + | report, err := svc.PruneRegistry(context.Background(), 1) | |
| 380 | 434 | ||
| 381 | 435 | require.NoError(t, err) | |
| 382 | 436 | assert.Equal(t, 2, report.Registry.TagsRemoved) | |
| @@ -404,10 +458,12 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) { | |||
| 404 | 458 | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |
| 405 | 459 | manifestStorage := newFakeManifestStorage() | |
| 406 | 460 | manifestStorage.repositories = []string{"gordon/api"} | |
| 407 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 461 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 408 | 462 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 463 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 409 | 464 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 410 | 465 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 466 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 411 | 467 | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 412 | 468 | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | |
| 413 | 469 | ||
| @@ -427,10 +483,12 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |||
| 427 | 483 | func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | |
| 428 | 484 | manifestStorage := newFakeManifestStorage() | |
| 429 | 485 | manifestStorage.repositories = []string{"gordon/api"} | |
| 430 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | |
| 486 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} | |
| 431 | 487 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |
| 488 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |
| 432 | 489 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |
| 433 | 490 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | |
| 491 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |
| 434 | 492 | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | |
| 435 | 493 | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | |
| 436 | 494 | ||
| @@ -17,7 +17,7 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | func TestService_ListImages_ReturnsAllImages(t *testing.T) { | 19 | func TestService_ListImages_ReturnsAllImages(t *testing.T) { | ||
| 20 | - | manifestStorage := noopManifestStorage{} | 20 | + | manifestStorage := newFakeManifestStorage() |
| 21 | blobStorage := noopBlobStorage{} | 21 | blobStorage := noopBlobStorage{} | ||
| 22 | 22 | ||||
| 23 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | 23 | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | ||
| @@ -74,7 +74,7 @@ func TestService_ListImages_ReturnsAllImages(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | func TestService_ListImages_EmptyWhenNoImages(t *testing.T) { | 76 | func TestService_ListImages_EmptyWhenNoImages(t *testing.T) { | ||
| 77 | - | manifestStorage := noopManifestStorage{} | 77 | + | manifestStorage := newFakeManifestStorage() |
| 78 | blobStorage := noopBlobStorage{} | 78 | blobStorage := noopBlobStorage{} | ||
| 79 | rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}} | 79 | rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}} | ||
| 80 | 80 | ||||
| @@ -87,7 +87,7 @@ func TestService_ListImages_EmptyWhenNoImages(t *testing.T) { | |||||
| 87 | } | 87 | } | ||
| 88 | 88 | ||||
| 89 | func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) { | 89 | func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) { | ||
| 90 | - | manifestStorage := noopManifestStorage{} | 90 | + | manifestStorage := newFakeManifestStorage() |
| 91 | blobStorage := noopBlobStorage{} | 91 | blobStorage := noopBlobStorage{} | ||
| 92 | createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC) | 92 | createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC) | ||
| 93 | 93 | ||||
| @@ -112,7 +112,7 @@ func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) { | |||||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) { | 114 | func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) { | ||
| 115 | - | manifestStorage := noopManifestStorage{} | 115 | + | manifestStorage := newFakeManifestStorage() |
| 116 | blobStorage := noopBlobStorage{} | 116 | blobStorage := noopBlobStorage{} | ||
| 117 | rt := &fakeRuntime{listErr: errors.New("runtime list failed")} | 117 | rt := &fakeRuntime{listErr: errors.New("runtime list failed")} | ||
| 118 | 118 | ||||
| @@ -124,6 +124,60 @@ func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) { | |||||
| 124 | assert.Contains(t, err.Error(), "failed to list images") | 124 | assert.Contains(t, err.Error(), "failed to list images") | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | + | func TestService_ListImages_IncludesRegistryTagsNotPresentInRuntime(t *testing.T) { | |||
| 128 | + | manifestStorage := newFakeManifestStorage() | |||
| 129 | + | manifestStorage.repositories = []string{"gordon/api"} | |||
| 130 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"v1.2.3", "v1.2.2"} | |||
| 131 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.2")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC) | |||
| 132 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1.2.3")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | |||
| 133 | + | ||||
| 134 | + | createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | |||
| 135 | + | rt := &fakeRuntime{ | |||
| 136 | + | listDetails: []pkgruntime.ImageDetail{ | |||
| 137 | + | { | |||
| 138 | + | ID: "sha256:runtime", | |||
| 139 | + | RepoTags: []string{"reg.bnema.dev/gordon/api:latest"}, | |||
| 140 | + | Size: 4096, | |||
| 141 | + | Created: createdAt, | |||
| 142 | + | }, | |||
| 143 | + | }, | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | svc := NewService(rt, manifestStorage, noopBlobStorage{}, zerowrap.Default()) | |||
| 147 | + | ||||
| 148 | + | images, err := svc.ListImages(context.Background()) | |||
| 149 | + | ||||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | require.Len(t, images, 3) | |||
| 152 | + | ||||
| 153 | + | assert.Equal(t, domain.ImageInfo{ | |||
| 154 | + | Repository: "reg.bnema.dev/gordon/api", | |||
| 155 | + | Tag: "latest", | |||
| 156 | + | Size: 4096, | |||
| 157 | + | Created: createdAt, | |||
| 158 | + | ID: "sha256:runtime", | |||
| 159 | + | Dangling: false, | |||
| 160 | + | }, images[0]) | |||
| 161 | + | ||||
| 162 | + | assert.Equal(t, domain.ImageInfo{ | |||
| 163 | + | Repository: "reg.bnema.dev/gordon/api", | |||
| 164 | + | Tag: "v1.2.3", | |||
| 165 | + | Size: 0, | |||
| 166 | + | Created: time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC), | |||
| 167 | + | ID: "", | |||
| 168 | + | Dangling: false, | |||
| 169 | + | }, images[1]) | |||
| 170 | + | ||||
| 171 | + | assert.Equal(t, domain.ImageInfo{ | |||
| 172 | + | Repository: "reg.bnema.dev/gordon/api", | |||
| 173 | + | Tag: "v1.2.2", | |||
| 174 | + | Size: 0, | |||
| 175 | + | Created: time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC), | |||
| 176 | + | ID: "", | |||
| 177 | + | Dangling: false, | |||
| 178 | + | }, images[2]) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 127 | func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) { | 181 | func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) { | ||
| 128 | manifestStorage := noopManifestStorage{} | 182 | manifestStorage := noopManifestStorage{} | ||
| 129 | blobStorage := noopBlobStorage{} | 183 | blobStorage := noopBlobStorage{} | ||
| @@ -191,6 +245,7 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) { | |||||
| 191 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC) | 245 | 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") | 246 | 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") | 247 | manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3") | ||
| 248 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 194 | 249 | ||||
| 195 | blobStorage := &fakeBlobStorage{} | 250 | blobStorage := &fakeBlobStorage{} | ||
| 196 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | 251 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | ||
| @@ -198,11 +253,8 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) { | |||||
| 198 | report, err := svc.PruneRegistry(context.Background(), 2) | 253 | report, err := svc.PruneRegistry(context.Background(), 2) | ||
| 199 | 254 | ||||
| 200 | require.NoError(t, err) | 255 | require.NoError(t, err) | ||
| 201 | - | assert.Equal(t, 2, report.Registry.TagsRemoved) | 256 | + | assert.Equal(t, 1, report.Registry.TagsRemoved) |
| 202 | - | assert.ElementsMatch(t, []manifestRef{ | 257 | + | assert.ElementsMatch(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests) |
| 203 | - | {name: "gordon/api", reference: "v2"}, | |||
| 204 | - | {name: "gordon/api", reference: "v1"}, | |||
| 205 | - | }, manifestStorage.deletedManifests) | |||
| 206 | } | 258 | } | ||
| 207 | 259 | ||||
| 208 | func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) { | 260 | func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) { | ||
| @@ -279,13 +331,15 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) { | |||||
| 279 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | 331 | func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) { | ||
| 280 | manifestStorage := newFakeManifestStorage() | 332 | manifestStorage := newFakeManifestStorage() | ||
| 281 | manifestStorage.repositories = []string{"gordon/api"} | 333 | manifestStorage.repositories = []string{"gordon/api"} | ||
| 282 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | 334 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} |
| 283 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | 335 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | ||
| 336 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 284 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | 337 | 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") | 338 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared") | ||
| 339 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-shared") | |||
| 286 | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared") | 340 | manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared") | ||
| 287 | 341 | ||||
| 288 | - | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}} | 342 | + | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:cfg-v2", "sha256:layer-shared", "sha256:cfg-v1"}} |
| 289 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | 343 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | ||
| 290 | 344 | ||||
| 291 | report, err := svc.PruneRegistry(context.Background(), 1) | 345 | report, err := svc.PruneRegistry(context.Background(), 1) | ||
| @@ -376,7 +430,7 @@ func TestService_PruneRegistry_MultipleRepositories(t *testing.T) { | |||||
| 376 | blobStorage := &fakeBlobStorage{} | 430 | blobStorage := &fakeBlobStorage{} | ||
| 377 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | 431 | svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default()) | ||
| 378 | 432 | ||||
| 379 | - | report, err := svc.PruneRegistry(context.Background(), 2) | 433 | + | report, err := svc.PruneRegistry(context.Background(), 1) |
| 380 | 434 | ||||
| 381 | require.NoError(t, err) | 435 | require.NoError(t, err) | ||
| 382 | assert.Equal(t, 2, report.Registry.TagsRemoved) | 436 | assert.Equal(t, 2, report.Registry.TagsRemoved) | ||
| @@ -404,10 +458,12 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) { | |||||
| 404 | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | 458 | func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | ||
| 405 | manifestStorage := newFakeManifestStorage() | 459 | manifestStorage := newFakeManifestStorage() | ||
| 406 | manifestStorage.repositories = []string{"gordon/api"} | 460 | manifestStorage.repositories = []string{"gordon/api"} | ||
| 407 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | 461 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} |
| 408 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | 462 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | ||
| 463 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 409 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | 464 | 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") | 465 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | ||
| 466 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 411 | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | 467 | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | ||
| 412 | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | 468 | rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}} | ||
| 413 | 469 | ||||
| @@ -427,10 +483,12 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) { | |||||
| 427 | func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | 483 | func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) { | ||
| 428 | manifestStorage := newFakeManifestStorage() | 484 | manifestStorage := newFakeManifestStorage() | ||
| 429 | manifestStorage.repositories = []string{"gordon/api"} | 485 | manifestStorage.repositories = []string{"gordon/api"} | ||
| 430 | - | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"} | 486 | + | manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"} |
| 431 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | 487 | manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC) | ||
| 488 | + | manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC) | |||
| 432 | manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC) | 489 | 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") | 490 | manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live") | ||
| 491 | + | manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2") | |||
| 434 | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | 492 | blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}} | ||
| 435 | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | 493 | rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")} | ||
| 436 | 494 | ||||