detached 95fe066

Merge pull request #74 from bnema/feat/image-versioning-rollback-list

feat(images): expose registry tags and improve rollback visibility
commit
95fe066d6c4b6f884e967c97bd1c0b91e81313e7
author
bnema · · 2026-02-11 20:53 UTC
committer
GitHub · · 2026-02-11 20:53 UTC
merge of
024bbc1 + a3b6bfd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +337 -38
M docs/cli/images.md +5 -3
@@ -10,7 +10,7 @@ gordon images <subcommand>
1010
1111 Subcommands:
1212
13-- `list` - List runtime images.
13+- `list` - List runtime images and registry tags.
1414 - `prune` - Prune dangling runtime images and optionally registry data.
1515
1616 > **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
@@ -22,6 +22,7 @@ gordon images list
2222 ```
2323
2424 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 `-`.
2526
2627 ## gordon images prune
2728
@@ -34,7 +35,7 @@ Flags:
3435 | Flag | Default | Description |
3536 |------|---------|-------------|
3637 | `--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) |
3839 | `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |
3940
4041 `--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:
4445 - `--runtime-only` forces `keep_last = 0`.
4546 - `--keep` must be `>= 0`.
4647 - `--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.
4749
4850 ## Examples
4951
@@ -54,7 +56,7 @@ gordon images list
5456 # Prune dangling runtime images only
5557 gordon images prune --runtime-only
5658
57-# Prune runtime images and keep 3 newest tags per repository
59+# Prune runtime images and keep latest + 3 previous tags per repository
5860 gordon images prune --keep 3
5961
6062 # 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 prune27 ## 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 ## Examples50 ## Examples
49 51
@@ -54,7 +56,7 @@ gordon images list
54 # Prune dangling runtime images only56 # Prune dangling runtime images only
55 gordon images prune --runtime-only57 gordon images prune --runtime-only
56 58
57-# Prune runtime images and keep 3 newest tags per repository59+# Prune runtime images and keep latest + 3 previous tags per repository
58 gordon images prune --keep 360 gordon images prune --keep 3
59 61
60 # Preview cleanup without applying62 # Preview cleanup without applying
M docs/cli/rollback.md +9 -3
@@ -8,6 +8,7 @@ Roll back a route to a previous image tag.
88
99 ```bash
1010 gordon rollback <domain> [options]
11+gordon rollback list <domain>
1112 ```
1213
1314 ### Arguments
@@ -26,9 +27,11 @@ gordon rollback <domain> [options]
2627
2728 ### Description
2829
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.
3235
3336 If the selected tag matches the current running tag, no action is taken.
3437
@@ -40,6 +43,9 @@ gordon rollback myapp.example.com
4043
4144 # Roll back to a specific tag
4245 gordon rollback myapp.example.com --tag v1.2.0
46+
47+# List available tags for a domain
48+gordon rollback list myapp.example.com
4349 ```
4450
4551 ### Notes
@@ -8,6 +8,7 @@ Roll back a route to a previous image tag.
8 8
9 ```bash9 ```bash
10 gordon rollback <domain> [options]10 gordon rollback <domain> [options]
11+gordon rollback list <domain>
11 ```12 ```
12 13
13 ### Arguments14 ### Arguments
@@ -26,9 +27,11 @@ gordon rollback <domain> [options]
26 27
27 ### Description28 ### Description
28 29
29-`gordon rollback` lists available tags in the Gordon registry for the route's30+`gordon rollback` deploys a selected tag for the route's repository.
30-repository, then deploys the selected version. Semver tags are sorted in31+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 tag44 # Roll back to a specific tag
42 gordon rollback myapp.example.com --tag v1.2.045 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 ### Notes51 ### Notes
M docs/config/images.md +2 -2
@@ -26,12 +26,12 @@ keep_last = 3
2626 |-----|------|---------|-------------|
2727 | `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
2828 | `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) |
3030
3131 ## Retention Behavior
3232
3333 - `latest` is always preserved.
34-- `keep_last` applies per repository.
34+- `keep_last` applies per repository and counts non-`latest` tags.
3535 - `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).
3636 - Negative `keep_last` values are invalid.
3737
@@ -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 Behavior31 ## 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
22
33 import "time"
44
5-// Image represents a runtime image in admin API responses.
5+// Image represents an image/tag in admin API responses.
66 type Image struct {
77 Repository string `json:"repository"`
88 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 (
44 "context"
55 "fmt"
66 "io"
7+ "strings"
78 "text/tabwriter"
89 "time"
910
@@ -41,7 +42,7 @@ These commands currently require remote mode with a configured target.`,
4142 func newImagesListCmd() *cobra.Command {
4243 return &cobra.Command{
4344 Use: "list",
44- Short: "List runtime images",
45+ Short: "List runtime images and registry tags",
4546 RunE: func(cmd *cobra.Command, _ []string) error {
4647 client, isRemote := GetRemoteClient()
4748 if !isRemote {
@@ -70,7 +71,7 @@ func newImagesPruneCmd() *cobra.Command {
7071 }
7172
7273 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)")
7475 cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")
7576
7677 return cmd
@@ -103,7 +104,7 @@ func runImagesList(ctx context.Context, client imagesClient, out io.Writer) erro
103104 danglingCount++
104105 }
105106
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 {
107108 return err
108109 }
109110 }
@@ -154,7 +155,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
154155 return err
155156 }
156157
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)
158159 return err
159160 }
160161
@@ -187,6 +188,10 @@ func formatImageCreatedAt(t time.Time) string {
187188 }
188189
189190 func formatImageSize(size int64) string {
191+ if size <= 0 {
192+ return "-"
193+ }
194+
190195 const unit = 1024
191196 if size < unit {
192197 return fmt.Sprintf("%d B", size)
@@ -198,3 +203,11 @@ func formatImageSize(size int64) string {
198203 }
199204 return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
200205 }
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 cmd77 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 err108 return err
108 }109 }
109 }110 }
@@ -154,7 +155,7 @@ func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOp
154 return err155 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 err159 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 = 1024195 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) {
8787 text := out.String()
8888 assert.Contains(t, text, "Dry run")
8989 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")
9191 assert.Equal(t, 1, client.listImagesCalls)
9292 assert.Equal(t, 0, client.pruneCalls)
9393 }
@@ -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 {
485485
486486 // Images API
487487
488-// ListImages returns runtime images from the admin API.
488+// ListImages returns runtime images and registry tags from the admin API.
489489 func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
490490 resp, err := c.request(ctx, http.MethodGet, "/images", nil)
491491 if err != nil {
@@ -485,7 +485,7 @@ type RouteHealth struct {
485 485
486 // Images API486 // 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
33 import (
44 "context"
55 "fmt"
6+ "io"
67 "os"
78 "sort"
89 "strings"
@@ -26,7 +27,8 @@ Tags are read from the Gordon registry.
2627
2728 Examples:
2829 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 ...`,
3032 Args: cobra.ExactArgs(1),
3133 RunE: func(cmd *cobra.Command, args []string) error {
3234 return runRollback(cmd.Context(), args[0], targetTag)
@@ -34,10 +36,22 @@ Examples:
3436 }
3537
3638 cmd.Flags().StringVar(&targetTag, "tag", "", "Target tag (skips interactive selection)")
39+ cmd.AddCommand(newRollbackListCmd())
3740
3841 return cmd
3942 }
4043
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+
4155 func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
4256 handle, err := resolveControlPlane(configPath)
4357 if err != nil {
@@ -73,6 +87,47 @@ func runRollback(ctx context.Context, rollbackDomain, targetTag string) error {
7387 return deploySelectedTag(ctx, handle.plane, route, rollbackDomain, imageName, selectedTag)
7488 }
7589
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+
76131 func fetchAndSortTags(ctx context.Context, cp ControlPlane, imageName string) ([]string, error) {
77132 tags, err := cp.ListTags(ctx, imageName)
78133 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 cmd41 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 @@
11 package cli
22
33 import (
4+ "bytes"
5+ "errors"
46 "testing"
57
68 "github.com/stretchr/testify/assert"
79 )
810
11+type failingWriter struct{}
12+
13+func (failingWriter) Write(_ []byte) (int, error) {
14+ return 0, errors.New("write failed")
15+}
16+
917 func TestSortSemverTags(t *testing.T) {
1018 tests := []struct {
1119 name string
@@ -51,3 +59,22 @@ func TestValidateTagExists(t *testing.T) {
5159 assert.True(t, validateTagExists("v1.0.0", []string{"v1.0.0", "v2.0.0"}))
5260 assert.False(t, validateTagExists("v3.0.0", []string{"v1.0.0", "v2.0.0"}))
5361 }
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 cli1 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 string19 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 (
99
1010 // ImageService defines image listing and prune operations.
1111 type ImageService interface {
12- // ListImages returns runtime images.
12+ // ListImages returns runtime images and known registry tags.
1313 ListImages(ctx context.Context) ([]domain.ImageInfo, error)
1414
1515 // 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
88 type ImagePruneConfig struct {
99 Enabled bool
1010 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.
1213 KeepLast int
1314 }
1415
@@ -33,7 +34,7 @@ type ImagePruneReport struct {
3334 Registry RegistryPruneResult
3435 }
3536
36-// ImageInfo describes a runtime image.
37+// ImageInfo describes an image/tag visible from runtime and registry data.
3738 type ImageInfo struct {
3839 Repository string
3940 Tag string
@@ -8,7 +8,8 @@ const DefaultImagePruneKeepLast = 3
8 type ImagePruneConfig struct {8 type ImagePruneConfig struct {
9 Enabled bool9 Enabled bool
10 Schedule BackupSchedule10 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 int13 KeepLast int
13 }14 }
14 15
@@ -33,7 +34,7 @@ type ImagePruneReport struct {
33 Registry RegistryPruneResult34 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 string39 Repository string
39 Tag string40 Tag string
M internal/usecase/images/service.go +142 -5
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/gordon/internal/boundaries/out"
1414 "github.com/bnema/gordon/internal/domain"
1515 "github.com/bnema/gordon/pkg/runtime"
16+ "github.com/bnema/gordon/pkg/validation"
1617 )
1718
1819 // Service implements image list and prune operations.
@@ -43,7 +44,7 @@ func NewService(
4344 }
4445 }
4546
46-// ListImages returns images known by the runtime.
47+// ListImages returns images known by the runtime and registry tags.
4748 func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
4849 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
4950 zerowrap.FieldLayer: "usecase",
@@ -56,7 +57,24 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
5657 return nil, log.WrapErr(err, "failed to list images")
5758 }
5859
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) {
5974 images := make([]domain.ImageInfo, 0, len(details))
75+ seenRepoTags := make(map[string]struct{}, len(details))
76+ repoDisplayByNormalized := make(map[string]string)
77+
6078 for _, detail := range details {
6179 if isDanglingImage(detail.RepoTags) {
6280 images = append(images, domain.ImageInfo{
@@ -75,6 +93,11 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
7593 continue
7694 }
7795 repository, tag := splitRepoTag(repoTag)
96+ normalizedRepository := normalizeRepository(repository)
97+ seenRepoTags[repoTagKey(normalizedRepository, tag)] = struct{}{}
98+ if normalizedRepository != "" && normalizedRepository != repository {
99+ repoDisplayByNormalized[normalizedRepository] = repository
100+ }
78101 images = append(images, domain.ImageInfo{
79102 Repository: repository,
80103 Tag: tag,
@@ -86,6 +109,62 @@ func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
86109 }
87110 }
88111
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+
89168 return images, nil
90169 }
91170
@@ -111,8 +190,8 @@ func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, er
111190 }
112191
113192 // 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.
116195 func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
117196 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118197 zerowrap.FieldLayer: "usecase",
@@ -203,8 +282,16 @@ func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
203282 keptTags["latest"] = struct{}{}
204283 }
205284
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++
208295 }
209296
210297 return keptTags
@@ -401,3 +488,53 @@ func splitRepoTag(repoTag string) (string, string) {
401488
402489 return repoTag[:idx], repoTag[idx+1:]
403490 }
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 continue93 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, nil168 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 keeps193+// 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 keptTags297 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 (
1717 )
1818
1919 func TestService_ListImages_ReturnsAllImages(t *testing.T) {
20- manifestStorage := noopManifestStorage{}
20+ manifestStorage := newFakeManifestStorage()
2121 blobStorage := noopBlobStorage{}
2222
2323 createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
@@ -74,7 +74,7 @@ func TestService_ListImages_ReturnsAllImages(t *testing.T) {
7474 }
7575
7676 func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
77- manifestStorage := noopManifestStorage{}
77+ manifestStorage := newFakeManifestStorage()
7878 blobStorage := noopBlobStorage{}
7979 rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
8080
@@ -87,7 +87,7 @@ func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
8787 }
8888
8989 func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
90- manifestStorage := noopManifestStorage{}
90+ manifestStorage := newFakeManifestStorage()
9191 blobStorage := noopBlobStorage{}
9292 createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
9393
@@ -112,7 +112,7 @@ func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
112112 }
113113
114114 func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
115- manifestStorage := noopManifestStorage{}
115+ manifestStorage := newFakeManifestStorage()
116116 blobStorage := noopBlobStorage{}
117117 rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
118118
@@ -124,6 +124,60 @@ func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
124124 assert.Contains(t, err.Error(), "failed to list images")
125125 }
126126
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+
127181 func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
128182 manifestStorage := noopManifestStorage{}
129183 blobStorage := noopBlobStorage{}
@@ -191,6 +245,7 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
191245 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
192246 manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
193247 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")
194249
195250 blobStorage := &fakeBlobStorage{}
196251 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
@@ -198,11 +253,8 @@ func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
198253 report, err := svc.PruneRegistry(context.Background(), 2)
199254
200255 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)
206258 }
207259
208260 func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
@@ -279,13 +331,15 @@ func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
279331 func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
280332 manifestStorage := newFakeManifestStorage()
281333 manifestStorage.repositories = []string{"gordon/api"}
282- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
334+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
283335 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)
284337 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
285338 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")
286340 manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
287341
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"}}
289343 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
290344
291345 report, err := svc.PruneRegistry(context.Background(), 1)
@@ -376,7 +430,7 @@ func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
376430 blobStorage := &fakeBlobStorage{}
377431 svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
378432
379- report, err := svc.PruneRegistry(context.Background(), 2)
433+ report, err := svc.PruneRegistry(context.Background(), 1)
380434
381435 require.NoError(t, err)
382436 assert.Equal(t, 2, report.Registry.TagsRemoved)
@@ -404,10 +458,12 @@ func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
404458 func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
405459 manifestStorage := newFakeManifestStorage()
406460 manifestStorage.repositories = []string{"gordon/api"}
407- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
461+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
408462 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)
409464 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
410465 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")
411467 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
412468 rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
413469
@@ -427,10 +483,12 @@ func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
427483 func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
428484 manifestStorage := newFakeManifestStorage()
429485 manifestStorage.repositories = []string{"gordon/api"}
430- manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
486+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
431487 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)
432489 manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
433490 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")
434492 blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
435493 rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
436494
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close