detached 27e4863

merge(main): resolve getting-started conflict for push flow

commit
27e4863faf2e15c8163c40246ccd9255681764c8
author
bnema · · 2026-02-11 20:55 UTC
merge of
ef77fce + af045e5 · diff against the first parent
signature
unsigned
18 files · +530 -58
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 docs/config/telemetry.md +1 -1
@@ -100,7 +100,7 @@ Attributes: `domain`, `image`
100100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101101 | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
102102
103-Attributes: `domain`, `source` (restarts only: `monitor` or `api`)
103+Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes
104104
105105 ### Registry
106106
@@ -100,7 +100,7 @@ Attributes: `domain`, `image`
100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |100 | `gordon.container.crash_loops` | Counter | - | Crash loop detections |
101 | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |101 | `gordon.container.managed` | UpDownCounter | - | Currently tracked containers |
102 102
103-Attributes: `domain`, `source` (restarts only: `monitor` or `api`)103+Attributes: `source` (restarts only: `monitor` or `api`); `gordon.container.managed` is a global gauge with no attributes
104 104
105 ### Registry105 ### Registry
106 106
M docs/getting-started.md +18 -12
@@ -6,7 +6,7 @@ Deploy your first app with Gordon in under 5 minutes.
66
77 - A Linux VPS with Docker or Podman installed
88 - A domain pointing to your VPS (DNS A record)
9-- Cloudflare account (free tier) for HTTPS termination
9+- HTTPS setup: Cloudflare proxy **or** native TLS in Gordon (`server.tls_enabled = true`)
1010 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
1111
1212 ## 1. Install Gordon
@@ -36,7 +36,7 @@ Gordon requires a `token_secret` for JWT authentication. You can store it in `pa
3636 > [auth]
3737 > enabled = false
3838 > ```
39-> Skip to [Step 4](#4-configure-your-registry-domain). For production, continue below.
39+> Skip to [Step 4](#4-configure-your-gordon-domain). For production, continue below.
4040
4141 **Initialize pass (if not already done):**
4242 ```bash
@@ -67,30 +67,36 @@ secrets_backend = "pass"
6767 token_secret = "gordon/auth/token_secret"
6868 ```
6969
70-## 4. Configure Your Registry Domain
70+## 4. Configure Your Gordon Domain
7171
7272 Edit `~/.config/gordon/gordon.toml`:
7373
7474 ```toml
7575 [server]
76-port = 8080 # Proxy port (use with Cloudflare)
76+port = 8080 # Proxy port (use with Cloudflare/rootless)
7777 registry_port = 5000 # Registry port
78-gordon_domain = "gordon.mydomain.com" # Your Gordon domain
78+gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
7979
8080 [routes]
8181 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
8282 ```
8383
84-## 5. Set Up DNS
84+## 5. Set Up DNS (Including Wildcard)
8585
86-In Cloudflare (or your DNS provider):
86+`gordon_domain` is the single domain used by both the registry and admin API.
8787
88-| Type | Name | Content |
89-|------|------|---------|
90-| A | `app` | `YOUR_SERVER_IP` |
91-| A | `registry` | `YOUR_SERVER_IP` |
88+In Cloudflare (or your DNS provider), create:
9289
93-Enable Cloudflare proxy (orange cloud) for HTTPS.
90+| Type | Name | Content | Proxy |
91+|------|------|---------|-------|
92+| A | `gordon` | `YOUR_SERVER_IP` | Yes |
93+| A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |
94+
95+Why wildcard (`*`)?
96+- It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
97+- You can add new domains in `[routes]` without creating DNS records one by one.
98+
99+If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
94100
95101 ## 6. Start Gordon as a Service
96102
@@ -6,7 +6,7 @@ Deploy your first app with Gordon in under 5 minutes.
6 6
7 - A Linux VPS with Docker or Podman installed7 - A Linux VPS with Docker or Podman installed
8 - A domain pointing to your VPS (DNS A record)8 - A domain pointing to your VPS (DNS A record)
9-- Cloudflare account (free tier) for HTTPS termination9+- HTTPS setup: Cloudflare proxy **or** native TLS in Gordon (`server.tls_enabled = true`)
10 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized10 - [pass](https://www.passwordstore.org/) (password manager) with GPG key initialized
11 11
12 ## 1. Install Gordon12 ## 1. Install Gordon
@@ -36,7 +36,7 @@ Gordon requires a `token_secret` for JWT authentication. You can store it in `pa
36 > [auth]36 > [auth]
37 > enabled = false37 > enabled = false
38 > ```38 > ```
39-> Skip to [Step 4](#4-configure-your-registry-domain). For production, continue below.39+> Skip to [Step 4](#4-configure-your-gordon-domain). For production, continue below.
40 40
41 **Initialize pass (if not already done):**41 **Initialize pass (if not already done):**
42 ```bash42 ```bash
@@ -67,30 +67,36 @@ secrets_backend = "pass"
67 token_secret = "gordon/auth/token_secret"67 token_secret = "gordon/auth/token_secret"
68 ```68 ```
69 69
70-## 4. Configure Your Registry Domain70+## 4. Configure Your Gordon Domain
71 71
72 Edit `~/.config/gordon/gordon.toml`:72 Edit `~/.config/gordon/gordon.toml`:
73 73
74 ```toml74 ```toml
75 [server]75 [server]
76-port = 8080 # Proxy port (use with Cloudflare)76+port = 8080 # Proxy port (use with Cloudflare/rootless)
77 registry_port = 5000 # Registry port77 registry_port = 5000 # Registry port
78-gordon_domain = "gordon.mydomain.com" # Your Gordon domain78+gordon_domain = "gordon.mydomain.com" # Registry + Admin API domain
79 79
80 [routes]80 [routes]
81 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping81 "app.mydomain.com" = "myapp:latest" # Domain → Image mapping
82 ```82 ```
83 83
84-## 5. Set Up DNS84+## 5. Set Up DNS (Including Wildcard)
85 85
86-In Cloudflare (or your DNS provider):86+`gordon_domain` is the single domain used by both the registry and admin API.
87 87
88-| Type | Name | Content |88+In Cloudflare (or your DNS provider), create:
89-|------|------|---------|
90-| A | `app` | `YOUR_SERVER_IP` |
91-| A | `registry` | `YOUR_SERVER_IP` |
92 89
93-Enable Cloudflare proxy (orange cloud) for HTTPS.90+| Type | Name | Content | Proxy |
91+|------|------|---------|-------|
92+| A | `gordon` | `YOUR_SERVER_IP` | Yes |
93+| A/CNAME | `*` | `YOUR_SERVER_IP` or `gordon.mydomain.com` | Yes |
94+
95+Why wildcard (`*`)?
96+- It automatically covers app routes like `app.mydomain.com`, `api.mydomain.com`, `demo.mydomain.com`, etc.
97+- You can add new domains in `[routes]` without creating DNS records one by one.
98+
99+If your DNS provider supports wildcard CNAME flattening (Cloudflare does), `* -> gordon.mydomain.com` is usually the cleanest option.
94 100
95 ## 6. Start Gordon as a Service101 ## 6. Start Gordon as a Service
96 102
M docs/index.md +1 -1
@@ -47,7 +47,7 @@ Gordon runs on your VPS and provides:
4747 - [Configuration Overview](./config/index.md) - All configuration options
4848 - [Server Settings](./config/server.md) - Ports, domains, and runtime
4949 - [Routes](./config/routes.md) - Domain to container mapping
50-- [Authentication](./config/auth.md) - Password and token authentication
50+- [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
5151 - [Secrets](./config/secrets.md) - Secure credential storage
5252 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
5353 - [Attachments](./config/attachments.md) - Service dependencies
@@ -47,7 +47,7 @@ Gordon runs on your VPS and provides:
47 - [Configuration Overview](./config/index.md) - All configuration options47 - [Configuration Overview](./config/index.md) - All configuration options
48 - [Server Settings](./config/server.md) - Ports, domains, and runtime48 - [Server Settings](./config/server.md) - Ports, domains, and runtime
49 - [Routes](./config/routes.md) - Domain to container mapping49 - [Routes](./config/routes.md) - Domain to container mapping
50-- [Authentication](./config/auth.md) - Password and token authentication50+- [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows
51 - [Secrets](./config/secrets.md) - Secure credential storage51 - [Secrets](./config/secrets.md) - Secure credential storage
52 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation52 - [Network Isolation](./config/network-isolation.md) - Per-app network isolation
53 - [Attachments](./config/attachments.md) - Service dependencies53 - [Attachments](./config/attachments.md) - Service dependencies
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/container/service.go +15 -6
@@ -76,6 +76,7 @@ type Service struct {
7676 metrics *telemetry.Metrics
7777 containers map[string]*domain.Container
7878 attachments map[string][]string // ownerDomain → []containerIDs
79+ managedCount int64 // tracks UpDownCounter value for delta computation
7980 mu sync.RWMutex
8081 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
8182 monitor *Monitor
@@ -456,13 +457,14 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri
456457 s.mu.Lock()
457458 _, wasTracked := s.containers[domainName]
458459 s.containers[domainName] = container
460+ if !wasTracked {
461+ s.managedCount++
462+ }
459463 s.mu.Unlock()
460464
461465 // Track managed container count (only increment for new domains, not replacements)
462466 if !wasTracked && s.metrics != nil {
463- s.metrics.ManagedContainers.Add(ctx, 1, metric.WithAttributes(
464- attribute.String("domain", domainName),
465- ))
467+ s.metrics.ManagedContainers.Add(ctx, 1)
466468 }
467469
468470 s.publishContainerDeployed(ctx, domainName, container.ID)
@@ -679,6 +681,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
679681 if c.ID == containerID {
680682 delete(s.containers, d)
681683 removedDomain = d
684+ s.managedCount--
682685 log.Info().Str("domain", d).Msg("container removed")
683686 break
684687 }
@@ -690,9 +693,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
690693
691694 // Decrement managed container count
692695 if removedDomain != "" && s.metrics != nil {
693- s.metrics.ManagedContainers.Add(ctx, -1, metric.WithAttributes(
694- attribute.String("domain", removedDomain),
695- ))
696+ s.metrics.ManagedContainers.Add(ctx, -1)
696697 }
697698
698699 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
@@ -912,8 +913,16 @@ func (s *Service) SyncContainers(ctx context.Context) error {
912913
913914 s.mu.Lock()
914915 s.containers = managed
916+ newCount := int64(len(managed))
917+ delta := newCount - s.managedCount
918+ s.managedCount = newCount
915919 s.mu.Unlock()
916920
921+ // Report initial/delta managed container count to OTel UpDownCounter.
922+ if delta != 0 && s.metrics != nil {
923+ s.metrics.ManagedContainers.Add(ctx, delta)
924+ }
925+
917926 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
918927 return nil
919928 }
@@ -76,6 +76,7 @@ type Service struct {
76 metrics *telemetry.Metrics76 metrics *telemetry.Metrics
77 containers map[string]*domain.Container77 containers map[string]*domain.Container
78 attachments map[string][]string // ownerDomain → []containerIDs78 attachments map[string][]string // ownerDomain → []containerIDs
79+ managedCount int64 // tracks UpDownCounter value for delta computation
79 mu sync.RWMutex80 mu sync.RWMutex
80 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)81 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
81 monitor *Monitor82 monitor *Monitor
@@ -456,13 +457,14 @@ func (s *Service) activateDeployedContainer(ctx context.Context, domainName stri
456 s.mu.Lock()457 s.mu.Lock()
457 _, wasTracked := s.containers[domainName]458 _, wasTracked := s.containers[domainName]
458 s.containers[domainName] = container459 s.containers[domainName] = container
460+ if !wasTracked {
461+ s.managedCount++
462+ }
459 s.mu.Unlock()463 s.mu.Unlock()
460 464
461 // Track managed container count (only increment for new domains, not replacements)465 // Track managed container count (only increment for new domains, not replacements)
462 if !wasTracked && s.metrics != nil {466 if !wasTracked && s.metrics != nil {
463- s.metrics.ManagedContainers.Add(ctx, 1, metric.WithAttributes(467+ s.metrics.ManagedContainers.Add(ctx, 1)
464- attribute.String("domain", domainName),
465- ))
466 }468 }
467 469
468 s.publishContainerDeployed(ctx, domainName, container.ID)470 s.publishContainerDeployed(ctx, domainName, container.ID)
@@ -679,6 +681,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
679 if c.ID == containerID {681 if c.ID == containerID {
680 delete(s.containers, d)682 delete(s.containers, d)
681 removedDomain = d683 removedDomain = d
684+ s.managedCount--
682 log.Info().Str("domain", d).Msg("container removed")685 log.Info().Str("domain", d).Msg("container removed")
683 break686 break
684 }687 }
@@ -690,9 +693,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
690 693
691 // Decrement managed container count694 // Decrement managed container count
692 if removedDomain != "" && s.metrics != nil {695 if removedDomain != "" && s.metrics != nil {
693- s.metrics.ManagedContainers.Add(ctx, -1, metric.WithAttributes(696+ s.metrics.ManagedContainers.Add(ctx, -1)
694- attribute.String("domain", removedDomain),
695- ))
696 }697 }
697 698
698 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:699 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
@@ -912,8 +913,16 @@ func (s *Service) SyncContainers(ctx context.Context) error {
912 913
913 s.mu.Lock()914 s.mu.Lock()
914 s.containers = managed915 s.containers = managed
916+ newCount := int64(len(managed))
917+ delta := newCount - s.managedCount
918+ s.managedCount = newCount
915 s.mu.Unlock()919 s.mu.Unlock()
916 920
921+ // Report initial/delta managed container count to OTel UpDownCounter.
922+ if delta != 0 && s.metrics != nil {
923+ s.metrics.ManagedContainers.Add(ctx, delta)
924+ }
925+
917 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")926 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
918 return nil927 return nil
919 }928 }
M internal/usecase/container/service_test.go +158 -0
@@ -11,7 +11,12 @@ import (
1111 "github.com/bnema/zerowrap"
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+ "go.opentelemetry.io/otel"
16+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
1418
19+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
1520 "github.com/bnema/gordon/internal/boundaries/out/mocks"
1621 "github.com/bnema/gordon/internal/domain"
1722 )
@@ -20,6 +25,159 @@ func testContext() context.Context {
2025 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
2126 }
2227
28+func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
29+ t.Helper()
30+
31+ prev := otel.GetMeterProvider()
32+ t.Cleanup(func() {
33+ otel.SetMeterProvider(prev)
34+ })
35+
36+ reader := sdkmetric.NewManualReader()
37+ mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
38+ otel.SetMeterProvider(mp)
39+ t.Cleanup(func() {
40+ _ = mp.Shutdown(context.Background())
41+ })
42+
43+ m, err := telemetry.NewMetrics()
44+ require.NoError(t, err)
45+ return m, reader
46+}
47+
48+func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, int, []metricdata.DataPoint[int64]) {
49+ t.Helper()
50+
51+ var rm metricdata.ResourceMetrics
52+ require.NoError(t, reader.Collect(context.Background(), &rm))
53+
54+ for _, sm := range rm.ScopeMetrics {
55+ for _, m := range sm.Metrics {
56+ if m.Name != "gordon.container.managed" {
57+ continue
58+ }
59+ sum, ok := m.Data.(metricdata.Sum[int64])
60+ require.True(t, ok)
61+ var total int64
62+ for _, dp := range sum.DataPoints {
63+ total += dp.Value
64+ }
65+ return total, len(sum.DataPoints), sum.DataPoints
66+ }
67+ }
68+
69+ return 0, 0, nil
70+}
71+
72+func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
73+ runtime := mocks.NewMockContainerRuntime(t)
74+ envLoader := mocks.NewMockEnvLoader(t)
75+ eventBus := mocks.NewMockEventPublisher(t)
76+
77+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
78+ ctx := testContext()
79+ metrics, reader := setupMetricsTest(t)
80+ svc.SetMetrics(metrics)
81+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Twice()
82+
83+ // First deploy increments metric.
84+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-1"})
85+ value, seriesCount, points := managedMetricState(t, reader)
86+ assert.Equal(t, int64(1), value)
87+ assert.Equal(t, 1, seriesCount)
88+ assert.Equal(t, 0, points[0].Attributes.Len())
89+
90+ // Replacing an existing domain should not increment.
91+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-2"})
92+ value, seriesCount, points = managedMetricState(t, reader)
93+ assert.Equal(t, int64(1), value)
94+ assert.Equal(t, 1, seriesCount)
95+ assert.Equal(t, 0, points[0].Attributes.Len())
96+
97+ runtime.EXPECT().RemoveContainer(mock.Anything, "container-2", true).Return(nil)
98+ require.NoError(t, svc.Remove(ctx, "container-2", true))
99+
100+ value, seriesCount, points = managedMetricState(t, reader)
101+ assert.Equal(t, int64(0), value)
102+ assert.Equal(t, 1, seriesCount)
103+ assert.Equal(t, 0, points[0].Attributes.Len())
104+}
105+
106+func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) {
107+ runtime := mocks.NewMockContainerRuntime(t)
108+ envLoader := mocks.NewMockEnvLoader(t)
109+ eventBus := mocks.NewMockEventPublisher(t)
110+
111+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
112+ ctx := testContext()
113+ metrics, reader := setupMetricsTest(t)
114+ svc.SetMetrics(metrics)
115+
116+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
117+ {
118+ ID: "container-1",
119+ Labels: map[string]string{
120+ "gordon.domain": "app1.example.com",
121+ "gordon.managed": "true",
122+ },
123+ },
124+ {
125+ ID: "container-2",
126+ Labels: map[string]string{
127+ "gordon.domain": "app2.example.com",
128+ "gordon.managed": "true",
129+ },
130+ },
131+ }, nil).Once()
132+ require.NoError(t, svc.SyncContainers(ctx))
133+
134+ value, seriesCount, points := managedMetricState(t, reader)
135+ assert.Equal(t, int64(2), value)
136+ assert.Equal(t, 1, seriesCount)
137+ assert.Equal(t, 0, points[0].Attributes.Len())
138+
139+ // No runtime change: metric should remain stable.
140+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
141+ {
142+ ID: "container-1",
143+ Labels: map[string]string{
144+ "gordon.domain": "app1.example.com",
145+ "gordon.managed": "true",
146+ },
147+ },
148+ {
149+ ID: "container-2",
150+ Labels: map[string]string{
151+ "gordon.domain": "app2.example.com",
152+ "gordon.managed": "true",
153+ },
154+ },
155+ }, nil).Once()
156+ require.NoError(t, svc.SyncContainers(ctx))
157+
158+ value, seriesCount, points = managedMetricState(t, reader)
159+ assert.Equal(t, int64(2), value)
160+ assert.Equal(t, 1, seriesCount)
161+ assert.Equal(t, 0, points[0].Attributes.Len())
162+
163+ // One container removed in runtime: metric should decrement by one.
164+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
165+ {
166+ ID: "container-1",
167+ Labels: map[string]string{
168+ "gordon.domain": "app1.example.com",
169+ "gordon.managed": "true",
170+ },
171+ },
172+ }, nil).Once()
173+ require.NoError(t, svc.SyncContainers(ctx))
174+
175+ value, seriesCount, points = managedMetricState(t, reader)
176+ assert.Equal(t, int64(1), value)
177+ assert.Equal(t, 1, seriesCount)
178+ assert.Equal(t, 0, points[0].Attributes.Len())
179+}
180+
23181 func TestService_Deploy_Success(t *testing.T) {
24182 runtime := mocks.NewMockContainerRuntime(t)
25183 envLoader := mocks.NewMockEnvLoader(t)
@@ -11,7 +11,12 @@ import (
11 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+ "go.opentelemetry.io/otel"
16+ sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17+ "go.opentelemetry.io/otel/sdk/metric/metricdata"
14 18
19+ "github.com/bnema/gordon/internal/adapters/out/telemetry"
15 "github.com/bnema/gordon/internal/boundaries/out/mocks"20 "github.com/bnema/gordon/internal/boundaries/out/mocks"
16 "github.com/bnema/gordon/internal/domain"21 "github.com/bnema/gordon/internal/domain"
17 )22 )
@@ -20,6 +25,159 @@ func testContext() context.Context {
20 return zerowrap.WithCtx(context.Background(), zerowrap.Default())25 return zerowrap.WithCtx(context.Background(), zerowrap.Default())
21 }26 }
22 27
28+func setupMetricsTest(t *testing.T) (*telemetry.Metrics, *sdkmetric.ManualReader) {
29+ t.Helper()
30+
31+ prev := otel.GetMeterProvider()
32+ t.Cleanup(func() {
33+ otel.SetMeterProvider(prev)
34+ })
35+
36+ reader := sdkmetric.NewManualReader()
37+ mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
38+ otel.SetMeterProvider(mp)
39+ t.Cleanup(func() {
40+ _ = mp.Shutdown(context.Background())
41+ })
42+
43+ m, err := telemetry.NewMetrics()
44+ require.NoError(t, err)
45+ return m, reader
46+}
47+
48+func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, int, []metricdata.DataPoint[int64]) {
49+ t.Helper()
50+
51+ var rm metricdata.ResourceMetrics
52+ require.NoError(t, reader.Collect(context.Background(), &rm))
53+
54+ for _, sm := range rm.ScopeMetrics {
55+ for _, m := range sm.Metrics {
56+ if m.Name != "gordon.container.managed" {
57+ continue
58+ }
59+ sum, ok := m.Data.(metricdata.Sum[int64])
60+ require.True(t, ok)
61+ var total int64
62+ for _, dp := range sum.DataPoints {
63+ total += dp.Value
64+ }
65+ return total, len(sum.DataPoints), sum.DataPoints
66+ }
67+ }
68+
69+ return 0, 0, nil
70+}
71+
72+func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
73+ runtime := mocks.NewMockContainerRuntime(t)
74+ envLoader := mocks.NewMockEnvLoader(t)
75+ eventBus := mocks.NewMockEventPublisher(t)
76+
77+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
78+ ctx := testContext()
79+ metrics, reader := setupMetricsTest(t)
80+ svc.SetMetrics(metrics)
81+ eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil).Twice()
82+
83+ // First deploy increments metric.
84+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-1"})
85+ value, seriesCount, points := managedMetricState(t, reader)
86+ assert.Equal(t, int64(1), value)
87+ assert.Equal(t, 1, seriesCount)
88+ assert.Equal(t, 0, points[0].Attributes.Len())
89+
90+ // Replacing an existing domain should not increment.
91+ svc.activateDeployedContainer(ctx, "test.example.com", &domain.Container{ID: "container-2"})
92+ value, seriesCount, points = managedMetricState(t, reader)
93+ assert.Equal(t, int64(1), value)
94+ assert.Equal(t, 1, seriesCount)
95+ assert.Equal(t, 0, points[0].Attributes.Len())
96+
97+ runtime.EXPECT().RemoveContainer(mock.Anything, "container-2", true).Return(nil)
98+ require.NoError(t, svc.Remove(ctx, "container-2", true))
99+
100+ value, seriesCount, points = managedMetricState(t, reader)
101+ assert.Equal(t, int64(0), value)
102+ assert.Equal(t, 1, seriesCount)
103+ assert.Equal(t, 0, points[0].Attributes.Len())
104+}
105+
106+func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T) {
107+ runtime := mocks.NewMockContainerRuntime(t)
108+ envLoader := mocks.NewMockEnvLoader(t)
109+ eventBus := mocks.NewMockEventPublisher(t)
110+
111+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
112+ ctx := testContext()
113+ metrics, reader := setupMetricsTest(t)
114+ svc.SetMetrics(metrics)
115+
116+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
117+ {
118+ ID: "container-1",
119+ Labels: map[string]string{
120+ "gordon.domain": "app1.example.com",
121+ "gordon.managed": "true",
122+ },
123+ },
124+ {
125+ ID: "container-2",
126+ Labels: map[string]string{
127+ "gordon.domain": "app2.example.com",
128+ "gordon.managed": "true",
129+ },
130+ },
131+ }, nil).Once()
132+ require.NoError(t, svc.SyncContainers(ctx))
133+
134+ value, seriesCount, points := managedMetricState(t, reader)
135+ assert.Equal(t, int64(2), value)
136+ assert.Equal(t, 1, seriesCount)
137+ assert.Equal(t, 0, points[0].Attributes.Len())
138+
139+ // No runtime change: metric should remain stable.
140+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
141+ {
142+ ID: "container-1",
143+ Labels: map[string]string{
144+ "gordon.domain": "app1.example.com",
145+ "gordon.managed": "true",
146+ },
147+ },
148+ {
149+ ID: "container-2",
150+ Labels: map[string]string{
151+ "gordon.domain": "app2.example.com",
152+ "gordon.managed": "true",
153+ },
154+ },
155+ }, nil).Once()
156+ require.NoError(t, svc.SyncContainers(ctx))
157+
158+ value, seriesCount, points = managedMetricState(t, reader)
159+ assert.Equal(t, int64(2), value)
160+ assert.Equal(t, 1, seriesCount)
161+ assert.Equal(t, 0, points[0].Attributes.Len())
162+
163+ // One container removed in runtime: metric should decrement by one.
164+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
165+ {
166+ ID: "container-1",
167+ Labels: map[string]string{
168+ "gordon.domain": "app1.example.com",
169+ "gordon.managed": "true",
170+ },
171+ },
172+ }, nil).Once()
173+ require.NoError(t, svc.SyncContainers(ctx))
174+
175+ value, seriesCount, points = managedMetricState(t, reader)
176+ assert.Equal(t, int64(1), value)
177+ assert.Equal(t, 1, seriesCount)
178+ assert.Equal(t, 0, points[0].Attributes.Len())
179+}
180+
23 func TestService_Deploy_Success(t *testing.T) {181 func TestService_Deploy_Success(t *testing.T) {
24 runtime := mocks.NewMockContainerRuntime(t)182 runtime := mocks.NewMockContainerRuntime(t)
25 envLoader := mocks.NewMockEnvLoader(t)183 envLoader := mocks.NewMockEnvLoader(t)
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