detached ac68d40

merge main into sec/auth-proxy-critical-fixes

commit
ac68d4066acf0b2b82ba3358bce695fe66ee94cc
author
brice · · 2026-02-09 05:52 UTC
merge of
bd2df2f + c808592 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
55 files · +4650 -268
A docs/cli/images.md +72 -0
@@ -0,0 +1,72 @@
1+# Images Command
2+
3+List and prune runtime/registry images.
4+
5+## gordon images
6+
7+```bash
8+gordon images <subcommand>
9+```
10+
11+Subcommands:
12+
13+- `list` - List runtime images.
14+- `prune` - Prune dangling runtime images and optionally registry data.
15+
16+> **Note:** Images commands require remote mode (`--remote` + `--token`, or configured remotes).
17+
18+## gordon images list
19+
20+```bash
21+gordon images list
22+```
23+
24+Shows image rows with repository, tag, size, creation time, image id, and dangling status.
25+
26+## gordon images prune
27+
28+```bash
29+gordon images prune [--dry-run] [--keep <n>] [--runtime-only]
30+```
31+
32+Flags:
33+
34+| Flag | Default | Description |
35+|------|---------|-------------|
36+| `--dry-run` | `false` | Show prune behavior without applying changes |
37+| `--keep` | `0` | Number of latest tags to keep per repository (`0` disables registry cleanup) |
38+| `--runtime-only` | `false` | Prune dangling runtime images only; forces registry cleanup off |
39+
40+`--keep` defaults to `0` for ad-hoc CLI safety (registry cleanup off by default), while scheduled pruning uses `images.prune.keep_last` from config (default `3`). Use config for recurring behavior; when a CLI `--keep` value is provided, that request value is used.
41+
42+Behavior notes:
43+
44+- `--runtime-only` forces `keep_last = 0`.
45+- `--keep` must be `>= 0`.
46+- `--dry-run` reports what would be pruned.
47+
48+## Examples
49+
50+```bash
51+# List images
52+gordon images list
53+
54+# Prune dangling runtime images only
55+gordon images prune --runtime-only
56+
57+# Prune runtime images and keep 3 newest tags per repository
58+gordon images prune --keep 3
59+
60+# Preview cleanup without applying
61+gordon images prune --dry-run --keep 5
62+```
63+
64+## Required Permissions
65+
66+- `list` requires `admin:status:read`.
67+- `prune` requires `admin:config:write`.
68+
69+## Related
70+
71+- [CLI Commands](./index.md)
72+- [Images Configuration](../config/images.md)
M docs/cli/index.md +6 -0
@@ -32,6 +32,7 @@ Management commands run locally through in-process services by default. Add `--r
32 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |32 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
33 | `gordon status` | Show Gordon server status | [status](./status.md) |33 | `gordon status` | Show Gordon server status | [status](./status.md) |
34 | `gordon backups` | Manage database backups | [backup](./backup.md) |34 | `gordon backups` | Manage database backups | [backup](./backup.md) |
35+| `gordon images` | List and prune images | [images](./images.md) |
35 36
36 ## Client Commands37 ## Client Commands
37 38
@@ -82,6 +83,11 @@ gordon backups run app.example.com
82 gordon backups detect app.example.com83 gordon backups detect app.example.com
83 gordon backups status84 gordon backups status
84 85
86+# Images
87+gordon images list
88+gordon images prune --runtime-only
89+gordon images prune --keep 3
90+
85 # Authentication91 # Authentication
86 gordon auth token generate --subject ci-bot --expiry 092 gordon auth token generate --subject ci-bot --expiry 0
87 gordon auth token list93 gordon auth token list
M docs/config/backups.md +3 -0
@@ -21,6 +21,7 @@ Current scope:
21 ```toml21 ```toml
22 [backups]22 [backups]
23 enabled = true23 enabled = true
24+schedule = "daily"
24 storage_dir = "~/.gordon/backups"25 storage_dir = "~/.gordon/backups"
25 26
26 [backups.retention]27 [backups.retention]
@@ -35,6 +36,7 @@ monthly = 12
35 | Key | Type | Default | Description |36 | Key | Type | Default | Description |
36 |-----|------|---------|-------------|37 |-----|------|---------|-------------|
37 | `backups.enabled` | bool | `false` | Enables backup service wiring |38 | `backups.enabled` | bool | `false` | Enables backup service wiring |
39+| `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
38 | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` |40 | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` |
39 | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB |41 | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB |
40 | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB |42 | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB |
@@ -58,6 +60,7 @@ Example:
58 ## Notes60 ## Notes
59 61
60 - Backups require PostgreSQL attachment containers to be running.62 - Backups require PostgreSQL attachment containers to be running.
63+- Scheduled backups run only when `backups.enabled = true`.
61 - The backup command executes inside the database container using Gordon runtime APIs.64 - The backup command executes inside the database container using Gordon runtime APIs.
62 - Retention applies per database and per schedule.65 - Retention applies per database and per schedule.
63 - `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.66 - `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.
A docs/config/images.md +41 -0
@@ -0,0 +1,41 @@
1+# Images Configuration
2+
3+Configure automatic image cleanup for Docker runtime images and local registry storage.
4+
5+## Overview
6+
7+When enabled, Gordon runs a scheduled image prune job that:
8+
9+- Prunes dangling runtime images.
10+- Applies tag retention to registry repositories.
11+- Preserves the `latest` tag.
12+- Removes unreferenced blobs after tag cleanup.
13+
14+## Configuration
15+
16+```toml
17+[images.prune]
18+enabled = false
19+schedule = "daily"
20+keep_last = 3
21+```
22+
23+## Settings
24+
25+| Key | Type | Default | Description |
26+|-----|------|---------|-------------|
27+| `images.prune.enabled` | bool | `false` | Enables scheduled image cleanup |
28+| `images.prune.schedule` | string | `"daily"` | Schedule preset: `hourly`, `daily`, `weekly`, `monthly` |
29+| `images.prune.keep_last` | int | `3` | Number of newest tags kept per repository during registry cleanup |
30+
31+## Retention Behavior
32+
33+- `latest` is always preserved.
34+- `keep_last` applies per repository.
35+- `keep_last = 0` skips registry tag/blob cleanup (runtime dangling prune still runs).
36+- Negative `keep_last` values are invalid.
37+
38+## Related
39+
40+- [CLI Images Command](../cli/images.md)
41+- [Configuration Reference](./reference.md)
M docs/config/index.md +13 -0
@@ -119,6 +119,7 @@ enabled = false # Auto-create routes from image names
119 # Backups119 # Backups
120 [backups]120 [backups]
121 enabled = true121 enabled = true
122+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
122 storage_dir = "~/.gordon/backups"123 storage_dir = "~/.gordon/backups"
123 124
124 [backups.retention]125 [backups.retention]
@@ -126,6 +127,12 @@ hourly = 24
126 daily = 7127 daily = 7
127 weekly = 4128 weekly = 4
128 monthly = 12129 monthly = 12
130+
131+# Images
132+[images.prune]
133+enabled = false
134+schedule = "daily"
135+keep_last = 3
129 ```136 ```
130 137
131 ## Configuration Sections138 ## Configuration Sections
@@ -146,6 +153,7 @@ monthly = 12
146 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |153 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
147 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |154 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
148 | `[backups]` | Database backups | [Backups](./backups.md) |155 | `[backups]` | Database backups | [Backups](./backups.md) |
156+| `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
149 157
150 ## Default Values158 ## Default Values
151 159
@@ -182,6 +190,10 @@ monthly = 12
182 | `network_isolation.enabled` | `false` |190 | `network_isolation.enabled` | `false` |
183 | `auto_route.enabled` | `false` |191 | `auto_route.enabled` | `false` |
184 | `backups.enabled` | `false` |192 | `backups.enabled` | `false` |
193+| `backups.schedule` | `"daily"` (`"hourly"`, `"daily"`, `"weekly"`, `"monthly"`) |
194+| `images.prune.enabled` | `false` |
195+| `images.prune.schedule` | `"daily"` |
196+| `images.prune.keep_last` | `3` |
185 197
186 When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.198 When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
187 199
@@ -225,3 +237,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
225 - [External Routes](./external-routes.md)237 - [External Routes](./external-routes.md)
226 - [Authentication](./auth.md)238 - [Authentication](./auth.md)
227 - [Backups](./backups.md)239 - [Backups](./backups.md)
240+- [Images](./images.md)
M docs/config/reference.md +14 -0
@@ -133,6 +133,7 @@ preserve = true # Keep volumes when containers are
133 # =============================================================================133 # =============================================================================
134 [backups]134 [backups]
135 enabled = false # Enable backup service135 enabled = false # Enable backup service
136+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
136 storage_dir = "" # Backup root (default: {data_dir}/backups)137 storage_dir = "" # Backup root (default: {data_dir}/backups)
137 138
138 [backups.retention]139 [backups.retention]
@@ -141,6 +142,14 @@ daily = 0 # Keep N daily backups per DB
141 weekly = 0 # Keep N weekly backups per DB142 weekly = 0 # Keep N weekly backups per DB
142 monthly = 0 # Keep N monthly backups per DB143 monthly = 0 # Keep N monthly backups per DB
143 144
145+# =============================================================================
146+# IMAGES
147+# =============================================================================
148+[images.prune]
149+enabled = false # Enable scheduled image cleanup
150+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
151+keep_last = 3 # Keep N newest tags per repository
152+
144 # Note: retention values set to 0 keep no backups for that tier.153 # Note: retention values set to 0 keep no backups for that tier.
145 # For practical defaults, consider setting daily = 7.154 # For practical defaults, consider setting daily = 7.
146 ```155 ```
@@ -192,11 +201,15 @@ monthly = 0 # Keep N monthly backups per DB
192 | `volumes.prefix` | `"gordon"` | Volume prefix |201 | `volumes.prefix` | `"gordon"` | Volume prefix |
193 | `volumes.preserve` | `true` | Keep volumes |202 | `volumes.preserve` | `true` | Keep volumes |
194 | `backups.enabled` | `false` | Backup service disabled |203 | `backups.enabled` | `false` | Backup service disabled |
204+| `backups.schedule` | `"daily"` | Backup scheduler preset |
195 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |205 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
196 | `backups.retention.hourly` | `0` | Keep no hourly backups by default |206 | `backups.retention.hourly` | `0` | Keep no hourly backups by default |
197 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |207 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
198 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |208 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
199 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |209 | `backups.retention.monthly` | `0` | Keep no monthly backups by default |
210+| `images.prune.enabled` | `false` | Scheduled image cleanup disabled |
211+| `images.prune.schedule` | `"daily"` | Cleanup schedule preset |
212+| `images.prune.keep_last` | `3` | Number of recent tags kept per repository |
200 213
201 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.214 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
202 215
@@ -269,3 +282,4 @@ gordon serve
269 - [Authentication](./auth.md)282 - [Authentication](./auth.md)
270 - [Network Isolation](./network-isolation.md)283 - [Network Isolation](./network-isolation.md)
271 - [Volumes](./volumes.md)284 - [Volumes](./volumes.md)
285+- [Images](./images.md)
M docs/config/server.md +24 -0
@@ -69,6 +69,30 @@ With TLS enabled:
69 - `port` remains available for plain HTTP unless you restrict it externally69 - `port` remains available for plain HTTP unless you restrict it externally
70 - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/`70 - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/`
71 71
72+For rootless user services, do not bind directly to `443`. Use an unprivileged TLS port (for example `8443`) and forward `443` to it at the firewall or edge proxy.
73+
74+```toml
75+[server]
76+tls_enabled = true
77+tls_port = 8443
78+```
79+
80+With firewalld and Tailscale:
81+
82+```bash
83+# Verify tailscale0 is in trusted zone
84+sudo firewall-cmd --get-zone-of-interface=tailscale0
85+
86+# If needed, place tailscale0 in trusted
87+sudo firewall-cmd --permanent --zone=trusted --add-interface=tailscale0
88+sudo firewall-cmd --reload
89+
90+# Forward 443 -> 8443 permanently (IPv4 + IPv6)
91+sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv4 forward-port port=443 protocol=tcp to-port=8443'
92+sudo firewall-cmd --permanent --zone=trusted --add-rich-rule='rule family=ipv6 forward-port port=443 protocol=tcp to-port=8443'
93+sudo firewall-cmd --reload
94+```
95+
72 ### Registry Port96 ### Registry Port
73 97
74 The `registry_port` setting controls where Gordon accepts image pushes:98 The `registry_port` setting controls where Gordon accepts image pushes:
M go.mod +10 -7
@@ -4,7 +4,7 @@ go 1.25.6
4 4
5 require (5 require (
6 github.com/bnema/zerowrap v1.3.06 github.com/bnema/zerowrap v1.3.0
7- github.com/charmbracelet/bubbles v0.21.07+ github.com/charmbracelet/bubbles v0.21.1
8 github.com/charmbracelet/bubbletea v1.3.108 github.com/charmbracelet/bubbletea v1.3.10
9 github.com/charmbracelet/lipgloss v1.1.09 github.com/charmbracelet/lipgloss v1.1.0
10 github.com/docker/docker v28.5.2+incompatible10 github.com/docker/docker v28.5.2+incompatible
@@ -24,14 +24,17 @@ require (
24 github.com/atotto/clipboard v0.1.4 // indirect24 github.com/atotto/clipboard v0.1.4 // indirect
25 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect25 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
26 github.com/cespare/xxhash/v2 v2.3.0 // indirect26 github.com/cespare/xxhash/v2 v2.3.0 // indirect
27- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect27+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
28- github.com/charmbracelet/x/ansi v0.10.1 // indirect28+ github.com/charmbracelet/x/ansi v0.11.5 // indirect
29- github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect29+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
30- github.com/charmbracelet/x/term v0.2.1 // indirect30+ github.com/charmbracelet/x/term v0.2.2 // indirect
31+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
32+ github.com/clipperhouse/stringish v0.1.1 // indirect
33+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
31 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect34 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect35+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
33 github.com/mattn/go-localereader v0.0.1 // indirect36 github.com/mattn/go-localereader v0.0.1 // indirect
34- github.com/mattn/go-runewidth v0.0.16 // indirect37+ github.com/mattn/go-runewidth v0.0.19 // indirect
35 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect38 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
36 github.com/muesli/cancelreader v0.2.2 // indirect39 github.com/muesli/cancelreader v0.2.2 // indirect
37 github.com/muesli/termenv v0.16.0 // indirect40 github.com/muesli/termenv v0.16.0 // indirect
M go.sum +24 -19 generated file: collapsed
A internal/adapters/dto/admin_images.go +42 -0
@@ -0,0 +1,42 @@
1+package dto
2+
3+import "time"
4+
5+// Image represents a runtime image in admin API responses.
6+type Image struct {
7+ Repository string `json:"repository"`
8+ Tag string `json:"tag"`
9+ Size int64 `json:"size"`
10+ Created time.Time `json:"created"`
11+ ID string `json:"id"`
12+ Dangling bool `json:"dangling"`
13+}
14+
15+// ImagesResponse is returned by image list endpoints.
16+type ImagesResponse struct {
17+ Images []Image `json:"images"`
18+}
19+
20+// ImagePruneRequest triggers image pruning.
21+type ImagePruneRequest struct {
22+ KeepLast *int `json:"keep_last,omitempty"`
23+}
24+
25+// RuntimePruneResult represents runtime prune results.
26+type RuntimePruneResult struct {
27+ DeletedCount int `json:"deleted_count"`
28+ SpaceReclaimed int64 `json:"space_reclaimed"`
29+}
30+
31+// RegistryPruneResult represents registry prune results.
32+type RegistryPruneResult struct {
33+ TagsRemoved int `json:"tags_removed"`
34+ BlobsRemoved int `json:"blobs_removed"`
35+ SpaceReclaimed int64 `json:"space_reclaimed"`
36+}
37+
38+// ImagePruneResponse is returned by image prune endpoints.
39+type ImagePruneResponse struct {
40+ Runtime RuntimePruneResult `json:"runtime"`
41+ Registry RegistryPruneResult `json:"registry"`
42+}
A internal/adapters/in/cli/images.go +200 -0
@@ -0,0 +1,200 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "text/tabwriter"
8+ "time"
9+
10+ "github.com/spf13/cobra"
11+
12+ "github.com/bnema/gordon/internal/adapters/dto"
13+)
14+
15+type imagesClient interface {
16+ ListImages(ctx context.Context) ([]dto.Image, error)
17+ PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error)
18+}
19+
20+type imagesPruneOptions struct {
21+ DryRun bool
22+ KeepLast int
23+ RuntimeOnly bool
24+}
25+
26+func newImagesCmd() *cobra.Command {
27+ cmd := &cobra.Command{
28+ Use: "images",
29+ Short: "List and prune images",
30+ Long: `Inspect and clean up runtime and registry images.
31+
32+These commands currently require remote mode with a configured target.`,
33+ }
34+
35+ cmd.AddCommand(newImagesListCmd())
36+ cmd.AddCommand(newImagesPruneCmd())
37+
38+ return cmd
39+}
40+
41+func newImagesListCmd() *cobra.Command {
42+ return &cobra.Command{
43+ Use: "list",
44+ Short: "List runtime images",
45+ RunE: func(cmd *cobra.Command, _ []string) error {
46+ client, isRemote := GetRemoteClient()
47+ if !isRemote {
48+ return fmt.Errorf("images commands require a configured remote target")
49+ }
50+
51+ return runImagesList(cmd.Context(), client, cmd.OutOrStdout())
52+ },
53+ }
54+}
55+
56+func newImagesPruneCmd() *cobra.Command {
57+ var opts imagesPruneOptions
58+
59+ cmd := &cobra.Command{
60+ Use: "prune",
61+ Short: "Prune unused images",
62+ RunE: func(cmd *cobra.Command, _ []string) error {
63+ client, isRemote := GetRemoteClient()
64+ if !isRemote {
65+ return fmt.Errorf("images commands require a configured remote target")
66+ }
67+
68+ return runImagesPrune(cmd.Context(), client, opts, cmd.OutOrStdout())
69+ },
70+ }
71+
72+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be pruned without applying changes")
73+ cmd.Flags().IntVar(&opts.KeepLast, "keep", 0, "Number of latest tags to keep per repository (0 disables registry cleanup)")
74+ cmd.Flags().BoolVar(&opts.RuntimeOnly, "runtime-only", false, "Prune dangling runtime images only (skip registry cleanup)")
75+
76+ return cmd
77+}
78+
79+func runImagesList(ctx context.Context, client imagesClient, out io.Writer) error {
80+ images, err := client.ListImages(ctx)
81+ if err != nil {
82+ return fmt.Errorf("failed to list images: %w", err)
83+ }
84+
85+ if len(images) == 0 {
86+ if _, err := fmt.Fprintln(out, "No images found"); err != nil {
87+ return err
88+ }
89+ _, err := fmt.Fprintln(out, "Total images: 0 (dangling: 0)")
90+ return err
91+ }
92+
93+ w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
94+ if _, err := fmt.Fprintln(w, "REPOSITORY\tTAG\tSIZE\tCREATED\tIMAGE_ID\tDANGLING"); err != nil {
95+ return err
96+ }
97+
98+ danglingCount := 0
99+ for _, img := range images {
100+ dangling := "no"
101+ if img.Dangling {
102+ dangling = "yes"
103+ danglingCount++
104+ }
105+
106+ if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", img.Repository, img.Tag, formatImageSize(img.Size), formatImageCreatedAt(img.Created), img.ID, dangling); err != nil {
107+ return err
108+ }
109+ }
110+
111+ if err := w.Flush(); err != nil {
112+ return err
113+ }
114+
115+ _, err = fmt.Fprintf(out, "\nTotal images: %d (dangling: %d)\n", len(images), danglingCount)
116+ return err
117+}
118+
119+func runImagesPrune(ctx context.Context, client imagesClient, opts imagesPruneOptions, out io.Writer) error {
120+ if opts.KeepLast < 0 {
121+ return fmt.Errorf("--keep must be >= 0")
122+ }
123+
124+ keepLast := opts.KeepLast
125+ if opts.RuntimeOnly {
126+ keepLast = 0
127+ }
128+
129+ if opts.DryRun {
130+ images, err := client.ListImages(ctx)
131+ if err != nil {
132+ return fmt.Errorf("failed to list images: %w", err)
133+ }
134+
135+ danglingCount := 0
136+ for _, img := range images {
137+ if img.Dangling {
138+ danglingCount++
139+ }
140+ }
141+
142+ if _, err := fmt.Fprintln(out, "Dry run: no changes applied"); err != nil {
143+ return err
144+ }
145+ if _, err := fmt.Fprintf(out, "Runtime: would prune %d dangling runtime images\n", danglingCount); err != nil {
146+ return err
147+ }
148+ if opts.RuntimeOnly {
149+ _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
150+ return err
151+ }
152+ if keepLast == 0 {
153+ _, err = fmt.Fprintln(out, "Registry cleanup skipped (--keep=0)")
154+ return err
155+ }
156+
157+ _, err = fmt.Fprintf(out, "Registry: would keep last %d tags per repository\n", keepLast)
158+ return err
159+ }
160+
161+ resp, err := client.PruneImages(ctx, keepLast)
162+ if err != nil {
163+ return fmt.Errorf("failed to prune images: %w", err)
164+ }
165+ if resp == nil {
166+ return fmt.Errorf("failed to prune images: empty response")
167+ }
168+
169+ if _, err := fmt.Fprintf(out, "Runtime: deleted=%d space_reclaimed=%d\n", resp.Runtime.DeletedCount, resp.Runtime.SpaceReclaimed); err != nil {
170+ return err
171+ }
172+
173+ if opts.RuntimeOnly {
174+ _, err = fmt.Fprintln(out, "Registry cleanup skipped (--runtime-only)")
175+ return err
176+ }
177+
178+ _, err = fmt.Fprintf(out, "Registry: tags_removed=%d blobs_removed=%d space_reclaimed=%d\n", resp.Registry.TagsRemoved, resp.Registry.BlobsRemoved, resp.Registry.SpaceReclaimed)
179+ return err
180+}
181+
182+func formatImageCreatedAt(t time.Time) string {
183+ if t.IsZero() {
184+ return "-"
185+ }
186+ return t.UTC().Format("2006-01-02T15:04:05Z")
187+}
188+
189+func formatImageSize(size int64) string {
190+ const unit = 1024
191+ if size < unit {
192+ return fmt.Sprintf("%d B", size)
193+ }
194+ div, exp := int64(unit), 0
195+ for n := size / unit; n >= unit; n /= unit {
196+ div *= unit
197+ exp++
198+ }
199+ return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
200+}
A internal/adapters/in/cli/images_test.go +158 -0
@@ -0,0 +1,158 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/dto"
14+)
15+
16+type fakeImagesClient struct {
17+ listImagesResp []dto.Image
18+ listImagesErr error
19+ pruneResp *dto.ImagePruneResponse
20+ pruneErr error
21+
22+ listImagesCalls int
23+ pruneCalls int
24+ lastPruneKeep int
25+}
26+
27+func (f *fakeImagesClient) ListImages(_ context.Context) ([]dto.Image, error) {
28+ f.listImagesCalls++
29+ if f.listImagesErr != nil {
30+ return nil, f.listImagesErr
31+ }
32+ return f.listImagesResp, nil
33+}
34+
35+func (f *fakeImagesClient) PruneImages(_ context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
36+ f.pruneCalls++
37+ f.lastPruneKeep = keepLast
38+ if f.pruneErr != nil {
39+ return nil, f.pruneErr
40+ }
41+ return f.pruneResp, nil
42+}
43+
44+func TestRunImagesList_PrintsRowsAndSummary(t *testing.T) {
45+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
46+ client := &fakeImagesClient{
47+ listImagesResp: []dto.Image{
48+ {Repository: "registry.example.com/app", Tag: "latest", Size: 12_000_000, Created: createdAt, ID: "sha256:1111", Dangling: false},
49+ {Repository: "<none>", Tag: "<none>", Size: 512_000, Created: createdAt, ID: "sha256:2222", Dangling: true},
50+ },
51+ }
52+
53+ var out bytes.Buffer
54+ err := runImagesList(context.Background(), client, &out)
55+ require.NoError(t, err)
56+
57+ text := out.String()
58+ assert.Contains(t, text, "REPOSITORY")
59+ assert.Contains(t, text, "registry.example.com/app")
60+ assert.Contains(t, text, "latest")
61+ assert.Contains(t, text, "Total images: 2 (dangling: 1)")
62+ assert.Equal(t, 1, client.listImagesCalls)
63+}
64+
65+func TestRunImagesList_EmptyOutput(t *testing.T) {
66+ client := &fakeImagesClient{listImagesResp: []dto.Image{}}
67+
68+ var out bytes.Buffer
69+ err := runImagesList(context.Background(), client, &out)
70+ require.NoError(t, err)
71+
72+ text := out.String()
73+ assert.Contains(t, text, "No images found")
74+ assert.Contains(t, text, "Total images: 0")
75+ assert.Equal(t, 1, client.listImagesCalls)
76+}
77+
78+func TestRunImagesPrune_DryRunCallsListOnly(t *testing.T) {
79+ client := &fakeImagesClient{
80+ listImagesResp: []dto.Image{{Dangling: true}, {Dangling: false}, {Dangling: true}},
81+ }
82+
83+ var out bytes.Buffer
84+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 3}, &out)
85+ require.NoError(t, err)
86+
87+ text := out.String()
88+ assert.Contains(t, text, "Dry run")
89+ assert.Contains(t, text, "would prune 2 dangling runtime images")
90+ assert.Contains(t, text, "would keep last 3 tags")
91+ assert.Equal(t, 1, client.listImagesCalls)
92+ assert.Equal(t, 0, client.pruneCalls)
93+}
94+
95+func TestRunImagesPrune_DryRunKeepZeroSkipsRegistry(t *testing.T) {
96+ client := &fakeImagesClient{listImagesResp: []dto.Image{{Dangling: true}}}
97+
98+ var out bytes.Buffer
99+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{DryRun: true, KeepLast: 0}, &out)
100+ require.NoError(t, err)
101+
102+ text := out.String()
103+ assert.Contains(t, text, "Registry cleanup skipped (--keep=0)")
104+ assert.Equal(t, 1, client.listImagesCalls)
105+ assert.Equal(t, 0, client.pruneCalls)
106+}
107+
108+func TestRunImagesPrune_RuntimeOnlyForcesKeepZero(t *testing.T) {
109+ client := &fakeImagesClient{pruneResp: &dto.ImagePruneResponse{}}
110+
111+ var out bytes.Buffer
112+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 9, RuntimeOnly: true}, &out)
113+ require.NoError(t, err)
114+
115+ assert.Equal(t, 1, client.pruneCalls)
116+ assert.Equal(t, 0, client.lastPruneKeep)
117+ assert.Contains(t, out.String(), "Registry cleanup skipped")
118+}
119+
120+func TestRunImagesPrune_UsesKeepFlagWhenNotRuntimeOnly(t *testing.T) {
121+ client := &fakeImagesClient{
122+ pruneResp: &dto.ImagePruneResponse{
123+ Runtime: dto.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
124+ Registry: dto.RegistryPruneResult{TagsRemoved: 3, BlobsRemoved: 1, SpaceReclaimed: 8192},
125+ },
126+ }
127+
128+ var out bytes.Buffer
129+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 5}, &out)
130+ require.NoError(t, err)
131+
132+ text := out.String()
133+ assert.Equal(t, 1, client.pruneCalls)
134+ assert.Equal(t, 5, client.lastPruneKeep)
135+ assert.Contains(t, text, "Runtime: deleted=2")
136+ assert.Contains(t, text, "Registry: tags_removed=3")
137+}
138+
139+func TestRunImagesPrune_ReturnsRemoteErrors(t *testing.T) {
140+ client := &fakeImagesClient{pruneErr: errors.New("request failed")}
141+
142+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 2}, &bytes.Buffer{})
143+ require.Error(t, err)
144+ assert.Contains(t, err.Error(), "failed to prune images")
145+}
146+
147+func TestRunImagesPrune_RejectsNegativeKeep(t *testing.T) {
148+ err := runImagesPrune(context.Background(), &fakeImagesClient{}, imagesPruneOptions{KeepLast: -1}, &bytes.Buffer{})
149+ require.Error(t, err)
150+ assert.Contains(t, err.Error(), "--keep must be >= 0")
151+}
152+
153+func TestRunImagesPrune_RejectsEmptyResponse(t *testing.T) {
154+ client := &fakeImagesClient{pruneResp: nil}
155+ err := runImagesPrune(context.Background(), client, imagesPruneOptions{KeepLast: 1}, &bytes.Buffer{})
156+ require.Error(t, err)
157+ assert.Contains(t, err.Error(), "empty response")
158+}
M internal/adapters/in/cli/push.go +107 -1
@@ -8,8 +8,10 @@ import (
8 "regexp"8 "regexp"
9 "strings"9 "strings"
10 10
11+ tea "github.com/charmbracelet/bubbletea"
11 "github.com/spf13/cobra"12 "github.com/spf13/cobra"
12 13
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
13 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"15 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"16 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
15 "github.com/bnema/gordon/pkg/validation"17 "github.com/bnema/gordon/pkg/validation"
@@ -236,7 +238,15 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
236 }238 }
237 }239 }
238 240
239- result, err := cp.Deploy(ctx, pushDomain)241+ var (
242+ result *remote.DeployResult
243+ err error
244+ )
245+ if remoteCP, ok := cp.(*remoteControlPlane); ok {
246+ result, err = deployWithSpinner(ctx, remoteCP.client, pushDomain)
247+ } else {
248+ result, err = cp.Deploy(ctx, pushDomain)
249+ }
240 if err != nil {250 if err != nil {
241 return fmt.Errorf("failed to deploy: %w", err)251 return fmt.Errorf("failed to deploy: %w", err)
242 }252 }
@@ -248,6 +258,102 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
248 return nil258 return nil
249 }259 }
250 260
261+func deployWithSpinner(ctx context.Context, client *remote.Client, pushDomain string) (*remote.DeployResult, error) {
262+ if !isInteractiveTerminal() {
263+ fmt.Printf("Deploying %s...\n", pushDomain)
264+ return client.Deploy(ctx, pushDomain)
265+ }
266+
267+ done := make(chan deployOutcome, 1)
268+ go func() {
269+ result, err := client.Deploy(ctx, pushDomain)
270+ done <- deployOutcome{result: result, err: err}
271+ }()
272+
273+ model := newDeploySpinnerModel(pushDomain, done)
274+ final, err := tea.NewProgram(model, tea.WithContext(ctx)).Run()
275+ fmt.Print("\r\033[K")
276+ if err != nil {
277+ return nil, err
278+ }
279+
280+ deployModel, ok := final.(deploySpinnerModel)
281+ if !ok {
282+ return nil, fmt.Errorf("spinner exited with unexpected model type %T", final)
283+ }
284+ if !deployModel.finished {
285+ return nil, fmt.Errorf("deploy spinner exited before deploy result was received")
286+ }
287+
288+ return deployModel.outcome.result, deployModel.outcome.err
289+}
290+
291+type deployOutcome struct {
292+ result *remote.DeployResult
293+ err error
294+}
295+
296+type deployDoneMsg deployOutcome
297+
298+type deploySpinnerModel struct {
299+ spinner components.SpinnerModel
300+ done <-chan deployOutcome
301+ outcome deployOutcome
302+ finished bool
303+}
304+
305+func newDeploySpinnerModel(pushDomain string, done <-chan deployOutcome) deploySpinnerModel {
306+ return deploySpinnerModel{
307+ spinner: components.NewSpinner(
308+ components.WithMessage(fmt.Sprintf("Deploying %s...", pushDomain)),
309+ components.WithSpinnerType(components.SpinnerMiniDot),
310+ ),
311+ done: done,
312+ }
313+}
314+
315+func (m deploySpinnerModel) Init() tea.Cmd {
316+ return tea.Batch(m.spinner.Init(), waitForDeployDone(m.done))
317+}
318+
319+func (m deploySpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
320+ switch msg := msg.(type) {
321+ case deployDoneMsg:
322+ m.outcome = deployOutcome(msg)
323+ m.finished = true
324+ return m, tea.Quit
325+ default:
326+ updated, cmd := m.spinner.Update(msg)
327+ spinnerModel, ok := updated.(components.SpinnerModel)
328+ if ok {
329+ m.spinner = spinnerModel
330+ }
331+ return m, cmd
332+ }
333+}
334+
335+func (m deploySpinnerModel) View() string {
336+ return m.spinner.View()
337+}
338+
339+func waitForDeployDone(done <-chan deployOutcome) tea.Cmd {
340+ return func() tea.Msg {
341+ return deployDoneMsg(<-done)
342+ }
343+}
344+
345+func isInteractiveTerminal() bool {
346+ term := os.Getenv("TERM")
347+ if term == "" || term == "dumb" {
348+ return false
349+ }
350+ info, err := os.Stdout.Stat()
351+ if err != nil {
352+ return false
353+ }
354+ return (info.Mode() & os.ModeCharDevice) != 0
355+}
356+
251 // parseImageRef splits "registry/name:tag" into components.357 // parseImageRef splits "registry/name:tag" into components.
252 func parseImageRef(image string) (registry, name, tag string) {358 func parseImageRef(image string) (registry, name, tag string) {
253 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"359 // Handle "reg.bnema.dev/jp-bot:latest" or "reg.bnema.dev/jp-bot"
M internal/adapters/in/cli/remote/client.go +36 -0
@@ -465,6 +465,42 @@ type RouteHealth struct {
465 465
466 // Backups API466 // Backups API
467 467
468+// Images API
469+
470+// ListImages returns runtime images from the admin API.
471+func (c *Client) ListImages(ctx context.Context) ([]dto.Image, error) {
472+ resp, err := c.request(ctx, http.MethodGet, "/images", nil)
473+ if err != nil {
474+ return nil, err
475+ }
476+
477+ var result dto.ImagesResponse
478+ if err := parseResponse(resp, &result); err != nil {
479+ return nil, err
480+ }
481+
482+ return result.Images, nil
483+}
484+
485+// PruneImages prunes runtime and registry images.
486+func (c *Client) PruneImages(ctx context.Context, keepLast int) (*dto.ImagePruneResponse, error) {
487+ if keepLast < 0 {
488+ return nil, fmt.Errorf("keepLast must be >= 0")
489+ }
490+
491+ resp, err := c.request(ctx, http.MethodPost, "/images/prune", dto.ImagePruneRequest{KeepLast: &keepLast})
492+ if err != nil {
493+ return nil, err
494+ }
495+
496+ var result dto.ImagePruneResponse
497+ if err := parseResponse(resp, &result); err != nil {
498+ return nil, err
499+ }
500+
501+ return &result, nil
502+}
503+
468 // ListBackups returns backups globally or for a domain.504 // ListBackups returns backups globally or for a domain.
469 func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {505 func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
470 path := "/backups"506 path := "/backups"
M internal/adapters/in/cli/remote/client_test.go +43 -0
@@ -2,6 +2,7 @@ package remote
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7 "sync/atomic"8 "sync/atomic"
@@ -10,6 +11,8 @@ import (
10 11
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
13 )16 )
14 17
15 func withFastRetry(t *testing.T) {18 func withFastRetry(t *testing.T) {
@@ -108,3 +111,43 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
108 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())111 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
109 require.NoError(t, err)112 require.NoError(t, err)
110 }113 }
114+
115+func TestClientListImages(t *testing.T) {
116+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
117+ require.Equal(t, "/admin/images", r.URL.Path)
118+ require.Equal(t, http.MethodGet, r.Method)
119+ w.Header().Set("Content-Type", "application/json")
120+ _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
121+ }))
122+ defer srv.Close()
123+
124+ client := NewClient(srv.URL)
125+ images, err := client.ListImages(context.Background())
126+ require.NoError(t, err)
127+ require.Len(t, images, 1)
128+ assert.Equal(t, "registry.example.com/app", images[0].Repository)
129+ assert.Equal(t, "latest", images[0].Tag)
130+}
131+
132+func TestClientPruneImages(t *testing.T) {
133+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
134+ require.Equal(t, "/admin/images/prune", r.URL.Path)
135+ require.Equal(t, http.MethodPost, r.Method)
136+
137+ var req dto.ImagePruneRequest
138+ require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
139+ require.NotNil(t, req.KeepLast)
140+ assert.Equal(t, 4, *req.KeepLast)
141+
142+ w.Header().Set("Content-Type", "application/json")
143+ _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
144+ }))
145+ defer srv.Close()
146+
147+ client := NewClient(srv.URL)
148+ resp, err := client.PruneImages(context.Background(), 4)
149+ require.NoError(t, err)
150+ require.NotNil(t, resp)
151+ assert.Equal(t, 2, resp.Runtime.DeletedCount)
152+ assert.Equal(t, 3, resp.Registry.TagsRemoved)
153+}
M internal/adapters/in/cli/root.go +4 -0
@@ -121,6 +121,10 @@ Commands are organized by where they run:
121 backupCmd.GroupID = groupManage121 backupCmd.GroupID = groupManage
122 rootCmd.AddCommand(backupCmd)122 rootCmd.AddCommand(backupCmd)
123 123
124+ imagesCmd := newImagesCmd()
125+ imagesCmd.GroupID = groupManage
126+ rootCmd.AddCommand(imagesCmd)
127+
124 // Client-only commands (no server needed)128 // Client-only commands (no server needed)
125 remotesCmd := newRemotesCmd()129 remotesCmd := newRemotesCmd()
126 remotesCmd.GroupID = groupClient130 remotesCmd.GroupID = groupClient
M internal/adapters/in/http/admin/handler.go +9 -0
@@ -33,6 +33,7 @@ type Handler struct {
33 authSvc in.AuthService33 authSvc in.AuthService
34 containerSvc in.ContainerService34 containerSvc in.ContainerService
35 backupSvc in.BackupService35 backupSvc in.BackupService
36+ imageSvc in.ImageService
36 healthSvc in.HealthService37 healthSvc in.HealthService
37 secretSvc in.SecretService38 secretSvc in.SecretService
38 logSvc in.LogService39 logSvc in.LogService
@@ -132,12 +133,19 @@ func NewHandler(
132 eventBus out.EventPublisher,133 eventBus out.EventPublisher,
133 log zerowrap.Logger,134 log zerowrap.Logger,
134 backupSvc in.BackupService,135 backupSvc in.BackupService,
136+ imageSvcs ...in.ImageService,
135 ) *Handler {137 ) *Handler {
138+ var imageSvc in.ImageService
139+ if len(imageSvcs) > 0 {
140+ imageSvc = imageSvcs[0]
141+ }
142+
136 return &Handler{143 return &Handler{
137 configSvc: configSvc,144 configSvc: configSvc,
138 authSvc: authSvc,145 authSvc: authSvc,
139 containerSvc: containerSvc,146 containerSvc: containerSvc,
140 backupSvc: backupSvc,147 backupSvc: backupSvc,
148+ imageSvc: imageSvc,
141 healthSvc: healthSvc,149 healthSvc: healthSvc,
142 secretSvc: secretSvc,150 secretSvc: secretSvc,
143 logSvc: logSvc,151 logSvc: logSvc,
@@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
207 {"/deploy", h.handleDeploy},215 {"/deploy", h.handleDeploy},
208 {"/restart", h.handleRestart},216 {"/restart", h.handleRestart},
209 {"/tags", h.handleTags},217 {"/tags", h.handleTags},
218+ {"/images", h.handleImages},
210 {"/logs", h.handleLogs},219 {"/logs", h.handleLogs},
211 }220 }
212 for _, route := range prefixRoutes {221 for _, route := range prefixRoutes {
M internal/adapters/in/http/admin/handler_test.go +249 -0
@@ -4,10 +4,12 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "fmt"8 "fmt"
8 "net/http"9 "net/http"
9 "net/http/httptest"10 "net/http/httptest"
10 "testing"11 "testing"
12+ "time"
11 13
12 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
@@ -17,6 +19,25 @@ import (
17 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
18 )20 )
19 21
22+type stubImageService struct {
23+ listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24+ pruneFunc func(context.Context, int) (domain.ImagePruneReport, error)
25+}
26+
27+func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
28+ if s.listImagesFunc == nil {
29+ return nil, nil
30+ }
31+ return s.listImagesFunc(ctx)
32+}
33+
34+func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
35+ if s.pruneFunc == nil {
36+ return domain.ImagePruneReport{}, nil
37+ }
38+ return s.pruneFunc(ctx, keepLast)
39+}
40+
20 func testLogger() zerowrap.Logger {41 func testLogger() zerowrap.Logger {
21 return zerowrap.Default()42 return zerowrap.Default()
22 }43 }
@@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) {
1290 assert.Equal(t, http.StatusOK, rec.Code)1311 assert.Equal(t, http.StatusOK, rec.Code)
1291 assert.Contains(t, rec.Body.String(), "postgres")1312 assert.Contains(t, rec.Body.String(), "postgres")
1292 }1313 }
1314+
1315+func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
1316+ configSvc := inmocks.NewMockConfigService(t)
1317+ authSvc := inmocks.NewMockAuthService(t)
1318+ containerSvc := inmocks.NewMockContainerService(t)
1319+ secretSvc := inmocks.NewMockSecretService(t)
1320+
1321+ createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
1322+ imageSvc := &stubImageService{
1323+ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1324+ return []domain.ImageInfo{
1325+ {
1326+ Repository: "myapp",
1327+ Tag: "latest",
1328+ Size: 1024,
1329+ Created: createdAt,
1330+ ID: "sha256:abc123",
1331+ Dangling: false,
1332+ },
1333+ {
1334+ Repository: "",
1335+ Tag: "",
1336+ Size: 512,
1337+ Created: createdAt,
1338+ ID: "sha256:def456",
1339+ Dangling: true,
1340+ },
1341+ }, nil
1342+ },
1343+ }
1344+
1345+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1346+
1347+ req := httptest.NewRequest("GET", "/admin/images", nil)
1348+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1349+ rec := httptest.NewRecorder()
1350+
1351+ handler.ServeHTTP(rec, req)
1352+
1353+ assert.Equal(t, http.StatusOK, rec.Code)
1354+
1355+ var response struct {
1356+ Images []struct {
1357+ Repository string `json:"repository"`
1358+ Tag string `json:"tag"`
1359+ Size int64 `json:"size"`
1360+ Created time.Time `json:"created"`
1361+ ID string `json:"id"`
1362+ Dangling bool `json:"dangling"`
1363+ } `json:"images"`
1364+ }
1365+
1366+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1367+ assert.Len(t, response.Images, 2)
1368+ assert.Equal(t, "myapp", response.Images[0].Repository)
1369+ assert.Equal(t, "latest", response.Images[0].Tag)
1370+ assert.Equal(t, int64(1024), response.Images[0].Size)
1371+ assert.Equal(t, createdAt, response.Images[0].Created)
1372+ assert.Equal(t, "sha256:abc123", response.Images[0].ID)
1373+ assert.False(t, response.Images[0].Dangling)
1374+ assert.True(t, response.Images[1].Dangling)
1375+}
1376+
1377+func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1378+ configSvc := inmocks.NewMockConfigService(t)
1379+ authSvc := inmocks.NewMockAuthService(t)
1380+ containerSvc := inmocks.NewMockContainerService(t)
1381+ secretSvc := inmocks.NewMockSecretService(t)
1382+
1383+ tests := []struct {
1384+ name string
1385+ body string
1386+ expectedKeepLast int
1387+ }{
1388+ {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast},
1389+ {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
1390+ }
1391+
1392+ for _, tt := range tests {
1393+ t.Run(tt.name, func(t *testing.T) {
1394+ called := false
1395+ imageSvc := &stubImageService{
1396+ pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1397+ called = true
1398+ assert.Equal(t, tt.expectedKeepLast, keepLast)
1399+ return domain.ImagePruneReport{
1400+ Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
1401+ Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
1402+ }, nil
1403+ },
1404+ }
1405+
1406+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1407+
1408+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1409+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1410+ rec := httptest.NewRecorder()
1411+
1412+ handler.ServeHTTP(rec, req)
1413+
1414+ assert.Equal(t, http.StatusOK, rec.Code)
1415+ assert.True(t, called)
1416+ assert.Contains(t, rec.Body.String(), "deleted_count")
1417+ assert.Contains(t, rec.Body.String(), "tags_removed")
1418+ })
1419+ }
1420+}
1421+
1422+func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1423+ t.Run("list failure maps to internal server error", func(t *testing.T) {
1424+ configSvc := inmocks.NewMockConfigService(t)
1425+ authSvc := inmocks.NewMockAuthService(t)
1426+ containerSvc := inmocks.NewMockContainerService(t)
1427+ secretSvc := inmocks.NewMockSecretService(t)
1428+
1429+ imageSvc := &stubImageService{
1430+ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1431+ return nil, errors.New("runtime list failed: confidential details")
1432+ },
1433+ }
1434+
1435+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1436+
1437+ req := httptest.NewRequest("GET", "/admin/images", nil)
1438+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1439+ rec := httptest.NewRecorder()
1440+
1441+ handler.ServeHTTP(rec, req)
1442+
1443+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1444+ assert.Contains(t, rec.Body.String(), "failed to list images")
1445+ assert.NotContains(t, rec.Body.String(), "confidential details")
1446+ })
1447+
1448+ t.Run("prune failure maps to internal server error", func(t *testing.T) {
1449+ configSvc := inmocks.NewMockConfigService(t)
1450+ authSvc := inmocks.NewMockAuthService(t)
1451+ containerSvc := inmocks.NewMockContainerService(t)
1452+ secretSvc := inmocks.NewMockSecretService(t)
1453+
1454+ imageSvc := &stubImageService{
1455+ pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1456+ return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
1457+ },
1458+ }
1459+
1460+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1461+
1462+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
1463+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1464+ rec := httptest.NewRecorder()
1465+
1466+ handler.ServeHTTP(rec, req)
1467+
1468+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1469+ assert.Contains(t, rec.Body.String(), "failed to prune images")
1470+ assert.NotContains(t, rec.Body.String(), "confidential details")
1471+ })
1472+}
1473+
1474+func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1475+ t.Run("rejects negative keep_last", func(t *testing.T) {
1476+ configSvc := inmocks.NewMockConfigService(t)
1477+ authSvc := inmocks.NewMockAuthService(t)
1478+ containerSvc := inmocks.NewMockContainerService(t)
1479+ secretSvc := inmocks.NewMockSecretService(t)
1480+
1481+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1482+
1483+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
1484+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1485+ rec := httptest.NewRecorder()
1486+
1487+ handler.ServeHTTP(rec, req)
1488+
1489+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1490+ assert.Contains(t, rec.Body.String(), "keep_last must be")
1491+ })
1492+
1493+ t.Run("returns service unavailable when image service missing", func(t *testing.T) {
1494+ configSvc := inmocks.NewMockConfigService(t)
1495+ authSvc := inmocks.NewMockAuthService(t)
1496+ containerSvc := inmocks.NewMockContainerService(t)
1497+ secretSvc := inmocks.NewMockSecretService(t)
1498+
1499+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1500+
1501+ req := httptest.NewRequest("GET", "/admin/images", nil)
1502+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1503+ rec := httptest.NewRecorder()
1504+
1505+ handler.ServeHTTP(rec, req)
1506+
1507+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
1508+ assert.Contains(t, rec.Body.String(), "image service not available")
1509+ })
1510+}
1511+
1512+func TestHandler_Images_Authorization(t *testing.T) {
1513+ configSvc := inmocks.NewMockConfigService(t)
1514+ authSvc := inmocks.NewMockAuthService(t)
1515+ containerSvc := inmocks.NewMockContainerService(t)
1516+ secretSvc := inmocks.NewMockSecretService(t)
1517+
1518+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1519+
1520+ t.Run("images list requires status:read", func(t *testing.T) {
1521+ req := httptest.NewRequest("GET", "/admin/images", nil)
1522+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1523+ rec := httptest.NewRecorder()
1524+
1525+ handler.ServeHTTP(rec, req)
1526+
1527+ assert.Equal(t, http.StatusForbidden, rec.Code)
1528+ assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1529+ })
1530+
1531+ t.Run("images prune requires config:write", func(t *testing.T) {
1532+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1533+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1534+ rec := httptest.NewRecorder()
1535+
1536+ handler.ServeHTTP(rec, req)
1537+
1538+ assert.Equal(t, http.StatusForbidden, rec.Code)
1539+ assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1540+ })
1541+}
A internal/adapters/in/http/admin/images.go +117 -0
@@ -0,0 +1,117 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "io"
7+ "net/http"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func toImageResponse(image domain.ImageInfo) dto.Image {
16+ return dto.Image{
17+ Repository: image.Repository,
18+ Tag: image.Tag,
19+ Size: image.Size,
20+ Created: image.Created,
21+ ID: image.ID,
22+ Dangling: image.Dangling,
23+ }
24+}
25+
26+func toImagePruneResponse(report domain.ImagePruneReport) dto.ImagePruneResponse {
27+ return dto.ImagePruneResponse{
28+ Runtime: dto.RuntimePruneResult{
29+ DeletedCount: report.Runtime.DeletedCount,
30+ SpaceReclaimed: report.Runtime.SpaceReclaimed,
31+ },
32+ Registry: dto.RegistryPruneResult{
33+ TagsRemoved: report.Registry.TagsRemoved,
34+ BlobsRemoved: report.Registry.BlobsRemoved,
35+ SpaceReclaimed: report.Registry.SpaceReclaimed,
36+ },
37+ }
38+}
39+
40+func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) {
41+ if path != "/images" && path != "/images/prune" {
42+ h.sendError(w, http.StatusNotFound, "route not found")
43+ return
44+ }
45+
46+ if h.imageSvc == nil {
47+ h.sendError(w, http.StatusServiceUnavailable, "image service not available")
48+ return
49+ }
50+
51+ switch {
52+ case path == "/images" && r.Method == http.MethodGet:
53+ h.handleImagesGet(w, r)
54+ case path == "/images/prune" && r.Method == http.MethodPost:
55+ h.handleImagesPrune(w, r)
56+ default:
57+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
58+ }
59+}
60+
61+func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) {
62+ ctx := r.Context()
63+
64+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
65+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
66+ return
67+ }
68+
69+ images, err := h.imageSvc.ListImages(ctx)
70+ if err != nil {
71+ h.sendError(w, http.StatusInternalServerError, "failed to list images")
72+ return
73+ }
74+
75+ response := make([]dto.Image, 0, len(images))
76+ for _, image := range images {
77+ response = append(response, toImageResponse(image))
78+ }
79+
80+ h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response})
81+}
82+
83+func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
84+ ctx := r.Context()
85+ log := zerowrap.FromCtx(ctx)
86+
87+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
88+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
89+ return
90+ }
91+
92+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
93+ var req dto.ImagePruneRequest
94+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
95+ log.Warn().Err(err).Msg("invalid image prune JSON")
96+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
97+ return
98+ }
99+
100+ keepLast := domain.DefaultImagePruneKeepLast
101+ if req.KeepLast != nil {
102+ if *req.KeepLast < 0 {
103+ h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104+ return
105+ }
106+ keepLast = *req.KeepLast
107+ }
108+
109+ report, err := h.imageSvc.Prune(ctx, keepLast)
110+ if err != nil {
111+ log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed")
112+ h.sendError(w, http.StatusInternalServerError, "failed to prune images")
113+ return
114+ }
115+
116+ h.sendJSON(w, http.StatusOK, toImagePruneResponse(report))
117+}
M internal/adapters/out/docker/runtime.go +102 -7
@@ -10,6 +10,7 @@ import (
10 "errors"10 "errors"
11 "fmt"11 "fmt"
12 "io"12 "io"
13+ "math"
13 "path/filepath"14 "path/filepath"
14 "sort"15 "sort"
15 "strconv"16 "strconv"
@@ -20,6 +21,7 @@ import (
20 "github.com/bnema/zerowrap"21 "github.com/bnema/zerowrap"
21 cerrdefs "github.com/containerd/errdefs"22 cerrdefs "github.com/containerd/errdefs"
22 "github.com/docker/docker/api/types/container"23 "github.com/docker/docker/api/types/container"
24+ "github.com/docker/docker/api/types/filters"
23 "github.com/docker/docker/api/types/image"25 "github.com/docker/docker/api/types/image"
24 "github.com/docker/docker/api/types/network"26 "github.com/docker/docker/api/types/network"
25 "github.com/docker/docker/api/types/registry"27 "github.com/docker/docker/api/types/registry"
@@ -30,6 +32,7 @@ import (
30 32
31 "github.com/bnema/gordon/internal/boundaries/out"33 "github.com/bnema/gordon/internal/boundaries/out"
32 "github.com/bnema/gordon/internal/domain"34 "github.com/bnema/gordon/internal/domain"
35+ runtimepkg "github.com/bnema/gordon/pkg/runtime"
33 )36 )
34 37
35 // Runtime implements the ContainerRuntime interface using Docker API.38 // Runtime implements the ContainerRuntime interface using Docker API.
@@ -347,12 +350,13 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
347 name := strings.TrimPrefix(resp.Name, "/")350 name := strings.TrimPrefix(resp.Name, "/")
348 351
349 return &domain.Container{352 return &domain.Container{
350- ID: resp.ID,353+ ID: resp.ID,
351- Image: resp.Config.Image,354+ Image: resp.Config.Image,
352- Name: name,355+ Name: name,
353- Status: resp.State.Status,356+ Status: resp.State.Status,
354- Ports: ports,357+ ExitCode: resp.State.ExitCode,
355- Labels: resp.Config.Labels,358+ Ports: ports,
359+ Labels: resp.Config.Labels,
356 }, nil360 }, nil
357 }361 }
358 362
@@ -555,6 +559,81 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
555 return result, nil559 return result, nil
556 }560 }
557 561
562+// ListImagesDetailed lists images with metadata.
563+func (r *Runtime) ListImagesDetailed(ctx context.Context) ([]runtimepkg.ImageDetail, error) {
564+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
565+ zerowrap.FieldLayer: "adapter",
566+ zerowrap.FieldAdapter: "docker",
567+ zerowrap.FieldAction: "ListImagesDetailed",
568+ })
569+ log := zerowrap.FromCtx(ctx)
570+
571+ images, err := r.client.ImageList(ctx, image.ListOptions{All: true})
572+ if err != nil {
573+ return nil, log.WrapErr(err, "failed to list detailed images")
574+ }
575+
576+ result := make([]runtimepkg.ImageDetail, 0, len(images))
577+ for _, img := range images {
578+ result = append(result, runtimepkg.ImageDetail{
579+ ID: img.ID,
580+ RepoTags: img.RepoTags,
581+ Size: img.Size,
582+ Created: time.Unix(img.Created, 0),
583+ })
584+ }
585+
586+ return result, nil
587+}
588+
589+// PruneImages prunes unused images and reports reclaimed space.
590+func (r *Runtime) PruneImages(ctx context.Context, danglingOnly bool) (runtimepkg.PruneReport, error) {
591+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
592+ zerowrap.FieldLayer: "adapter",
593+ zerowrap.FieldAdapter: "docker",
594+ zerowrap.FieldAction: "PruneImages",
595+ "dangling_only": danglingOnly,
596+ })
597+ log := zerowrap.FromCtx(ctx)
598+
599+ pruneFilters := filters.NewArgs()
600+ if danglingOnly {
601+ pruneFilters.Add("dangling", "true")
602+ }
603+
604+ pruneResult, err := r.client.ImagesPrune(ctx, pruneFilters)
605+ if err != nil {
606+ return runtimepkg.PruneReport{}, log.WrapErr(err, "failed to prune images")
607+ }
608+
609+ deletedIDs := make([]string, 0, len(pruneResult.ImagesDeleted))
610+ for _, deleted := range pruneResult.ImagesDeleted {
611+ if deleted.Deleted != "" {
612+ deletedIDs = append(deletedIDs, deleted.Deleted)
613+ }
614+ if deleted.Untagged != "" {
615+ deletedIDs = append(deletedIDs, deleted.Untagged)
616+ }
617+ }
618+
619+ spaceReclaimed := pruneResult.SpaceReclaimed
620+ if spaceReclaimed > math.MaxInt64 {
621+ log.Warn().
622+ Uint64("space_reclaimed_bytes", spaceReclaimed).
623+ Int64("space_reclaimed_capped_bytes", math.MaxInt64).
624+ Msg("space reclaimed exceeds int64 max; capping value")
625+ spaceReclaimed = uint64(math.MaxInt64)
626+ }
627+
628+ //nolint:gosec // spaceReclaimed is capped to MaxInt64 above
629+ spaceReclaimedInt := int64(spaceReclaimed)
630+
631+ return runtimepkg.PruneReport{
632+ DeletedIDs: deletedIDs,
633+ SpaceReclaimed: spaceReclaimedInt,
634+ }, nil
635+}
636+
558 // Ping checks if Docker is responsive.637 // Ping checks if Docker is responsive.
559 func (r *Runtime) Ping(ctx context.Context) error {638 func (r *Runtime) Ping(ctx context.Context) error {
560 ctx = zerowrap.CtxWithFields(ctx, map[string]any{639 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -610,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
610 if err != nil {689 if err != nil {
611 return "", false, log.WrapErr(err, "failed to inspect container")690 return "", false, log.WrapErr(err, "failed to inspect container")
612 }691 }
613- if resp.State == nil || resp.State.Health == nil {692+
693+ // Detect configured healthchecks from container config rather than relying on
694+ // State.Health presence. Some runtimes can expose an empty State.Health struct
695+ // even when no healthcheck is configured.
696+ hasHealthcheck := hasConfiguredHealthcheck(resp.Config)
697+ if !hasHealthcheck {
614 return "", false, nil698 return "", false, nil
615 }699 }
700+ if resp.State == nil || resp.State.Health == nil {
701+ return "", true, nil
702+ }
616 703
617 return resp.State.Health.Status, true, nil704 return resp.State.Health.Status, true, nil
618 }705 }
619 706
707+func hasConfiguredHealthcheck(cfg *container.Config) bool {
708+ if cfg == nil || cfg.Healthcheck == nil || len(cfg.Healthcheck.Test) == 0 {
709+ return false
710+ }
711+ // Docker uses ["NONE"] to explicitly disable healthchecks inherited from base images.
712+ return !strings.EqualFold(strings.TrimSpace(cfg.Healthcheck.Test[0]), "NONE")
713+}
714+
620 // GetContainerPort gets the host port for a container's internal port.715 // GetContainerPort gets the host port for a container's internal port.
621 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {716 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
622 ctx = zerowrap.CtxWithFields(ctx, map[string]any{717 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
A internal/adapters/out/docker/runtime_health_status_test.go +77 -0
@@ -0,0 +1,77 @@
1+package docker
2+
3+import (
4+ "context"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestRuntime_GetContainerHealthStatus_ConfiguredHealthcheck(t *testing.T) {
14+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+ assert.Equal(t, http.MethodGet, r.Method)
16+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
17+
18+ w.Header().Set("Content-Type", "application/json")
19+ _, _ = w.Write([]byte(`{
20+ "Id":"test-container",
21+ "State":{"Status":"running","Health":{"Status":"healthy","FailingStreak":0,"Log":[]}},
22+ "Config":{"Healthcheck":{"Test":["CMD-SHELL","curl -f http://localhost/health || exit 1"]}}
23+ }`))
24+ }))
25+ defer server.Close()
26+
27+ runtime := newRuntimeForHTTPServer(t, server)
28+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
29+
30+ require.NoError(t, err)
31+ assert.True(t, hasHealthcheck)
32+ assert.Equal(t, "healthy", status)
33+}
34+
35+func TestRuntime_GetContainerHealthStatus_NoConfiguredHealthcheckWithStateHealthObject(t *testing.T) {
36+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+ assert.Equal(t, http.MethodGet, r.Method)
38+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
39+
40+ w.Header().Set("Content-Type", "application/json")
41+ _, _ = w.Write([]byte(`{
42+ "Id":"test-container",
43+ "State":{"Status":"running","Health":{"Status":"","FailingStreak":0,"Log":null}},
44+ "Config":{"Healthcheck":null}
45+ }`))
46+ }))
47+ defer server.Close()
48+
49+ runtime := newRuntimeForHTTPServer(t, server)
50+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
51+
52+ require.NoError(t, err)
53+ assert.False(t, hasHealthcheck)
54+ assert.Equal(t, "", status)
55+}
56+
57+func TestRuntime_GetContainerHealthStatus_DisabledHealthcheck(t *testing.T) {
58+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+ assert.Equal(t, http.MethodGet, r.Method)
60+ assert.Equal(t, "/v1.41/containers/test-container/json", r.URL.Path)
61+
62+ w.Header().Set("Content-Type", "application/json")
63+ _, _ = w.Write([]byte(`{
64+ "Id":"test-container",
65+ "State":{"Status":"running","Health":{"Status":"starting","FailingStreak":0,"Log":[]}},
66+ "Config":{"Healthcheck":{"Test":["NONE"]}}
67+ }`))
68+ }))
69+ defer server.Close()
70+
71+ runtime := newRuntimeForHTTPServer(t, server)
72+ status, hasHealthcheck, err := runtime.GetContainerHealthStatus(context.Background(), "test-container")
73+
74+ require.NoError(t, err)
75+ assert.False(t, hasHealthcheck)
76+ assert.Equal(t, "", status)
77+}
A internal/adapters/out/docker/runtime_image_cleanup_test.go +190 -0
@@ -0,0 +1,190 @@
1+package docker
2+
3+import (
4+ "context"
5+ "math"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/docker/docker/api/types/filters"
13+ "github.com/docker/docker/client"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+)
17+
18+func TestRuntime_PruneImages_DanglingOnlyFilter(t *testing.T) {
19+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
20+ assert.Equal(t, http.MethodPost, r.Method)
21+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
22+
23+ parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
24+ require.NoError(t, err)
25+ assert.Equal(t, []string{"true"}, parsedFilters.Get("dangling"))
26+
27+ w.Header().Set("Content-Type", "application/json")
28+ _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:abc"},{"Untagged":"example:old"}],"SpaceReclaimed":1234}`))
29+ }))
30+ defer server.Close()
31+
32+ runtime := newRuntimeForHTTPServer(t, server)
33+ report, err := runtime.PruneImages(context.Background(), true)
34+
35+ require.NoError(t, err)
36+ assert.Equal(t, []string{"sha256:abc", "example:old"}, report.DeletedIDs)
37+ assert.EqualValues(t, 1234, report.SpaceReclaimed)
38+}
39+
40+func TestRuntime_PruneImages_FullUnusedHasNoDanglingFilter(t *testing.T) {
41+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42+ assert.Equal(t, http.MethodPost, r.Method)
43+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
44+
45+ parsedFilters, err := filters.FromJSON(r.URL.Query().Get("filters"))
46+ require.NoError(t, err)
47+ assert.Empty(t, parsedFilters.Get("dangling"))
48+
49+ w.Header().Set("Content-Type", "application/json")
50+ _, _ = w.Write([]byte(`{"ImagesDeleted":[{"Deleted":"sha256:def"}],"SpaceReclaimed":5678}`))
51+ }))
52+ defer server.Close()
53+
54+ runtime := newRuntimeForHTTPServer(t, server)
55+ report, err := runtime.PruneImages(context.Background(), false)
56+
57+ require.NoError(t, err)
58+ assert.Equal(t, []string{"sha256:def"}, report.DeletedIDs)
59+ assert.EqualValues(t, 5678, report.SpaceReclaimed)
60+}
61+
62+func TestRuntime_PruneImages_ReturnsErrorOnNon2xxResponse(t *testing.T) {
63+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64+ assert.Equal(t, http.MethodPost, r.Method)
65+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
66+
67+ w.WriteHeader(http.StatusInternalServerError)
68+ _, _ = w.Write([]byte(`{"message":"internal error"}`))
69+ }))
70+ defer server.Close()
71+
72+ runtime := newRuntimeForHTTPServer(t, server)
73+ _, err := runtime.PruneImages(context.Background(), true)
74+
75+ require.Error(t, err)
76+}
77+
78+func TestRuntime_PruneImages_ReturnsErrorOnInvalidPayload(t *testing.T) {
79+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80+ assert.Equal(t, http.MethodPost, r.Method)
81+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
82+
83+ w.Header().Set("Content-Type", "application/json")
84+ _, _ = w.Write([]byte(`not-json`))
85+ }))
86+ defer server.Close()
87+
88+ runtime := newRuntimeForHTTPServer(t, server)
89+ _, err := runtime.PruneImages(context.Background(), true)
90+
91+ require.Error(t, err)
92+}
93+
94+func TestRuntime_PruneImages_CapsSpaceReclaimedOverflow(t *testing.T) {
95+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+ assert.Equal(t, http.MethodPost, r.Method)
97+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
98+
99+ w.Header().Set("Content-Type", "application/json")
100+ _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775808}`))
101+ }))
102+ defer server.Close()
103+
104+ runtime := newRuntimeForHTTPServer(t, server)
105+ report, err := runtime.PruneImages(context.Background(), true)
106+
107+ require.NoError(t, err)
108+ assert.EqualValues(t, math.MaxInt64, report.SpaceReclaimed)
109+}
110+
111+func TestRuntime_ListImagesDetailed_MapsImageSummary(t *testing.T) {
112+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
113+ assert.Equal(t, http.MethodGet, r.Method)
114+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
115+ assert.Equal(t, "1", r.URL.Query().Get("all"))
116+
117+ w.Header().Set("Content-Type", "application/json")
118+ _, _ = w.Write([]byte(`[{"Id":"sha256:img1","RepoTags":["alpine:latest"],"Size":321,"Created":1700000000}]`))
119+ }))
120+ defer server.Close()
121+
122+ runtime := newRuntimeForHTTPServer(t, server)
123+ images, err := runtime.ListImagesDetailed(context.Background())
124+
125+ require.NoError(t, err)
126+ require.Len(t, images, 1)
127+ assert.Equal(t, "sha256:img1", images[0].ID)
128+ assert.Equal(t, []string{"alpine:latest"}, images[0].RepoTags)
129+ assert.EqualValues(t, 321, images[0].Size)
130+ assert.Equal(t, time.Unix(1700000000, 0), images[0].Created)
131+}
132+
133+func TestRuntime_ListImagesDetailed_ReturnsErrorOnNon2xxResponse(t *testing.T) {
134+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
135+ assert.Equal(t, http.MethodGet, r.Method)
136+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
137+
138+ w.WriteHeader(http.StatusBadGateway)
139+ _, _ = w.Write([]byte(`{"message":"upstream error"}`))
140+ }))
141+ defer server.Close()
142+
143+ runtime := newRuntimeForHTTPServer(t, server)
144+ _, err := runtime.ListImagesDetailed(context.Background())
145+
146+ require.Error(t, err)
147+}
148+
149+func TestRuntime_ListImagesDetailed_ReturnsErrorOnInvalidPayload(t *testing.T) {
150+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
151+ assert.Equal(t, http.MethodGet, r.Method)
152+ assert.Equal(t, "/v1.41/images/json", r.URL.Path)
153+
154+ w.Header().Set("Content-Type", "application/json")
155+ _, _ = w.Write([]byte(`{"not":`))
156+ }))
157+ defer server.Close()
158+
159+ runtime := newRuntimeForHTTPServer(t, server)
160+ _, err := runtime.ListImagesDetailed(context.Background())
161+
162+ require.Error(t, err)
163+}
164+
165+func TestRuntime_PruneImages_UsesMaxInt64Boundary(t *testing.T) {
166+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167+ assert.Equal(t, http.MethodPost, r.Method)
168+ assert.Equal(t, "/v1.41/images/prune", r.URL.Path)
169+
170+ w.Header().Set("Content-Type", "application/json")
171+ _, _ = w.Write([]byte(`{"ImagesDeleted":[],"SpaceReclaimed":9223372036854775807}`))
172+ }))
173+ defer server.Close()
174+
175+ runtime := newRuntimeForHTTPServer(t, server)
176+ report, err := runtime.PruneImages(context.Background(), true)
177+
178+ require.NoError(t, err)
179+ assert.EqualValues(t, math.MaxInt64, report.SpaceReclaimed)
180+}
181+
182+func newRuntimeForHTTPServer(t *testing.T, server *httptest.Server) *Runtime {
183+ t.Helper()
184+
185+ host := strings.TrimPrefix(server.URL, "http://")
186+ cli, err := client.NewClientWithOpts(client.WithHost("tcp://"+host), client.WithVersion("1.41"), client.WithHTTPClient(server.Client()))
187+ require.NoError(t, err)
188+
189+ return NewRuntimeWithClient(cli)
190+}
M internal/adapters/out/filesystem/backup_storage.go +13 -0
@@ -27,6 +27,8 @@ type BackupStorage struct {
27 27
28 // NewBackupStorage creates a new filesystem backup storage.28 // NewBackupStorage creates a new filesystem backup storage.
29 func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {29 func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
30+ rootDir = expandTilde(rootDir)
31+
30 if err := os.MkdirAll(rootDir, 0750); err != nil {32 if err := os.MkdirAll(rootDir, 0750); err != nil {
31 return nil, fmt.Errorf("failed to create backup directory: %w", err)33 return nil, fmt.Errorf("failed to create backup directory: %w", err)
32 }34 }
@@ -34,6 +36,17 @@ func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, erro
34 return &BackupStorage{rootDir: rootDir, log: log}, nil36 return &BackupStorage{rootDir: rootDir, log: log}, nil
35 }37 }
36 38
39+// expandTilde replaces a leading "~/" with the user's home directory.
40+func expandTilde(path string) string {
41+ if !strings.HasPrefix(path, "~/") {
42+ return path
43+ }
44+ if home, err := os.UserHomeDir(); err == nil {
45+ return filepath.Join(home, path[2:])
46+ }
47+ return path
48+}
49+
37 // Store saves backup data and returns the absolute storage path.50 // Store saves backup data and returns the absolute storage path.
38 func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) {51 func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) {
39 domainPart := sanitizeBackupPathComponent(domainName)52 domainPart := sanitizeBackupPathComponent(domainName)
M internal/adapters/out/filesystem/blob.go +46 -0
@@ -165,6 +165,52 @@ func (s *BlobStorage) BlobExists(digest string) bool {
165 return err == nil165 return err == nil
166 }166 }
167 167
168+// ListBlobs returns all blob digests.
169+func (s *BlobStorage) ListBlobs() ([]string, error) {
170+ blobsDir := filepath.Join(s.rootDir, "blobs")
171+
172+ if _, err := os.Stat(blobsDir); err != nil {
173+ if os.IsNotExist(err) {
174+ return []string{}, nil
175+ }
176+ return nil, fmt.Errorf("failed to access blobs directory: %w", err)
177+ }
178+
179+ digests := make([]string, 0)
180+ err := filepath.Walk(blobsDir, func(path string, info os.FileInfo, err error) error {
181+ if err != nil {
182+ return err
183+ }
184+
185+ if info.IsDir() {
186+ return nil
187+ }
188+
189+ relPath, err := filepath.Rel(blobsDir, path)
190+ if err != nil {
191+ return err
192+ }
193+
194+ parts := strings.Split(relPath, string(filepath.Separator))
195+ if len(parts) < 2 {
196+ return nil
197+ }
198+
199+ digest := parts[0] + ":" + parts[len(parts)-1]
200+ if err := validation.ValidateDigest(digest); err != nil {
201+ return nil
202+ }
203+
204+ digests = append(digests, digest)
205+ return nil
206+ })
207+ if err != nil {
208+ return nil, fmt.Errorf("failed to list blobs: %w", err)
209+ }
210+
211+ return digests, nil
212+}
213+
168 // StartBlobUpload starts a new blob upload and returns the upload UUID.214 // StartBlobUpload starts a new blob upload and returns the upload UUID.
169 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {215 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
170 uploadID := uuid.New().String()216 uploadID := uuid.New().String()
M internal/adapters/out/filesystem/blob_test.go +33 -0
@@ -211,6 +211,39 @@ func TestBlobStorage_BlobExists(t *testing.T) {
211 assert.True(t, storage.BlobExists(testDigest2))211 assert.True(t, storage.BlobExists(testDigest2))
212 }212 }
213 213
214+func TestBlobStorage_ListBlobs(t *testing.T) {
215+ tmpDir := t.TempDir()
216+ log := testLogger()
217+
218+ storage, err := NewBlobStorage(tmpDir, log)
219+ require.NoError(t, err)
220+
221+ blobData := []byte("test blob content")
222+
223+ err = storage.PutBlob(testDigest1, bytes.NewReader(blobData), int64(len(blobData)))
224+ require.NoError(t, err)
225+
226+ err = storage.PutBlob(testDigest2, bytes.NewReader(blobData), int64(len(blobData)))
227+ require.NoError(t, err)
228+
229+ digests, err := storage.ListBlobs()
230+ require.NoError(t, err)
231+
232+ assert.ElementsMatch(t, []string{testDigest1, testDigest2}, digests)
233+}
234+
235+func TestBlobStorage_ListBlobs_Empty(t *testing.T) {
236+ tmpDir := t.TempDir()
237+ log := testLogger()
238+
239+ storage, err := NewBlobStorage(tmpDir, log)
240+ require.NoError(t, err)
241+
242+ digests, err := storage.ListBlobs()
243+ require.NoError(t, err)
244+ assert.Empty(t, digests)
245+}
246+
214 func TestBlobStorage_StartBlobUpload(t *testing.T) {247 func TestBlobStorage_StartBlobUpload(t *testing.T) {
215 tmpDir := t.TempDir()248 tmpDir := t.TempDir()
216 log := testLogger()249 log := testLogger()
M internal/adapters/out/filesystem/manifest.go +19 -0
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "time"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10 11
@@ -222,6 +223,24 @@ func (s *ManifestStorage) ListRepositories() ([]string, error) {
222 return repositories, nil223 return repositories, nil
223 }224 }
224 225
226+// GetManifestModTime returns the manifest modification time.
227+func (s *ManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
228+ manifestPath, err := s.getManifestPath(name, reference)
229+ if err != nil {
230+ return time.Time{}, fmt.Errorf("invalid path: %w", err)
231+ }
232+
233+ info, err := os.Stat(manifestPath)
234+ if err != nil {
235+ if os.IsNotExist(err) {
236+ return time.Time{}, fmt.Errorf("manifest not found: %s/%s", name, reference)
237+ }
238+ return time.Time{}, fmt.Errorf("failed to stat manifest: %w", err)
239+ }
240+
241+ return info.ModTime(), nil
242+}
243+
225 // Helper methods for path generation with security validation244 // Helper methods for path generation with security validation
226 245
227 func (s *ManifestStorage) getManifestPath(name, reference string) (string, error) {246 func (s *ManifestStorage) getManifestPath(name, reference string) (string, error) {
M internal/adapters/out/filesystem/manifest_test.go +37 -0
@@ -4,6 +4,7 @@ import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -67,6 +68,42 @@ func TestManifestStorage_GetManifest_NotFound(t *testing.T) {
67 assert.Contains(t, err.Error(), "manifest not found")68 assert.Contains(t, err.Error(), "manifest not found")
68 }69 }
69 70
71+func TestManifestStorage_GetManifestModTime(t *testing.T) {
72+ tmpDir := t.TempDir()
73+ log := testLogger()
74+
75+ storage, err := NewManifestStorage(tmpDir, log)
76+ require.NoError(t, err)
77+
78+ manifestData := []byte(`{"schemaVersion": 2}`)
79+ contentType := "application/vnd.docker.distribution.manifest.v2+json"
80+
81+ err = storage.PutManifest("myapp", "latest", contentType, manifestData)
82+ require.NoError(t, err)
83+
84+ modTime, err := storage.GetManifestModTime("myapp", "latest")
85+ require.NoError(t, err)
86+
87+ manifestPath := filepath.Join(tmpDir, "repositories", "myapp", "manifests", "latest")
88+ info, err := os.Stat(manifestPath)
89+ require.NoError(t, err)
90+
91+ assert.WithinDuration(t, info.ModTime(), modTime, time.Second)
92+}
93+
94+func TestManifestStorage_GetManifestModTime_NotFound(t *testing.T) {
95+ tmpDir := t.TempDir()
96+ log := testLogger()
97+
98+ storage, err := NewManifestStorage(tmpDir, log)
99+ require.NoError(t, err)
100+
101+ _, err = storage.GetManifestModTime("notexists", "latest")
102+
103+ assert.Error(t, err)
104+ assert.Contains(t, err.Error(), "manifest not found")
105+}
106+
70 func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) {107 func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) {
71 tmpDir := t.TempDir()108 tmpDir := t.TempDir()
72 log := testLogger()109 log := testLogger()
M internal/app/run.go +209 -14
@@ -58,7 +58,9 @@ import (
58 "github.com/bnema/gordon/internal/usecase/backup"58 "github.com/bnema/gordon/internal/usecase/backup"
59 "github.com/bnema/gordon/internal/usecase/config"59 "github.com/bnema/gordon/internal/usecase/config"
60 "github.com/bnema/gordon/internal/usecase/container"60 "github.com/bnema/gordon/internal/usecase/container"
61+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
61 "github.com/bnema/gordon/internal/usecase/health"62 "github.com/bnema/gordon/internal/usecase/health"
63+ "github.com/bnema/gordon/internal/usecase/images"
62 "github.com/bnema/gordon/internal/usecase/logs"64 "github.com/bnema/gordon/internal/usecase/logs"
63 "github.com/bnema/gordon/internal/usecase/proxy"65 "github.com/bnema/gordon/internal/usecase/proxy"
64 registrySvc "github.com/bnema/gordon/internal/usecase/registry"66 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
@@ -134,6 +136,7 @@ type Config struct {
134 136
135 Backups struct {137 Backups struct {
136 Enabled bool `mapstructure:"enabled"`138 Enabled bool `mapstructure:"enabled"`
139+ Schedule string `mapstructure:"schedule"`
137 StorageDir string `mapstructure:"storage_dir"`140 StorageDir string `mapstructure:"storage_dir"`
138 Retention struct {141 Retention struct {
139 Hourly int `mapstructure:"hourly"`142 Hourly int `mapstructure:"hourly"`
@@ -142,6 +145,14 @@ type Config struct {
142 Monthly int `mapstructure:"monthly"`145 Monthly int `mapstructure:"monthly"`
143 } `mapstructure:"retention"`146 } `mapstructure:"retention"`
144 } `mapstructure:"backups"`147 } `mapstructure:"backups"`
148+
149+ Images struct {
150+ Prune struct {
151+ Enabled bool `mapstructure:"enabled"`
152+ Schedule string `mapstructure:"schedule"`
153+ KeepLast int `mapstructure:"keep_last"`
154+ } `mapstructure:"prune"`
155+ } `mapstructure:"images"`
145 }156 }
146 157
147 // services holds all the services used by the application.158 // services holds all the services used by the application.
@@ -161,6 +172,7 @@ type services struct {
161 registrySvc *registrySvc.Service172 registrySvc *registrySvc.Service
162 healthSvc *health.Service173 healthSvc *health.Service
163 logSvc *logs.Service174 logSvc *logs.Service
175+ imageSvc *images.Service
164 proxySvc *proxy.Service176 proxySvc *proxy.Service
165 authSvc *auth.Service177 authSvc *auth.Service
166 authHandler *authhandler.Handler178 authHandler *authhandler.Handler
@@ -225,7 +237,7 @@ func Run(ctx context.Context, configPath string) error {
225 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)237 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
226 238
227 // Start servers, wait for listeners to bind, then sync/auto-start containers.239 // Start servers, wait for listeners to bind, then sync/auto-start containers.
228- return runServers(ctx, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)240+ return runServers(ctx, v, cfg, registryHandler, proxyHandler, svc.containerSvc, svc.eventBus, svc, log)
229 }241 }
230 242
231 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {243 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -456,6 +468,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
456 }468 }
457 469
458 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)470 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
471+ svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
459 472
460 proxyCfg, err := buildProxyConfig(cfg, log)473 proxyCfg, err := buildProxyConfig(cfg, log)
461 if err != nil {474 if err != nil {
@@ -483,10 +496,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
483 svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)496 svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)
484 497
485 // Create log service for accessing logs via admin API498 // Create log service for accessing logs via admin API
486- svc.logSvc = logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)499+ svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log)
487 500
488 // Create admin handler for admin API501 // Create admin handler for admin API
489- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.healthSvc, svc.secretSvc, svc.logSvc, svc.registrySvc, svc.eventBus, log, svc.backupSvc)502+ svc.adminHandler = admin.NewHandler(
503+ svc.configSvc,
504+ svc.authSvc,
505+ svc.containerSvc,
506+ svc.healthSvc,
507+ svc.secretSvc,
508+ svc.logSvc,
509+ svc.registrySvc,
510+ svc.eventBus,
511+ log,
512+ svc.backupSvc,
513+ svc.imageSvc,
514+ )
490 515
491 return svc, nil516 return svc, nil
492 }517 }
@@ -1247,6 +1272,9 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
1247 log.Warn().Err(err).Msg("failed to auto-start containers")1272 log.Warn().Err(err).Msg("failed to auto-start containers")
1248 }1273 }
1249 }1274 }
1275+
1276+ // Start background monitor to restart crashed containers.
1277+ svc.containerSvc.StartMonitor(ctx)
1250 }1278 }
1251 func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {1279 func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
1252 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {1280 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1475,7 +1503,7 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf
1475 // - SIGUSR2: Triggers manual deploy for a specific route1503 // - SIGUSR2: Triggers manual deploy for a specific route
1476 // The deferred signal.Stop calls ensure signal handlers are properly1504 // The deferred signal.Stop calls ensure signal handlers are properly
1477 // cleaned up before program exit, preventing signal handler leaks.1505 // cleaned up before program exit, preventing signal handler leaks.
1478-func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {1506+func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler, proxyHandler http.Handler, containerSvc *container.Service, eventBus out.EventBus, svc *services, log zerowrap.Logger) error {
1479 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)1507 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
1480 defer cancel()1508 defer cancel()
1481 1509
@@ -1511,22 +1539,16 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1511 )1539 )
1512 }1540 }
1513 1541
1514- // Wait for both servers to bind their ports before auto-starting containers.1542+ // Wait for all enabled servers to bind their ports before auto-starting containers.
1515 // This prevents the race where auto-start pulls from the registry before it's listening.1543 // This prevents the race where auto-start pulls from the registry before it's listening.
1516- select {1544+ if err := waitForServerReady(registryReady, errChan); err != nil {
1517- case <-registryReady:
1518- case err := <-errChan:
1519 return err1545 return err
1520 }1546 }
1521- select {1547+ if err := waitForServerReady(proxyReady, errChan); err != nil {
1522- case <-proxyReady:
1523- case err := <-errChan:
1524 return err1548 return err
1525 }1549 }
1526 if cfg.Server.TLSEnabled {1550 if cfg.Server.TLSEnabled {
1527- select {1551+ if err := waitForServerReady(tlsReady, errChan); err != nil {
1528- case <-tlsReady:
1529- case err := <-errChan:
1530 return err1552 return err
1531 }1553 }
1532 }1554 }
@@ -1539,6 +1561,14 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1539 }1561 }
1540 logEvent.Msg("Gordon is running")1562 logEvent.Msg("Gordon is running")
1541 1563
1564+ schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
1565+ if err != nil {
1566+ return err
1567+ }
1568+ if schedulerCleanup != nil {
1569+ defer schedulerCleanup()
1570+ }
1571+
1542 // Auto-start after servers are listening (registry port is now bound).1572 // Auto-start after servers are listening (registry port is now bound).
1543 syncAndAutoStart(ctx, svc, log)1573 syncAndAutoStart(ctx, svc, log)
1544 1574
@@ -1547,6 +1577,165 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
1547 return nil1577 return nil
1548 }1578 }
1549 1579
1580+func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
1581+ select {
1582+ case <-ready:
1583+ return nil
1584+ case err := <-errChan:
1585+ return err
1586+ }
1587+}
1588+
1589+func startOptionalSchedulers(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, v *viper.Viper) (func(), error) {
1590+ schedulers := make([]*cronSvc.Scheduler, 0, 2)
1591+
1592+ backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log)
1593+ if err != nil {
1594+ return nil, err
1595+ }
1596+ if backupScheduler != nil {
1597+ schedulers = append(schedulers, backupScheduler)
1598+ }
1599+
1600+ imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
1601+ return v.GetInt("images.prune.keep_last")
1602+ })
1603+ if err != nil {
1604+ return nil, err
1605+ }
1606+ if imageScheduler != nil {
1607+ schedulers = append(schedulers, imageScheduler)
1608+ }
1609+
1610+ if len(schedulers) == 0 {
1611+ return nil, nil
1612+ }
1613+
1614+ return func() {
1615+ for i := len(schedulers) - 1; i >= 0; i-- {
1616+ schedulers[i].Stop()
1617+ }
1618+ }, nil
1619+}
1620+
1621+func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) {
1622+ if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil {
1623+ return nil, nil
1624+ }
1625+
1626+ preset, err := resolveBackupSchedule(cfg.Backups.Schedule)
1627+ if err != nil {
1628+ return nil, err
1629+ }
1630+
1631+ scheduler := cronSvc.NewScheduler(log)
1632+ err = scheduler.Add(
1633+ "backup-scheduler",
1634+ "Backups",
1635+ domain.CronSchedule{Preset: preset},
1636+ func(jobCtx context.Context) error {
1637+ if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil {
1638+ return err
1639+ }
1640+ log.Info().
1641+ Str("schedule", string(preset)).
1642+ Msg("scheduled backup run complete")
1643+ return nil
1644+ },
1645+ )
1646+ if err != nil {
1647+ return nil, log.WrapErr(err, "failed to register backup schedule")
1648+ }
1649+
1650+ scheduler.Start(ctx)
1651+ log.Info().
1652+ Str("schedule", string(preset)).
1653+ Msg("backup scheduler enabled")
1654+
1655+ return scheduler, nil
1656+}
1657+
1658+func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) {
1659+ return resolveSchedulePreset(raw, "backups.schedule", domain.ScheduleDaily)
1660+}
1661+
1662+func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
1663+ if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
1664+ return nil, nil
1665+ }
1666+ if keepLastGetter == nil {
1667+ keepLastGetter = func() int { return cfg.Images.Prune.KeepLast }
1668+ }
1669+ if keepLastGetter() < 0 {
1670+ return nil, fmt.Errorf("images.prune.keep_last must be >= 0")
1671+ }
1672+
1673+ preset, err := resolveImagePruneSchedule(cfg.Images.Prune.Schedule)
1674+ if err != nil {
1675+ return nil, err
1676+ }
1677+
1678+ scheduler := cronSvc.NewScheduler(log)
1679+ err = scheduler.Add(
1680+ "image-prune",
1681+ "Image prune",
1682+ domain.CronSchedule{Preset: preset},
1683+ func(jobCtx context.Context) error {
1684+ keepLast := keepLastGetter()
1685+ if keepLast < 0 {
1686+ log.Warn().
1687+ Int("configured_keep_last", keepLast).
1688+ Int("fallback_keep_last", domain.DefaultImagePruneKeepLast).
1689+ Msg("invalid images.prune.keep_last; using default")
1690+ keepLast = domain.DefaultImagePruneKeepLast
1691+ }
1692+
1693+ report, err := svc.imageSvc.Prune(jobCtx, keepLast)
1694+ if err != nil {
1695+ return err
1696+ }
1697+
1698+ log.Info().
1699+ Int("keep_last", keepLast).
1700+ Int("runtime_deleted", report.Runtime.DeletedCount).
1701+ Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1702+ Int("registry_tags_removed", report.Registry.TagsRemoved).
1703+ Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1704+ Msg("scheduled image prune complete")
1705+ return nil
1706+ },
1707+ )
1708+ if err != nil {
1709+ return nil, log.WrapErr(err, "failed to register image prune schedule")
1710+ }
1711+
1712+ scheduler.Start(ctx)
1713+ log.Info().
1714+ Str("schedule", string(preset)).
1715+ Int("keep_last", keepLastGetter()).
1716+ Msg("image prune scheduler enabled")
1717+
1718+ return scheduler, nil
1719+}
1720+
1721+func resolveImagePruneSchedule(raw string) (domain.BackupSchedule, error) {
1722+ return resolveSchedulePreset(raw, "images.prune.schedule", domain.ScheduleDaily)
1723+}
1724+
1725+func resolveSchedulePreset(raw, name string, defaultVal domain.BackupSchedule) (domain.BackupSchedule, error) {
1726+ schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw)))
1727+ if schedule == "" {
1728+ schedule = defaultVal
1729+ }
1730+
1731+ switch schedule {
1732+ case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
1733+ return schedule, nil
1734+ default:
1735+ return "", fmt.Errorf("%s must be one of: hourly, daily, weekly, monthly", name)
1736+ }
1737+}
1738+
1550 // waitForShutdown blocks on the event loop, handling server errors and1739 // waitForShutdown blocks on the event loop, handling server errors and
1551 // Unix signals (reload, deploy, shutdown) until the context is cancelled.1740 // Unix signals (reload, deploy, shutdown) until the context is cancelled.
1552 func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {1741 func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, deployChan <-chan os.Signal, eventBus out.EventBus, log zerowrap.Logger) {
@@ -1593,6 +1782,8 @@ func gracefulShutdown(registrySrv, proxySrv *http.Server, containerSvc *containe
1593 log.Warn().Err(err).Msg("proxy server shutdown error")1782 log.Warn().Err(err).Msg("proxy server shutdown error")
1594 }1783 }
1595 1784
1785+ containerSvc.StopMonitor()
1786+
1596 if err := containerSvc.Shutdown(shutdownCtx); err != nil {1787 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
1597 log.Warn().Err(err).Msg("error during container shutdown")1788 log.Warn().Err(err).Msg("error during container shutdown")
1598 }1789 }
@@ -1961,11 +2152,15 @@ func loadConfig(v *viper.Viper, configPath string) error {
1961 v.SetDefault("volumes.preserve", true)2152 v.SetDefault("volumes.preserve", true)
1962 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)2153 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1963 v.SetDefault("backups.enabled", false)2154 v.SetDefault("backups.enabled", false)
2155+ v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
1964 v.SetDefault("backups.storage_dir", "")2156 v.SetDefault("backups.storage_dir", "")
1965 v.SetDefault("backups.retention.hourly", 0)2157 v.SetDefault("backups.retention.hourly", 0)
1966 v.SetDefault("backups.retention.daily", 0)2158 v.SetDefault("backups.retention.daily", 0)
1967 v.SetDefault("backups.retention.weekly", 0)2159 v.SetDefault("backups.retention.weekly", 0)
1968 v.SetDefault("backups.retention.monthly", 0)2160 v.SetDefault("backups.retention.monthly", 0)
2161+ v.SetDefault("images.prune.enabled", false)
2162+ v.SetDefault("images.prune.schedule", string(domain.ScheduleDaily))
2163+ v.SetDefault("images.prune.keep_last", domain.DefaultImagePruneKeepLast)
1969 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit2164 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1970 v.SetDefault("server.registry_allowed_ips", []string{})2165 v.SetDefault("server.registry_allowed_ips", []string{})
1971 v.SetDefault("deploy.readiness_delay", "5s")2166 v.SetDefault("deploy.readiness_delay", "5s")
M internal/app/run_backup_test.go +40 -0
@@ -3,8 +3,11 @@ package app
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/spf13/viper"
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
8 )11 )
9 12
10 func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {13 func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
@@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
86 assert.Equal(t, 0, retention.Monthly)89 assert.Equal(t, 0, retention.Monthly)
87 })90 })
88 }91 }
92+
93+func TestLoadConfigSetsBackupScheduleDefault(t *testing.T) {
94+ v := viper.New()
95+
96+ require.NoError(t, loadConfig(v, ""))
97+
98+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule"))
99+}
100+
101+func TestResolveBackupSchedule(t *testing.T) {
102+ t.Run("valid values", func(t *testing.T) {
103+ tests := []struct {
104+ input string
105+ want domain.BackupSchedule
106+ }{
107+ {input: "hourly", want: domain.ScheduleHourly},
108+ {input: "daily", want: domain.ScheduleDaily},
109+ {input: "weekly", want: domain.ScheduleWeekly},
110+ {input: "monthly", want: domain.ScheduleMonthly},
111+ {input: " DAILY ", want: domain.ScheduleDaily},
112+ }
113+
114+ for _, tt := range tests {
115+ t.Run(tt.input, func(t *testing.T) {
116+ got, err := resolveBackupSchedule(tt.input)
117+ require.NoError(t, err)
118+ assert.Equal(t, tt.want, got)
119+ })
120+ }
121+ })
122+
123+ t.Run("invalid value", func(t *testing.T) {
124+ _, err := resolveBackupSchedule("every-minute")
125+ require.Error(t, err)
126+ assert.Contains(t, err.Error(), "backups.schedule")
127+ })
128+}
A internal/app/run_images_test.go +50 -0
@@ -0,0 +1,50 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+func TestLoadConfigSetsImagePruneDefaults(t *testing.T) {
14+ v := viper.New()
15+
16+ require.NoError(t, loadConfig(v, ""))
17+
18+ assert.False(t, v.GetBool("images.prune.enabled"))
19+ assert.Equal(t, string(domain.ScheduleDaily), v.GetString("images.prune.schedule"))
20+ assert.Equal(t, domain.DefaultImagePruneKeepLast, v.GetInt("images.prune.keep_last"))
21+}
22+
23+func TestResolveImagePruneSchedule(t *testing.T) {
24+ t.Run("valid values", func(t *testing.T) {
25+ tests := []struct {
26+ input string
27+ want domain.BackupSchedule
28+ }{
29+ {input: "hourly", want: domain.ScheduleHourly},
30+ {input: "daily", want: domain.ScheduleDaily},
31+ {input: "weekly", want: domain.ScheduleWeekly},
32+ {input: "monthly", want: domain.ScheduleMonthly},
33+ {input: " DAILY ", want: domain.ScheduleDaily},
34+ }
35+
36+ for _, tt := range tests {
37+ t.Run(tt.input, func(t *testing.T) {
38+ got, err := resolveImagePruneSchedule(tt.input)
39+ require.NoError(t, err)
40+ assert.Equal(t, tt.want, got)
41+ })
42+ }
43+ })
44+
45+ t.Run("invalid value", func(t *testing.T) {
46+ _, err := resolveImagePruneSchedule("every-minute")
47+ require.Error(t, err)
48+ assert.Contains(t, err.Error(), "images.prune.schedule")
49+ })
50+}
A internal/boundaries/in/images.go +17 -0
@@ -0,0 +1,17 @@
1+// Package in defines input ports (interfaces) for use cases.
2+package in
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// ImageService defines image listing and prune operations.
11+type ImageService interface {
12+ // ListImages returns runtime images.
13+ ListImages(ctx context.Context) ([]domain.ImageInfo, error)
14+
15+ // Prune removes unused images and applies retention.
16+ Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error)
17+}
M internal/boundaries/out/mocks/mock_blob_storage.go +55 -0
@@ -503,6 +503,61 @@ func (_c *MockBlobStorage_GetBlobUpload_Call) RunAndReturn(run func(uuid string)
503 return _c503 return _c
504 }504 }
505 505
506+// ListBlobs provides a mock function for the type MockBlobStorage
507+func (_mock *MockBlobStorage) ListBlobs() ([]string, error) {
508+ ret := _mock.Called()
509+
510+ if len(ret) == 0 {
511+ panic("no return value specified for ListBlobs")
512+ }
513+
514+ var r0 []string
515+ var r1 error
516+ if returnFunc, ok := ret.Get(0).(func() ([]string, error)); ok {
517+ return returnFunc()
518+ }
519+ if returnFunc, ok := ret.Get(0).(func() []string); ok {
520+ r0 = returnFunc()
521+ } else {
522+ if ret.Get(0) != nil {
523+ r0 = ret.Get(0).([]string)
524+ }
525+ }
526+ if returnFunc, ok := ret.Get(1).(func() error); ok {
527+ r1 = returnFunc()
528+ } else {
529+ r1 = ret.Error(1)
530+ }
531+ return r0, r1
532+}
533+
534+// MockBlobStorage_ListBlobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBlobs'
535+type MockBlobStorage_ListBlobs_Call struct {
536+ *mock.Call
537+}
538+
539+// ListBlobs is a helper method to define mock.On call
540+func (_e *MockBlobStorage_Expecter) ListBlobs() *MockBlobStorage_ListBlobs_Call {
541+ return &MockBlobStorage_ListBlobs_Call{Call: _e.mock.On("ListBlobs")}
542+}
543+
544+func (_c *MockBlobStorage_ListBlobs_Call) Run(run func()) *MockBlobStorage_ListBlobs_Call {
545+ _c.Call.Run(func(args mock.Arguments) {
546+ run()
547+ })
548+ return _c
549+}
550+
551+func (_c *MockBlobStorage_ListBlobs_Call) Return(strings []string, err error) *MockBlobStorage_ListBlobs_Call {
552+ _c.Call.Return(strings, err)
553+ return _c
554+}
555+
556+func (_c *MockBlobStorage_ListBlobs_Call) RunAndReturn(run func() ([]string, error)) *MockBlobStorage_ListBlobs_Call {
557+ _c.Call.Return(run)
558+ return _c
559+}
560+
506 // PutBlob provides a mock function for the type MockBlobStorage561 // PutBlob provides a mock function for the type MockBlobStorage
507 func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {562 func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
508 ret := _mock.Called(digest, data, size)563 ret := _mock.Called(digest, data, size)
M internal/boundaries/out/mocks/mock_manifest_storage.go +68 -0
@@ -5,6 +5,8 @@
5 package mocks5 package mocks
6 6
7 import (7 import (
8+ "time"
9+
8 mock "github.com/stretchr/testify/mock"10 mock "github.com/stretchr/testify/mock"
9 )11 )
10 12
@@ -166,6 +168,72 @@ func (_c *MockManifestStorage_GetManifest_Call) RunAndReturn(run func(name strin
166 return _c168 return _c
167 }169 }
168 170
171+// GetManifestModTime provides a mock function for the type MockManifestStorage
172+func (_mock *MockManifestStorage) GetManifestModTime(name string, reference string) (time.Time, error) {
173+ ret := _mock.Called(name, reference)
174+
175+ if len(ret) == 0 {
176+ panic("no return value specified for GetManifestModTime")
177+ }
178+
179+ var r0 time.Time
180+ var r1 error
181+ if returnFunc, ok := ret.Get(0).(func(string, string) (time.Time, error)); ok {
182+ return returnFunc(name, reference)
183+ }
184+ if returnFunc, ok := ret.Get(0).(func(string, string) time.Time); ok {
185+ r0 = returnFunc(name, reference)
186+ } else {
187+ r0 = ret.Get(0).(time.Time)
188+ }
189+ if returnFunc, ok := ret.Get(1).(func(string, string) error); ok {
190+ r1 = returnFunc(name, reference)
191+ } else {
192+ r1 = ret.Error(1)
193+ }
194+ return r0, r1
195+}
196+
197+// MockManifestStorage_GetManifestModTime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetManifestModTime'
198+type MockManifestStorage_GetManifestModTime_Call struct {
199+ *mock.Call
200+}
201+
202+// GetManifestModTime is a helper method to define mock.On call
203+// - name string
204+// - reference string
205+func (_e *MockManifestStorage_Expecter) GetManifestModTime(name interface{}, reference interface{}) *MockManifestStorage_GetManifestModTime_Call {
206+ return &MockManifestStorage_GetManifestModTime_Call{Call: _e.mock.On("GetManifestModTime", name, reference)}
207+}
208+
209+func (_c *MockManifestStorage_GetManifestModTime_Call) Run(run func(name string, reference string)) *MockManifestStorage_GetManifestModTime_Call {
210+ _c.Call.Run(func(args mock.Arguments) {
211+ var arg0 string
212+ if args[0] != nil {
213+ arg0 = args[0].(string)
214+ }
215+ var arg1 string
216+ if args[1] != nil {
217+ arg1 = args[1].(string)
218+ }
219+ run(
220+ arg0,
221+ arg1,
222+ )
223+ })
224+ return _c
225+}
226+
227+func (_c *MockManifestStorage_GetManifestModTime_Call) Return(time1 time.Time, err error) *MockManifestStorage_GetManifestModTime_Call {
228+ _c.Call.Return(time1, err)
229+ return _c
230+}
231+
232+func (_c *MockManifestStorage_GetManifestModTime_Call) RunAndReturn(run func(name string, reference string) (time.Time, error)) *MockManifestStorage_GetManifestModTime_Call {
233+ _c.Call.Return(run)
234+ return _c
235+}
236+
169 // ListRepositories provides a mock function for the type MockManifestStorage237 // ListRepositories provides a mock function for the type MockManifestStorage
170 func (_mock *MockManifestStorage) ListRepositories() ([]string, error) {238 func (_mock *MockManifestStorage) ListRepositories() ([]string, error) {
171 ret := _mock.Called()239 ret := _mock.Called()
M internal/boundaries/out/storage.go +7 -0
@@ -2,6 +2,7 @@ package out
2 2
3 import (3 import (
4 "io"4 "io"
5+ "time"
5 )6 )
6 7
7 // BlobStorage defines the contract for blob storage operations.8 // BlobStorage defines the contract for blob storage operations.
@@ -21,6 +22,9 @@ type BlobStorage interface {
21 // BlobExists checks if a blob exists.22 // BlobExists checks if a blob exists.
22 BlobExists(digest string) bool23 BlobExists(digest string) bool
23 24
25+ // ListBlobs returns all blob digests.
26+ ListBlobs() ([]string, error)
27+
24 // StartBlobUpload starts a new blob upload and returns the upload UUID.28 // StartBlobUpload starts a new blob upload and returns the upload UUID.
25 StartBlobUpload(name string) (string, error)29 StartBlobUpload(name string) (string, error)
26 30
@@ -54,4 +58,7 @@ type ManifestStorage interface {
54 58
55 // ListRepositories returns all repository names.59 // ListRepositories returns all repository names.
56 ListRepositories() ([]string, error)60 ListRepositories() ([]string, error)
61+
62+ // GetManifestModTime returns the manifest modification time.
63+ GetManifestModTime(name, reference string) (time.Time, error)
57 }64 }
M internal/domain/container.go +7 -6
@@ -4,12 +4,13 @@ package domain
4 4
5 // Container represents a running container in the system.5 // Container represents a running container in the system.
6 type Container struct {6 type Container struct {
7- ID string7+ ID string
8- Image string8+ Image string
9- Name string9+ Name string
10- Status string10+ Status string
11- Ports []int11+ ExitCode int
12- Labels map[string]string12+ Ports []int
13+ Labels map[string]string
13 }14 }
14 15
15 // NetworkInfo represents network configuration and state.16 // NetworkInfo represents network configuration and state.
M internal/domain/event.go +17 -0
@@ -81,3 +81,20 @@ func IsInternalDeploy(ctx context.Context) bool {
81 func WithInternalDeploy(ctx context.Context) context.Context {81 func WithInternalDeploy(ctx context.Context) context.Context {
82 return context.WithValue(ctx, ContextKeyInternalDeploy, true)82 return context.WithValue(ctx, ContextKeyInternalDeploy, true)
83 }83 }
84+
85+const (
86+ // ContextKeySkipReadiness indicates that readiness checks should be skipped
87+ // (e.g., during AutoStart where the background monitor handles crash recovery).
88+ ContextKeySkipReadiness contextKey = "skip_readiness"
89+)
90+
91+// WithSkipReadiness returns a context that skips readiness checks on deploy.
92+func WithSkipReadiness(ctx context.Context) context.Context {
93+ return context.WithValue(ctx, ContextKeySkipReadiness, true)
94+}
95+
96+// IsSkipReadiness checks if the context indicates readiness should be skipped.
97+func IsSkipReadiness(ctx context.Context) bool {
98+ v, ok := ctx.Value(ContextKeySkipReadiness).(bool)
99+ return ok && v
100+}
A internal/domain/images.go +46 -0
@@ -0,0 +1,46 @@
1+package domain
2+
3+import "time"
4+
5+const DefaultImagePruneKeepLast = 3
6+
7+// ImagePruneConfig defines scheduled image cleanup behavior.
8+type ImagePruneConfig struct {
9+ Enabled bool
10+ Schedule BackupSchedule
11+ // KeepLast is the number of most-recent tags to retain per repository.
12+ KeepLast int
13+}
14+
15+// RuntimePruneResult reports runtime image cleanup results.
16+type RuntimePruneResult struct {
17+ DeletedCount int
18+ // SpaceReclaimed is reported in bytes.
19+ SpaceReclaimed int64
20+}
21+
22+// RegistryPruneResult reports registry cleanup results.
23+type RegistryPruneResult struct {
24+ TagsRemoved int
25+ BlobsRemoved int
26+ // SpaceReclaimed is reported in bytes.
27+ SpaceReclaimed int64
28+}
29+
30+// ImagePruneReport aggregates runtime and registry cleanup results.
31+type ImagePruneReport struct {
32+ Runtime RuntimePruneResult
33+ Registry RegistryPruneResult
34+}
35+
36+// ImageInfo describes a runtime image.
37+type ImageInfo struct {
38+ Repository string
39+ Tag string
40+ // Size is reported in bytes.
41+ Size int64
42+ Created time.Time
43+ // ID is the runtime image identifier (for example, image ID/digest).
44+ ID string
45+ Dangling bool
46+}
M internal/usecase/backup/detector.go +49 -27
@@ -1,49 +1,71 @@
1 package backup1 package backup
2 2
3 import (3 import (
4+ "slices"
4 "strings"5 "strings"
5 6
6 "github.com/bnema/gordon/internal/domain"7 "github.com/bnema/gordon/internal/domain"
7 )8 )
8 9
10+// postgresAliases contains substrings that identify a PostgreSQL image or service.
11+var postgresAliases = []string{"postgres", "postgresql", "pgsql", "postgis"}
12+
9 // detectDatabaseFromAttachment maps a container attachment to DB metadata.13 // detectDatabaseFromAttachment maps a container attachment to DB metadata.
10 func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) {14 func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) {
11 image := strings.ToLower(a.Image)15 image := strings.ToLower(a.Image)
12 name := strings.ToLower(a.Name)16 name := strings.ToLower(a.Name)
13 17
14 switch {18 switch {
15- case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"):19+ case looksLikePostgres(image) || looksLikePostgres(name):
16- port := 543220+ return buildPostgresInfo(domainName, a), true
17- hasPostgresPort := false21+ case hasPort(a.Ports, 5432):
18- firstPositive := 022+ // Port 5432 fallback: likely PostgreSQL even with non-standard naming.
19- for _, p := range a.Ports {23+ return buildPostgresInfo(domainName, a), true
20- if p == 5432 {
21- port = 5432
22- hasPostgresPort = true
23- break
24- }
25- if p > 0 && firstPositive == 0 {
26- firstPositive = p
27- }
28- }
29- if !hasPostgresPort && firstPositive > 0 {
30- port = firstPositive
31- }
32- return domain.DBInfo{
33- Type: domain.DBTypePostgreSQL,
34- Version: postgresVersionFromImage(a.Image),
35- Domain: domainName,
36- Name: a.Name,
37- Host: a.Name,
38- Port: port,
39- ContainerID: a.ContainerID,
40- ImageName: a.Image,
41- }, true
42 default:24 default:
43 return domain.DBInfo{}, false25 return domain.DBInfo{}, false
44 }26 }
45 }27 }
46 28
29+// looksLikePostgres returns true if s contains any known PostgreSQL alias.
30+func looksLikePostgres(s string) bool {
31+ for _, alias := range postgresAliases {
32+ if strings.Contains(s, alias) {
33+ return true
34+ }
35+ }
36+ return false
37+}
38+
39+// hasPort returns true if the port list contains p.
40+func hasPort(ports []int, p int) bool {
41+ return slices.Contains(ports, p)
42+}
43+
44+// buildPostgresInfo constructs a DBInfo for a PostgreSQL attachment.
45+func buildPostgresInfo(domainName string, a domain.Attachment) domain.DBInfo {
46+ port := 5432
47+ for _, p := range a.Ports {
48+ if p == 5432 {
49+ port = 5432
50+ break
51+ }
52+ if p > 0 && port == 5432 {
53+ port = p
54+ }
55+ }
56+
57+ return domain.DBInfo{
58+ Type: domain.DBTypePostgreSQL,
59+ Version: postgresVersionFromImage(a.Image),
60+ Domain: domainName,
61+ Name: a.Name,
62+ Host: a.Name,
63+ Port: port,
64+ ContainerID: a.ContainerID,
65+ ImageName: a.Image,
66+ }
67+}
68+
47 func postgresVersionFromImage(image string) string {69 func postgresVersionFromImage(image string) string {
48 lastColon := strings.LastIndex(image, ":")70 lastColon := strings.LastIndex(image, ":")
49 if lastColon == -1 {71 if lastColon == -1 {
M internal/usecase/backup/detector_test.go +102 -0
@@ -76,3 +76,105 @@ func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) {
76 })76 })
77 assert.False(t, ok)77 assert.False(t, ok)
78 }78 }
79+
80+func TestDetectDatabaseFromAttachment_PgsqlImage(t *testing.T) {
81+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
82+ Name: "pgsql",
83+ Image: "pgsql:16",
84+ ContainerID: "pgsql-1",
85+ Ports: []int{5432},
86+ })
87+
88+ require.True(t, ok)
89+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
90+ assert.Equal(t, "16", db.Version)
91+ assert.Equal(t, 5432, db.Port)
92+}
93+
94+func TestDetectDatabaseFromAttachment_PgsqlInCompositeName(t *testing.T) {
95+ db, ok := detectDatabaseFromAttachment("mon-fauteuil.fr", domain.Attachment{
96+ Name: "mon-fauteuil-prod-pgsql",
97+ Image: "mon-fauteuil-prod-pgsql:v18",
98+ ContainerID: "pgsql-2",
99+ Ports: []int{5432},
100+ })
101+
102+ require.True(t, ok)
103+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
104+ assert.Equal(t, "", db.Version) // v18 tag starts with non-digit, no version extracted
105+}
106+
107+func TestDetectDatabaseFromAttachment_PostgresqlFullName(t *testing.T) {
108+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
109+ Name: "postgresql",
110+ Image: "postgresql:15",
111+ ContainerID: "pg-full-1",
112+ Ports: []int{5432},
113+ })
114+
115+ require.True(t, ok)
116+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
117+ assert.Equal(t, "15", db.Version)
118+}
119+
120+func TestDetectDatabaseFromAttachment_PostgisImage(t *testing.T) {
121+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
122+ Name: "postgis",
123+ Image: "postgis/postgis:16-3.4",
124+ ContainerID: "postgis-1",
125+ Ports: []int{5432},
126+ })
127+
128+ require.True(t, ok)
129+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
130+ assert.Equal(t, "16", db.Version)
131+}
132+
133+func TestDetectDatabaseFromAttachment_Port5432Fallback(t *testing.T) {
134+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
135+ Name: "custom-db",
136+ Image: "custom-db:latest",
137+ ContainerID: "fallback-1",
138+ Ports: []int{5432},
139+ })
140+
141+ require.True(t, ok, "port 5432 should trigger PostgreSQL fallback detection")
142+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
143+ assert.Equal(t, 5432, db.Port)
144+}
145+
146+func TestDetectDatabaseFromAttachment_UnknownImageNoPostgresPort(t *testing.T) {
147+ _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
148+ Name: "custom-db",
149+ Image: "custom-db:latest",
150+ ContainerID: "unknown-1",
151+ Ports: []int{3306},
152+ })
153+ assert.False(t, ok, "non-postgres port with unknown image should not match")
154+}
155+
156+func TestLooksLikePostgres(t *testing.T) {
157+ tests := []struct {
158+ name string
159+ image string
160+ want bool
161+ }{
162+ {name: "postgres base image", image: "postgres", want: true},
163+ {name: "postgres with version tag", image: "postgres:16", want: true},
164+ {name: "name contains postgres", image: "my-postgres-db", want: true},
165+ {name: "postgresql alias", image: "postgresql", want: true},
166+ {name: "pgsql alias", image: "pgsql", want: true},
167+ {name: "project specific pgsql alias", image: "mon-fauteuil-prod-pgsql", want: true},
168+ {name: "postgis alias", image: "postgis/postgis", want: true},
169+ {name: "mysql negative", image: "mysql", want: false},
170+ {name: "redis negative", image: "redis", want: false},
171+ {name: "custom negative", image: "custom-db", want: false},
172+ {name: "empty negative", image: "", want: false},
173+ }
174+
175+ for _, tc := range tests {
176+ t.Run(tc.name, func(t *testing.T) {
177+ assert.Equal(t, tc.want, looksLikePostgres(tc.image))
178+ })
179+ }
180+}
M internal/usecase/backup/service.go +65 -8
@@ -66,8 +66,54 @@ func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]dom
66 66
67 // RunBackup triggers a logical PostgreSQL backup for a detected DB.67 // RunBackup triggers a logical PostgreSQL backup for a detected DB.
68 func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) {68 func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) {
69- started := time.Now().UTC()69+ return s.runBackup(ctx, domainName, dbName, domain.BackupSchedule(""))
70+}
71+
72+// RunForSchedule executes backups for all detected databases and applies retention.
73+func (s *Service) RunForSchedule(ctx context.Context, schedule domain.BackupSchedule) error {
74+ if !isValidBackupSchedule(schedule) {
75+ return fmt.Errorf("invalid backup schedule: %q", schedule)
76+ }
77+
78+ routes := s.containerSvc.List(ctx)
79+ domainNames := make([]string, 0, len(routes))
80+ for domainName := range routes {
81+ domainNames = append(domainNames, domainName)
82+ }
83+ sort.Strings(domainNames)
84+
85+ var firstErr error
86+ for _, domainName := range domainNames {
87+ dbs, err := s.DetectDatabases(ctx, domainName)
88+ if err != nil {
89+ s.log.Error().Err(err).Str("domain", domainName).Msg("scheduled backup database detection failed")
90+ if firstErr == nil {
91+ firstErr = fmt.Errorf("detect databases for %s: %w", domainName, err)
92+ }
93+ continue
94+ }
95+
96+ for _, db := range dbs {
97+ if _, err := s.runBackupForDB(ctx, domainName, db, schedule); err != nil {
98+ s.log.Error().Err(err).Str("domain", domainName).Str("db", db.Name).Msg("scheduled backup failed")
99+ if firstErr == nil {
100+ firstErr = fmt.Errorf("run backup for %s/%s: %w", domainName, db.Name, err)
101+ }
102+ }
103+ }
104+
105+ if _, err := s.storage.ApplyRetention(ctx, domainName, s.config.Retention); err != nil {
106+ s.log.Error().Err(err).Str("domain", domainName).Msg("scheduled backup retention failed")
107+ if firstErr == nil {
108+ firstErr = fmt.Errorf("apply retention for %s: %w", domainName, err)
109+ }
110+ }
111+ }
70 112
113+ return firstErr
114+}
115+
116+func (s *Service) runBackup(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
71 dbs, err := s.DetectDatabases(ctx, domainName)117 dbs, err := s.DetectDatabases(ctx, domainName)
72 if err != nil {118 if err != nil {
73 return nil, err119 return nil, err
@@ -78,6 +124,12 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
78 return nil, err124 return nil, err
79 }125 }
80 126
127+ return s.runBackupForDB(ctx, domainName, db, schedule)
128+}
129+
130+func (s *Service) runBackupForDB(ctx context.Context, domainName string, db domain.DBInfo, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
131+ started := time.Now().UTC()
132+
81 if db.Type != domain.DBTypePostgreSQL {133 if db.Type != domain.DBTypePostgreSQL {
82 return nil, fmt.Errorf("unsupported database type: %s", db.Type)134 return nil, fmt.Errorf("unsupported database type: %s", db.Type)
83 }135 }
@@ -108,7 +160,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
108 defer dumpStream.Close()160 defer dumpStream.Close()
109 161
110 counter := &byteCounter{}162 counter := &byteCounter{}
111- path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, io.TeeReader(dumpStream, counter))163+ path, err := s.storage.Store(ctx, domainName, db.Name, schedule, started, io.TeeReader(dumpStream, counter))
112 if err != nil {164 if err != nil {
113 return nil, err165 return nil, err
114 }166 }
@@ -131,6 +183,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
131 }, nil183 }, nil
132 }184 }
133 185
186+func isValidBackupSchedule(schedule domain.BackupSchedule) bool {
187+ switch schedule {
188+ case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly:
189+ return true
190+ default:
191+ return false
192+ }
193+}
194+
134 // Restore restores a backup by ID.195 // Restore restores a backup by ID.
135 func (s *Service) Restore(context.Context, string, string) error {196 func (s *Service) Restore(context.Context, string, string) error {
136 return fmt.Errorf("backup restore not implemented yet")197 return fmt.Errorf("backup restore not implemented yet")
@@ -230,18 +291,14 @@ func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error
230 return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested)291 return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested)
231 }292 }
232 293
233-func postgresDumpCommand(dbName string) string {294+func postgresDumpCommand(_ string) string {
234- return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName))295+ return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\""
235 }296 }
236 297
237 func pgDumpToPathCommand(path, dbName string) string {298 func pgDumpToPathCommand(path, dbName string) string {
238 return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path)299 return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path)
239 }300 }
240 301
241-func shellQuote(value string) string {
242- return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
243-}
244-
245 func (s *Service) cleanupDumpFile(containerID, dumpPath string) {302 func (s *Service) cleanupDumpFile(containerID, dumpPath string) {
246 cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)303 cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
247 defer cancel()304 defer cancel()
M internal/usecase/backup/service_test.go +61 -4
@@ -50,7 +50,7 @@ func TestService_RunBackup_Postgres(t *testing.T) {
50 return false50 return false
51 }51 }
52 return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) &&52 return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) &&
53- bytes.Contains([]byte(cmd[2]), []byte("PGDATABASE='postgres'")) &&53+ bytes.Contains([]byte(cmd[2]), []byte("${POSTGRES_DB:-postgres}")) &&
54 bytes.Contains([]byte(cmd[2]), []byte(" > "))54 bytes.Contains([]byte(cmd[2]), []byte(" > "))
55 })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)55 })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
56 56
@@ -125,9 +125,10 @@ func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) {
125 assert.Equal(t, "postgres", db.Name)125 assert.Equal(t, "postgres", db.Name)
126 }126 }
127 127
128-func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) {128+func TestPostgresDumpCommandUsesEnvVar(t *testing.T) {
129- cmd := postgresDumpCommand("customer data")129+ cmd := postgresDumpCommand("ignored")
130- assert.Contains(t, cmd, "PGDATABASE='customer data'")130+ assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}")
131+ assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}")
131 }132 }
132 133
133 func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {134 func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {
@@ -177,3 +178,59 @@ func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testi
177 t.Fatal("Status did not return after context cancellation while waiting for semaphore")178 t.Fatal("Status did not return after context cancellation while waiting for semaphore")
178 }179 }
179 }180 }
181+
182+func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) {
183+ runtime := outmocks.NewMockContainerRuntime(t)
184+ storage := outmocks.NewMockBackupStorage(t)
185+ containerSvc := inmocks.NewMockContainerService(t)
186+
187+ containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{
188+ "app.example.com": {},
189+ })
190+ containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{
191+ {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"},
192+ })
193+
194+ runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
195+ if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" {
196+ return false
197+ }
198+ return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc"))
199+ })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
200+
201+ runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool {
202+ return path != ""
203+ })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil)
204+
205+ runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool {
206+ return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f"))
207+ })).Return(&outiface.ExecResult{ExitCode: 0}, nil)
208+
209+ storage.EXPECT().Store(
210+ mock.Anything,
211+ "app.example.com",
212+ "postgres",
213+ domain.ScheduleDaily,
214+ mock.Anything,
215+ mock.Anything,
216+ ).Return("/tmp/daily-backup.bak", nil)
217+
218+ storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil)
219+
220+ svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default())
221+
222+ err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily)
223+ require.NoError(t, err)
224+}
225+
226+func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) {
227+ runtime := outmocks.NewMockContainerRuntime(t)
228+ storage := outmocks.NewMockBackupStorage(t)
229+ containerSvc := inmocks.NewMockContainerService(t)
230+
231+ svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default())
232+
233+ err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute"))
234+ require.Error(t, err)
235+ assert.Contains(t, err.Error(), "invalid backup schedule")
236+}
A internal/usecase/container/monitor.go +239 -0
@@ -0,0 +1,239 @@
1+package container
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+const (
14+ monitorDefaultInterval = 15 * time.Second
15+ crashLoopThreshold = 3
16+ crashLoopWindow = 5 * time.Minute
17+ backoffCap = 15 * time.Minute
18+ stableRunningDuration = 5 * time.Minute
19+)
20+
21+// restartRecord tracks restart attempts for crash loop detection.
22+type restartRecord struct {
23+ attempts []time.Time
24+ backoffEnd time.Time
25+ consecutive int
26+ lastSeen time.Time // last time the container was seen running
27+}
28+
29+// Monitor watches tracked containers and restarts crashed ones.
30+type Monitor struct {
31+ service *Service
32+ stopCh chan struct{}
33+ stopped chan struct{}
34+ interval time.Duration
35+ mu sync.Mutex
36+ history map[string]*restartRecord // keyed by domain
37+}
38+
39+// newMonitor creates a new container monitor.
40+func newMonitor(service *Service) *Monitor {
41+ return &Monitor{
42+ service: service,
43+ stopCh: make(chan struct{}),
44+ stopped: make(chan struct{}),
45+ interval: monitorDefaultInterval,
46+ history: make(map[string]*restartRecord),
47+ }
48+}
49+
50+// Start begins the background monitoring loop.
51+func (m *Monitor) Start(ctx context.Context) {
52+ log := zerowrap.FromCtx(ctx)
53+ log.Info().Dur("interval", m.interval).Msg("container monitor started")
54+
55+ go m.run(ctx)
56+}
57+
58+// Stop signals the monitor to stop and waits for it to finish.
59+func (m *Monitor) Stop() {
60+ close(m.stopCh)
61+ <-m.stopped
62+}
63+
64+func (m *Monitor) run(ctx context.Context) {
65+ defer close(m.stopped)
66+
67+ ticker := time.NewTicker(m.interval)
68+ defer ticker.Stop()
69+
70+ for {
71+ select {
72+ case <-m.stopCh:
73+ return
74+ case <-ctx.Done():
75+ return
76+ case <-ticker.C:
77+ m.check(ctx)
78+ }
79+ }
80+}
81+
82+func (m *Monitor) check(ctx context.Context) {
83+ log := zerowrap.FromCtx(ctx)
84+
85+ // Snapshot tracked containers under lock.
86+ m.service.mu.RLock()
87+ snapshot := make(map[string]*domain.Container, len(m.service.containers))
88+ for d, c := range m.service.containers {
89+ snapshot[d] = c
90+ }
91+ m.service.mu.RUnlock()
92+
93+ now := time.Now()
94+
95+ for domainName, tracked := range snapshot {
96+ m.checkContainer(ctx, log, domainName, tracked, now)
97+ }
98+}
99+
100+func (m *Monitor) checkContainer(ctx context.Context, log zerowrap.Logger, domainName string, tracked *domain.Container, now time.Time) {
101+ if tracked == nil {
102+ return
103+ }
104+
105+ inspected, err := m.service.runtime.InspectContainer(ctx, tracked.ID)
106+ if err != nil {
107+ log.Debug().Err(err).Str("domain", domainName).Str("container_id", tracked.ID).
108+ Msg("monitor: failed to inspect container, may have been replaced by deploy")
109+ return
110+ }
111+
112+ switch {
113+ case inspected.Status == string(domain.ContainerStatusRunning):
114+ m.handleRunning(ctx, log, domainName, tracked.ID, now)
115+
116+ case inspected.Status == string(domain.ContainerStatusExited):
117+ if inspected.ExitCode == 0 {
118+ // Graceful stop (exit 0): respect user intent, don't restart.
119+ log.Debug().Str("domain", domainName).Msg("monitor: container exited gracefully (code 0), skipping")
120+ return
121+ }
122+ // Crashed (non-zero exit): restart if not in backoff.
123+ m.handleCrash(ctx, log, domainName, tracked.ID, inspected.ExitCode, now)
124+ }
125+}
126+
127+func (m *Monitor) handleRunning(ctx context.Context, log zerowrap.Logger, domainName, containerID string, now time.Time) {
128+ // Check Docker health status for running containers.
129+ healthStatus, hasHealthcheck, err := m.service.runtime.GetContainerHealthStatus(ctx, containerID)
130+ if err != nil {
131+ return
132+ }
133+
134+ if hasHealthcheck && healthStatus == "unhealthy" {
135+ log.Warn().Str("domain", domainName).Msg("monitor: container is unhealthy, restarting")
136+ if err := m.service.runtime.RestartContainer(ctx, containerID); err != nil {
137+ log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart unhealthy container")
138+ }
139+ return
140+ }
141+
142+ // Container is running and healthy — clear backoff if stable long enough.
143+ m.mu.Lock()
144+ rec, exists := m.history[domainName]
145+ if exists {
146+ if rec.lastSeen.IsZero() {
147+ rec.lastSeen = now
148+ }
149+ if now.Sub(rec.lastSeen) >= stableRunningDuration {
150+ // Container has been running for 5+ min — clear crash history.
151+ delete(m.history, domainName)
152+ m.mu.Unlock()
153+ log.Info().Str("domain", domainName).Msg("monitor: container stable, cleared crash history")
154+ return
155+ }
156+ }
157+ m.mu.Unlock()
158+}
159+
160+func (m *Monitor) handleCrash(ctx context.Context, log zerowrap.Logger, domainName, containerID string, exitCode int, now time.Time) {
161+ m.mu.Lock()
162+ rec := m.history[domainName]
163+ if rec == nil {
164+ rec = &restartRecord{}
165+ m.history[domainName] = rec
166+ }
167+
168+ // Check backoff.
169+ if now.Before(rec.backoffEnd) {
170+ m.mu.Unlock()
171+ log.Debug().Str("domain", domainName).
172+ Time("backoff_until", rec.backoffEnd).
173+ Msg("monitor: in crash loop backoff, skipping restart")
174+ return
175+ }
176+
177+ // Record this crash attempt.
178+ rec.attempts = append(rec.attempts, now)
179+ rec.consecutive++
180+ rec.lastSeen = time.Time{} // reset stable timer
181+
182+ // Prune old attempts outside the crash loop window.
183+ cutoff := now.Add(-crashLoopWindow)
184+ pruned := rec.attempts[:0]
185+ for _, t := range rec.attempts {
186+ if t.After(cutoff) {
187+ pruned = append(pruned, t)
188+ }
189+ }
190+ rec.attempts = pruned
191+
192+ // Check for crash loop.
193+ if len(rec.attempts) >= crashLoopThreshold {
194+ backoff := m.computeBackoff(rec.consecutive)
195+ rec.backoffEnd = now.Add(backoff)
196+ m.mu.Unlock()
197+
198+ log.Error().Str("domain", domainName).
199+ Int("consecutive", rec.consecutive).
200+ Dur("backoff", backoff).
201+ Msg("monitor: crash loop detected, backing off")
202+ return
203+ }
204+ m.mu.Unlock()
205+
206+ // Verify container ID still matches tracked state (deploy may have replaced it).
207+ m.service.mu.RLock()
208+ current := m.service.containers[domainName]
209+ m.service.mu.RUnlock()
210+ if current == nil || current.ID != containerID {
211+ log.Debug().Str("domain", domainName).Msg("monitor: container replaced by deploy, skipping restart")
212+ return
213+ }
214+
215+ log.Warn().Str("domain", domainName).Str("container_id", containerID).
216+ Int("exit_code", exitCode).
217+ Msg("monitor: restarting crashed container")
218+
219+ if err := m.service.runtime.StartContainer(ctx, containerID); err != nil {
220+ log.Warn().Err(err).Str("domain", domainName).Msg("monitor: failed to restart container")
221+ }
222+}
223+
224+func (m *Monitor) computeBackoff(consecutive int) time.Duration {
225+ // Exponential backoff: 1min, 2min, 4min, 8min, cap 15min.
226+ base := time.Minute
227+ shift := consecutive - crashLoopThreshold
228+ if shift < 0 {
229+ shift = 0
230+ }
231+ if shift > 4 {
232+ shift = 4 // cap shift to avoid overflow (max 16min, then capped to backoffCap)
233+ }
234+ backoff := base << uint(shift) // #nosec G115 -- shift is bounded [0,4]
235+ if backoff > backoffCap {
236+ backoff = backoffCap
237+ }
238+ return backoff
239+}
A internal/usecase/container/monitor_test.go +324 -0
@@ -0,0 +1,324 @@
1+package container
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zerowrap"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+
12+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
13+ "github.com/bnema/gordon/internal/domain"
14+)
15+
16+func newTestService(runtime *mocks.MockContainerRuntime) *Service {
17+ return &Service{
18+ runtime: runtime,
19+ config: Config{},
20+ containers: make(map[string]*domain.Container),
21+ attachments: make(map[string][]string),
22+ }
23+}
24+
25+func monitorTestContext() context.Context {
26+ return zerowrap.WithCtx(context.Background(), zerowrap.Default())
27+}
28+
29+func TestMonitor_RestartsCrashedContainer(t *testing.T) {
30+ runtime := mocks.NewMockContainerRuntime(t)
31+ svc := newTestService(runtime)
32+
33+ // Track a container.
34+ svc.containers["app.example.com"] = &domain.Container{
35+ ID: "ctr-1",
36+ Image: "myapp:latest",
37+ }
38+
39+ // Inspect returns exited with non-zero exit code.
40+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
41+ ID: "ctr-1",
42+ Status: string(domain.ContainerStatusExited),
43+ ExitCode: 1,
44+ }, nil)
45+
46+ // Monitor should restart it.
47+ runtime.EXPECT().StartContainer(mock.Anything, "ctr-1").Return(nil)
48+
49+ m := newMonitor(svc)
50+ m.check(monitorTestContext())
51+
52+ runtime.AssertExpectations(t)
53+}
54+
55+func TestMonitor_SkipsGracefulExit(t *testing.T) {
56+ runtime := mocks.NewMockContainerRuntime(t)
57+ svc := newTestService(runtime)
58+
59+ svc.containers["app.example.com"] = &domain.Container{
60+ ID: "ctr-1",
61+ Image: "myapp:latest",
62+ }
63+
64+ // Exited with code 0 — graceful stop.
65+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
66+ ID: "ctr-1",
67+ Status: string(domain.ContainerStatusExited),
68+ ExitCode: 0,
69+ }, nil)
70+
71+ // Should NOT call StartContainer.
72+
73+ m := newMonitor(svc)
74+ m.check(monitorTestContext())
75+
76+ runtime.AssertExpectations(t)
77+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
78+}
79+
80+func TestMonitor_SkipsRunningContainer(t *testing.T) {
81+ runtime := mocks.NewMockContainerRuntime(t)
82+ svc := newTestService(runtime)
83+
84+ svc.containers["app.example.com"] = &domain.Container{
85+ ID: "ctr-1",
86+ Image: "myapp:latest",
87+ }
88+
89+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
90+ ID: "ctr-1",
91+ Status: string(domain.ContainerStatusRunning),
92+ }, nil)
93+
94+ // Running container — check health status.
95+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil)
96+
97+ m := newMonitor(svc)
98+ m.check(monitorTestContext())
99+
100+ runtime.AssertExpectations(t)
101+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
102+}
103+
104+func TestMonitor_RestartsUnhealthyContainer(t *testing.T) {
105+ runtime := mocks.NewMockContainerRuntime(t)
106+ svc := newTestService(runtime)
107+
108+ svc.containers["app.example.com"] = &domain.Container{
109+ ID: "ctr-1",
110+ Image: "myapp:latest",
111+ }
112+
113+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
114+ ID: "ctr-1",
115+ Status: string(domain.ContainerStatusRunning),
116+ }, nil)
117+
118+ // Running but Docker health says unhealthy.
119+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("unhealthy", true, nil)
120+
121+ // Should restart the unhealthy container.
122+ runtime.EXPECT().RestartContainer(mock.Anything, "ctr-1").Return(nil)
123+
124+ m := newMonitor(svc)
125+ m.check(monitorTestContext())
126+
127+ runtime.AssertExpectations(t)
128+}
129+
130+func TestMonitor_CrashLoopBackoff(t *testing.T) {
131+ runtime := mocks.NewMockContainerRuntime(t)
132+ svc := newTestService(runtime)
133+
134+ svc.containers["app.example.com"] = &domain.Container{
135+ ID: "ctr-1",
136+ Image: "myapp:latest",
137+ }
138+
139+ m := newMonitor(svc)
140+ ctx := monitorTestContext()
141+ now := time.Now()
142+
143+ // Simulate 3 crashes within the crash loop window.
144+ m.mu.Lock()
145+ m.history["app.example.com"] = &restartRecord{
146+ attempts: []time.Time{now.Add(-2 * time.Minute), now.Add(-1 * time.Minute)},
147+ consecutive: 2,
148+ }
149+ m.mu.Unlock()
150+
151+ // Third crash — this should trigger backoff.
152+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
153+ ID: "ctr-1",
154+ Status: string(domain.ContainerStatusExited),
155+ ExitCode: 137,
156+ }, nil)
157+
158+ // Should NOT call StartContainer because crash loop is detected.
159+ m.check(ctx)
160+
161+ runtime.AssertExpectations(t)
162+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
163+
164+ // Verify backoff was set.
165+ m.mu.Lock()
166+ rec := m.history["app.example.com"]
167+ assert.False(t, rec.backoffEnd.IsZero(), "backoff should be set after crash loop detection")
168+ assert.True(t, rec.backoffEnd.After(time.Now()), "backoff end should be in the future")
169+ m.mu.Unlock()
170+}
171+
172+func TestMonitor_BackoffPreventsRestart(t *testing.T) {
173+ runtime := mocks.NewMockContainerRuntime(t)
174+ svc := newTestService(runtime)
175+
176+ svc.containers["app.example.com"] = &domain.Container{
177+ ID: "ctr-1",
178+ Image: "myapp:latest",
179+ }
180+
181+ m := newMonitor(svc)
182+
183+ // Set backoff into the future.
184+ m.mu.Lock()
185+ m.history["app.example.com"] = &restartRecord{
186+ backoffEnd: time.Now().Add(5 * time.Minute),
187+ consecutive: 3,
188+ }
189+ m.mu.Unlock()
190+
191+ // Container is crashed.
192+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
193+ ID: "ctr-1",
194+ Status: string(domain.ContainerStatusExited),
195+ ExitCode: 1,
196+ }, nil)
197+
198+ // Should NOT restart — in backoff.
199+ m.check(monitorTestContext())
200+
201+ runtime.AssertExpectations(t)
202+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
203+}
204+
205+func TestMonitor_ClearsCrashHistoryAfterStableRunning(t *testing.T) {
206+ runtime := mocks.NewMockContainerRuntime(t)
207+ svc := newTestService(runtime)
208+
209+ svc.containers["app.example.com"] = &domain.Container{
210+ ID: "ctr-1",
211+ Image: "myapp:latest",
212+ }
213+
214+ m := newMonitor(svc)
215+
216+ // Container has been running for longer than stableRunningDuration.
217+ m.mu.Lock()
218+ m.history["app.example.com"] = &restartRecord{
219+ attempts: []time.Time{time.Now().Add(-10 * time.Minute)},
220+ consecutive: 2,
221+ lastSeen: time.Now().Add(-6 * time.Minute), // 6 min ago > stableRunningDuration (5 min)
222+ }
223+ m.mu.Unlock()
224+
225+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-1").Return(&domain.Container{
226+ ID: "ctr-1",
227+ Status: string(domain.ContainerStatusRunning),
228+ }, nil)
229+
230+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "ctr-1").Return("healthy", true, nil)
231+
232+ m.check(monitorTestContext())
233+
234+ // History should be cleared.
235+ m.mu.Lock()
236+ _, exists := m.history["app.example.com"]
237+ m.mu.Unlock()
238+ assert.False(t, exists, "crash history should be cleared after stable running")
239+
240+ runtime.AssertExpectations(t)
241+}
242+
243+func TestMonitor_SkipsReplacedContainer(t *testing.T) {
244+ runtime := mocks.NewMockContainerRuntime(t)
245+ svc := newTestService(runtime)
246+
247+ // Track the OLD container ID.
248+ svc.containers["app.example.com"] = &domain.Container{
249+ ID: "ctr-NEW",
250+ Image: "myapp:latest",
251+ }
252+
253+ m := newMonitor(svc)
254+
255+ // Monitor snapshot captured "ctr-OLD" but a deploy replaced it with "ctr-NEW".
256+ // We simulate this by directly calling checkContainer with the old ID.
257+ ctx := monitorTestContext()
258+ log := zerowrap.FromCtx(ctx)
259+
260+ // Inspect the old container — it's exited.
261+ runtime.EXPECT().InspectContainer(mock.Anything, "ctr-OLD").Return(&domain.Container{
262+ ID: "ctr-OLD",
263+ Status: string(domain.ContainerStatusExited),
264+ ExitCode: 1,
265+ }, nil)
266+
267+ // Should NOT restart — tracked container has been replaced.
268+ oldTracked := &domain.Container{ID: "ctr-OLD", Image: "myapp:latest"}
269+ m.checkContainer(ctx, log, "app.example.com", oldTracked, time.Now())
270+
271+ runtime.AssertExpectations(t)
272+ runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
273+}
274+
275+func TestMonitor_ComputeBackoff(t *testing.T) {
276+ m := &Monitor{}
277+
278+ tests := []struct {
279+ consecutive int
280+ expected time.Duration
281+ }{
282+ {3, 1 * time.Minute}, // first backoff
283+ {4, 2 * time.Minute}, // second
284+ {5, 4 * time.Minute}, // third
285+ {6, 8 * time.Minute}, // fourth
286+ {7, 15 * time.Minute}, // capped
287+ {10, 15 * time.Minute}, // still capped
288+ }
289+
290+ for _, tt := range tests {
291+ got := m.computeBackoff(tt.consecutive)
292+ assert.Equal(t, tt.expected, got, "consecutive=%d", tt.consecutive)
293+ }
294+}
295+
296+func TestMonitor_StartStop(t *testing.T) {
297+ runtime := mocks.NewMockContainerRuntime(t)
298+ svc := newTestService(runtime)
299+
300+ m := newMonitor(svc)
301+ m.interval = 50 * time.Millisecond // fast ticking for test
302+
303+ ctx, cancel := context.WithCancel(monitorTestContext())
304+ defer cancel()
305+
306+ m.Start(ctx)
307+
308+ // Let it tick a couple times.
309+ time.Sleep(150 * time.Millisecond)
310+
311+ // Stop should return promptly.
312+ done := make(chan struct{})
313+ go func() {
314+ m.Stop()
315+ close(done)
316+ }()
317+
318+ select {
319+ case <-done:
320+ // OK
321+ case <-time.After(2 * time.Second):
322+ t.Fatal("monitor.Stop() did not return within 2 seconds")
323+ }
324+}
M internal/usecase/container/service.go +90 -66
@@ -69,6 +69,7 @@ type Service struct {
69 attachments map[string][]string // ownerDomain → []containerIDs69 attachments map[string][]string // ownerDomain → []containerIDs
70 mu sync.RWMutex70 mu sync.RWMutex
71 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)71 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
72+ monitor *Monitor
72 }73 }
73 74
74 // domainDeployLock is a context-aware mutex using a buffered channel.75 // domainDeployLock is a context-aware mutex using a buffered channel.
@@ -347,9 +348,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
347 s.runtime.RemoveContainer(ctx, newContainer.ID, true)348 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
348 return nil, log.WrapErr(err, "failed to start container")349 return nil, log.WrapErr(err, "failed to start container")
349 }350 }
350- if err := s.waitForReady(ctx, newContainer.ID); err != nil {351+ if !domain.IsSkipReadiness(ctx) {
351- s.cleanupFailedContainer(ctx, newContainer.ID)352+ if err := s.waitForReady(ctx, newContainer.ID); err != nil {
352- return nil, log.WrapErr(err, "container failed readiness check")353+ s.cleanupFailedContainer(ctx, newContainer.ID)
354+ return nil, log.WrapErr(err, "container failed readiness check")
355+ }
353 }356 }
354 357
355 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)358 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
@@ -805,6 +808,8 @@ func (s *Service) SyncContainers(ctx context.Context) error {
805 }808 }
806 809
807 // AutoStart starts containers for the provided routes that aren't running.810 // AutoStart starts containers for the provided routes that aren't running.
811+// It skips readiness checks to avoid blocking boot; the background monitor
812+// handles crash recovery.
808 func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {813 func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
809 ctx = zerowrap.CtxWithFields(ctx, map[string]any{814 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
810 zerowrap.FieldLayer: "usecase",815 zerowrap.FieldLayer: "usecase",
@@ -814,37 +819,67 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
814 819
815 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")820 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
816 821
817- var started, skipped, errors int822+ // Filter out routes that already have a running container.
823+ var pending []domain.Route
824+ skipped := 0
818 for _, route := range routes {825 for _, route := range routes {
819- // Check if container already exists and is running
820 if _, exists := s.Get(ctx, route.Domain); exists {826 if _, exists := s.Get(ctx, route.Domain); exists {
821 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")827 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
822 skipped++828 skipped++
823- continue829+ } else {
830+ pending = append(pending, route)
824 }831 }
832+ }
825 833
826- log.Info().834+ if len(pending) == 0 {
827- Str("domain", route.Domain).835+ log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running")
828- Str("image", route.Image).836+ return nil
829- Msg("auto-starting container for route")837+ }
830 838
831- if _, err := s.Deploy(ctx, route); err != nil {839+ // Deploy all pending routes concurrently with readiness checks skipped.
832- log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")840+ deployCtx := domain.WithSkipReadiness(ctx)
833- errors++841+
834- continue842+ type result struct {
835- }843+ route domain.Route
844+ err error
845+ }
846+ results := make(chan result, len(pending))
847+ sem := make(chan struct{}, 4) // limit concurrency
836 848
837- started++849+ for _, route := range pending {
850+ sem <- struct{}{}
851+ go func(r domain.Route) {
852+ defer func() { <-sem }()
853+
854+ log.Info().Str("domain", r.Domain).Str("image", r.Image).Msg("auto-starting container for route")
855+
856+ if _, err := s.Deploy(deployCtx, r); err != nil {
857+ results <- result{route: r, err: err}
858+ return
859+ }
860+ results <- result{route: r}
861+ }(route)
862+ }
863+
864+ var started, errCount int
865+ for i := 0; i < len(pending); i++ {
866+ res := <-results
867+ if res.err != nil {
868+ log.Warn().Err(res.err).Str("domain", res.route.Domain).Msg("failed to auto-start container")
869+ errCount++
870+ } else {
871+ started++
872+ }
838 }873 }
839 874
840 log.Info().875 log.Info().
841 Int("started", started).876 Int("started", started).
842 Int("skipped", skipped).877 Int("skipped", skipped).
843- Int("errors", errors).878+ Int("errors", errCount).
844 Msg("auto-start completed")879 Msg("auto-start completed")
845 880
846- if errors > 0 {881+ if errCount > 0 {
847- return fmt.Errorf("auto-start completed with %d errors", errors)882+ return fmt.Errorf("auto-start completed with %d errors", errCount)
848 }883 }
849 return nil884 return nil
850 }885 }
@@ -858,9 +893,8 @@ func (s *Service) Shutdown(ctx context.Context) error {
858 log := zerowrap.FromCtx(ctx)893 log := zerowrap.FromCtx(ctx)
859 log.Info().Msg("shutting down container manager...")894 log.Info().Msg("shutting down container manager...")
860 895
861- if err := s.stopAllManagedContainers(ctx); err != nil {896+ // Containers are left running across Gordon restarts.
862- log.WrapErr(err, "failed to stop all containers during shutdown")897+ // SyncContainers + AutoStart will pick them back up on next boot.
863- }
864 898
865 // Close log writer to stop all log collection899 // Close log writer to stop all log collection
866 if s.logWriter != nil {900 if s.logWriter != nil {
@@ -873,6 +907,33 @@ func (s *Service) Shutdown(ctx context.Context) error {
873 return nil907 return nil
874 }908 }
875 909
910+// StartMonitor begins background monitoring of tracked containers.
911+// The monitor restarts crashed containers and detects crash loops.
912+// Safe to call multiple times; subsequent calls are no-ops.
913+func (s *Service) StartMonitor(ctx context.Context) {
914+ s.mu.Lock()
915+ if s.monitor != nil {
916+ s.mu.Unlock()
917+ return
918+ }
919+ s.monitor = newMonitor(s)
920+ m := s.monitor
921+ m.Start(ctx)
922+ s.mu.Unlock()
923+}
924+
925+// StopMonitor stops the background container monitor.
926+// Safe to call multiple times; subsequent calls are no-ops.
927+func (s *Service) StopMonitor() {
928+ s.mu.Lock()
929+ m := s.monitor
930+ s.monitor = nil
931+ s.mu.Unlock()
932+ if m != nil {
933+ m.Stop()
934+ }
935+}
936+
876 // UpdateConfig updates the service configuration.937 // UpdateConfig updates the service configuration.
877 func (s *Service) UpdateConfig(config Config) {938 func (s *Service) UpdateConfig(config Config) {
878 s.mu.Lock()939 s.mu.Lock()
@@ -1720,46 +1781,6 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
1720 return nil1781 return nil
1721 }1782 }
1722 1783
1723-func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1724- log := zerowrap.FromCtx(ctx)
1725-
1726- s.mu.RLock()
1727- containers := make(map[string]*domain.Container, len(s.containers))
1728- maps.Copy(containers, s.containers)
1729- s.mu.RUnlock()
1730-
1731- if len(containers) == 0 {
1732- log.Info().Msg("no managed containers to stop")
1733- return nil
1734- }
1735-
1736- log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1737-
1738- errorCount := 0
1739- for d, c := range containers {
1740- log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1741-
1742- if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1743- log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1744- errorCount++
1745- continue
1746- }
1747-
1748- s.mu.Lock()
1749- delete(s.containers, d)
1750- s.mu.Unlock()
1751-
1752- log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1753- }
1754-
1755- if errorCount > 0 {
1756- return fmt.Errorf("failed to stop %d containers", errorCount)
1757- }
1758-
1759- log.Info().Msg("all managed containers stopped successfully")
1760- return nil
1761-}
1762-
1763 // Utility functions1784 // Utility functions
1764 1785
1765 func normalizeImageRef(image string) string {1786 func normalizeImageRef(image string) string {
@@ -1969,7 +1990,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
1969 waitCtx, cancel := context.WithTimeout(ctx, timeout)1990 waitCtx, cancel := context.WithTimeout(ctx, timeout)
1970 defer cancel()1991 defer cancel()
1971 1992
1972- lastStatus := "starting"1993+ var lastStatus string
1973 for {1994 for {
1974 status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)1995 status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
1975 if err != nil {1996 if err != nil {
@@ -1978,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
1978 if !hasHealthcheck {1999 if !hasHealthcheck {
1979 return errors.New("no healthcheck detected")2000 return errors.New("no healthcheck detected")
1980 }2001 }
2002+ // Treat empty health status as transitional startup. Some runtimes can
2003+ // temporarily report an empty status before first probe results.
2004+ if status == "" {
2005+ status = "starting"
2006+ }
1981 if status == "healthy" {2007 if status == "healthy" {
1982 return nil2008 return nil
1983 }2009 }
1984- if status != "" {2010+ lastStatus = status
1985- lastStatus = status
1986- }
1987 2011
1988 select {2012 select {
1989 case <-time.After(time.Second):2013 case <-time.After(time.Second):
A internal/usecase/container/service_readiness_test.go +47 -0
@@ -0,0 +1,47 @@
1+package container
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+)
13+
14+func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T) {
15+ runtime := mocks.NewMockContainerRuntime(t)
16+ svc := NewService(runtime, nil, nil, nil, Config{
17+ ReadinessMode: "auto",
18+ ReadinessDelay: time.Millisecond,
19+ })
20+
21+ ctx := testContext()
22+ containerID := "container-1"
23+
24+ // Initial running poll
25+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
26+ // Auto mode probes for healthcheck support first
27+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
28+ // Delay-mode fallback verification
29+ runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
30+
31+ err := svc.waitForReady(ctx, containerID)
32+ assert.NoError(t, err)
33+}
34+
35+func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
36+ runtime := mocks.NewMockContainerRuntime(t)
37+ svc := NewService(runtime, nil, nil, nil, Config{})
38+
39+ ctx := testContext()
40+ containerID := "container-1"
41+
42+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", true, nil).Once()
43+
44+ err := svc.waitForHealthy(ctx, containerID, 10*time.Millisecond)
45+ assert.Error(t, err)
46+ assert.True(t, strings.Contains(err.Error(), "last status: starting"))
47+}
M internal/usecase/container/service_test.go +9 -33
@@ -630,35 +630,13 @@ func TestService_Shutdown(t *testing.T) {
630 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}630 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
631 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}631 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
632 632
633- runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)633+ // Shutdown no longer stops containers — they are left running for
634- runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil)634+ // SyncContainers + AutoStart to pick back up on next boot.
635-
636 err := svc.Shutdown(ctx)635 err := svc.Shutdown(ctx)
637 636
638 assert.NoError(t, err)637 assert.NoError(t, err)
639- assert.Empty(t, svc.containers)638+ // Containers remain tracked (not stopped).
640-}639+ assert.Len(t, svc.containers, 2)
641-
642-func TestService_Shutdown_PartialFailure(t *testing.T) {
643- runtime := mocks.NewMockContainerRuntime(t)
644- envLoader := mocks.NewMockEnvLoader(t)
645- eventBus := mocks.NewMockEventPublisher(t)
646-
647- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
648- ctx := testContext()
649-
650- svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
651- svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
652-
653- runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
654- runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed"))
655-
656- err := svc.Shutdown(ctx)
657-
658- // Shutdown logs errors but always returns nil for graceful degradation
659- assert.NoError(t, err)
660- // One container should still be tracked (the one that failed to stop)
661- assert.Len(t, svc.containers, 1)
662 }640 }
663 641
664 func TestService_Restart_NotFound(t *testing.T) {642 func TestService_Restart_NotFound(t *testing.T) {
@@ -1196,14 +1174,14 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1196 {Domain: "app2.example.com", Image: "myapp2:latest"},1174 {Domain: "app2.example.com", Image: "myapp2:latest"},
1197 }1175 }
1198 1176
1199- // Setup mocks for first route deployment1177+ // Setup mocks for route deployments
1200 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)1178 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
1201 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)1179 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
1202 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)1180 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
1203 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)1181 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1204 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)1182 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
1205 1183
1206- // Create and start containers1184+ // Create and start containers — readiness is skipped so no IsContainerRunning calls.
1207 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{1185 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
1208 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",1186 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1209 }, nil).Once()1187 }, nil).Once()
@@ -1211,7 +1189,6 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1211 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",1189 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
1212 }, nil).Once()1190 }, nil).Once()
1213 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)1191 runtime.EXPECT().StartContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Times(2)
1214- runtime.EXPECT().IsContainerRunning(mock.Anything, mock.AnythingOfType("string")).Return(true, nil).Times(4)
1215 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{1192 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
1216 Status: "running",1193 Status: "running",
1217 }, nil).Times(2)1194 }, nil).Times(2)
@@ -1245,7 +1222,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1245 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route1222 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
1246 }1223 }
1247 1224
1248- // Only deploy for app2 (app1 is skipped)1225+ // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
1249 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()1226 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1250 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()1227 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
1251 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()1228 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
@@ -1255,7 +1232,6 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1255 ID: "container-2", Status: "created",1232 ID: "container-2", Status: "created",
1256 }, nil).Once()1233 }, nil).Once()
1257 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()1234 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1258- runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
1259 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{1235 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
1260 Status: "running",1236 Status: "running",
1261 }, nil).Once()1237 }, nil).Once()
@@ -1313,7 +1289,8 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1313 }1289 }
1314 1290
1315 // Key assertion: PullImage should be called with localhost:5000 rewrite,1291 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1316- // NOT the original reg.example.com/myapp:latest1292+ // NOT the original reg.example.com/myapp:latest.
1293+ // Readiness is skipped — no IsContainerRunning calls.
1317 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)1294 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
1318 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)1295 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
1319 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)1296 runtime.EXPECT().TagImage(mock.Anything, "localhost:5000/myapp:latest", "reg.example.com/myapp:latest").Return(nil)
@@ -1325,7 +1302,6 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1325 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",1302 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
1326 }, nil)1303 }, nil)
1327 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)1304 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1328- runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2)
1329 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{1305 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
1330 Status: "running",1306 Status: "running",
1331 }, nil)1307 }, nil)
A internal/usecase/images/service.go +403 -0
@@ -0,0 +1,403 @@
1+// Package images implements the image management use case.
2+package images
3+
4+import (
5+ "context"
6+ "encoding/json"
7+ "sort"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/zerowrap"
12+
13+ "github.com/bnema/gordon/internal/boundaries/out"
14+ "github.com/bnema/gordon/internal/domain"
15+ "github.com/bnema/gordon/pkg/runtime"
16+)
17+
18+// Service implements image list and prune operations.
19+type Service struct {
20+ runtime imageRuntime
21+ manifestStorage out.ManifestStorage
22+ blobStorage out.BlobStorage
23+ log zerowrap.Logger
24+}
25+
26+type imageRuntime interface {
27+ ListImagesDetailed(ctx context.Context) ([]runtime.ImageDetail, error)
28+ PruneImages(ctx context.Context, danglingOnly bool) (runtime.PruneReport, error)
29+}
30+
31+// NewService creates a new images service.
32+func NewService(
33+ rt imageRuntime,
34+ manifestStorage out.ManifestStorage,
35+ blobStorage out.BlobStorage,
36+ log zerowrap.Logger,
37+) *Service {
38+ return &Service{
39+ runtime: rt,
40+ manifestStorage: manifestStorage,
41+ blobStorage: blobStorage,
42+ log: log,
43+ }
44+}
45+
46+// ListImages returns images known by the runtime.
47+func (s *Service) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
48+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
49+ zerowrap.FieldLayer: "usecase",
50+ zerowrap.FieldUseCase: "ListImages",
51+ })
52+ log := zerowrap.FromCtx(ctx)
53+
54+ details, err := s.runtime.ListImagesDetailed(ctx)
55+ if err != nil {
56+ return nil, log.WrapErr(err, "failed to list images")
57+ }
58+
59+ images := make([]domain.ImageInfo, 0, len(details))
60+ for _, detail := range details {
61+ if isDanglingImage(detail.RepoTags) {
62+ images = append(images, domain.ImageInfo{
63+ Repository: "",
64+ Tag: "",
65+ Size: detail.Size,
66+ Created: detail.Created,
67+ ID: detail.ID,
68+ Dangling: true,
69+ })
70+ continue
71+ }
72+
73+ for _, repoTag := range detail.RepoTags {
74+ if repoTag == "" || repoTag == "<none>:<none>" {
75+ continue
76+ }
77+ repository, tag := splitRepoTag(repoTag)
78+ images = append(images, domain.ImageInfo{
79+ Repository: repository,
80+ Tag: tag,
81+ Size: detail.Size,
82+ Created: detail.Created,
83+ ID: detail.ID,
84+ Dangling: false,
85+ })
86+ }
87+ }
88+
89+ return images, nil
90+}
91+
92+// PruneRuntime prunes dangling images from the runtime.
93+func (s *Service) PruneRuntime(ctx context.Context) (domain.ImagePruneReport, error) {
94+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
95+ zerowrap.FieldLayer: "usecase",
96+ zerowrap.FieldUseCase: "PruneRuntime",
97+ })
98+ log := zerowrap.FromCtx(ctx)
99+
100+ pruneReport, err := s.runtime.PruneImages(ctx, true)
101+ if err != nil {
102+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to prune runtime images")
103+ }
104+
105+ return domain.ImagePruneReport{
106+ Runtime: domain.RuntimePruneResult{
107+ DeletedCount: len(pruneReport.DeletedIDs),
108+ SpaceReclaimed: pruneReport.SpaceReclaimed,
109+ },
110+ }, nil
111+}
112+
113+// PruneRegistry applies tag retention and blob garbage collection.
114+// It keeps the most recent keepLast tags from tagInfos and always keeps
115+// the "latest" tag when present, so up to keepLast+1 tags can be retained.
116+func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
117+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
118+ zerowrap.FieldLayer: "usecase",
119+ zerowrap.FieldUseCase: "PruneRegistry",
120+ "keepLast": keepLast,
121+ })
122+ log := zerowrap.FromCtx(ctx)
123+
124+ if keepLast <= 0 {
125+ return domain.ImagePruneReport{}, nil
126+ }
127+
128+ repositories, err := s.manifestStorage.ListRepositories()
129+ if err != nil {
130+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list repositories")
131+ }
132+
133+ referencedDigests := make(map[string]struct{})
134+ report := domain.ImagePruneReport{}
135+
136+ for _, repository := range repositories {
137+ tagInfos, err := s.loadRepositoryTagInfos(repository)
138+ if err != nil {
139+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to load repository tags")
140+ }
141+ if len(tagInfos) == 0 {
142+ continue
143+ }
144+
145+ keptTags := buildKeptTagSet(tagInfos, keepLast)
146+ removed, err := s.deleteUnkeptManifests(repository, tagInfos, keptTags)
147+ if err != nil {
148+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete manifest")
149+ }
150+ report.Registry.TagsRemoved += removed
151+
152+ if err := s.collectKeptTagDigests(log, repository, tagInfos, keptTags, referencedDigests); err != nil {
153+ return domain.ImagePruneReport{}, err
154+ }
155+ }
156+
157+ blobs, err := s.blobStorage.ListBlobs()
158+ if err != nil {
159+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to list blobs")
160+ }
161+
162+ for _, digest := range blobs {
163+ if _, referenced := referencedDigests[digest]; referenced {
164+ continue
165+ }
166+ if err := s.blobStorage.DeleteBlob(digest); err != nil {
167+ return domain.ImagePruneReport{}, log.WrapErr(err, "failed to delete blob")
168+ }
169+ report.Registry.BlobsRemoved++
170+ }
171+
172+ return report, nil
173+}
174+
175+func (s *Service) loadRepositoryTagInfos(repository string) ([]registryTag, error) {
176+ tags, err := s.manifestStorage.ListTags(repository)
177+ if err != nil {
178+ return nil, err
179+ }
180+
181+ tagInfos := make([]registryTag, 0, len(tags))
182+ for _, tag := range tags {
183+ modTime, err := s.manifestStorage.GetManifestModTime(repository, tag)
184+ if err != nil {
185+ return nil, err
186+ }
187+ tagInfos = append(tagInfos, registryTag{name: tag, modTime: modTime})
188+ }
189+
190+ sort.Slice(tagInfos, func(i, j int) bool {
191+ if tagInfos[i].modTime.Equal(tagInfos[j].modTime) {
192+ return tagInfos[i].name > tagInfos[j].name
193+ }
194+ return tagInfos[i].modTime.After(tagInfos[j].modTime)
195+ })
196+
197+ return tagInfos, nil
198+}
199+
200+func buildKeptTagSet(tagInfos []registryTag, keepLast int) map[string]struct{} {
201+ keptTags := make(map[string]struct{})
202+ if containsTag(tagInfos, "latest") {
203+ keptTags["latest"] = struct{}{}
204+ }
205+
206+ for i := 0; i < len(tagInfos) && i < keepLast; i++ {
207+ keptTags[tagInfos[i].name] = struct{}{}
208+ }
209+
210+ return keptTags
211+}
212+
213+func (s *Service) deleteUnkeptManifests(repository string, tagInfos []registryTag, keptTags map[string]struct{}) (int, error) {
214+ removed := 0
215+ for _, tag := range tagInfos {
216+ if _, kept := keptTags[tag.name]; kept {
217+ continue
218+ }
219+ if err := s.manifestStorage.DeleteManifest(repository, tag.name); err != nil {
220+ return 0, err
221+ }
222+ removed++
223+ }
224+
225+ return removed, nil
226+}
227+
228+func (s *Service) collectKeptTagDigests(
229+ log zerowrap.Logger,
230+ repository string,
231+ tagInfos []registryTag,
232+ keptTags map[string]struct{},
233+ referencedDigests map[string]struct{},
234+) error {
235+ for _, tag := range tagInfos {
236+ if _, kept := keptTags[tag.name]; !kept {
237+ continue
238+ }
239+ if err := s.collectReferencedDigests(log, repository, tag.name, referencedDigests, make(map[string]struct{})); err != nil {
240+ return err
241+ }
242+ }
243+
244+ return nil
245+}
246+
247+// Prune runs runtime prune and registry prune and aggregates reports.
248+func (s *Service) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
249+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
250+ zerowrap.FieldLayer: "usecase",
251+ zerowrap.FieldUseCase: "Prune",
252+ "keepLast": keepLast,
253+ })
254+ log := zerowrap.FromCtx(ctx)
255+
256+ var report domain.ImagePruneReport
257+
258+ runtimeReport, err := s.PruneRuntime(ctx)
259+ if err != nil {
260+ log.Warn().Err(err).Msg("runtime prune failed; continuing with registry prune")
261+ } else {
262+ report.Runtime = runtimeReport.Runtime
263+ }
264+
265+ registryReport, err := s.PruneRegistry(ctx, keepLast)
266+ if err != nil {
267+ return report, err
268+ }
269+
270+ report.Registry = registryReport.Registry
271+ return report, nil
272+}
273+
274+type registryTag struct {
275+ name string
276+ modTime time.Time
277+}
278+
279+type manifestReferences struct {
280+ blobs []string
281+ childManifests []string
282+}
283+
284+func containsTag(tags []registryTag, name string) bool {
285+ for _, tag := range tags {
286+ if tag.name == name {
287+ return true
288+ }
289+ }
290+
291+ return false
292+}
293+
294+func (s *Service) collectReferencedDigests(
295+ log zerowrap.Logger,
296+ repository, reference string,
297+ referencedDigests map[string]struct{},
298+ visited map[string]struct{},
299+) error {
300+ visitKey := repository + "@" + reference
301+ if _, seen := visited[visitKey]; seen {
302+ return nil
303+ }
304+ visited[visitKey] = struct{}{}
305+
306+ manifestData, _, err := s.manifestStorage.GetManifest(repository, reference)
307+ if err != nil {
308+ return log.WrapErr(err, "failed to read manifest")
309+ }
310+
311+ refs, err := parseManifestReferences(manifestData)
312+ if err != nil {
313+ return log.WrapErr(err, "failed to parse manifest")
314+ }
315+
316+ for _, digest := range refs.blobs {
317+ referencedDigests[digest] = struct{}{}
318+ }
319+
320+ for _, childRef := range refs.childManifests {
321+ referencedDigests[childRef] = struct{}{}
322+ if err := s.collectReferencedDigests(log, repository, childRef, referencedDigests, visited); err != nil {
323+ return err
324+ }
325+ }
326+
327+ return nil
328+}
329+
330+func parseManifestReferences(data []byte) (manifestReferences, error) {
331+ var manifest struct {
332+ Config struct {
333+ Digest string `json:"digest"`
334+ } `json:"config"`
335+ Layers []struct {
336+ Digest string `json:"digest"`
337+ } `json:"layers"`
338+ Manifests []struct {
339+ Digest string `json:"digest"`
340+ } `json:"manifests"`
341+ }
342+
343+ if err := json.Unmarshal(data, &manifest); err != nil {
344+ return manifestReferences{}, err
345+ }
346+
347+ refs := manifestReferences{
348+ blobs: make([]string, 0, len(manifest.Layers)+1),
349+ childManifests: make([]string, 0, len(manifest.Manifests)),
350+ }
351+
352+ if manifest.Config.Digest != "" {
353+ refs.blobs = append(refs.blobs, manifest.Config.Digest)
354+ }
355+
356+ for _, layer := range manifest.Layers {
357+ if layer.Digest == "" {
358+ continue
359+ }
360+ refs.blobs = append(refs.blobs, layer.Digest)
361+ }
362+
363+ for _, child := range manifest.Manifests {
364+ if child.Digest == "" {
365+ continue
366+ }
367+ refs.childManifests = append(refs.childManifests, child.Digest)
368+ }
369+
370+ return refs, nil
371+}
372+
373+func isDanglingImage(repoTags []string) bool {
374+ if len(repoTags) == 0 {
375+ return true
376+ }
377+
378+ for _, tag := range repoTags {
379+ if tag == "<none>:<none>" || tag == "" {
380+ continue
381+ }
382+ return false
383+ }
384+
385+ return true
386+}
387+
388+func splitRepoTag(repoTag string) (string, string) {
389+ if repoTag == "" {
390+ return "", ""
391+ }
392+ if strings.Contains(repoTag, "@") {
393+ return repoTag, ""
394+ }
395+
396+ idx := strings.LastIndex(repoTag, ":")
397+ slashIdx := strings.LastIndex(repoTag, "/")
398+ if idx <= slashIdx || idx >= len(repoTag)-1 {
399+ return repoTag, ""
400+ }
401+
402+ return repoTag[:idx], repoTag[idx+1:]
403+}
A internal/usecase/images/service_test.go +642 -0
@@ -0,0 +1,642 @@
1+package images
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zerowrap"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/gordon/internal/boundaries/out"
15+ "github.com/bnema/gordon/internal/domain"
16+ pkgruntime "github.com/bnema/gordon/pkg/runtime"
17+)
18+
19+func TestService_ListImages_ReturnsAllImages(t *testing.T) {
20+ manifestStorage := noopManifestStorage{}
21+ blobStorage := noopBlobStorage{}
22+
23+ createdAt := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
24+ rt := &fakeRuntime{
25+ listDetails: []pkgruntime.ImageDetail{
26+ {
27+ ID: "sha256:111",
28+ RepoTags: []string{"gordon/api:latest", "gordon/api:v1.2.3"},
29+ Size: 1234,
30+ Created: createdAt,
31+ },
32+ {
33+ ID: "sha256:222",
34+ RepoTags: []string{"<none>:<none>"},
35+ Size: 4321,
36+ Created: createdAt.Add(-time.Hour),
37+ },
38+ },
39+ }
40+
41+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
42+
43+ images, err := svc.ListImages(context.Background())
44+
45+ require.NoError(t, err)
46+ require.Len(t, images, 3)
47+
48+ assert.Equal(t, domain.ImageInfo{
49+ Repository: "gordon/api",
50+ Tag: "latest",
51+ Size: 1234,
52+ Created: createdAt,
53+ ID: "sha256:111",
54+ Dangling: false,
55+ }, images[0])
56+
57+ assert.Equal(t, domain.ImageInfo{
58+ Repository: "gordon/api",
59+ Tag: "v1.2.3",
60+ Size: 1234,
61+ Created: createdAt,
62+ ID: "sha256:111",
63+ Dangling: false,
64+ }, images[1])
65+
66+ assert.Equal(t, domain.ImageInfo{
67+ Repository: "",
68+ Tag: "",
69+ Size: 4321,
70+ Created: createdAt.Add(-time.Hour),
71+ ID: "sha256:222",
72+ Dangling: true,
73+ }, images[2])
74+}
75+
76+func TestService_ListImages_EmptyWhenNoImages(t *testing.T) {
77+ manifestStorage := noopManifestStorage{}
78+ blobStorage := noopBlobStorage{}
79+ rt := &fakeRuntime{listDetails: []pkgruntime.ImageDetail{}}
80+
81+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
82+
83+ images, err := svc.ListImages(context.Background())
84+
85+ require.NoError(t, err)
86+ assert.Empty(t, images)
87+}
88+
89+func TestService_ListImages_SkipsPlaceholderTagsInMixedImage(t *testing.T) {
90+ manifestStorage := noopManifestStorage{}
91+ blobStorage := noopBlobStorage{}
92+ createdAt := time.Date(2026, 2, 8, 13, 0, 0, 0, time.UTC)
93+
94+ rt := &fakeRuntime{
95+ listDetails: []pkgruntime.ImageDetail{{
96+ ID: "sha256:mixed",
97+ RepoTags: []string{"<none>:<none>", "gordon/web:latest"},
98+ Size: 100,
99+ Created: createdAt,
100+ }},
101+ }
102+
103+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
104+
105+ images, err := svc.ListImages(context.Background())
106+
107+ require.NoError(t, err)
108+ require.Len(t, images, 1)
109+ assert.Equal(t, "gordon/web", images[0].Repository)
110+ assert.Equal(t, "latest", images[0].Tag)
111+ assert.False(t, images[0].Dangling)
112+}
113+
114+func TestService_ListImages_ReturnsErrorWhenRuntimeFails(t *testing.T) {
115+ manifestStorage := noopManifestStorage{}
116+ blobStorage := noopBlobStorage{}
117+ rt := &fakeRuntime{listErr: errors.New("runtime list failed")}
118+
119+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
120+
121+ _, err := svc.ListImages(context.Background())
122+
123+ require.Error(t, err)
124+ assert.Contains(t, err.Error(), "failed to list images")
125+}
126+
127+func TestService_PruneRuntime_RemovesDanglingImages(t *testing.T) {
128+ manifestStorage := noopManifestStorage{}
129+ blobStorage := noopBlobStorage{}
130+ rt := &fakeRuntime{
131+ pruneReport: pkgruntime.PruneReport{
132+ DeletedIDs: []string{"sha256:a", "sha256:b"},
133+ SpaceReclaimed: 2048,
134+ },
135+ }
136+
137+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
138+
139+ report, err := svc.PruneRuntime(context.Background())
140+
141+ require.NoError(t, err)
142+ assert.Equal(t, 2, report.Runtime.DeletedCount)
143+ assert.Equal(t, int64(2048), report.Runtime.SpaceReclaimed)
144+ assert.True(t, rt.pruneCalled)
145+ assert.True(t, rt.pruneDanglingOnly)
146+}
147+
148+func TestService_PruneRuntime_NoDanglingImages(t *testing.T) {
149+ manifestStorage := noopManifestStorage{}
150+ blobStorage := noopBlobStorage{}
151+ rt := &fakeRuntime{
152+ pruneReport: pkgruntime.PruneReport{
153+ DeletedIDs: nil,
154+ SpaceReclaimed: 0,
155+ },
156+ }
157+
158+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
159+
160+ report, err := svc.PruneRuntime(context.Background())
161+
162+ require.NoError(t, err)
163+ assert.Equal(t, 0, report.Runtime.DeletedCount)
164+ assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
165+ assert.True(t, rt.pruneCalled)
166+ assert.True(t, rt.pruneDanglingOnly)
167+}
168+
169+func TestService_PruneRuntime_ReturnsErrorWhenRuntimeFails(t *testing.T) {
170+ manifestStorage := noopManifestStorage{}
171+ blobStorage := noopBlobStorage{}
172+ rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
173+
174+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
175+
176+ _, err := svc.PruneRuntime(context.Background())
177+
178+ require.Error(t, err)
179+ assert.Contains(t, err.Error(), "failed to prune runtime images")
180+ assert.True(t, rt.pruneCalled)
181+ assert.True(t, rt.pruneDanglingOnly)
182+}
183+
184+func TestService_PruneRegistry_KeepsLastNTags(t *testing.T) {
185+ manifestStorage := newFakeManifestStorage()
186+ manifestStorage.repositories = []string{"gordon/api"}
187+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2", "v1"}
188+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
189+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
190+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
191+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 9, 0, 0, 0, time.UTC)
192+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
193+ manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-v3")
194+
195+ blobStorage := &fakeBlobStorage{}
196+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
197+
198+ report, err := svc.PruneRegistry(context.Background(), 2)
199+
200+ require.NoError(t, err)
201+ assert.Equal(t, 2, report.Registry.TagsRemoved)
202+ assert.ElementsMatch(t, []manifestRef{
203+ {name: "gordon/api", reference: "v2"},
204+ {name: "gordon/api", reference: "v1"},
205+ }, manifestStorage.deletedManifests)
206+}
207+
208+func TestService_PruneRegistry_AlwaysKeepsLatestTag(t *testing.T) {
209+ manifestStorage := newFakeManifestStorage()
210+ manifestStorage.repositories = []string{"gordon/api"}
211+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v3", "v2"}
212+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 8, 0, 0, 0, time.UTC)
213+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
214+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
215+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
216+ manifestStorage.manifests[manifestRefKey("gordon/api", "v3")] = mustManifestJSON(t, "sha256:cfg-v3", "sha256:layer-shared")
217+
218+ blobStorage := &fakeBlobStorage{}
219+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
220+
221+ report, err := svc.PruneRegistry(context.Background(), 1)
222+
223+ require.NoError(t, err)
224+ assert.Equal(t, 1, report.Registry.TagsRemoved)
225+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v2"}}, manifestStorage.deletedManifests)
226+}
227+
228+func TestService_PruneRegistry_SkipsWhenFewerThanKeepLast(t *testing.T) {
229+ manifestStorage := newFakeManifestStorage()
230+ manifestStorage.repositories = []string{"gordon/api"}
231+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
232+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
233+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
234+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-latest")
235+ manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-v1")
236+
237+ blobStorage := &fakeBlobStorage{}
238+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
239+
240+ report, err := svc.PruneRegistry(context.Background(), 5)
241+
242+ require.NoError(t, err)
243+ assert.Equal(t, 0, report.Registry.TagsRemoved)
244+ assert.Empty(t, manifestStorage.deletedManifests)
245+}
246+
247+func TestService_PruneRegistry_KeepLastZeroSkipsRegistryCleanup(t *testing.T) {
248+ manifestStorage := newFakeManifestStorage()
249+ manifestStorage.repositories = []string{"gordon/api"}
250+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:orphan"}}
251+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
252+
253+ report, err := svc.PruneRegistry(context.Background(), 0)
254+
255+ require.NoError(t, err)
256+ assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
257+ assert.Equal(t, 0, manifestStorage.listRepositoriesCalls)
258+ assert.Empty(t, manifestStorage.deletedManifests)
259+ assert.Empty(t, blobStorage.deletedBlobs)
260+}
261+
262+func TestService_PruneRegistry_GarbageCollectsUnreferencedBlobs(t *testing.T) {
263+ manifestStorage := newFakeManifestStorage()
264+ manifestStorage.repositories = []string{"gordon/api"}
265+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
266+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
267+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
268+
269+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
270+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
271+
272+ report, err := svc.PruneRegistry(context.Background(), 1)
273+
274+ require.NoError(t, err)
275+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
276+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
277+}
278+
279+func TestService_PruneRegistry_PreservesSharedBlobs(t *testing.T) {
280+ manifestStorage := newFakeManifestStorage()
281+ manifestStorage.repositories = []string{"gordon/api"}
282+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
283+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
284+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
285+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-latest", "sha256:layer-shared")
286+ manifestStorage.manifests[manifestRefKey("gordon/api", "v1")] = mustManifestJSON(t, "sha256:cfg-v1", "sha256:layer-shared")
287+
288+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-latest", "sha256:layer-shared", "sha256:cfg-v1"}}
289+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
290+
291+ report, err := svc.PruneRegistry(context.Background(), 1)
292+
293+ require.NoError(t, err)
294+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
295+ assert.Equal(t, []string{"sha256:cfg-v1"}, blobStorage.deletedBlobs)
296+}
297+
298+func TestService_PruneRegistry_PreservesManifestListChildDigests(t *testing.T) {
299+ manifestStorage := newFakeManifestStorage()
300+ manifestStorage.repositories = []string{"gordon/api"}
301+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
302+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
303+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-amd64", "sha256:child-arm64")
304+ manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-amd64")] = mustManifestJSON(t, "sha256:cfg-amd64", "sha256:layer-amd64")
305+ manifestStorage.manifests[manifestRefKey("gordon/api", "sha256:child-arm64")] = mustManifestJSON(t, "sha256:cfg-arm64", "sha256:layer-arm64")
306+
307+ blobStorage := &fakeBlobStorage{blobs: []string{
308+ "sha256:child-amd64",
309+ "sha256:child-arm64",
310+ "sha256:cfg-amd64",
311+ "sha256:layer-amd64",
312+ "sha256:cfg-arm64",
313+ "sha256:layer-arm64",
314+ "sha256:orphan",
315+ }}
316+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
317+
318+ report, err := svc.PruneRegistry(context.Background(), 1)
319+
320+ require.NoError(t, err)
321+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
322+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
323+}
324+
325+func TestService_PruneRegistry_TieBreaksByTagName(t *testing.T) {
326+ manifestStorage := newFakeManifestStorage()
327+ manifestStorage.repositories = []string{"gordon/api"}
328+ manifestStorage.tagsByRepo["gordon/api"] = []string{"v1", "v2"}
329+ tie := time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
330+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = tie
331+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = tie
332+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-v2", "sha256:layer-v2")
333+
334+ blobStorage := &fakeBlobStorage{}
335+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
336+
337+ report, err := svc.PruneRegistry(context.Background(), 1)
338+
339+ require.NoError(t, err)
340+ assert.Equal(t, 1, report.Registry.TagsRemoved)
341+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
342+}
343+
344+func TestService_PruneRegistry_ReturnsErrorWhenChildManifestMissing(t *testing.T) {
345+ manifestStorage := newFakeManifestStorage()
346+ manifestStorage.repositories = []string{"gordon/api"}
347+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest"}
348+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
349+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestIndexJSON(t, "sha256:child-missing")
350+
351+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:child-missing", "sha256:orphan"}}
352+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
353+
354+ _, err := svc.PruneRegistry(context.Background(), 1)
355+
356+ require.Error(t, err)
357+ assert.Contains(t, err.Error(), "failed to read manifest")
358+}
359+
360+func TestService_PruneRegistry_MultipleRepositories(t *testing.T) {
361+ manifestStorage := newFakeManifestStorage()
362+ manifestStorage.repositories = []string{"gordon/api", "gordon/web"}
363+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v2", "v1"}
364+ manifestStorage.tagsByRepo["gordon/web"] = []string{"latest", "v3", "v2"}
365+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
366+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v2")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
367+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
368+ manifestStorage.modTimes[manifestRefKey("gordon/web", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
369+ manifestStorage.modTimes[manifestRefKey("gordon/web", "v3")] = time.Date(2026, 2, 8, 11, 0, 0, 0, time.UTC)
370+ manifestStorage.modTimes[manifestRefKey("gordon/web", "v2")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
371+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-api-latest", "sha256:layer-api-latest")
372+ manifestStorage.manifests[manifestRefKey("gordon/api", "v2")] = mustManifestJSON(t, "sha256:cfg-api-v2", "sha256:layer-api-v2")
373+ manifestStorage.manifests[manifestRefKey("gordon/web", "latest")] = mustManifestJSON(t, "sha256:cfg-web-latest", "sha256:layer-web-latest")
374+ manifestStorage.manifests[manifestRefKey("gordon/web", "v3")] = mustManifestJSON(t, "sha256:cfg-web-v3", "sha256:layer-web-v3")
375+
376+ blobStorage := &fakeBlobStorage{}
377+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
378+
379+ report, err := svc.PruneRegistry(context.Background(), 2)
380+
381+ require.NoError(t, err)
382+ assert.Equal(t, 2, report.Registry.TagsRemoved)
383+ assert.ElementsMatch(t, []manifestRef{
384+ {name: "gordon/api", reference: "v1"},
385+ {name: "gordon/web", reference: "v2"},
386+ }, manifestStorage.deletedManifests)
387+}
388+
389+func TestService_PruneRegistry_EmptyRepository(t *testing.T) {
390+ manifestStorage := newFakeManifestStorage()
391+ manifestStorage.repositories = []string{"gordon/api"}
392+ manifestStorage.tagsByRepo["gordon/api"] = []string{}
393+ blobStorage := &fakeBlobStorage{}
394+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
395+
396+ report, err := svc.PruneRegistry(context.Background(), 2)
397+
398+ require.NoError(t, err)
399+ assert.Equal(t, domain.RegistryPruneResult{}, report.Registry)
400+ assert.Empty(t, manifestStorage.deletedManifests)
401+ assert.Empty(t, blobStorage.deletedBlobs)
402+}
403+
404+func TestService_Prune_RunsBothRuntimeAndRegistry(t *testing.T) {
405+ manifestStorage := newFakeManifestStorage()
406+ manifestStorage.repositories = []string{"gordon/api"}
407+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
408+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
409+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
410+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
411+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
412+ rt := &fakeRuntime{pruneReport: pkgruntime.PruneReport{DeletedIDs: []string{"sha256:img1"}, SpaceReclaimed: 1024}}
413+
414+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
415+
416+ report, err := svc.Prune(context.Background(), 1)
417+
418+ require.NoError(t, err)
419+ assert.Equal(t, 1, report.Runtime.DeletedCount)
420+ assert.Equal(t, int64(1024), report.Runtime.SpaceReclaimed)
421+ assert.Equal(t, 1, report.Registry.TagsRemoved)
422+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
423+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
424+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
425+}
426+
427+func TestService_Prune_RuntimeFailureDoesNotBlockRegistry(t *testing.T) {
428+ manifestStorage := newFakeManifestStorage()
429+ manifestStorage.repositories = []string{"gordon/api"}
430+ manifestStorage.tagsByRepo["gordon/api"] = []string{"latest", "v1"}
431+ manifestStorage.modTimes[manifestRefKey("gordon/api", "latest")] = time.Date(2026, 2, 8, 12, 0, 0, 0, time.UTC)
432+ manifestStorage.modTimes[manifestRefKey("gordon/api", "v1")] = time.Date(2026, 2, 8, 10, 0, 0, 0, time.UTC)
433+ manifestStorage.manifests[manifestRefKey("gordon/api", "latest")] = mustManifestJSON(t, "sha256:cfg-live", "sha256:layer-live")
434+ blobStorage := &fakeBlobStorage{blobs: []string{"sha256:cfg-live", "sha256:layer-live", "sha256:orphan"}}
435+ rt := &fakeRuntime{pruneErr: errors.New("runtime prune failed")}
436+
437+ svc := NewService(rt, manifestStorage, blobStorage, zerowrap.Default())
438+
439+ report, err := svc.Prune(context.Background(), 1)
440+
441+ require.NoError(t, err)
442+ assert.Equal(t, 0, report.Runtime.DeletedCount)
443+ assert.Equal(t, int64(0), report.Runtime.SpaceReclaimed)
444+ assert.Equal(t, 1, report.Registry.TagsRemoved)
445+ assert.Equal(t, 1, report.Registry.BlobsRemoved)
446+ assert.Equal(t, []manifestRef{{name: "gordon/api", reference: "v1"}}, manifestStorage.deletedManifests)
447+ assert.Equal(t, []string{"sha256:orphan"}, blobStorage.deletedBlobs)
448+}
449+
450+type fakeRuntime struct {
451+ pkgruntime.Runtime
452+
453+ listDetails []pkgruntime.ImageDetail
454+ listErr error
455+
456+ pruneReport pkgruntime.PruneReport
457+ pruneErr error
458+ pruneCalled bool
459+ pruneDanglingOnly bool
460+}
461+
462+type noopManifestStorage struct {
463+ out.ManifestStorage
464+}
465+
466+type noopBlobStorage struct {
467+ out.BlobStorage
468+}
469+
470+type manifestRef struct {
471+ name string
472+ reference string
473+}
474+
475+type fakeManifestStorage struct {
476+ out.ManifestStorage
477+
478+ repositories []string
479+ tagsByRepo map[string][]string
480+ modTimes map[string]time.Time
481+ manifests map[string][]byte
482+
483+ listRepositoriesCalls int
484+ deletedManifests []manifestRef
485+}
486+
487+type fakeBlobStorage struct {
488+ out.BlobStorage
489+
490+ blobs []string
491+ deletedBlobs []string
492+}
493+
494+func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
495+ return f.listDetails, f.listErr
496+}
497+
498+func (f *fakeRuntime) PruneImages(_ context.Context, danglingOnly bool) (pkgruntime.PruneReport, error) {
499+ f.pruneCalled = true
500+ f.pruneDanglingOnly = danglingOnly
501+ return f.pruneReport, f.pruneErr
502+}
503+
504+func newFakeManifestStorage() *fakeManifestStorage {
505+ return &fakeManifestStorage{
506+ tagsByRepo: make(map[string][]string),
507+ modTimes: make(map[string]time.Time),
508+ manifests: make(map[string][]byte),
509+ }
510+}
511+
512+func manifestRefKey(name, reference string) string {
513+ return name + "@" + reference
514+}
515+
516+func mustManifestJSON(t *testing.T, configDigest string, layerDigests ...string) []byte {
517+ t.Helper()
518+
519+ type descriptor struct {
520+ Digest string `json:"digest"`
521+ }
522+
523+ payload := struct {
524+ Config descriptor `json:"config"`
525+ Layers []descriptor `json:"layers"`
526+ }{
527+ Config: descriptor{Digest: configDigest},
528+ Layers: make([]descriptor, 0, len(layerDigests)),
529+ }
530+
531+ for _, digest := range layerDigests {
532+ payload.Layers = append(payload.Layers, descriptor{Digest: digest})
533+ }
534+
535+ data, err := json.Marshal(payload)
536+ require.NoError(t, err)
537+ return data
538+}
539+
540+func mustManifestIndexJSON(t *testing.T, digests ...string) []byte {
541+ t.Helper()
542+
543+ type descriptor struct {
544+ Digest string `json:"digest"`
545+ }
546+
547+ payload := struct {
548+ Manifests []descriptor `json:"manifests"`
549+ }{
550+ Manifests: make([]descriptor, 0, len(digests)),
551+ }
552+
553+ for _, digest := range digests {
554+ payload.Manifests = append(payload.Manifests, descriptor{Digest: digest})
555+ }
556+
557+ data, err := json.Marshal(payload)
558+ require.NoError(t, err)
559+ return data
560+}
561+
562+func (f *fakeManifestStorage) ListRepositories() ([]string, error) {
563+ f.listRepositoriesCalls++
564+ return append([]string(nil), f.repositories...), nil
565+}
566+
567+func (f *fakeManifestStorage) ListTags(name string) ([]string, error) {
568+ return append([]string(nil), f.tagsByRepo[name]...), nil
569+}
570+
571+func (f *fakeManifestStorage) GetManifestModTime(name, reference string) (time.Time, error) {
572+ modTime, ok := f.modTimes[manifestRefKey(name, reference)]
573+ if !ok {
574+ return time.Time{}, errors.New("manifest modtime not found")
575+ }
576+ return modTime, nil
577+}
578+
579+func (f *fakeManifestStorage) DeleteManifest(name, reference string) error {
580+ f.deletedManifests = append(f.deletedManifests, manifestRef{name: name, reference: reference})
581+ tags := f.tagsByRepo[name]
582+ filtered := tags[:0]
583+ for _, tag := range tags {
584+ if tag != reference {
585+ filtered = append(filtered, tag)
586+ }
587+ }
588+ f.tagsByRepo[name] = filtered
589+ delete(f.manifests, manifestRefKey(name, reference))
590+ return nil
591+}
592+
593+func (f *fakeManifestStorage) GetManifest(name, reference string) ([]byte, string, error) {
594+ manifest, ok := f.manifests[manifestRefKey(name, reference)]
595+ if !ok {
596+ return nil, "", errors.New("manifest not found")
597+ }
598+ return manifest, "application/vnd.oci.image.manifest.v1+json", nil
599+}
600+
601+func (f *fakeBlobStorage) ListBlobs() ([]string, error) {
602+ return append([]string(nil), f.blobs...), nil
603+}
604+
605+func (f *fakeBlobStorage) DeleteBlob(digest string) error {
606+ f.deletedBlobs = append(f.deletedBlobs, digest)
607+ for i, existing := range f.blobs {
608+ if existing != digest {
609+ continue
610+ }
611+ f.blobs = append(f.blobs[:i], f.blobs[i+1:]...)
612+ break
613+ }
614+ return nil
615+}
616+
617+func TestSplitRepoTag(t *testing.T) {
618+ tests := []struct {
619+ name string
620+ input string
621+ wantRepo string
622+ wantTag string
623+ }{
624+ {name: "repository and tag", input: "alpine:latest", wantRepo: "alpine", wantTag: "latest"},
625+ {name: "sha-like tag stays a tag", input: "image:sha256vdfhnijkvfedhbni", wantRepo: "image", wantTag: "sha256vdfhnijkvfedhbni"},
626+ {name: "registry port and tag", input: "localhost:5000/repo:v1", wantRepo: "localhost:5000/repo", wantTag: "v1"},
627+ {name: "registry port without tag", input: "localhost:5000/repo", wantRepo: "localhost:5000/repo", wantTag: ""},
628+ {name: "digest reference not split as tag", input: "image@sha256:abcdef012345", wantRepo: "image@sha256:abcdef012345", wantTag: ""},
629+ {name: "missing tag", input: "alpine", wantRepo: "alpine", wantTag: ""},
630+ {name: "trailing colon", input: "alpine:", wantRepo: "alpine:", wantTag: ""},
631+ }
632+
633+ for _, tt := range tests {
634+ t.Run(tt.name, func(t *testing.T) {
635+ repo, tag := splitRepoTag(tt.input)
636+ assert.Equal(t, tt.wantRepo, repo)
637+ assert.Equal(t, tt.wantTag, tag)
638+ })
639+ }
640+}
641+
642+var _ pkgruntime.Runtime = (*fakeRuntime)(nil)
M internal/usecase/logs/service.go +243 -57
@@ -3,11 +3,14 @@ package logs
3 3
4 import (4 import (
5 "bufio"5 "bufio"
6+ "bytes"
6 "context"7 "context"
7 "encoding/binary"8 "encoding/binary"
8 "fmt"9 "fmt"
9 "io"10 "io"
10 "os"11 "os"
12+ "os/exec"
13+ "strconv"
11 "strings"14 "strings"
12 "time"15 "time"
13 16
@@ -19,24 +22,29 @@ import (
19 22
20 // Service implements the LogService interface.23 // Service implements the LogService interface.
21 type Service struct {24 type Service struct {
22- logFilePath string25+ logFilePath string
23- containerSvc in.ContainerService26+ fileLoggingEnabled bool
24- runtime out.ContainerRuntime27+ containerSvc in.ContainerService
25- log zerowrap.Logger28+ runtime out.ContainerRuntime
29+ log zerowrap.Logger
26 }30 }
27 31
32+var execCommandContext = exec.CommandContext
33+
28 // NewService creates a new log service.34 // NewService creates a new log service.
29 func NewService(35 func NewService(
30 logFilePath string,36 logFilePath string,
37+ fileLoggingEnabled bool,
31 containerSvc in.ContainerService,38 containerSvc in.ContainerService,
32 runtime out.ContainerRuntime,39 runtime out.ContainerRuntime,
33 log zerowrap.Logger,40 log zerowrap.Logger,
34 ) *Service {41 ) *Service {
35 return &Service{42 return &Service{
36- logFilePath: logFilePath,43+ logFilePath: logFilePath,
37- containerSvc: containerSvc,44+ fileLoggingEnabled: fileLoggingEnabled,
38- runtime: runtime,45+ containerSvc: containerSvc,
39- log: log,46+ runtime: runtime,
47+ log: log,
40 }48 }
41 }49 }
42 50
@@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro
49 })57 })
50 log := zerowrap.FromCtx(ctx)58 log := zerowrap.FromCtx(ctx)
51 59
52- if s.logFilePath == "" {60+ if s.useFileProcessLogs() {
53- return nil, fmt.Errorf("log file path not configured")61+ if s.logFilePath == "" {
54- }62+ return nil, fmt.Errorf("log file path not configured")
63+ }
55 64
56- file, err := os.Open(s.logFilePath)65+ file, err := os.Open(s.logFilePath)
57- if err != nil {66+ if err != nil {
58- if os.IsNotExist(err) {67+ if os.IsNotExist(err) {
59- return []string{}, nil68+ log.Warn().Str("log_path", s.logFilePath).
69+ Msg("process log file missing, falling back to journalctl")
70+ return s.getProcessLogsFromJournal(ctx, lines)
71+ }
72+ return nil, log.WrapErr(err, "failed to open log file")
60 }73 }
61- return nil, log.WrapErr(err, "failed to open log file")74+ defer file.Close()
75+
76+ return tailLines(file, lines)
62 }77 }
63- defer file.Close()
64 78
65- return tailLines(file, lines)79+ return s.getProcessLogsFromJournal(ctx, lines)
66 }80 }
67 81
68 // FollowProcessLogs returns a channel that streams Gordon process log lines.82 // FollowProcessLogs returns a channel that streams Gordon process log lines.
@@ -72,67 +86,198 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
72 zerowrap.FieldUseCase: "FollowProcessLogs",86 zerowrap.FieldUseCase: "FollowProcessLogs",
73 "initial_lines": initialLines,87 "initial_lines": initialLines,
74 })88 })
89+ if !s.useFileProcessLogs() {
90+ return s.followProcessLogsFromJournal(ctx, initialLines)
91+ }
92+
93+ return s.followProcessLogsFromFileOrJournal(ctx, initialLines)
94+}
95+
96+func (s *Service) useFileProcessLogs() bool {
97+ return s.fileLoggingEnabled
98+}
99+
100+func (s *Service) followProcessLogsFromFileOrJournal(
101+ ctx context.Context,
102+ initialLines int,
103+) (<-chan string, error) {
75 log := zerowrap.FromCtx(ctx)104 log := zerowrap.FromCtx(ctx)
76 105
106+ file, err := s.openProcessLogFile()
107+ if err != nil {
108+ if os.IsNotExist(err) {
109+ log.Warn().Str("log_path", s.logFilePath).
110+ Msg("process log file missing, falling back to journalctl follow")
111+ return s.followProcessLogsFromJournal(ctx, initialLines)
112+ }
113+ return nil, log.WrapErr(err, "failed to open log file")
114+ }
115+
116+ return s.followProcessLogsFromFile(ctx, file, initialLines), nil
117+}
118+
119+func (s *Service) openProcessLogFile() (*os.File, error) {
77 if s.logFilePath == "" {120 if s.logFilePath == "" {
78 return nil, fmt.Errorf("log file path not configured")121 return nil, fmt.Errorf("log file path not configured")
79 }122 }
80 123
81- file, err := os.Open(s.logFilePath)124+ return os.Open(s.logFilePath)
82- if err != nil {125+}
83- return nil, log.WrapErr(err, "failed to open log file")
84- }
85 126
127+func (s *Service) followProcessLogsFromFile(
128+ ctx context.Context,
129+ file *os.File,
130+ initialLines int,
131+) <-chan string {
132+ log := zerowrap.FromCtx(ctx)
86 ch := make(chan string, 100)133 ch := make(chan string, 100)
87 134
88 go func() {135 go func() {
89 defer close(ch)136 defer close(ch)
90 defer file.Close()137 defer file.Close()
91 138
92- // First, get initial lines139+ if !s.emitInitialFileLogs(ctx, file, initialLines, ch) {
93- if initialLines > 0 {140+ return
94- lines, err := tailLines(file, initialLines)141+ }
95- if err != nil {142+ if err := s.seekToEndForFileFollow(file); err != nil {
96- log.Warn().Err(err).Msg("failed to read initial lines")143+ log.Warn().Err(err).Msg("failed to seek to end")
97- } else {144+ return
98- for _, line := range lines {145+ }
99- select {146+
100- case ch <- line:147+ s.streamFileFollowLogs(ctx, file, ch)
101- case <-ctx.Done():148+ }()
102- return149+
103- }150+ return ch
104- }151+}
152+
153+func (s *Service) emitInitialFileLogs(
154+ ctx context.Context,
155+ file *os.File,
156+ initialLines int,
157+ ch chan<- string,
158+) bool {
159+ if initialLines <= 0 {
160+ return true
161+ }
162+
163+ log := zerowrap.FromCtx(ctx)
164+ lines, err := tailLines(file, initialLines)
165+ if err != nil {
166+ log.Warn().Err(err).Msg("failed to read initial lines")
167+ return true
168+ }
169+
170+ for _, line := range lines {
171+ if !sendLogLineWithChannel(ctx, ch, line) {
172+ return false
173+ }
174+ }
175+
176+ return true
177+}
178+
179+func (s *Service) seekToEndForFileFollow(file *os.File) error {
180+ _, err := file.Seek(0, io.SeekEnd)
181+ return err
182+}
183+
184+func (s *Service) streamFileFollowLogs(ctx context.Context, file *os.File, ch chan<- string) {
185+ log := zerowrap.FromCtx(ctx)
186+ reader := bufio.NewReader(file)
187+
188+ for {
189+ if ctx.Err() != nil {
190+ return
191+ }
192+
193+ line, err := reader.ReadString('\n')
194+ if err != nil {
195+ if err == io.EOF {
196+ time.Sleep(100 * time.Millisecond)
197+ continue
105 }198 }
199+ log.Warn().Err(err).Msg("error reading log file")
200+ return
106 }201 }
107 202
108- // Seek to end for following203+ line = strings.TrimRight(line, "\n\r")
109- if _, err := file.Seek(0, io.SeekEnd); err != nil {204+ if !sendLogLineWithChannel(ctx, ch, line) {
110- log.Warn().Err(err).Msg("failed to seek to end")
111 return205 return
112 }206 }
207+ }
208+}
209+
210+func sendLogLineWithChannel(ctx context.Context, ch chan<- string, line string) bool {
211+ select {
212+ case ch <- line:
213+ return true
214+ case <-ctx.Done():
215+ return false
216+ }
217+}
218+
219+func (s *Service) getProcessLogsFromJournal(ctx context.Context, lines int) ([]string, error) {
220+ userScope, err := resolveJournalctlScope(ctx)
221+ if err != nil {
222+ return nil, err
223+ }
224+
225+ args := []string{"-u", "gordon", "-n", strconv.Itoa(lines), "--no-pager", "-o", "cat"}
226+ if userScope {
227+ args = append([]string{"--user"}, args...)
228+ }
113 229
114- // Follow new lines230+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
115- reader := bufio.NewReader(file)231+ if err != nil {
116- for {232+ return nil, fmt.Errorf("failed to read journalctl logs: %w", err)
233+ }
234+
235+ return outputLines(out), nil
236+}
237+
238+func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) {
239+ userScope, err := resolveJournalctlScope(ctx)
240+ if err != nil {
241+ return nil, err
242+ }
243+
244+ args := []string{
245+ "-u", "gordon",
246+ "-n", strconv.Itoa(initialLines),
247+ "--no-pager",
248+ "-o", "cat",
249+ "-f",
250+ }
251+ if userScope {
252+ args = append([]string{"--user"}, args...)
253+ }
254+
255+ cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only
256+ stdout, err := cmd.StdoutPipe()
257+ if err != nil {
258+ return nil, fmt.Errorf("failed to open journalctl stdout: %w", err)
259+ }
260+ cmd.Stderr = io.Discard
261+
262+ if err := cmd.Start(); err != nil {
263+ return nil, fmt.Errorf("failed to start journalctl follow: %w", err)
264+ }
265+
266+ ch := make(chan string, 100)
267+
268+ go func() {
269+ defer close(ch)
270+ defer stdout.Close()
271+ defer cmd.Wait() //nolint:errcheck // best-effort cleanup
272+
273+ scanner := bufio.NewScanner(stdout)
274+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
275+
276+ for scanner.Scan() {
117 select {277 select {
278+ case ch <- scanner.Text():
118 case <-ctx.Done():279 case <-ctx.Done():
119 return280 return
120- default:
121- line, err := reader.ReadString('\n')
122- if err != nil {
123- if err == io.EOF {
124- time.Sleep(100 * time.Millisecond)
125- continue
126- }
127- log.Warn().Err(err).Msg("error reading log file")
128- return
129- }
130- line = strings.TrimRight(line, "\n\r")
131- select {
132- case ch <- line:
133- case <-ctx.Done():
134- return
135- }
136 }281 }
137 }282 }
138 }()283 }()
@@ -140,6 +285,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
140 return ch, nil285 return ch, nil
141 }286 }
142 287
288+func resolveJournalctlScope(ctx context.Context) (bool, error) {
289+ userLogs, userErr := hasJournalctlLogs(ctx, true)
290+ if userErr == nil && userLogs {
291+ return true, nil
292+ }
293+
294+ systemLogs, systemErr := hasJournalctlLogs(ctx, false)
295+ if systemErr == nil && systemLogs {
296+ return false, nil
297+ }
298+
299+ if userErr != nil && systemErr != nil {
300+ return false, fmt.Errorf("journalctl unavailable for user and system services")
301+ }
302+
303+ // Default to user scope when logs are currently empty; this matches rootless setups.
304+ return true, nil
305+}
306+
307+func hasJournalctlLogs(ctx context.Context, userService bool) (bool, error) {
308+ args := []string{"-u", "gordon", "-n", "1", "--no-pager", "-q", "-o", "cat"}
309+ if userService {
310+ args = append([]string{"--user"}, args...)
311+ }
312+
313+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
314+ if err != nil {
315+ return false, err
316+ }
317+
318+ return len(bytes.TrimSpace(out)) > 0, nil
319+}
320+
321+func outputLines(out []byte) []string {
322+ trimmed := strings.TrimRight(string(out), "\n\r")
323+ if trimmed == "" {
324+ return []string{}
325+ }
326+ return strings.Split(trimmed, "\n")
327+}
328+
143 // validateDomain checks that domain is safe for use in logs and error messages.329 // validateDomain checks that domain is safe for use in logs and error messages.
144 func validateDomain(domain string) error {330 func validateDomain(domain string) error {
145 if domain == "" {331 if domain == "" {
M internal/usecase/logs/service_test.go +28 -8
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "os"6 "os"
7+ "os/exec"
7 "path/filepath"8 "path/filepath"
8 "testing"9 "testing"
9 "time"10 "time"
@@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) {
32 containerSvc := mocks.NewMockContainerService(t)33 containerSvc := mocks.NewMockContainerService(t)
33 runtime := outMocks.NewMockContainerRuntime(t)34 runtime := outMocks.NewMockContainerRuntime(t)
34 35
35- svc := NewService(logPath, containerSvc, runtime, log)36+ svc := NewService(logPath, true, containerSvc, runtime, log)
36 37
37 lines, err := svc.GetProcessLogs(context.Background(), 3)38 lines, err := svc.GetProcessLogs(context.Background(), 3)
38 require.NoError(t, err)39 require.NoError(t, err)
@@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) {
44 containerSvc := mocks.NewMockContainerService(t)45 containerSvc := mocks.NewMockContainerService(t)
45 runtime := outMocks.NewMockContainerRuntime(t)46 runtime := outMocks.NewMockContainerRuntime(t)
46 47
47- svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)48+ svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log)
48 49
49 lines, err := svc.GetProcessLogs(context.Background(), 10)50 lines, err := svc.GetProcessLogs(context.Background(), 10)
50 require.NoError(t, err)51 require.NoError(t, err)
@@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) {
55 containerSvc := mocks.NewMockContainerService(t)56 containerSvc := mocks.NewMockContainerService(t)
56 runtime := outMocks.NewMockContainerRuntime(t)57 runtime := outMocks.NewMockContainerRuntime(t)
57 58
58- svc := NewService("", containerSvc, runtime, log)59+ svc := NewService("", true, containerSvc, runtime, log)
59 60
60 _, err := svc.GetProcessLogs(context.Background(), 10)61 _, err := svc.GetProcessLogs(context.Background(), 10)
61 assert.Error(t, err)62 assert.Error(t, err)
@@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) {
72 containerSvc := mocks.NewMockContainerService(t)73 containerSvc := mocks.NewMockContainerService(t)
73 runtime := outMocks.NewMockContainerRuntime(t)74 runtime := outMocks.NewMockContainerRuntime(t)
74 75
75- svc := NewService(logPath, containerSvc, runtime, log)76+ svc := NewService(logPath, true, containerSvc, runtime, log)
76 77
77 lines, err := svc.GetProcessLogs(context.Background(), 10)78 lines, err := svc.GetProcessLogs(context.Background(), 10)
78 require.NoError(t, err)79 require.NoError(t, err)
79 assert.Equal(t, []string{"line1", "line2"}, lines)80 assert.Equal(t, []string{"line1", "line2"}, lines)
80 })81 })
82+
83+ t.Run("falls back to journalctl when file logging disabled", func(t *testing.T) {
84+ origExec := execCommandContext
85+ execCommandContext = func(ctx context.Context, name string, args ...string) *exec.Cmd {
86+ return exec.CommandContext(ctx, "bash", "-lc", "printf 'journal-line-1\\njournal-line-2\\n'") // #nosec G204
87+ }
88+ defer func() {
89+ execCommandContext = origExec
90+ }()
91+
92+ containerSvc := mocks.NewMockContainerService(t)
93+ runtime := outMocks.NewMockContainerRuntime(t)
94+
95+ svc := NewService("/tmp/unused.log", false, containerSvc, runtime, log)
96+
97+ lines, err := svc.GetProcessLogs(context.Background(), 10)
98+ require.NoError(t, err)
99+ assert.Equal(t, []string{"journal-line-1", "journal-line-2"}, lines)
100+ })
81 }101 }
82 102
83 func TestService_FollowProcessLogs(t *testing.T) {103 func TestService_FollowProcessLogs(t *testing.T) {
@@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) {
93 containerSvc := mocks.NewMockContainerService(t)113 containerSvc := mocks.NewMockContainerService(t)
94 runtime := outMocks.NewMockContainerRuntime(t)114 runtime := outMocks.NewMockContainerRuntime(t)
95 115
96- svc := NewService(logPath, containerSvc, runtime, log)116+ svc := NewService(logPath, true, containerSvc, runtime, log)
97 117
98 ctx, cancel := context.WithCancel(context.Background())118 ctx, cancel := context.WithCancel(context.Background())
99 defer cancel()119 defer cancel()
@@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) {
135 155
136 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)156 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
137 157
138- svc := NewService("/tmp/test.log", containerSvc, runtime, log)158+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
139 159
140 _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)160 _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
141 assert.Error(t, err)161 assert.Error(t, err)
@@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) {
156 // Create a mock reader that returns empty content176 // Create a mock reader that returns empty content
157 runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)177 runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
158 178
159- svc := NewService("/tmp/test.log", containerSvc, runtime, log)179+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
160 180
161 lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)181 lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
162 require.NoError(t, err)182 require.NoError(t, err)
@@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) {
173 193
174 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)194 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
175 195
176- svc := NewService("/tmp/test.log", containerSvc, runtime, log)196+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
177 197
178 _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)198 _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
179 assert.Error(t, err)199 assert.Error(t, err)
M internal/usecase/proxy/service.go +27 -8
@@ -24,8 +24,8 @@ import (
24 "github.com/bnema/gordon/internal/domain"24 "github.com/bnema/gordon/internal/domain"
25 )25 )
26 26
27-// proxyTransport is a shared HTTP transport with proper timeouts.27+// proxyTransport is a shared HTTP transport for proxying to application containers.
28-// This prevents resource exhaustion from slow backends or network issues.28+// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
29 var proxyTransport = &http.Transport{29 var proxyTransport = &http.Transport{
30 DialContext: (&net.Dialer{30 DialContext: (&net.Dialer{
31 Timeout: 10 * time.Second,31 Timeout: 10 * time.Second,
@@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{
38 IdleConnTimeout: 90 * time.Second,38 IdleConnTimeout: 90 * time.Second,
39 }39 }
40 40
41+// registryTransport is used for the registry reverse proxy (localhost loopback).
42+// It needs a longer ResponseHeaderTimeout because admin endpoints like /admin/deploy
43+// perform blocking operations (image pull, container start, readiness checks) that
44+// can take well over 30 seconds.
45+var registryTransport = &http.Transport{
46+ DialContext: (&net.Dialer{
47+ Timeout: 10 * time.Second,
48+ KeepAlive: 30 * time.Second,
49+ }).DialContext,
50+ ResponseHeaderTimeout: 3 * time.Minute,
51+ MaxIdleConns: 20,
52+ MaxIdleConnsPerHost: 10,
53+ IdleConnTimeout: 90 * time.Second,
54+}
55+
41 // Config holds configuration needed by the proxy service.56 // Config holds configuration needed by the proxy service.
42 type Config struct {57 type Config struct {
43 RegistryDomain string58 RegistryDomain string
@@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
558 return573 return
559 }574 }
560 575
561- proxy := newReverseProxy(targetURL, "",576+ proxy := &httputil.ReverseProxy{
562- func(w http.ResponseWriter, _ *http.Request, err error) {577+ Rewrite: func(pr *httputil.ProxyRequest) {
578+ pr.SetURL(targetURL)
579+ pr.SetXForwarded()
580+ pr.Out.Host = targetURL.Host
581+ },
582+ Transport: registryTransport,
583+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
563 var maxBytesErr *http.MaxBytesError584 var maxBytesErr *http.MaxBytesError
564 if errors.As(err, &maxBytesErr) {585 if errors.As(err, &maxBytesErr) {
565 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")586 log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
@@ -569,13 +590,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
569 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")590 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
570 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)591 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
571 },592 },
572- func(resp *http.Response) error {593+ ModifyResponse: func(resp *http.Response) error {
573 resp.Header.Set("X-Proxied-By", "Gordon")594 resp.Header.Set("X-Proxied-By", "Gordon")
574 resp.Header.Set("X-Registry-Backend", "gordon-registry")595 resp.Header.Set("X-Registry-Backend", "gordon-registry")
575 596
576 // Remove security headers from registry response to prevent duplicates.597 // Remove security headers from registry response to prevent duplicates.
577- // The proxy middleware already adds these headers, so the registry's
578- // copies would create duplicates in the final response.
579 resp.Header.Del("X-Content-Type-Options")598 resp.Header.Del("X-Content-Type-Options")
580 resp.Header.Del("X-Frame-Options")599 resp.Header.Del("X-Frame-Options")
581 resp.Header.Del("X-XSS-Protection")600 resp.Header.Del("X-XSS-Protection")
@@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
586 605
587 return nil606 return nil
588 },607 },
589- )608+ }
590 609
591 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")610 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
592 611
M internal/usecase/registry/service.go +5 -2
@@ -6,6 +6,7 @@ import (
6 "crypto/sha256"6 "crypto/sha256"
7 "fmt"7 "fmt"
8 "io"8 "io"
9+ "strings"
9 10
10 "github.com/bnema/zerowrap"11 "github.com/bnema/zerowrap"
11 12
@@ -74,8 +75,10 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
74 return "", log.WrapErr(err, "failed to store manifest")75 return "", log.WrapErr(err, "failed to store manifest")
75 }76 }
76 77
77- // Publish image pushed event78+ // Publish image pushed event only for tag references (not digests).
78- if s.eventBus != nil {79+ // A docker push sends manifests by both digest and tag; firing only on
80+ // tag prevents duplicate deploy triggers for the same push.
81+ if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
79 if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{82 if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
80 Name: manifest.Name,83 Name: manifest.Name,
81 Reference: manifest.Reference,84 Reference: manifest.Reference,
M pkg/runtime/interface.go +26 -0
@@ -3,6 +3,7 @@ package runtime
3 import (3 import (
4 "context"4 "context"
5 "io"5 "io"
6+ "time"
6 )7 )
7 8
8 // Container represents a running container9 // Container represents a running container
@@ -40,6 +41,26 @@ type ContainerConfig struct {
40 Aliases []string // Additional network aliases41 Aliases []string // Additional network aliases
41 }42 }
42 43
44+// PruneReport represents the result of an image prune operation.
45+type PruneReport struct {
46+ // DeletedIDs contains runtime-provided image identifiers removed by prune.
47+ DeletedIDs []string
48+ // SpaceReclaimed is the number of bytes reclaimed by prune.
49+ SpaceReclaimed int64
50+}
51+
52+// ImageDetail represents detailed metadata for an image.
53+type ImageDetail struct {
54+ // ID is the runtime image identifier (for example, image ID/digest).
55+ ID string
56+ // RepoTags contains repository:tag references known for the image.
57+ RepoTags []string
58+ // Size is the image size in bytes.
59+ Size int64
60+ // Created is the image creation timestamp as reported by the runtime.
61+ Created time.Time
62+}
63+
43 // Runtime interface defines the contract for container runtime implementations64 // Runtime interface defines the contract for container runtime implementations
44 type Runtime interface {65 type Runtime interface {
45 // Container lifecycle66 // Container lifecycle
@@ -59,6 +80,11 @@ type Runtime interface {
59 PullImageWithAuth(ctx context.Context, image, username, password string) error80 PullImageWithAuth(ctx context.Context, image, username, password string) error
60 RemoveImage(ctx context.Context, image string, force bool) error81 RemoveImage(ctx context.Context, image string, force bool) error
61 ListImages(ctx context.Context) ([]string, error)82 ListImages(ctx context.Context) ([]string, error)
83+ // ListImagesDetailed returns metadata for all images visible to the runtime.
84+ ListImagesDetailed(ctx context.Context) ([]ImageDetail, error)
85+ // PruneImages removes images eligible for prune; when danglingOnly is true,
86+ // only dangling images are pruned.
87+ PruneImages(ctx context.Context, danglingOnly bool) (PruneReport, error)
62 88
63 // Runtime information89 // Runtime information
64 Ping(ctx context.Context) error90 Ping(ctx context.Context) error
M wiki/guides/remote-cli.md +36 -1
@@ -20,7 +20,7 @@ By default (without `--remote`), management commands execute locally through in-
20 20
21 ## Understanding the Remote URL21 ## Understanding the Remote URL
22 22
23-The `--remote` URL must be the `gordon_domain` configured on the remote Gordon instance. This domain serves both the container registry and the Admin API.23+Default: use the Gordon domain configured on the remote instance.
24 24
25 ```toml25 ```toml
26 # On the remote Gordon server26 # On the remote Gordon server
@@ -30,6 +30,40 @@ gordon_domain = "gordon.example.com" # Use this as --remote URL
30 30
31 The CLI connects to `https://gordon.example.com/admin/*` endpoints.31 The CLI connects to `https://gordon.example.com/admin/*` endpoints.
32 32
33+Private tailnet setup (recommended for hardened VPS): keep using the HTTPS domain, point that DNS record to the VPS tailnet IP, and set insecure TLS for self-signed certs.
34+
35+> [!CAUTION]
36+> `--insecure` and `insecure_tls = true` disable TLS certificate verification.
37+> This removes server identity validation and increases man-in-the-middle risk.
38+> Use this only for private tailnet/VPN deployments with strict network access controls (restricted CIDRs, firewall allowlists, and no public ingress to admin endpoints).
39+> Safer alternatives:
40+> - Use a publicly trusted certificate (for example, Let's Encrypt).
41+> - Use an internal CA and trust that CA on operator machines.
42+> - Use Tailscale certificate generation (`tailscale cert`) and serve Gordon with that cert.
43+
44+```bash
45+# Save remote once
46+gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure
47+gordon remotes use tailnet-reg
48+
49+# Use it for auth/admin commands
50+gordon auth login
51+gordon routes list
52+```
53+
54+Equivalent `remotes.toml` entry:
55+
56+```toml
57+active = "tailnet-reg"
58+
59+[remotes.tailnet-reg]
60+url = "https://gordon.example.com"
61+token_env = "GORDON_TOKEN"
62+insecure_tls = true
63+```
64+
65+Use this mode when registry/auth/admin access is intentionally restricted to tailnet CIDRs.
66+
33 ## Quick Start67 ## Quick Start
34 68
35 ### One-off Remote Command69 ### One-off Remote Command
@@ -67,6 +101,7 @@ These flags are available on all commands:
67 |------|-------------|101 |------|-------------|
68 | `--remote <URL>` | Remote Gordon URL |102 | `--remote <URL>` | Remote Gordon URL |
69 | `--token <TOKEN>` | Authentication token |103 | `--token <TOKEN>` | Authentication token |
104+| `--insecure` | Skip TLS verification (self-signed/private certs) |
70 105
71 ```bash106 ```bash
72 # List routes on remote107 # List routes on remote
M wiki/guides/secure-vps-setup.md +49 -0
@@ -142,6 +142,55 @@ Verify public SSH is blocked:
142 ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout142 ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout
143 ```143 ```
144 144
145+## Step 6.5: Restrict Registry and Auth to Tailnet CIDR
146+
147+If you want `/auth/*` and registry (`/v2/*`) reachable only from your tailnet, set Gordon's registry allowlist to the Tailscale CGNAT range.
148+
149+Edit `~/.config/gordon/gordon.toml`:
150+
151+```toml
152+[server]
153+registry_allowed_ips = ["100.64.0.0/10"]
154+```
155+
156+Apply the change:
157+
158+```bash
159+systemctl --user restart gordon
160+```
161+
162+Behavior:
163+
164+- Requests from tailnet IPs (`100.64.0.0/10`) are allowed.
165+- Requests outside that CIDR get `403 Forbidden` on registry/auth endpoints.
166+- App routes still use normal host-based routing through your proxy ports.
167+
168+For private operations (auth, push/pull, admin API), keep using the HTTPS registry domain, but resolve that domain to the VPS tailnet IP (for example, via Cloudflare DNS to the tailnet address). With self-signed certs, configure the CLI remote with insecure TLS enabled.
169+
170+```bash
171+# Example: save remote once
172+gordon remotes add tailnet-reg https://gordon.example.com --token-env GORDON_TOKEN --insecure
173+gordon remotes use tailnet-reg
174+
175+# Then use commands normally (auth + admin API)
176+gordon auth login
177+gordon routes list
178+gordon backup status
179+```
180+
181+`~/.config/gordon/remotes.toml` should contain an entry like:
182+
183+```toml
184+active = "tailnet-reg"
185+
186+[remotes.tailnet-reg]
187+url = "https://gordon.example.com"
188+token_env = "GORDON_TOKEN"
189+insecure_tls = true
190+```
191+
192+This keeps transport on HTTPS while allowing self-signed certificates in tailnet-only deployments.
193+
145 ---194 ---
146 195
147 ## Step 7: Install CrowdSec196 ## Step 7: Install CrowdSec

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