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
3232 | `gordon logs` | Display Gordon process or container logs | [serve](./serve.md#gordon-logs) |
3333 | `gordon status` | Show Gordon server status | [status](./status.md) |
3434 | `gordon backups` | Manage database backups | [backup](./backup.md) |
35+| `gordon images` | List and prune images | [images](./images.md) |
3536
3637 ## Client Commands
3738
@@ -82,6 +83,11 @@ gordon backups run app.example.com
8283 gordon backups detect app.example.com
8384 gordon backups status
8485
86+# Images
87+gordon images list
88+gordon images prune --runtime-only
89+gordon images prune --keep 3
90+
8591 # Authentication
8692 gordon auth token generate --subject ci-bot --expiry 0
8793 gordon auth token list
M docs/config/backups.md +3 -0
@@ -21,6 +21,7 @@ Current scope:
2121 ```toml
2222 [backups]
2323 enabled = true
24+schedule = "daily"
2425 storage_dir = "~/.gordon/backups"
2526
2627 [backups.retention]
@@ -35,6 +36,7 @@ monthly = 12
3536 | Key | Type | Default | Description |
3637 |-----|------|---------|-------------|
3738 | `backups.enabled` | bool | `false` | Enables backup service wiring |
39+| `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
3840 | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` |
3941 | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB |
4042 | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB |
@@ -58,6 +60,7 @@ Example:
5860 ## Notes
5961
6062 - Backups require PostgreSQL attachment containers to be running.
63+- Scheduled backups run only when `backups.enabled = true`.
6164 - The backup command executes inside the database container using Gordon runtime APIs.
6265 - Retention applies per database and per schedule.
6366 - `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
119119 # Backups
120120 [backups]
121121 enabled = true
122+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
122123 storage_dir = "~/.gordon/backups"
123124
124125 [backups.retention]
@@ -126,6 +127,12 @@ hourly = 24
126127 daily = 7
127128 weekly = 4
128129 monthly = 12
130+
131+# Images
132+[images.prune]
133+enabled = false
134+schedule = "daily"
135+keep_last = 3
129136 ```
130137
131138 ## Configuration Sections
@@ -146,6 +153,7 @@ monthly = 12
146153 | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) |
147154 | `[attachments]` | Service dependencies | [Attachments](./attachments.md) |
148155 | `[backups]` | Database backups | [Backups](./backups.md) |
156+| `[images.prune]` | Scheduled image cleanup | [Images](./images.md) |
149157
150158 ## Default Values
151159
@@ -182,6 +190,10 @@ monthly = 12
182190 | `network_isolation.enabled` | `false` |
183191 | `auto_route.enabled` | `false` |
184192 | `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` |
185197
186198 When `auth.enabled=false`, Gordon runs in local-only mode: `/admin/*` is disabled and `/v2/*` is loopback-only.
187199
@@ -225,3 +237,4 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
225237 - [External Routes](./external-routes.md)
226238 - [Authentication](./auth.md)
227239 - [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
133133 # =============================================================================
134134 [backups]
135135 enabled = false # Enable backup service
136+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
136137 storage_dir = "" # Backup root (default: {data_dir}/backups)
137138
138139 [backups.retention]
@@ -141,6 +142,14 @@ daily = 0 # Keep N daily backups per DB
141142 weekly = 0 # Keep N weekly backups per DB
142143 monthly = 0 # Keep N monthly backups per DB
143144
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+
144153 # Note: retention values set to 0 keep no backups for that tier.
145154 # For practical defaults, consider setting daily = 7.
146155 ```
@@ -192,11 +201,15 @@ monthly = 0 # Keep N monthly backups per DB
192201 | `volumes.prefix` | `"gordon"` | Volume prefix |
193202 | `volumes.preserve` | `true` | Keep volumes |
194203 | `backups.enabled` | `false` | Backup service disabled |
204+| `backups.schedule` | `"daily"` | Backup scheduler preset |
195205 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
196206 | `backups.retention.hourly` | `0` | Keep no hourly backups by default |
197207 | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
198208 | `backups.retention.weekly` | `0` | Keep no weekly backups by default |
199209 | `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 |
200213
201214 Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
202215
@@ -269,3 +282,4 @@ gordon serve
269282 - [Authentication](./auth.md)
270283 - [Network Isolation](./network-isolation.md)
271284 - [Volumes](./volumes.md)
285+- [Images](./images.md)
M docs/config/server.md +24 -0
@@ -69,6 +69,30 @@ With TLS enabled:
6969 - `port` remains available for plain HTTP unless you restrict it externally
7070 - If `tls_cert_file`/`tls_key_file` are empty, Gordon auto-generates a self-signed pair in `{data_dir}/tls/`
7171
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+
7296 ### Registry Port
7397
7498 The `registry_port` setting controls where Gordon accepts image pushes:
M go.mod +10 -7
@@ -4,7 +4,7 @@ go 1.25.6
44
55 require (
66 github.com/bnema/zerowrap v1.3.0
7- github.com/charmbracelet/bubbles v0.21.0
7+ github.com/charmbracelet/bubbles v0.21.1
88 github.com/charmbracelet/bubbletea v1.3.10
99 github.com/charmbracelet/lipgloss v1.1.0
1010 github.com/docker/docker v28.5.2+incompatible
@@ -24,14 +24,17 @@ require (
2424 github.com/atotto/clipboard v0.1.4 // indirect
2525 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
2626 github.com/cespare/xxhash/v2 v2.3.0 // indirect
27- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
28- github.com/charmbracelet/x/ansi v0.10.1 // indirect
29- github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
30- github.com/charmbracelet/x/term v0.2.1 // indirect
27+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
28+ github.com/charmbracelet/x/ansi v0.11.5 // indirect
29+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
30+ github.com/charmbracelet/x/term v0.2.2 // indirect
31+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
32+ github.com/clipperhouse/stringish v0.1.1 // indirect
33+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
3134 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
32- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
35+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3336 github.com/mattn/go-localereader v0.0.1 // indirect
34- github.com/mattn/go-runewidth v0.0.16 // indirect
37+ github.com/mattn/go-runewidth v0.0.19 // indirect
3538 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3639 github.com/muesli/cancelreader v0.2.2 // indirect
3740 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 (
88 "regexp"
99 "strings"
1010
11+ tea "github.com/charmbracelet/bubbletea"
1112 "github.com/spf13/cobra"
1213
14+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1315 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1416 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
1517 "github.com/bnema/gordon/pkg/validation"
@@ -236,7 +238,15 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
236238 }
237239 }
238240
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+ }
240250 if err != nil {
241251 return fmt.Errorf("failed to deploy: %w", err)
242252 }
@@ -248,6 +258,102 @@ func deployAfterPush(ctx context.Context, cp ControlPlane, pushDomain string, no
248258 return nil
249259 }
250260
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+
251357 // parseImageRef splits "registry/name:tag" into components.
252358 func parseImageRef(image string) (registry, name, tag string) {
253359 // 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 {
465465
466466 // Backups API
467467
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+
468504 // ListBackups returns backups globally or for a domain.
469505 func (c *Client) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
470506 path := "/backups"
M internal/adapters/in/cli/remote/client_test.go +43 -0
@@ -2,6 +2,7 @@ package remote
22
33 import (
44 "context"
5+ "encoding/json"
56 "net/http"
67 "net/http/httptest"
78 "sync/atomic"
@@ -10,6 +11,8 @@ import (
1011
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/gordon/internal/adapters/dto"
1316 )
1417
1518 func withFastRetry(t *testing.T) {
@@ -108,3 +111,43 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
108111 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
109112 require.NoError(t, err)
110113 }
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:
121121 backupCmd.GroupID = groupManage
122122 rootCmd.AddCommand(backupCmd)
123123
124+ imagesCmd := newImagesCmd()
125+ imagesCmd.GroupID = groupManage
126+ rootCmd.AddCommand(imagesCmd)
127+
124128 // Client-only commands (no server needed)
125129 remotesCmd := newRemotesCmd()
126130 remotesCmd.GroupID = groupClient
M internal/adapters/in/http/admin/handler.go +9 -0
@@ -33,6 +33,7 @@ type Handler struct {
3333 authSvc in.AuthService
3434 containerSvc in.ContainerService
3535 backupSvc in.BackupService
36+ imageSvc in.ImageService
3637 healthSvc in.HealthService
3738 secretSvc in.SecretService
3839 logSvc in.LogService
@@ -132,12 +133,19 @@ func NewHandler(
132133 eventBus out.EventPublisher,
133134 log zerowrap.Logger,
134135 backupSvc in.BackupService,
136+ imageSvcs ...in.ImageService,
135137 ) *Handler {
138+ var imageSvc in.ImageService
139+ if len(imageSvcs) > 0 {
140+ imageSvc = imageSvcs[0]
141+ }
142+
136143 return &Handler{
137144 configSvc: configSvc,
138145 authSvc: authSvc,
139146 containerSvc: containerSvc,
140147 backupSvc: backupSvc,
148+ imageSvc: imageSvc,
141149 healthSvc: healthSvc,
142150 secretSvc: secretSvc,
143151 logSvc: logSvc,
@@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
207215 {"/deploy", h.handleDeploy},
208216 {"/restart", h.handleRestart},
209217 {"/tags", h.handleTags},
218+ {"/images", h.handleImages},
210219 {"/logs", h.handleLogs},
211220 }
212221 for _, route := range prefixRoutes {
M internal/adapters/in/http/admin/handler_test.go +249 -0
@@ -4,10 +4,12 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
78 "fmt"
89 "net/http"
910 "net/http/httptest"
1011 "testing"
12+ "time"
1113
1214 "github.com/bnema/zerowrap"
1315 "github.com/stretchr/testify/assert"
@@ -17,6 +19,25 @@ import (
1719 "github.com/bnema/gordon/internal/domain"
1820 )
1921
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+
2041 func testLogger() zerowrap.Logger {
2142 return zerowrap.Default()
2243 }
@@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) {
12901311 assert.Equal(t, http.StatusOK, rec.Code)
12911312 assert.Contains(t, rec.Body.String(), "postgres")
12921313 }
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 (
1010 "errors"
1111 "fmt"
1212 "io"
13+ "math"
1314 "path/filepath"
1415 "sort"
1516 "strconv"
@@ -20,6 +21,7 @@ import (
2021 "github.com/bnema/zerowrap"
2122 cerrdefs "github.com/containerd/errdefs"
2223 "github.com/docker/docker/api/types/container"
24+ "github.com/docker/docker/api/types/filters"
2325 "github.com/docker/docker/api/types/image"
2426 "github.com/docker/docker/api/types/network"
2527 "github.com/docker/docker/api/types/registry"
@@ -30,6 +32,7 @@ import (
3032
3133 "github.com/bnema/gordon/internal/boundaries/out"
3234 "github.com/bnema/gordon/internal/domain"
35+ runtimepkg "github.com/bnema/gordon/pkg/runtime"
3336 )
3437
3538 // Runtime implements the ContainerRuntime interface using Docker API.
@@ -347,12 +350,13 @@ func (r *Runtime) InspectContainer(ctx context.Context, containerID string) (*do
347350 name := strings.TrimPrefix(resp.Name, "/")
348351
349352 return &domain.Container{
350- ID: resp.ID,
351- Image: resp.Config.Image,
352- Name: name,
353- Status: resp.State.Status,
354- Ports: ports,
355- Labels: resp.Config.Labels,
353+ ID: resp.ID,
354+ Image: resp.Config.Image,
355+ Name: name,
356+ Status: resp.State.Status,
357+ ExitCode: resp.State.ExitCode,
358+ Ports: ports,
359+ Labels: resp.Config.Labels,
356360 }, nil
357361 }
358362
@@ -555,6 +559,81 @@ func (r *Runtime) ListImages(ctx context.Context) ([]string, error) {
555559 return result, nil
556560 }
557561
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+
558637 // Ping checks if Docker is responsive.
559638 func (r *Runtime) Ping(ctx context.Context) error {
560639 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -610,13 +689,29 @@ func (r *Runtime) GetContainerHealthStatus(ctx context.Context, containerID stri
610689 if err != nil {
611690 return "", false, log.WrapErr(err, "failed to inspect container")
612691 }
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 {
614698 return "", false, nil
615699 }
700+ if resp.State == nil || resp.State.Health == nil {
701+ return "", true, nil
702+ }
616703
617704 return resp.State.Health.Status, true, nil
618705 }
619706
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+
620715 // GetContainerPort gets the host port for a container's internal port.
621716 func (r *Runtime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
622717 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 {
2727
2828 // NewBackupStorage creates a new filesystem backup storage.
2929 func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
30+ rootDir = expandTilde(rootDir)
31+
3032 if err := os.MkdirAll(rootDir, 0750); err != nil {
3133 return nil, fmt.Errorf("failed to create backup directory: %w", err)
3234 }
@@ -34,6 +36,17 @@ func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, erro
3436 return &BackupStorage{rootDir: rootDir, log: log}, nil
3537 }
3638
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+
3750 // Store saves backup data and returns the absolute storage path.
3851 func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error) {
3952 domainPart := sanitizeBackupPathComponent(domainName)
M internal/adapters/out/filesystem/blob.go +46 -0
@@ -165,6 +165,52 @@ func (s *BlobStorage) BlobExists(digest string) bool {
165165 return err == nil
166166 }
167167
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+
168214 // StartBlobUpload starts a new blob upload and returns the upload UUID.
169215 func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
170216 uploadID := uuid.New().String()
M internal/adapters/out/filesystem/blob_test.go +33 -0
@@ -211,6 +211,39 @@ func TestBlobStorage_BlobExists(t *testing.T) {
211211 assert.True(t, storage.BlobExists(testDigest2))
212212 }
213213
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+
214247 func TestBlobStorage_StartBlobUpload(t *testing.T) {
215248 tmpDir := t.TempDir()
216249 log := testLogger()
M internal/adapters/out/filesystem/manifest.go +19 -0
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "time"
89
910 "github.com/bnema/zerowrap"
1011
@@ -222,6 +223,24 @@ func (s *ManifestStorage) ListRepositories() ([]string, error) {
222223 return repositories, nil
223224 }
224225
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+
225244 // Helper methods for path generation with security validation
226245
227246 func (s *ManifestStorage) getManifestPath(name, reference string) (string, error) {
M internal/adapters/out/filesystem/manifest_test.go +37 -0
@@ -4,6 +4,7 @@ import (
44 "os"
55 "path/filepath"
66 "testing"
7+ "time"
78
89 "github.com/stretchr/testify/assert"
910 "github.com/stretchr/testify/require"
@@ -67,6 +68,42 @@ func TestManifestStorage_GetManifest_NotFound(t *testing.T) {
6768 assert.Contains(t, err.Error(), "manifest not found")
6869 }
6970
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+
70107 func TestManifestStorage_GetManifest_FallbackContentType(t *testing.T) {
71108 tmpDir := t.TempDir()
72109 log := testLogger()
M internal/app/run.go +209 -14
@@ -58,7 +58,9 @@ import (
5858 "github.com/bnema/gordon/internal/usecase/backup"
5959 "github.com/bnema/gordon/internal/usecase/config"
6060 "github.com/bnema/gordon/internal/usecase/container"
61+ cronSvc "github.com/bnema/gordon/internal/usecase/cron"
6162 "github.com/bnema/gordon/internal/usecase/health"
63+ "github.com/bnema/gordon/internal/usecase/images"
6264 "github.com/bnema/gordon/internal/usecase/logs"
6365 "github.com/bnema/gordon/internal/usecase/proxy"
6466 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
@@ -134,6 +136,7 @@ type Config struct {
134136
135137 Backups struct {
136138 Enabled bool `mapstructure:"enabled"`
139+ Schedule string `mapstructure:"schedule"`
137140 StorageDir string `mapstructure:"storage_dir"`
138141 Retention struct {
139142 Hourly int `mapstructure:"hourly"`
@@ -142,6 +145,14 @@ type Config struct {
142145 Monthly int `mapstructure:"monthly"`
143146 } `mapstructure:"retention"`
144147 } `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"`
145156 }
146157
147158 // services holds all the services used by the application.
@@ -161,6 +172,7 @@ type services struct {
161172 registrySvc *registrySvc.Service
162173 healthSvc *health.Service
163174 logSvc *logs.Service
175+ imageSvc *images.Service
164176 proxySvc *proxy.Service
165177 authSvc *auth.Service
166178 authHandler *authhandler.Handler
@@ -225,7 +237,7 @@ func Run(ctx context.Context, configPath string) error {
225237 registryHandler, proxyHandler := createHTTPHandlers(svc, cfg, log)
226238
227239 // 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)
229241 }
230242
231243 func ensureTLSConfig(cfg *Config, log zerowrap.Logger) error {
@@ -456,6 +468,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
456468 }
457469
458470 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
471+ svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
459472
460473 proxyCfg, err := buildProxyConfig(cfg, log)
461474 if err != nil {
@@ -483,10 +496,22 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
483496 svc.healthSvc = health.NewService(svc.configSvc, svc.containerSvc, prober, log)
484497
485498 // 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)
487500
488501 // 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+ )
490515
491516 return svc, nil
492517 }
@@ -1247,6 +1272,9 @@ func syncAndAutoStart(ctx context.Context, svc *services, log zerowrap.Logger) {
12471272 log.Warn().Err(err).Msg("failed to auto-start containers")
12481273 }
12491274 }
1275+
1276+ // Start background monitor to restart crashed containers.
1277+ svc.containerSvc.StartMonitor(ctx)
12501278 }
12511279 func loopbackOnly(next http.Handler, log zerowrap.Logger) http.Handler {
12521280 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1475,7 +1503,7 @@ func registerAdminRoutes(registryMux, proxyMux *http.ServeMux, svc *services, cf
14751503 // - SIGUSR2: Triggers manual deploy for a specific route
14761504 // The deferred signal.Stop calls ensure signal handlers are properly
14771505 // 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 {
14791507 ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
14801508 defer cancel()
14811509
@@ -1511,22 +1539,16 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
15111539 )
15121540 }
15131541
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.
15151543 // This prevents the race where auto-start pulls from the registry before it's listening.
1516- select {
1517- case <-registryReady:
1518- case err := <-errChan:
1544+ if err := waitForServerReady(registryReady, errChan); err != nil {
15191545 return err
15201546 }
1521- select {
1522- case <-proxyReady:
1523- case err := <-errChan:
1547+ if err := waitForServerReady(proxyReady, errChan); err != nil {
15241548 return err
15251549 }
15261550 if cfg.Server.TLSEnabled {
1527- select {
1528- case <-tlsReady:
1529- case err := <-errChan:
1551+ if err := waitForServerReady(tlsReady, errChan); err != nil {
15301552 return err
15311553 }
15321554 }
@@ -1539,6 +1561,14 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
15391561 }
15401562 logEvent.Msg("Gordon is running")
15411563
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+
15421572 // Auto-start after servers are listening (registry port is now bound).
15431573 syncAndAutoStart(ctx, svc, log)
15441574
@@ -1547,6 +1577,165 @@ func runServers(ctx context.Context, cfg Config, registryHandler, proxyHandler h
15471577 return nil
15481578 }
15491579
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+
15501739 // waitForShutdown blocks on the event loop, handling server errors and
15511740 // Unix signals (reload, deploy, shutdown) until the context is cancelled.
15521741 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
15931782 log.Warn().Err(err).Msg("proxy server shutdown error")
15941783 }
15951784
1785+ containerSvc.StopMonitor()
1786+
15961787 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
15971788 log.Warn().Err(err).Msg("error during container shutdown")
15981789 }
@@ -1961,11 +2152,15 @@ func loadConfig(v *viper.Viper, configPath string) error {
19612152 v.SetDefault("volumes.preserve", true)
19622153 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
19632154 v.SetDefault("backups.enabled", false)
2155+ v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
19642156 v.SetDefault("backups.storage_dir", "")
19652157 v.SetDefault("backups.retention.hourly", 0)
19662158 v.SetDefault("backups.retention.daily", 0)
19672159 v.SetDefault("backups.retention.weekly", 0)
19682160 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)
19692164 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
19702165 v.SetDefault("server.registry_allowed_ips", []string{})
19712166 v.SetDefault("deploy.readiness_delay", "5s")
M internal/app/run_backup_test.go +40 -0
@@ -3,8 +3,11 @@ package app
33 import (
44 "testing"
55
6+ "github.com/spf13/viper"
67 "github.com/stretchr/testify/assert"
78 "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
811 )
912
1013 func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
@@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
8689 assert.Equal(t, 0, retention.Monthly)
8790 })
8891 }
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)
503503 return _c
504504 }
505505
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+
506561 // PutBlob provides a mock function for the type MockBlobStorage
507562 func (_mock *MockBlobStorage) PutBlob(digest string, data io.Reader, size int64) error {
508563 ret := _mock.Called(digest, data, size)
M internal/boundaries/out/mocks/mock_manifest_storage.go +68 -0
@@ -5,6 +5,8 @@
55 package mocks
66
77 import (
8+ "time"
9+
810 mock "github.com/stretchr/testify/mock"
911 )
1012
@@ -166,6 +168,72 @@ func (_c *MockManifestStorage_GetManifest_Call) RunAndReturn(run func(name strin
166168 return _c
167169 }
168170
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+
169237 // ListRepositories provides a mock function for the type MockManifestStorage
170238 func (_mock *MockManifestStorage) ListRepositories() ([]string, error) {
171239 ret := _mock.Called()
M internal/boundaries/out/storage.go +7 -0
@@ -2,6 +2,7 @@ package out
22
33 import (
44 "io"
5+ "time"
56 )
67
78 // BlobStorage defines the contract for blob storage operations.
@@ -21,6 +22,9 @@ type BlobStorage interface {
2122 // BlobExists checks if a blob exists.
2223 BlobExists(digest string) bool
2324
25+ // ListBlobs returns all blob digests.
26+ ListBlobs() ([]string, error)
27+
2428 // StartBlobUpload starts a new blob upload and returns the upload UUID.
2529 StartBlobUpload(name string) (string, error)
2630
@@ -54,4 +58,7 @@ type ManifestStorage interface {
5458
5559 // ListRepositories returns all repository names.
5660 ListRepositories() ([]string, error)
61+
62+ // GetManifestModTime returns the manifest modification time.
63+ GetManifestModTime(name, reference string) (time.Time, error)
5764 }
M internal/domain/container.go +7 -6
@@ -4,12 +4,13 @@ package domain
44
55 // Container represents a running container in the system.
66 type Container struct {
7- ID string
8- Image string
9- Name string
10- Status string
11- Ports []int
12- Labels map[string]string
7+ ID string
8+ Image string
9+ Name string
10+ Status string
11+ ExitCode int
12+ Ports []int
13+ Labels map[string]string
1314 }
1415
1516 // NetworkInfo represents network configuration and state.
M internal/domain/event.go +17 -0
@@ -81,3 +81,20 @@ func IsInternalDeploy(ctx context.Context) bool {
8181 func WithInternalDeploy(ctx context.Context) context.Context {
8282 return context.WithValue(ctx, ContextKeyInternalDeploy, true)
8383 }
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 @@
11 package backup
22
33 import (
4+ "slices"
45 "strings"
56
67 "github.com/bnema/gordon/internal/domain"
78 )
89
10+// postgresAliases contains substrings that identify a PostgreSQL image or service.
11+var postgresAliases = []string{"postgres", "postgresql", "pgsql", "postgis"}
12+
913 // detectDatabaseFromAttachment maps a container attachment to DB metadata.
1014 func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domain.DBInfo, bool) {
1115 image := strings.ToLower(a.Image)
1216 name := strings.ToLower(a.Name)
1317
1418 switch {
15- case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"):
16- port := 5432
17- hasPostgresPort := false
18- firstPositive := 0
19- for _, p := range a.Ports {
20- if p == 5432 {
21- port = 5432
22- hasPostgresPort = true
23- break
24- }
25- if p > 0 && firstPositive == 0 {
26- firstPositive = p
27- }
28- }
29- if !hasPostgresPort && firstPositive > 0 {
30- port = firstPositive
31- }
32- return domain.DBInfo{
33- Type: domain.DBTypePostgreSQL,
34- Version: postgresVersionFromImage(a.Image),
35- Domain: domainName,
36- Name: a.Name,
37- Host: a.Name,
38- Port: port,
39- ContainerID: a.ContainerID,
40- ImageName: a.Image,
41- }, true
19+ case looksLikePostgres(image) || looksLikePostgres(name):
20+ return buildPostgresInfo(domainName, a), true
21+ case hasPort(a.Ports, 5432):
22+ // Port 5432 fallback: likely PostgreSQL even with non-standard naming.
23+ return buildPostgresInfo(domainName, a), true
4224 default:
4325 return domain.DBInfo{}, false
4426 }
4527 }
4628
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+
4769 func postgresVersionFromImage(image string) string {
4870 lastColon := strings.LastIndex(image, ":")
4971 if lastColon == -1 {
M internal/usecase/backup/detector_test.go +102 -0
@@ -76,3 +76,105 @@ func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) {
7676 })
7777 assert.False(t, ok)
7878 }
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
6666
6767 // RunBackup triggers a logical PostgreSQL backup for a detected DB.
6868 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+ }
70112
113+ return firstErr
114+}
115+
116+func (s *Service) runBackup(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule) (*domain.BackupResult, error) {
71117 dbs, err := s.DetectDatabases(ctx, domainName)
72118 if err != nil {
73119 return nil, err
@@ -78,6 +124,12 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
78124 return nil, err
79125 }
80126
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+
81133 if db.Type != domain.DBTypePostgreSQL {
82134 return nil, fmt.Errorf("unsupported database type: %s", db.Type)
83135 }
@@ -108,7 +160,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
108160 defer dumpStream.Close()
109161
110162 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))
112164 if err != nil {
113165 return nil, err
114166 }
@@ -131,6 +183,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
131183 }, nil
132184 }
133185
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+
134195 // Restore restores a backup by ID.
135196 func (s *Service) Restore(context.Context, string, string) error {
136197 return fmt.Errorf("backup restore not implemented yet")
@@ -230,18 +291,14 @@ func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error
230291 return domain.DBInfo{}, fmt.Errorf("database %q not found for domain", requested)
231292 }
232293
233-func postgresDumpCommand(dbName string) string {
234- return fmt.Sprintf("PGDATABASE=%s pg_dump -Fc --dbname=\"$PGDATABASE\" --username=\"${POSTGRES_USER:-postgres}\"", shellQuote(dbName))
294+func postgresDumpCommand(_ string) string {
295+ return "pg_dump -Fc --dbname=\"${POSTGRES_DB:-postgres}\" --username=\"${POSTGRES_USER:-postgres}\""
235296 }
236297
237298 func pgDumpToPathCommand(path, dbName string) string {
238299 return fmt.Sprintf("%s > %q", postgresDumpCommand(dbName), path)
239300 }
240301
241-func shellQuote(value string) string {
242- return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
243-}
244-
245302 func (s *Service) cleanupDumpFile(containerID, dumpPath string) {
246303 cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
247304 defer cancel()
M internal/usecase/backup/service_test.go +61 -4
@@ -50,7 +50,7 @@ func TestService_RunBackup_Postgres(t *testing.T) {
5050 return false
5151 }
5252 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}")) &&
5454 bytes.Contains([]byte(cmd[2]), []byte(" > "))
5555 })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil)
5656
@@ -125,9 +125,10 @@ func TestSelectDatabaseAutoSelectsOnlyDatabaseWhenUnspecified(t *testing.T) {
125125 assert.Equal(t, "postgres", db.Name)
126126 }
127127
128-func TestPostgresDumpCommandQuotesDatabaseName(t *testing.T) {
129- cmd := postgresDumpCommand("customer data")
130- assert.Contains(t, cmd, "PGDATABASE='customer data'")
128+func TestPostgresDumpCommandUsesEnvVar(t *testing.T) {
129+ cmd := postgresDumpCommand("ignored")
130+ assert.Contains(t, cmd, "${POSTGRES_DB:-postgres}")
131+ assert.Contains(t, cmd, "${POSTGRES_USER:-postgres}")
131132 }
132133
133134 func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testing.T) {
@@ -177,3 +178,59 @@ func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testi
177178 t.Fatal("Status did not return after context cancellation while waiting for semaphore")
178179 }
179180 }
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 {
6969 attachments map[string][]string // ownerDomain → []containerIDs
7070 mu sync.RWMutex
7171 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
72+ monitor *Monitor
7273 }
7374
7475 // domainDeployLock is a context-aware mutex using a buffered channel.
@@ -347,9 +348,11 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
347348 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
348349 return nil, log.WrapErr(err, "failed to start container")
349350 }
350- if err := s.waitForReady(ctx, newContainer.ID); err != nil {
351- s.cleanupFailedContainer(ctx, newContainer.ID)
352- return nil, log.WrapErr(err, "container failed readiness check")
351+ if !domain.IsSkipReadiness(ctx) {
352+ if err := s.waitForReady(ctx, newContainer.ID); err != nil {
353+ s.cleanupFailedContainer(ctx, newContainer.ID)
354+ return nil, log.WrapErr(err, "container failed readiness check")
355+ }
353356 }
354357
355358 inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
@@ -805,6 +808,8 @@ func (s *Service) SyncContainers(ctx context.Context) error {
805808 }
806809
807810 // 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.
808813 func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
809814 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
810815 zerowrap.FieldLayer: "usecase",
@@ -814,37 +819,67 @@ func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
814819
815820 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
816821
817- var started, skipped, errors int
822+ // Filter out routes that already have a running container.
823+ var pending []domain.Route
824+ skipped := 0
818825 for _, route := range routes {
819- // Check if container already exists and is running
820826 if _, exists := s.Get(ctx, route.Domain); exists {
821827 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
822828 skipped++
823- continue
829+ } else {
830+ pending = append(pending, route)
824831 }
832+ }
825833
826- log.Info().
827- Str("domain", route.Domain).
828- Str("image", route.Image).
829- Msg("auto-starting container for route")
834+ if len(pending) == 0 {
835+ log.Info().Int("skipped", skipped).Msg("auto-start completed, all containers already running")
836+ return nil
837+ }
830838
831- if _, err := s.Deploy(ctx, route); err != nil {
832- log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
833- errors++
834- continue
835- }
839+ // Deploy all pending routes concurrently with readiness checks skipped.
840+ deployCtx := domain.WithSkipReadiness(ctx)
841+
842+ type result struct {
843+ route domain.Route
844+ err error
845+ }
846+ results := make(chan result, len(pending))
847+ sem := make(chan struct{}, 4) // limit concurrency
836848
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+ }
838873 }
839874
840875 log.Info().
841876 Int("started", started).
842877 Int("skipped", skipped).
843- Int("errors", errors).
878+ Int("errors", errCount).
844879 Msg("auto-start completed")
845880
846- if errors > 0 {
847- return fmt.Errorf("auto-start completed with %d errors", errors)
881+ if errCount > 0 {
882+ return fmt.Errorf("auto-start completed with %d errors", errCount)
848883 }
849884 return nil
850885 }
@@ -858,9 +893,8 @@ func (s *Service) Shutdown(ctx context.Context) error {
858893 log := zerowrap.FromCtx(ctx)
859894 log.Info().Msg("shutting down container manager...")
860895
861- if err := s.stopAllManagedContainers(ctx); err != nil {
862- log.WrapErr(err, "failed to stop all containers during shutdown")
863- }
896+ // Containers are left running across Gordon restarts.
897+ // SyncContainers + AutoStart will pick them back up on next boot.
864898
865899 // Close log writer to stop all log collection
866900 if s.logWriter != nil {
@@ -873,6 +907,33 @@ func (s *Service) Shutdown(ctx context.Context) error {
873907 return nil
874908 }
875909
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+
876937 // UpdateConfig updates the service configuration.
877938 func (s *Service) UpdateConfig(config Config) {
878939 s.mu.Lock()
@@ -1720,46 +1781,6 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
17201781 return nil
17211782 }
17221783
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-
17631784 // Utility functions
17641785
17651786 func normalizeImageRef(image string) string {
@@ -1969,7 +1990,7 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
19691990 waitCtx, cancel := context.WithTimeout(ctx, timeout)
19701991 defer cancel()
19711992
1972- lastStatus := "starting"
1993+ var lastStatus string
19731994 for {
19741995 status, hasHealthcheck, err := s.runtime.GetContainerHealthStatus(waitCtx, containerID)
19751996 if err != nil {
@@ -1978,12 +1999,15 @@ func (s *Service) waitForHealthy(ctx context.Context, containerID string, timeou
19781999 if !hasHealthcheck {
19792000 return errors.New("no healthcheck detected")
19802001 }
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+ }
19812007 if status == "healthy" {
19822008 return nil
19832009 }
1984- if status != "" {
1985- lastStatus = status
1986- }
2010+ lastStatus = status
19872011
19882012 select {
19892013 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) {
630630 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
631631 svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
632632
633- runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
634- runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(nil)
635-
633+ // Shutdown no longer stops containers — they are left running for
634+ // SyncContainers + AutoStart to pick back up on next boot.
636635 err := svc.Shutdown(ctx)
637636
638637 assert.NoError(t, err)
639- assert.Empty(t, svc.containers)
640-}
641-
642-func TestService_Shutdown_PartialFailure(t *testing.T) {
643- runtime := mocks.NewMockContainerRuntime(t)
644- envLoader := mocks.NewMockEnvLoader(t)
645- eventBus := mocks.NewMockEventPublisher(t)
646-
647- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
648- ctx := testContext()
649-
650- svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
651- svc.containers["app2.example.com"] = &domain.Container{ID: "container-2"}
652-
653- runtime.EXPECT().StopContainer(mock.Anything, "container-1").Return(nil)
654- runtime.EXPECT().StopContainer(mock.Anything, "container-2").Return(errors.New("stop failed"))
655-
656- err := svc.Shutdown(ctx)
657-
658- // Shutdown logs errors but always returns nil for graceful degradation
659- assert.NoError(t, err)
660- // One container should still be tracked (the one that failed to stop)
661- assert.Len(t, svc.containers, 1)
638+ // Containers remain tracked (not stopped).
639+ assert.Len(t, svc.containers, 2)
662640 }
663641
664642 func TestService_Restart_NotFound(t *testing.T) {
@@ -1196,14 +1174,14 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
11961174 {Domain: "app2.example.com", Image: "myapp2:latest"},
11971175 }
11981176
1199- // Setup mocks for first route deployment
1177+ // Setup mocks for route deployments
12001178 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Times(2)
12011179 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp1:latest", "myapp2:latest"}, nil).Times(2)
12021180 runtime.EXPECT().GetImageExposedPorts(mock.Anything, mock.AnythingOfType("string")).Return([]int{8080}, nil).Times(2)
12031181 envLoader.EXPECT().LoadEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
12041182 runtime.EXPECT().InspectImageEnv(mock.Anything, mock.AnythingOfType("string")).Return([]string{}, nil).Times(2)
12051183
1206- // Create and start containers
1184+ // Create and start containers — readiness is skipped so no IsContainerRunning calls.
12071185 runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{
12081186 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
12091187 }, nil).Once()
@@ -1211,7 +1189,6 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
12111189 ID: "container-2", Name: "gordon-app2.example.com", Status: "created",
12121190 }, nil).Once()
12131191 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)
12151192 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).Return(&domain.Container{
12161193 Status: "running",
12171194 }, nil).Times(2)
@@ -1245,7 +1222,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
12451222 {Domain: "app2.example.com", Image: "myapp2:latest"}, // New route
12461223 }
12471224
1248- // Only deploy for app2 (app1 is skipped)
1225+ // Only deploy for app2 (app1 is skipped). Readiness is skipped — no IsContainerRunning.
12491226 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
12501227 runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp2:latest"}, nil).Once()
12511228 runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp2:latest").Return([]int{8080}, nil).Once()
@@ -1255,7 +1232,6 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
12551232 ID: "container-2", Status: "created",
12561233 }, nil).Once()
12571234 runtime.EXPECT().StartContainer(mock.Anything, "container-2").Return(nil).Once()
1258- runtime.EXPECT().IsContainerRunning(mock.Anything, "container-2").Return(true, nil).Times(2)
12591235 runtime.EXPECT().InspectContainer(mock.Anything, "container-2").Return(&domain.Container{
12601236 Status: "running",
12611237 }, nil).Once()
@@ -1313,7 +1289,8 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
13131289 }
13141290
13151291 // Key assertion: PullImage should be called with localhost:5000 rewrite,
1316- // NOT the original reg.example.com/myapp:latest
1292+ // NOT the original reg.example.com/myapp:latest.
1293+ // Readiness is skipped — no IsContainerRunning calls.
13171294 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil)
13181295 runtime.EXPECT().PullImage(mock.Anything, "localhost:5000/myapp:latest").Return(nil)
13191296 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) {
13251302 ID: "container-1", Name: "gordon-app1.example.com", Status: "created",
13261303 }, nil)
13271304 runtime.EXPECT().StartContainer(mock.Anything, "container-1").Return(nil)
1328- runtime.EXPECT().IsContainerRunning(mock.Anything, "container-1").Return(true, nil).Times(2)
13291305 runtime.EXPECT().InspectContainer(mock.Anything, "container-1").Return(&domain.Container{
13301306 Status: "running",
13311307 }, 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
33
44 import (
55 "bufio"
6+ "bytes"
67 "context"
78 "encoding/binary"
89 "fmt"
910 "io"
1011 "os"
12+ "os/exec"
13+ "strconv"
1114 "strings"
1215 "time"
1316
@@ -19,24 +22,29 @@ import (
1922
2023 // Service implements the LogService interface.
2124 type Service struct {
22- logFilePath string
23- containerSvc in.ContainerService
24- runtime out.ContainerRuntime
25- log zerowrap.Logger
25+ logFilePath string
26+ fileLoggingEnabled bool
27+ containerSvc in.ContainerService
28+ runtime out.ContainerRuntime
29+ log zerowrap.Logger
2630 }
2731
32+var execCommandContext = exec.CommandContext
33+
2834 // NewService creates a new log service.
2935 func NewService(
3036 logFilePath string,
37+ fileLoggingEnabled bool,
3138 containerSvc in.ContainerService,
3239 runtime out.ContainerRuntime,
3340 log zerowrap.Logger,
3441 ) *Service {
3542 return &Service{
36- logFilePath: logFilePath,
37- containerSvc: containerSvc,
38- runtime: runtime,
39- log: log,
43+ logFilePath: logFilePath,
44+ fileLoggingEnabled: fileLoggingEnabled,
45+ containerSvc: containerSvc,
46+ runtime: runtime,
47+ log: log,
4048 }
4149 }
4250
@@ -49,20 +57,26 @@ func (s *Service) GetProcessLogs(ctx context.Context, lines int) ([]string, erro
4957 })
5058 log := zerowrap.FromCtx(ctx)
5159
52- if s.logFilePath == "" {
53- return nil, fmt.Errorf("log file path not configured")
54- }
60+ if s.useFileProcessLogs() {
61+ if s.logFilePath == "" {
62+ return nil, fmt.Errorf("log file path not configured")
63+ }
5564
56- file, err := os.Open(s.logFilePath)
57- if err != nil {
58- if os.IsNotExist(err) {
59- return []string{}, nil
65+ file, err := os.Open(s.logFilePath)
66+ if err != nil {
67+ if os.IsNotExist(err) {
68+ log.Warn().Str("log_path", s.logFilePath).
69+ Msg("process log file missing, falling back to journalctl")
70+ return s.getProcessLogsFromJournal(ctx, lines)
71+ }
72+ return nil, log.WrapErr(err, "failed to open log file")
6073 }
61- return nil, log.WrapErr(err, "failed to open log file")
74+ defer file.Close()
75+
76+ return tailLines(file, lines)
6277 }
63- defer file.Close()
6478
65- return tailLines(file, lines)
79+ return s.getProcessLogsFromJournal(ctx, lines)
6680 }
6781
6882 // FollowProcessLogs returns a channel that streams Gordon process log lines.
@@ -72,67 +86,198 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
7286 zerowrap.FieldUseCase: "FollowProcessLogs",
7387 "initial_lines": initialLines,
7488 })
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) {
75104 log := zerowrap.FromCtx(ctx)
76105
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) {
77120 if s.logFilePath == "" {
78121 return nil, fmt.Errorf("log file path not configured")
79122 }
80123
81- file, err := os.Open(s.logFilePath)
82- if err != nil {
83- return nil, log.WrapErr(err, "failed to open log file")
84- }
124+ return os.Open(s.logFilePath)
125+}
85126
127+func (s *Service) followProcessLogsFromFile(
128+ ctx context.Context,
129+ file *os.File,
130+ initialLines int,
131+) <-chan string {
132+ log := zerowrap.FromCtx(ctx)
86133 ch := make(chan string, 100)
87134
88135 go func() {
89136 defer close(ch)
90137 defer file.Close()
91138
92- // First, get initial lines
93- if initialLines > 0 {
94- lines, err := tailLines(file, initialLines)
95- if err != nil {
96- log.Warn().Err(err).Msg("failed to read initial lines")
97- } else {
98- for _, line := range lines {
99- select {
100- case ch <- line:
101- case <-ctx.Done():
102- return
103- }
104- }
139+ if !s.emitInitialFileLogs(ctx, file, initialLines, ch) {
140+ return
141+ }
142+ if err := s.seekToEndForFileFollow(file); err != nil {
143+ log.Warn().Err(err).Msg("failed to seek to end")
144+ return
145+ }
146+
147+ s.streamFileFollowLogs(ctx, file, ch)
148+ }()
149+
150+ return ch
151+}
152+
153+func (s *Service) emitInitialFileLogs(
154+ ctx context.Context,
155+ file *os.File,
156+ initialLines int,
157+ ch chan<- string,
158+) bool {
159+ if initialLines <= 0 {
160+ return true
161+ }
162+
163+ log := zerowrap.FromCtx(ctx)
164+ lines, err := tailLines(file, initialLines)
165+ if err != nil {
166+ log.Warn().Err(err).Msg("failed to read initial lines")
167+ return true
168+ }
169+
170+ for _, line := range lines {
171+ if !sendLogLineWithChannel(ctx, ch, line) {
172+ return false
173+ }
174+ }
175+
176+ return true
177+}
178+
179+func (s *Service) seekToEndForFileFollow(file *os.File) error {
180+ _, err := file.Seek(0, io.SeekEnd)
181+ return err
182+}
183+
184+func (s *Service) streamFileFollowLogs(ctx context.Context, file *os.File, ch chan<- string) {
185+ log := zerowrap.FromCtx(ctx)
186+ reader := bufio.NewReader(file)
187+
188+ for {
189+ if ctx.Err() != nil {
190+ return
191+ }
192+
193+ line, err := reader.ReadString('\n')
194+ if err != nil {
195+ if err == io.EOF {
196+ time.Sleep(100 * time.Millisecond)
197+ continue
105198 }
199+ log.Warn().Err(err).Msg("error reading log file")
200+ return
106201 }
107202
108- // Seek to end for following
109- if _, err := file.Seek(0, io.SeekEnd); err != nil {
110- log.Warn().Err(err).Msg("failed to seek to end")
203+ line = strings.TrimRight(line, "\n\r")
204+ if !sendLogLineWithChannel(ctx, ch, line) {
111205 return
112206 }
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+ }
113229
114- // Follow new lines
115- reader := bufio.NewReader(file)
116- for {
230+ out, err := execCommandContext(ctx, "journalctl", args...).Output()
231+ if err != nil {
232+ return nil, fmt.Errorf("failed to read journalctl logs: %w", err)
233+ }
234+
235+ return outputLines(out), nil
236+}
237+
238+func (s *Service) followProcessLogsFromJournal(ctx context.Context, initialLines int) (<-chan string, error) {
239+ userScope, err := resolveJournalctlScope(ctx)
240+ if err != nil {
241+ return nil, err
242+ }
243+
244+ args := []string{
245+ "-u", "gordon",
246+ "-n", strconv.Itoa(initialLines),
247+ "--no-pager",
248+ "-o", "cat",
249+ "-f",
250+ }
251+ if userScope {
252+ args = append([]string{"--user"}, args...)
253+ }
254+
255+ cmd := execCommandContext(ctx, "journalctl", args...) // #nosec G204 -- internal args only
256+ stdout, err := cmd.StdoutPipe()
257+ if err != nil {
258+ return nil, fmt.Errorf("failed to open journalctl stdout: %w", err)
259+ }
260+ cmd.Stderr = io.Discard
261+
262+ if err := cmd.Start(); err != nil {
263+ return nil, fmt.Errorf("failed to start journalctl follow: %w", err)
264+ }
265+
266+ ch := make(chan string, 100)
267+
268+ go func() {
269+ defer close(ch)
270+ defer stdout.Close()
271+ defer cmd.Wait() //nolint:errcheck // best-effort cleanup
272+
273+ scanner := bufio.NewScanner(stdout)
274+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
275+
276+ for scanner.Scan() {
117277 select {
278+ case ch <- scanner.Text():
118279 case <-ctx.Done():
119280 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- }
136281 }
137282 }
138283 }()
@@ -140,6 +285,47 @@ func (s *Service) FollowProcessLogs(ctx context.Context, initialLines int) (<-ch
140285 return ch, nil
141286 }
142287
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+
143329 // validateDomain checks that domain is safe for use in logs and error messages.
144330 func validateDomain(domain string) error {
145331 if domain == "" {
M internal/usecase/logs/service_test.go +28 -8
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "os"
7+ "os/exec"
78 "path/filepath"
89 "testing"
910 "time"
@@ -32,7 +33,7 @@ func TestService_GetProcessLogs(t *testing.T) {
3233 containerSvc := mocks.NewMockContainerService(t)
3334 runtime := outMocks.NewMockContainerRuntime(t)
3435
35- svc := NewService(logPath, containerSvc, runtime, log)
36+ svc := NewService(logPath, true, containerSvc, runtime, log)
3637
3738 lines, err := svc.GetProcessLogs(context.Background(), 3)
3839 require.NoError(t, err)
@@ -44,7 +45,7 @@ func TestService_GetProcessLogs(t *testing.T) {
4445 containerSvc := mocks.NewMockContainerService(t)
4546 runtime := outMocks.NewMockContainerRuntime(t)
4647
47- svc := NewService("/nonexistent/file.log", containerSvc, runtime, log)
48+ svc := NewService("/nonexistent/file.log", true, containerSvc, runtime, log)
4849
4950 lines, err := svc.GetProcessLogs(context.Background(), 10)
5051 require.NoError(t, err)
@@ -55,7 +56,7 @@ func TestService_GetProcessLogs(t *testing.T) {
5556 containerSvc := mocks.NewMockContainerService(t)
5657 runtime := outMocks.NewMockContainerRuntime(t)
5758
58- svc := NewService("", containerSvc, runtime, log)
59+ svc := NewService("", true, containerSvc, runtime, log)
5960
6061 _, err := svc.GetProcessLogs(context.Background(), 10)
6162 assert.Error(t, err)
@@ -72,12 +73,31 @@ func TestService_GetProcessLogs(t *testing.T) {
7273 containerSvc := mocks.NewMockContainerService(t)
7374 runtime := outMocks.NewMockContainerRuntime(t)
7475
75- svc := NewService(logPath, containerSvc, runtime, log)
76+ svc := NewService(logPath, true, containerSvc, runtime, log)
7677
7778 lines, err := svc.GetProcessLogs(context.Background(), 10)
7879 require.NoError(t, err)
7980 assert.Equal(t, []string{"line1", "line2"}, lines)
8081 })
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+ })
81101 }
82102
83103 func TestService_FollowProcessLogs(t *testing.T) {
@@ -93,7 +113,7 @@ func TestService_FollowProcessLogs(t *testing.T) {
93113 containerSvc := mocks.NewMockContainerService(t)
94114 runtime := outMocks.NewMockContainerRuntime(t)
95115
96- svc := NewService(logPath, containerSvc, runtime, log)
116+ svc := NewService(logPath, true, containerSvc, runtime, log)
97117
98118 ctx, cancel := context.WithCancel(context.Background())
99119 defer cancel()
@@ -135,7 +155,7 @@ func TestService_GetContainerLogs(t *testing.T) {
135155
136156 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
137157
138- svc := NewService("/tmp/test.log", containerSvc, runtime, log)
158+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
139159
140160 _, err := svc.GetContainerLogs(context.Background(), "unknown.local", 10)
141161 assert.Error(t, err)
@@ -156,7 +176,7 @@ func TestService_GetContainerLogs(t *testing.T) {
156176 // Create a mock reader that returns empty content
157177 runtime.EXPECT().GetContainerLogs(mock.Anything, "abc123", false).Return(&mockReader{}, nil)
158178
159- svc := NewService("/tmp/test.log", containerSvc, runtime, log)
179+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
160180
161181 lines, err := svc.GetContainerLogs(context.Background(), "app.local", 10)
162182 require.NoError(t, err)
@@ -173,7 +193,7 @@ func TestService_FollowContainerLogs(t *testing.T) {
173193
174194 containerSvc.EXPECT().Get(mock.Anything, "unknown.local").Return(nil, false)
175195
176- svc := NewService("/tmp/test.log", containerSvc, runtime, log)
196+ svc := NewService("/tmp/test.log", true, containerSvc, runtime, log)
177197
178198 _, err := svc.FollowContainerLogs(context.Background(), "unknown.local", 10)
179199 assert.Error(t, err)
M internal/usecase/proxy/service.go +27 -8
@@ -24,8 +24,8 @@ import (
2424 "github.com/bnema/gordon/internal/domain"
2525 )
2626
27-// proxyTransport is a shared HTTP transport with proper timeouts.
28-// This prevents resource exhaustion from slow backends or network issues.
27+// proxyTransport is a shared HTTP transport for proxying to application containers.
28+// ResponseHeaderTimeout is kept short to detect unresponsive backends quickly.
2929 var proxyTransport = &http.Transport{
3030 DialContext: (&net.Dialer{
3131 Timeout: 10 * time.Second,
@@ -38,6 +38,21 @@ var proxyTransport = &http.Transport{
3838 IdleConnTimeout: 90 * time.Second,
3939 }
4040
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+
4156 // Config holds configuration needed by the proxy service.
4257 type Config struct {
4358 RegistryDomain string
@@ -558,8 +573,14 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
558573 return
559574 }
560575
561- proxy := newReverseProxy(targetURL, "",
562- func(w http.ResponseWriter, _ *http.Request, err error) {
576+ proxy := &httputil.ReverseProxy{
577+ Rewrite: func(pr *httputil.ProxyRequest) {
578+ pr.SetURL(targetURL)
579+ pr.SetXForwarded()
580+ pr.Out.Host = targetURL.Host
581+ },
582+ Transport: registryTransport,
583+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
563584 var maxBytesErr *http.MaxBytesError
564585 if errors.As(err, &maxBytesErr) {
565586 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) {
569590 log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
570591 proxyError(w, "Registry Unavailable", http.StatusServiceUnavailable)
571592 },
572- func(resp *http.Response) error {
593+ ModifyResponse: func(resp *http.Response) error {
573594 resp.Header.Set("X-Proxied-By", "Gordon")
574595 resp.Header.Set("X-Registry-Backend", "gordon-registry")
575596
576597 // 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.
579598 resp.Header.Del("X-Content-Type-Options")
580599 resp.Header.Del("X-Frame-Options")
581600 resp.Header.Del("X-XSS-Protection")
@@ -586,7 +605,7 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
586605
587606 return nil
588607 },
589- )
608+ }
590609
591610 log.Debug().Str("target", targetURL.String()).Msg("proxying request to registry")
592611
M internal/usecase/registry/service.go +5 -2
@@ -6,6 +6,7 @@ import (
66 "crypto/sha256"
77 "fmt"
88 "io"
9+ "strings"
910
1011 "github.com/bnema/zerowrap"
1112
@@ -74,8 +75,10 @@ func (s *Service) PutManifest(ctx context.Context, manifest *domain.Manifest) (s
7475 return "", log.WrapErr(err, "failed to store manifest")
7576 }
7677
77- // Publish image pushed event
78- if s.eventBus != nil {
78+ // Publish image pushed event only for tag references (not digests).
79+ // A docker push sends manifests by both digest and tag; firing only on
80+ // tag prevents duplicate deploy triggers for the same push.
81+ if s.eventBus != nil && !strings.HasPrefix(manifest.Reference, "sha256:") {
7982 if err := s.eventBus.Publish(domain.EventImagePushed, domain.ImagePushedPayload{
8083 Name: manifest.Name,
8184 Reference: manifest.Reference,
M pkg/runtime/interface.go +26 -0
@@ -3,6 +3,7 @@ package runtime
33 import (
44 "context"
55 "io"
6+ "time"
67 )
78
89 // Container represents a running container
@@ -40,6 +41,26 @@ type ContainerConfig struct {
4041 Aliases []string // Additional network aliases
4142 }
4243
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+
4364 // Runtime interface defines the contract for container runtime implementations
4465 type Runtime interface {
4566 // Container lifecycle
@@ -59,6 +80,11 @@ type Runtime interface {
5980 PullImageWithAuth(ctx context.Context, image, username, password string) error
6081 RemoveImage(ctx context.Context, image string, force bool) error
6182 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)
6288
6389 // Runtime information
6490 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-
2020
2121 ## Understanding the Remote URL
2222
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.
2424
2525 ```toml
2626 # On the remote Gordon server
@@ -30,6 +30,40 @@ gordon_domain = "gordon.example.com" # Use this as --remote URL
3030
3131 The CLI connects to `https://gordon.example.com/admin/*` endpoints.
3232
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+
3367 ## Quick Start
3468
3569 ### One-off Remote Command
@@ -67,6 +101,7 @@ These flags are available on all commands:
67101 |------|-------------|
68102 | `--remote <URL>` | Remote Gordon URL |
69103 | `--token <TOKEN>` | Authentication token |
104+| `--insecure` | Skip TLS verification (self-signed/private certs) |
70105
71106 ```bash
72107 # List routes on remote
M wiki/guides/secure-vps-setup.md +49 -0
@@ -142,6 +142,55 @@ Verify public SSH is blocked:
142142 ssh -o ConnectTimeout=5 root@<public-ip> # Should timeout
143143 ```
144144
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+
145194 ---
146195
147196 ## 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