detached 05b767c

feat: infrastructure hygiene — runtime detection, upload cleanup, volume management (#131)

* feat(docker): add runtime socket auto-detection (#128)

* feat(docker): add NewRuntimeWithSocket constructor (#128)

* feat(boundaries): add CleanupStaleUploads to BlobStorage interface (#129)

* feat(domain): add VolumeInfo and VolumePruneReport types (#130)

* feat(filesystem): implement CleanupStaleUploads (#129)

* feat(runtime): add ListVolumes to ContainerRuntime (#130)

* feat(volumes): add VolumeService with list and prune (#130)

* feat(images): include stale upload cleanup in registry prune (#129)

PruneRegistry now removes upload files older than 24 hours.
Upload cleanup is non-fatal — failures are logged as warnings.
Scheduler log includes upload removal stats.

* feat(app): auto-detect container runtime at startup (#128)

Probes Docker and Podman sockets instead of hardcoding Docker.
Supports explicit socket path via server.runtime config.
Clear error message when no runtime is found.

* feat(dto): add volume DTOs (#130)

* feat(cli): extend ControlPlane with volume methods (#130)

* feat(http): add volume list and prune API endpoints (#130)

* feat(cli): add volumes list and volumes prune commands (#130)

* fix: address CodeRabbit review findings and tidy up

- Log warning on entry.Info() failure in CleanupStaleUploads
- Acquire upload lock before file removal to prevent race condition
- Use generic error messages in volume HTTP handlers
- Remove unused imports and dead code in touched files

* fix: remove duplicated setConfigDefaults, add server.runtime to loadConfig

The agent created a 65-line duplicate of all config defaults just to
test one default value. Removed it and placed server.runtime in
loadConfig where all other defaults live.

* fix: address CodeRabbit review findings (round 2)

- Test fakes: volume stubs panic on unexpected calls (matches pattern)
- Fix import ordering in volumes_test.go (stdlib/external/internal)
- Add --json flag to volumes prune command
- Set RuntimeName on DOCKER_HOST_passthrough to avoid empty log field
- Capture and assert 24h duration in stale upload cleanup test
- Add error path tests for VolumeService (ListVolumes error, partial prune)
- Extract renderPrunePreview to reduce cyclomatic complexity

* fix: JSON prune bug, URI normalization, test coverage (CR round 3)

- Fix: --json without --dry-run now executes actual prune instead of
  returning preview (real bug caught by CodeRabbit)
- Fix: JSON mode skips TTY confirmation and human preview
- Fix: resolveRuntimeConfig strips unix:// prefix to prevent double scheme
- Add regression test for JSON prune execution path
- Fix import ordering in volumes service_test.go
commit
05b767c1454450920bd82299a57cba63bc152cb8
author
bnema · · 2026-03-18 06:53 UTC
committer
GitHub · · 2026-03-18 06:53 UTC
parent
a28bceb
signature
unsigned
33 files · +1556 -16
M .mockery.yaml +1 -0
@@ -36,6 +36,7 @@ packages:
3636 HTTPProber:
3737 SecretService:
3838 LogService:
39+ VolumeService:
3940 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
4041 # Mocked here because it abstracts Docker SDK calls that require a running
4142 # daemon, making unit/integration tests impractical without a test double.
@@ -36,6 +36,7 @@ packages:
36 HTTPProber:36 HTTPProber:
37 SecretService:37 SecretService:
38 LogService:38 LogService:
39+ VolumeService:
39 # Exception: pushImageOps is a CLI-local interface, not a boundary port.40 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
40 # Mocked here because it abstracts Docker SDK calls that require a running41 # Mocked here because it abstracts Docker SDK calls that require a running
41 # daemon, making unit/integration tests impractical without a test double.42 # daemon, making unit/integration tests impractical without a test double.
A internal/adapters/dto/volumes.go +27 -0
@@ -0,0 +1,27 @@
1+package dto
2+
3+import "time"
4+
5+// Volume represents a volume in API responses.
6+type Volume struct {
7+ Name string `json:"name"`
8+ Driver string `json:"driver"`
9+ MountPoint string `json:"mount_point"`
10+ Size int64 `json:"size"`
11+ CreatedAt time.Time `json:"created_at"`
12+ InUse bool `json:"in_use"`
13+ Containers []string `json:"containers,omitempty"`
14+ Labels map[string]string `json:"labels,omitempty"`
15+}
16+
17+// VolumePruneRequest defines parameters for volume pruning.
18+type VolumePruneRequest struct {
19+ DryRun bool `json:"dry_run"`
20+}
21+
22+// VolumePruneResponse contains the results of a volume prune operation.
23+type VolumePruneResponse struct {
24+ VolumesRemoved int `json:"volumes_removed"`
25+ SpaceReclaimed int64 `json:"space_reclaimed"`
26+ Volumes []Volume `json:"volumes,omitempty"`
27+}
@@ -0,0 +1,27 @@
1+package dto
2+
3+import "time"
4+
5+// Volume represents a volume in API responses.
6+type Volume struct {
7+ Name string `json:"name"`
8+ Driver string `json:"driver"`
9+ MountPoint string `json:"mount_point"`
10+ Size int64 `json:"size"`
11+ CreatedAt time.Time `json:"created_at"`
12+ InUse bool `json:"in_use"`
13+ Containers []string `json:"containers,omitempty"`
14+ Labels map[string]string `json:"labels,omitempty"`
15+}
16+
17+// VolumePruneRequest defines parameters for volume pruning.
18+type VolumePruneRequest struct {
19+ DryRun bool `json:"dry_run"`
20+}
21+
22+// VolumePruneResponse contains the results of a volume prune operation.
23+type VolumePruneResponse struct {
24+ VolumesRemoved int `json:"volumes_removed"`
25+ SpaceReclaimed int64 `json:"space_reclaimed"`
26+ Volumes []Volume `json:"volumes,omitempty"`
27+}
M internal/adapters/in/cli/attachments_push_test.go +8 -0
@@ -175,6 +175,14 @@ func (c *attachmentPushTestControlPlane) StreamContainerLogs(context.Context, st
175175 panic("unexpected call")
176176 }
177177
178+func (c *attachmentPushTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
179+ panic("unexpected call")
180+}
181+
182+func (c *attachmentPushTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
183+ panic("unexpected call")
184+}
185+
178186 func TestResolveAttachmentImage_OneTarget(t *testing.T) {
179187 cp := &attachmentPushTestControlPlane{
180188 findAttachmentTargets: func(context.Context, string) ([]string, error) {
@@ -175,6 +175,14 @@ func (c *attachmentPushTestControlPlane) StreamContainerLogs(context.Context, st
175 panic("unexpected call")175 panic("unexpected call")
176 }176 }
177 177
178+func (c *attachmentPushTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
179+ panic("unexpected call")
180+}
181+
182+func (c *attachmentPushTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
183+ panic("unexpected call")
184+}
185+
178 func TestResolveAttachmentImage_OneTarget(t *testing.T) {186 func TestResolveAttachmentImage_OneTarget(t *testing.T) {
179 cp := &attachmentPushTestControlPlane{187 cp := &attachmentPushTestControlPlane{
180 findAttachmentTargets: func(context.Context, string) ([]string, error) {188 findAttachmentTargets: func(context.Context, string) ([]string, error) {
M internal/adapters/in/cli/controlplane.go +3 -0
@@ -55,4 +55,7 @@ type ControlPlane interface {
5555 GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
5656 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
5757 StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
58+
59+ ListVolumes(ctx context.Context) ([]dto.Volume, error)
60+ PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
5861 }
@@ -55,4 +55,7 @@ type ControlPlane interface {
55 GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)55 GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error)
56 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)56 StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error)
57 StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)57 StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error)
58+
59+ ListVolumes(ctx context.Context) ([]dto.Volume, error)
60+ PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
58 }61 }
M internal/adapters/in/cli/controlplane_local.go +51 -0
@@ -23,6 +23,7 @@ type localControlPlane struct {
2323 deployCoord in.DeployCoordinator
2424 healthSvc in.HealthService
2525 logSvc in.LogService
26+ volumeSvc in.VolumeService
2627 }
2728
2829 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
@@ -47,6 +48,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
4748 deployCoord: deployCoord,
4849 healthSvc: kernel.Health(),
4950 logSvc: kernel.Logs(),
51+ volumeSvc: kernel.Volumes(),
5052 }
5153 }
5254
@@ -532,6 +534,55 @@ func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain s
532534 return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
533535 }
534536
537+func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
538+ if l.volumeSvc == nil {
539+ return nil, fmt.Errorf("volume service unavailable")
540+ }
541+ vols, err := l.volumeSvc.ListVolumes(ctx)
542+ if err != nil {
543+ return nil, err
544+ }
545+
546+ result := make([]dto.Volume, len(vols))
547+ for i, v := range vols {
548+ result[i] = dto.Volume{
549+ Name: v.Name,
550+ Driver: v.Driver,
551+ MountPoint: v.MountPoint,
552+ Size: v.Size,
553+ CreatedAt: v.CreatedAt,
554+ InUse: v.InUse,
555+ Containers: v.Containers,
556+ Labels: v.Labels,
557+ }
558+ }
559+ return result, nil
560+}
561+
562+func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
563+ if l.volumeSvc == nil {
564+ return nil, fmt.Errorf("volume service unavailable")
565+ }
566+ report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
567+ if err != nil {
568+ return nil, err
569+ }
570+
571+ vols := make([]dto.Volume, len(removed))
572+ for i, v := range removed {
573+ vols[i] = dto.Volume{
574+ Name: v.Name,
575+ Size: v.Size,
576+ }
577+ }
578+
579+ return &dto.VolumePruneResponse{
580+ VolumesRemoved: report.VolumesRemoved,
581+ SpaceReclaimed: report.SpaceReclaimed,
582+ Volumes: vols,
583+ }, nil
584+}
585+
535586 func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
536587 out := make([]remote.RouteInfo, 0, len(routes))
537588 for _, route := range routes {
@@ -23,6 +23,7 @@ type localControlPlane struct {
23 deployCoord in.DeployCoordinator23 deployCoord in.DeployCoordinator
24 healthSvc in.HealthService24 healthSvc in.HealthService
25 logSvc in.LogService25 logSvc in.LogService
26+ volumeSvc in.VolumeService
26 }27 }
27 28
28 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {29 func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
@@ -47,6 +48,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
47 deployCoord: deployCoord,48 deployCoord: deployCoord,
48 healthSvc: kernel.Health(),49 healthSvc: kernel.Health(),
49 logSvc: kernel.Logs(),50 logSvc: kernel.Logs(),
51+ volumeSvc: kernel.Volumes(),
50 }52 }
51 }53 }
52 54
@@ -532,6 +534,55 @@ func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain s
532 return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)534 return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
533 }535 }
534 536
537+func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
538+ if l.volumeSvc == nil {
539+ return nil, fmt.Errorf("volume service unavailable")
540+ }
541+ vols, err := l.volumeSvc.ListVolumes(ctx)
542+ if err != nil {
543+ return nil, err
544+ }
545+
546+ result := make([]dto.Volume, len(vols))
547+ for i, v := range vols {
548+ result[i] = dto.Volume{
549+ Name: v.Name,
550+ Driver: v.Driver,
551+ MountPoint: v.MountPoint,
552+ Size: v.Size,
553+ CreatedAt: v.CreatedAt,
554+ InUse: v.InUse,
555+ Containers: v.Containers,
556+ Labels: v.Labels,
557+ }
558+ }
559+ return result, nil
560+}
561+
562+func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
563+ if l.volumeSvc == nil {
564+ return nil, fmt.Errorf("volume service unavailable")
565+ }
566+ report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
567+ if err != nil {
568+ return nil, err
569+ }
570+
571+ vols := make([]dto.Volume, len(removed))
572+ for i, v := range removed {
573+ vols[i] = dto.Volume{
574+ Name: v.Name,
575+ Size: v.Size,
576+ }
577+ }
578+
579+ return &dto.VolumePruneResponse{
580+ VolumesRemoved: report.VolumesRemoved,
581+ SpaceReclaimed: report.SpaceReclaimed,
582+ Volumes: vols,
583+ }, nil
584+}
585+
535 func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {586 func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
536 out := make([]remote.RouteInfo, 0, len(routes))587 out := make([]remote.RouteInfo, 0, len(routes))
537 for _, route := range routes {588 for _, route := range routes {
M internal/adapters/in/cli/controlplane_remote.go +8 -0
@@ -163,3 +163,11 @@ func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (
163163 func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
164164 return r.client.StreamContainerLogs(ctx, logDomain, lines)
165165 }
166+
167+func (r *remoteControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
168+ return r.client.ListVolumes(ctx)
169+}
170+
171+func (r *remoteControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
172+ return r.client.PruneVolumes(ctx, req)
173+}
@@ -163,3 +163,11 @@ func (r *remoteControlPlane) StreamProcessLogs(ctx context.Context, lines int) (
163 func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {163 func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
164 return r.client.StreamContainerLogs(ctx, logDomain, lines)164 return r.client.StreamContainerLogs(ctx, logDomain, lines)
165 }165 }
166+
167+func (r *remoteControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
168+ return r.client.ListVolumes(ctx)
169+}
170+
171+func (r *remoteControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
172+ return r.client.PruneVolumes(ctx, req)
173+}
M internal/adapters/in/cli/push_test.go +8 -0
@@ -172,6 +172,14 @@ func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context,
172172 panic("unexpected call")
173173 }
174174
175+func (c *resolveFromImageTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
176+ panic("unexpected call")
177+}
178+
179+func (c *resolveFromImageTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
180+ panic("unexpected call")
181+}
182+
175183 func TestValidateBuildArg(t *testing.T) {
176184 tests := []struct {
177185 name string
@@ -172,6 +172,14 @@ func (c *resolveFromImageTestControlPlane) StreamContainerLogs(context.Context,
172 panic("unexpected call")172 panic("unexpected call")
173 }173 }
174 174
175+func (c *resolveFromImageTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) {
176+ panic("unexpected call")
177+}
178+
179+func (c *resolveFromImageTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
180+ panic("unexpected call")
181+}
182+
175 func TestValidateBuildArg(t *testing.T) {183 func TestValidateBuildArg(t *testing.T) {
176 tests := []struct {184 tests := []struct {
177 name string185 name string
M internal/adapters/in/cli/remote/client.go +30 -0
@@ -1083,3 +1083,33 @@ func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error
10831083
10841084 return &result, nil
10851085 }
1086+
1087+// ListVolumes lists all volumes via the admin API.
1088+func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1089+ resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
1090+ if err != nil {
1091+ return nil, err
1092+ }
1093+
1094+ var volumes []dto.Volume
1095+ if err := parseResponse(resp, &volumes); err != nil {
1096+ return nil, err
1097+ }
1098+
1099+ return volumes, nil
1100+}
1101+
1102+// PruneVolumes prunes orphaned volumes via the admin API.
1103+func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1104+ resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1105+ if err != nil {
1106+ return nil, err
1107+ }
1108+
1109+ var result dto.VolumePruneResponse
1110+ if err := parseResponse(resp, &result); err != nil {
1111+ return nil, err
1112+ }
1113+
1114+ return &result, nil
1115+}
@@ -1083,3 +1083,33 @@ func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error
1083 1083
1084 return &result, nil1084 return &result, nil
1085 }1085 }
1086+
1087+// ListVolumes lists all volumes via the admin API.
1088+func (c *Client) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
1089+ resp, err := c.request(ctx, http.MethodGet, "/volumes", nil)
1090+ if err != nil {
1091+ return nil, err
1092+ }
1093+
1094+ var volumes []dto.Volume
1095+ if err := parseResponse(resp, &volumes); err != nil {
1096+ return nil, err
1097+ }
1098+
1099+ return volumes, nil
1100+}
1101+
1102+// PruneVolumes prunes orphaned volumes via the admin API.
1103+func (c *Client) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
1104+ resp, err := c.request(ctx, http.MethodPost, "/volumes/prune", req)
1105+ if err != nil {
1106+ return nil, err
1107+ }
1108+
1109+ var result dto.VolumePruneResponse
1110+ if err := parseResponse(resp, &result); err != nil {
1111+ return nil, err
1112+ }
1113+
1114+ return &result, nil
1115+}
M internal/adapters/in/cli/root.go +4 -0
@@ -137,6 +137,10 @@ Commands are organized by where they run:
137137 networksCmd.GroupID = groupManage
138138 rootCmd.AddCommand(networksCmd)
139139
140+ volumesCmd := newVolumesCmd()
141+ volumesCmd.GroupID = groupManage
142+ rootCmd.AddCommand(volumesCmd)
143+
140144 autorouteCmd := newAutorouteCmd()
141145 autorouteCmd.GroupID = groupManage
142146 rootCmd.AddCommand(autorouteCmd)
@@ -137,6 +137,10 @@ Commands are organized by where they run:
137 networksCmd.GroupID = groupManage137 networksCmd.GroupID = groupManage
138 rootCmd.AddCommand(networksCmd)138 rootCmd.AddCommand(networksCmd)
139 139
140+ volumesCmd := newVolumesCmd()
141+ volumesCmd.GroupID = groupManage
142+ rootCmd.AddCommand(volumesCmd)
143+
140 autorouteCmd := newAutorouteCmd()144 autorouteCmd := newAutorouteCmd()
141 autorouteCmd.GroupID = groupManage145 autorouteCmd.GroupID = groupManage
142 rootCmd.AddCommand(autorouteCmd)146 rootCmd.AddCommand(autorouteCmd)
A internal/adapters/in/cli/volumes.go +175 -0
@@ -0,0 +1,175 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+
10+ "github.com/bnema/gordon/internal/adapters/dto"
11+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
12+)
13+
14+type volumesClient interface {
15+ ListVolumes(ctx context.Context) ([]dto.Volume, error)
16+ PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
17+}
18+
19+type volumesPruneOptions struct {
20+ DryRun bool
21+ NoConfirm bool
22+ Json bool
23+}
24+
25+func newVolumesCmd() *cobra.Command {
26+ cmd := &cobra.Command{
27+ Use: "volumes",
28+ Short: "Manage volumes",
29+ GroupID: groupManage,
30+ }
31+ cmd.AddCommand(newVolumesListCmd())
32+ cmd.AddCommand(newVolumesPruneCmd())
33+ return cmd
34+}
35+
36+func newVolumesListCmd() *cobra.Command {
37+ var jsonOut bool
38+ cmd := &cobra.Command{
39+ Use: "list",
40+ Short: "List all volumes",
41+ RunE: func(cmd *cobra.Command, _ []string) error {
42+ handle, err := resolveControlPlane(configPath)
43+ if err != nil {
44+ return err
45+ }
46+ defer handle.close()
47+ return runVolumesList(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
48+ },
49+ }
50+ cmd.Flags().BoolVar(&jsonOut, "json", false, "output as JSON")
51+ return cmd
52+}
53+
54+func newVolumesPruneCmd() *cobra.Command {
55+ var opts volumesPruneOptions
56+ cmd := &cobra.Command{
57+ Use: "prune",
58+ Short: "Remove orphaned volumes",
59+ RunE: func(cmd *cobra.Command, _ []string) error {
60+ handle, err := resolveControlPlane(configPath)
61+ if err != nil {
62+ return err
63+ }
64+ defer handle.close()
65+ return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout())
66+ },
67+ }
68+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be removed")
69+ cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt")
70+ cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON")
71+ return cmd
72+}
73+
74+func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, jsonOut bool) error {
75+ volumes, err := client.ListVolumes(ctx)
76+ if err != nil {
77+ return err
78+ }
79+
80+ if jsonOut {
81+ return writeJSON(out, volumes)
82+ }
83+
84+ if len(volumes) == 0 {
85+ return cliWriteLine(out, "No volumes found.")
86+ }
87+
88+ if err := cliWriteLine(out, cliRenderTitle("Volumes")); err != nil {
89+ return err
90+ }
91+
92+ for _, v := range volumes {
93+ status := "orphaned"
94+ if v.InUse {
95+ status = "in-use"
96+ }
97+ containers := "-"
98+ if len(v.Containers) > 0 {
99+ containers = fmt.Sprintf("%v", v.Containers)
100+ }
101+ if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, formatImageSize(v.Size), containers); err != nil {
102+ return err
103+ }
104+ }
105+
106+ var inUse, orphaned int
107+ for _, v := range volumes {
108+ if v.InUse {
109+ inUse++
110+ } else {
111+ orphaned++
112+ }
113+ }
114+ return cliWritef(out, "\n%d volumes (%d in-use, %d orphaned)\n", len(volumes), inUse, orphaned)
115+}
116+
117+func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error {
118+ if err := cliWriteLine(out, cliRenderTitle("Volumes to remove")); err != nil {
119+ return err
120+ }
121+ for _, v := range resp.Volumes {
122+ if err := cliWritef(out, " %s (%s)\n", v.Name, formatImageSize(v.Size)); err != nil {
123+ return err
124+ }
125+ }
126+ return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, formatImageSize(resp.SpaceReclaimed))
127+}
128+
129+func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error {
130+ preview, err := client.PruneVolumes(ctx, dto.VolumePruneRequest{DryRun: true})
131+ if err != nil {
132+ return err
133+ }
134+
135+ if preview.VolumesRemoved == 0 {
136+ if opts.Json {
137+ return writeJSON(out, preview)
138+ }
139+ return cliWriteLine(out, "No orphaned volumes to remove.")
140+ }
141+
142+ if opts.DryRun {
143+ if opts.Json {
144+ return writeJSON(out, preview)
145+ }
146+ return renderPrunePreview(out, preview)
147+ }
148+
149+ if !opts.Json {
150+ if err := renderPrunePreview(out, preview); err != nil {
151+ return err
152+ }
153+ }
154+
155+ if !opts.NoConfirm && !opts.Json {
156+ confirmed, err := components.RunConfirm("Remove these volumes?")
157+ if err != nil {
158+ return err
159+ }
160+ if !confirmed {
161+ return cliWriteLine(out, "Cancelled.")
162+ }
163+ }
164+
165+ result, err := client.PruneVolumes(ctx, dto.VolumePruneRequest{DryRun: false})
166+ if err != nil {
167+ return err
168+ }
169+
170+ if opts.Json {
171+ return writeJSON(out, result)
172+ }
173+
174+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, formatImageSize(result.SpaceReclaimed))))
175+}
@@ -0,0 +1,175 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+
8+ "github.com/spf13/cobra"
9+
10+ "github.com/bnema/gordon/internal/adapters/dto"
11+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
12+)
13+
14+type volumesClient interface {
15+ ListVolumes(ctx context.Context) ([]dto.Volume, error)
16+ PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error)
17+}
18+
19+type volumesPruneOptions struct {
20+ DryRun bool
21+ NoConfirm bool
22+ Json bool
23+}
24+
25+func newVolumesCmd() *cobra.Command {
26+ cmd := &cobra.Command{
27+ Use: "volumes",
28+ Short: "Manage volumes",
29+ GroupID: groupManage,
30+ }
31+ cmd.AddCommand(newVolumesListCmd())
32+ cmd.AddCommand(newVolumesPruneCmd())
33+ return cmd
34+}
35+
36+func newVolumesListCmd() *cobra.Command {
37+ var jsonOut bool
38+ cmd := &cobra.Command{
39+ Use: "list",
40+ Short: "List all volumes",
41+ RunE: func(cmd *cobra.Command, _ []string) error {
42+ handle, err := resolveControlPlane(configPath)
43+ if err != nil {
44+ return err
45+ }
46+ defer handle.close()
47+ return runVolumesList(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut)
48+ },
49+ }
50+ cmd.Flags().BoolVar(&jsonOut, "json", false, "output as JSON")
51+ return cmd
52+}
53+
54+func newVolumesPruneCmd() *cobra.Command {
55+ var opts volumesPruneOptions
56+ cmd := &cobra.Command{
57+ Use: "prune",
58+ Short: "Remove orphaned volumes",
59+ RunE: func(cmd *cobra.Command, _ []string) error {
60+ handle, err := resolveControlPlane(configPath)
61+ if err != nil {
62+ return err
63+ }
64+ defer handle.close()
65+ return runVolumesPrune(cmd.Context(), handle.plane, opts, cmd.OutOrStdout())
66+ },
67+ }
68+ cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "show what would be removed")
69+ cmd.Flags().BoolVar(&opts.NoConfirm, "no-confirm", false, "skip confirmation prompt")
70+ cmd.Flags().BoolVar(&opts.Json, "json", false, "output as JSON")
71+ return cmd
72+}
73+
74+func runVolumesList(ctx context.Context, client volumesClient, out io.Writer, jsonOut bool) error {
75+ volumes, err := client.ListVolumes(ctx)
76+ if err != nil {
77+ return err
78+ }
79+
80+ if jsonOut {
81+ return writeJSON(out, volumes)
82+ }
83+
84+ if len(volumes) == 0 {
85+ return cliWriteLine(out, "No volumes found.")
86+ }
87+
88+ if err := cliWriteLine(out, cliRenderTitle("Volumes")); err != nil {
89+ return err
90+ }
91+
92+ for _, v := range volumes {
93+ status := "orphaned"
94+ if v.InUse {
95+ status = "in-use"
96+ }
97+ containers := "-"
98+ if len(v.Containers) > 0 {
99+ containers = fmt.Sprintf("%v", v.Containers)
100+ }
101+ if err := cliWritef(out, " %-30s %-10s %-10s %s\n", v.Name, status, formatImageSize(v.Size), containers); err != nil {
102+ return err
103+ }
104+ }
105+
106+ var inUse, orphaned int
107+ for _, v := range volumes {
108+ if v.InUse {
109+ inUse++
110+ } else {
111+ orphaned++
112+ }
113+ }
114+ return cliWritef(out, "\n%d volumes (%d in-use, %d orphaned)\n", len(volumes), inUse, orphaned)
115+}
116+
117+func renderPrunePreview(out io.Writer, resp *dto.VolumePruneResponse) error {
118+ if err := cliWriteLine(out, cliRenderTitle("Volumes to remove")); err != nil {
119+ return err
120+ }
121+ for _, v := range resp.Volumes {
122+ if err := cliWritef(out, " %s (%s)\n", v.Name, formatImageSize(v.Size)); err != nil {
123+ return err
124+ }
125+ }
126+ return cliWritef(out, "\n%d volumes, %s total\n", resp.VolumesRemoved, formatImageSize(resp.SpaceReclaimed))
127+}
128+
129+func runVolumesPrune(ctx context.Context, client volumesClient, opts volumesPruneOptions, out io.Writer) error {
130+ preview, err := client.PruneVolumes(ctx, dto.VolumePruneRequest{DryRun: true})
131+ if err != nil {
132+ return err
133+ }
134+
135+ if preview.VolumesRemoved == 0 {
136+ if opts.Json {
137+ return writeJSON(out, preview)
138+ }
139+ return cliWriteLine(out, "No orphaned volumes to remove.")
140+ }
141+
142+ if opts.DryRun {
143+ if opts.Json {
144+ return writeJSON(out, preview)
145+ }
146+ return renderPrunePreview(out, preview)
147+ }
148+
149+ if !opts.Json {
150+ if err := renderPrunePreview(out, preview); err != nil {
151+ return err
152+ }
153+ }
154+
155+ if !opts.NoConfirm && !opts.Json {
156+ confirmed, err := components.RunConfirm("Remove these volumes?")
157+ if err != nil {
158+ return err
159+ }
160+ if !confirmed {
161+ return cliWriteLine(out, "Cancelled.")
162+ }
163+ }
164+
165+ result, err := client.PruneVolumes(ctx, dto.VolumePruneRequest{DryRun: false})
166+ if err != nil {
167+ return err
168+ }
169+
170+ if opts.Json {
171+ return writeJSON(out, result)
172+ }
173+
174+ return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Removed %d volumes, reclaimed %s", result.VolumesRemoved, formatImageSize(result.SpaceReclaimed))))
175+}
A internal/adapters/in/cli/volumes_test.go +96 -0
@@ -0,0 +1,96 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+)
13+
14+type fakeVolumesClient struct {
15+ listResp []dto.Volume
16+ pruneResp *dto.VolumePruneResponse
17+ listErr error
18+ pruneErr error
19+
20+ pruneCalls []dto.VolumePruneRequest
21+}
22+
23+func (f *fakeVolumesClient) ListVolumes(_ context.Context) ([]dto.Volume, error) {
24+ return f.listResp, f.listErr
25+}
26+
27+func (f *fakeVolumesClient) PruneVolumes(_ context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
28+ f.pruneCalls = append(f.pruneCalls, req)
29+ return f.pruneResp, f.pruneErr
30+}
31+
32+func TestRunVolumesList_ShowsVolumes(t *testing.T) {
33+ client := &fakeVolumesClient{
34+ listResp: []dto.Volume{
35+ {Name: "db-data", InUse: true, Containers: []string{"postgres"}, Size: 1024 * 1024},
36+ {Name: "orphaned-vol", InUse: false, Size: 512},
37+ },
38+ }
39+
40+ var out bytes.Buffer
41+ err := runVolumesList(context.Background(), client, &out, false)
42+ require.NoError(t, err)
43+ assert.Contains(t, out.String(), "db-data")
44+ assert.Contains(t, out.String(), "orphaned-vol")
45+}
46+
47+func TestRunVolumesList_JSON(t *testing.T) {
48+ client := &fakeVolumesClient{
49+ listResp: []dto.Volume{
50+ {Name: "vol1", InUse: true},
51+ },
52+ }
53+
54+ var out bytes.Buffer
55+ err := runVolumesList(context.Background(), client, &out, true)
56+ require.NoError(t, err)
57+ assert.Contains(t, out.String(), `"name"`)
58+}
59+
60+func TestRunVolumesPrune_JSONExecutesActualPrune(t *testing.T) {
61+ client := &fakeVolumesClient{
62+ pruneResp: &dto.VolumePruneResponse{
63+ VolumesRemoved: 1,
64+ SpaceReclaimed: 1024,
65+ Volumes: []dto.Volume{{Name: "orphan1", Size: 1024}},
66+ },
67+ }
68+
69+ var out bytes.Buffer
70+ err := runVolumesPrune(context.Background(), client, volumesPruneOptions{Json: true}, &out)
71+ require.NoError(t, err)
72+
73+ // Should have called PruneVolumes twice: once dry-run (preview), once actual
74+ require.Len(t, client.pruneCalls, 2)
75+ assert.True(t, client.pruneCalls[0].DryRun, "first call should be dry-run preview")
76+ assert.False(t, client.pruneCalls[1].DryRun, "second call should be actual prune")
77+ assert.Contains(t, out.String(), `"volumes_removed"`)
78+}
79+
80+func TestRunVolumesPrune_DryRun(t *testing.T) {
81+ client := &fakeVolumesClient{
82+ pruneResp: &dto.VolumePruneResponse{
83+ VolumesRemoved: 2,
84+ SpaceReclaimed: 4096,
85+ Volumes: []dto.Volume{
86+ {Name: "orphan1", Size: 2048},
87+ {Name: "orphan2", Size: 2048},
88+ },
89+ },
90+ }
91+
92+ var out bytes.Buffer
93+ err := runVolumesPrune(context.Background(), client, volumesPruneOptions{DryRun: true}, &out)
94+ require.NoError(t, err)
95+ assert.Contains(t, out.String(), "orphan1")
96+}
@@ -0,0 +1,96 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+)
13+
14+type fakeVolumesClient struct {
15+ listResp []dto.Volume
16+ pruneResp *dto.VolumePruneResponse
17+ listErr error
18+ pruneErr error
19+
20+ pruneCalls []dto.VolumePruneRequest
21+}
22+
23+func (f *fakeVolumesClient) ListVolumes(_ context.Context) ([]dto.Volume, error) {
24+ return f.listResp, f.listErr
25+}
26+
27+func (f *fakeVolumesClient) PruneVolumes(_ context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
28+ f.pruneCalls = append(f.pruneCalls, req)
29+ return f.pruneResp, f.pruneErr
30+}
31+
32+func TestRunVolumesList_ShowsVolumes(t *testing.T) {
33+ client := &fakeVolumesClient{
34+ listResp: []dto.Volume{
35+ {Name: "db-data", InUse: true, Containers: []string{"postgres"}, Size: 1024 * 1024},
36+ {Name: "orphaned-vol", InUse: false, Size: 512},
37+ },
38+ }
39+
40+ var out bytes.Buffer
41+ err := runVolumesList(context.Background(), client, &out, false)
42+ require.NoError(t, err)
43+ assert.Contains(t, out.String(), "db-data")
44+ assert.Contains(t, out.String(), "orphaned-vol")
45+}
46+
47+func TestRunVolumesList_JSON(t *testing.T) {
48+ client := &fakeVolumesClient{
49+ listResp: []dto.Volume{
50+ {Name: "vol1", InUse: true},
51+ },
52+ }
53+
54+ var out bytes.Buffer
55+ err := runVolumesList(context.Background(), client, &out, true)
56+ require.NoError(t, err)
57+ assert.Contains(t, out.String(), `"name"`)
58+}
59+
60+func TestRunVolumesPrune_JSONExecutesActualPrune(t *testing.T) {
61+ client := &fakeVolumesClient{
62+ pruneResp: &dto.VolumePruneResponse{
63+ VolumesRemoved: 1,
64+ SpaceReclaimed: 1024,
65+ Volumes: []dto.Volume{{Name: "orphan1", Size: 1024}},
66+ },
67+ }
68+
69+ var out bytes.Buffer
70+ err := runVolumesPrune(context.Background(), client, volumesPruneOptions{Json: true}, &out)
71+ require.NoError(t, err)
72+
73+ // Should have called PruneVolumes twice: once dry-run (preview), once actual
74+ require.Len(t, client.pruneCalls, 2)
75+ assert.True(t, client.pruneCalls[0].DryRun, "first call should be dry-run preview")
76+ assert.False(t, client.pruneCalls[1].DryRun, "second call should be actual prune")
77+ assert.Contains(t, out.String(), `"volumes_removed"`)
78+}
79+
80+func TestRunVolumesPrune_DryRun(t *testing.T) {
81+ client := &fakeVolumesClient{
82+ pruneResp: &dto.VolumePruneResponse{
83+ VolumesRemoved: 2,
84+ SpaceReclaimed: 4096,
85+ Volumes: []dto.Volume{
86+ {Name: "orphan1", Size: 2048},
87+ {Name: "orphan2", Size: 2048},
88+ },
89+ },
90+ }
91+
92+ var out bytes.Buffer
93+ err := runVolumesPrune(context.Background(), client, volumesPruneOptions{DryRun: true}, &out)
94+ require.NoError(t, err)
95+ assert.Contains(t, out.String(), "orphan1")
96+}
M internal/adapters/in/http/admin/handler.go +16 -7
@@ -45,6 +45,7 @@ type Handler struct {
4545 healthSvc in.HealthService
4646 secretSvc in.SecretService
4747 logSvc in.LogService
48+ volumeSvc in.VolumeService
4849 registrySvc registryDeployService
4950 eventBus out.EventPublisher
5051 log zerowrap.Logger
@@ -163,6 +164,12 @@ func NewHandler(
163164 }
164165 }
165166
167+// WithVolumeService sets the volume service on the handler.
168+func (h *Handler) WithVolumeService(volumeSvc in.VolumeService) *Handler {
169+ h.volumeSvc = volumeSvc
170+ return h
171+}
172+
166173 // RegisterRoutes registers the admin routes on the given mux.
167174 func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
168175 mux.HandleFunc("/admin/", h.handleAdminRoutes)
@@ -200,13 +207,15 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
200207 func (h *Handler) matchRoute(path string) (routeHandler, bool) {
201208 // Exact match routes
202209 exactRoutes := map[string]routeHandler{
203- "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
204- "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
205- "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
206- "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
207- "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
208- "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
209- "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
210+ "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
211+ "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
212+ "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
213+ "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
214+ "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
215+ "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
216+ "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
217+ "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
218+ "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
210219 }
211220 if handler, ok := exactRoutes[path]; ok {
212221 return handler, true
@@ -45,6 +45,7 @@ type Handler struct {
45 healthSvc in.HealthService45 healthSvc in.HealthService
46 secretSvc in.SecretService46 secretSvc in.SecretService
47 logSvc in.LogService47 logSvc in.LogService
48+ volumeSvc in.VolumeService
48 registrySvc registryDeployService49 registrySvc registryDeployService
49 eventBus out.EventPublisher50 eventBus out.EventPublisher
50 log zerowrap.Logger51 log zerowrap.Logger
@@ -163,6 +164,12 @@ func NewHandler(
163 }164 }
164 }165 }
165 166
167+// WithVolumeService sets the volume service on the handler.
168+func (h *Handler) WithVolumeService(volumeSvc in.VolumeService) *Handler {
169+ h.volumeSvc = volumeSvc
170+ return h
171+}
172+
166 // RegisterRoutes registers the admin routes on the given mux.173 // RegisterRoutes registers the admin routes on the given mux.
167 func (h *Handler) RegisterRoutes(mux *http.ServeMux) {174 func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
168 mux.HandleFunc("/admin/", h.handleAdminRoutes)175 mux.HandleFunc("/admin/", h.handleAdminRoutes)
@@ -200,13 +207,15 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string)
200 func (h *Handler) matchRoute(path string) (routeHandler, bool) {207 func (h *Handler) matchRoute(path string) (routeHandler, bool) {
201 // Exact match routes208 // Exact match routes
202 exactRoutes := map[string]routeHandler{209 exactRoutes := map[string]routeHandler{
203- "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },210+ "/networks": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleNetworks(w, r) },
204- "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },211+ "/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleStatus(w, r) },
205- "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },212+ "/health": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleHealth(w, r) },
206- "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },213+ "/bootstrap": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleBootstrap(w, r) },
207- "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },214+ "/reload": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleReload(w, r) },
208- "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },215+ "/config": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleConfig(w, r) },
209- "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },216+ "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) },
217+ "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) },
218+ "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) },
210 }219 }
211 if handler, ok := exactRoutes[path]; ok {220 if handler, ok := exactRoutes[path]; ok {
212 return handler, true221 return handler, true
A internal/adapters/in/http/admin/handler_volumes.go +104 -0
@@ -0,0 +1,104 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/adapters/dto"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// handleListVolumes handles GET /admin/volumes endpoint.
14+func (h *Handler) handleListVolumes(w http.ResponseWriter, r *http.Request) {
15+ if r.Method != http.MethodGet {
16+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
17+ return
18+ }
19+
20+ ctx := r.Context()
21+
22+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
23+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
24+ return
25+ }
26+
27+ if h.volumeSvc == nil {
28+ h.sendError(w, http.StatusServiceUnavailable, "volume service not available")
29+ return
30+ }
31+
32+ vols, err := h.volumeSvc.ListVolumes(ctx)
33+ if err != nil {
34+ log := zerowrap.FromCtx(ctx)
35+ log.Error().Err(err).Msg("failed to list volumes")
36+ h.sendError(w, http.StatusInternalServerError, "failed to list volumes")
37+ return
38+ }
39+
40+ result := make([]dto.Volume, len(vols))
41+ for i, v := range vols {
42+ result[i] = dto.Volume{
43+ Name: v.Name,
44+ Driver: v.Driver,
45+ MountPoint: v.MountPoint,
46+ Size: v.Size,
47+ CreatedAt: v.CreatedAt,
48+ InUse: v.InUse,
49+ Containers: v.Containers,
50+ Labels: v.Labels,
51+ }
52+ }
53+
54+ h.sendJSON(w, http.StatusOK, result)
55+}
56+
57+// handlePruneVolumes handles POST /admin/volumes/prune endpoint.
58+func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
59+ if r.Method != http.MethodPost {
60+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
61+ return
62+ }
63+
64+ ctx := r.Context()
65+
66+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
67+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
68+ return
69+ }
70+
71+ if h.volumeSvc == nil {
72+ h.sendError(w, http.StatusServiceUnavailable, "volume service not available")
73+ return
74+ }
75+
76+ var req dto.VolumePruneRequest
77+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
78+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
79+ h.sendError(w, http.StatusBadRequest, "invalid request body")
80+ return
81+ }
82+
83+ report, removed, err := h.volumeSvc.PruneVolumes(ctx, req.DryRun)
84+ if err != nil {
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Msg("failed to prune volumes")
87+ h.sendError(w, http.StatusInternalServerError, "failed to prune volumes")
88+ return
89+ }
90+
91+ vols := make([]dto.Volume, len(removed))
92+ for i, v := range removed {
93+ vols[i] = dto.Volume{
94+ Name: v.Name,
95+ Size: v.Size,
96+ }
97+ }
98+
99+ h.sendJSON(w, http.StatusOK, dto.VolumePruneResponse{
100+ VolumesRemoved: report.VolumesRemoved,
101+ SpaceReclaimed: report.SpaceReclaimed,
102+ Volumes: vols,
103+ })
104+}
@@ -0,0 +1,104 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "net/http"
6+
7+ "github.com/bnema/zerowrap"
8+
9+ "github.com/bnema/gordon/internal/adapters/dto"
10+ "github.com/bnema/gordon/internal/domain"
11+)
12+
13+// handleListVolumes handles GET /admin/volumes endpoint.
14+func (h *Handler) handleListVolumes(w http.ResponseWriter, r *http.Request) {
15+ if r.Method != http.MethodGet {
16+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
17+ return
18+ }
19+
20+ ctx := r.Context()
21+
22+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
23+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
24+ return
25+ }
26+
27+ if h.volumeSvc == nil {
28+ h.sendError(w, http.StatusServiceUnavailable, "volume service not available")
29+ return
30+ }
31+
32+ vols, err := h.volumeSvc.ListVolumes(ctx)
33+ if err != nil {
34+ log := zerowrap.FromCtx(ctx)
35+ log.Error().Err(err).Msg("failed to list volumes")
36+ h.sendError(w, http.StatusInternalServerError, "failed to list volumes")
37+ return
38+ }
39+
40+ result := make([]dto.Volume, len(vols))
41+ for i, v := range vols {
42+ result[i] = dto.Volume{
43+ Name: v.Name,
44+ Driver: v.Driver,
45+ MountPoint: v.MountPoint,
46+ Size: v.Size,
47+ CreatedAt: v.CreatedAt,
48+ InUse: v.InUse,
49+ Containers: v.Containers,
50+ Labels: v.Labels,
51+ }
52+ }
53+
54+ h.sendJSON(w, http.StatusOK, result)
55+}
56+
57+// handlePruneVolumes handles POST /admin/volumes/prune endpoint.
58+func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
59+ if r.Method != http.MethodPost {
60+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
61+ return
62+ }
63+
64+ ctx := r.Context()
65+
66+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
67+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
68+ return
69+ }
70+
71+ if h.volumeSvc == nil {
72+ h.sendError(w, http.StatusServiceUnavailable, "volume service not available")
73+ return
74+ }
75+
76+ var req dto.VolumePruneRequest
77+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
78+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
79+ h.sendError(w, http.StatusBadRequest, "invalid request body")
80+ return
81+ }
82+
83+ report, removed, err := h.volumeSvc.PruneVolumes(ctx, req.DryRun)
84+ if err != nil {
85+ log := zerowrap.FromCtx(ctx)
86+ log.Error().Err(err).Msg("failed to prune volumes")
87+ h.sendError(w, http.StatusInternalServerError, "failed to prune volumes")
88+ return
89+ }
90+
91+ vols := make([]dto.Volume, len(removed))
92+ for i, v := range removed {
93+ vols[i] = dto.Volume{
94+ Name: v.Name,
95+ Size: v.Size,
96+ }
97+ }
98+
99+ h.sendJSON(w, http.StatusOK, dto.VolumePruneResponse{
100+ VolumesRemoved: report.VolumesRemoved,
101+ SpaceReclaimed: report.SpaceReclaimed,
102+ Volumes: vols,
103+ })
104+}
A internal/adapters/out/docker/detect.go +97 -0
@@ -0,0 +1,97 @@
1+package docker
2+
3+import (
4+ "net"
5+ "os"
6+ "strings"
7+ "time"
8+)
9+
10+// DetectionResult holds the result of runtime socket detection.
11+type DetectionResult struct {
12+ SocketPath string
13+ Source string
14+ RuntimeName string
15+}
16+
17+var defaultSocketPaths = []string{
18+ "/var/run/docker.sock",
19+ "/run/podman/podman.sock",
20+}
21+
22+// DetectRuntimeSocket detects the container runtime socket to use.
23+// If explicit is non-empty, it is used directly. Otherwise, DOCKER_HOST is
24+// checked, then well-known socket paths are probed.
25+func DetectRuntimeSocket(explicit string) DetectionResult {
26+ if explicit != "" {
27+ return DetectionResult{
28+ SocketPath: explicit,
29+ Source: "explicit",
30+ RuntimeName: guessRuntimeName(explicit),
31+ }
32+ }
33+
34+ dockerHost := os.Getenv("DOCKER_HOST")
35+ if dockerHost != "" {
36+ if path, ok := parseUnixSocketPath(dockerHost); ok {
37+ return DetectionResult{
38+ SocketPath: path,
39+ Source: "DOCKER_HOST",
40+ RuntimeName: guessRuntimeName(path),
41+ }
42+ }
43+ return DetectionResult{
44+ Source: "DOCKER_HOST_passthrough",
45+ }
46+ }
47+
48+ for _, path := range defaultSocketPaths {
49+ if socketResponds(path) {
50+ return DetectionResult{
51+ SocketPath: path,
52+ Source: "probed",
53+ RuntimeName: guessRuntimeName(path),
54+ }
55+ }
56+ }
57+
58+ xdg := os.Getenv("XDG_RUNTIME_DIR")
59+ if xdg != "" {
60+ podmanPath := xdg + "/podman/podman.sock"
61+ if socketResponds(podmanPath) {
62+ return DetectionResult{
63+ SocketPath: podmanPath,
64+ Source: "probed",
65+ RuntimeName: "podman",
66+ }
67+ }
68+ }
69+
70+ return DetectionResult{Source: "none"}
71+}
72+
73+func parseUnixSocketPath(value string) (string, bool) {
74+ if strings.HasPrefix(value, "unix://") {
75+ return strings.TrimPrefix(value, "unix://"), true
76+ }
77+ if strings.HasPrefix(value, "/") {
78+ return value, true
79+ }
80+ return "", false
81+}
82+
83+func socketResponds(path string) bool {
84+ conn, err := net.DialTimeout("unix", path, 2*time.Second)
85+ if err != nil {
86+ return false
87+ }
88+ conn.Close()
89+ return true
90+}
91+
92+func guessRuntimeName(path string) string {
93+ if strings.Contains(path, "podman") {
94+ return "podman"
95+ }
96+ return "docker"
97+}
@@ -0,0 +1,97 @@
1+package docker
2+
3+import (
4+ "net"
5+ "os"
6+ "strings"
7+ "time"
8+)
9+
10+// DetectionResult holds the result of runtime socket detection.
11+type DetectionResult struct {
12+ SocketPath string
13+ Source string
14+ RuntimeName string
15+}
16+
17+var defaultSocketPaths = []string{
18+ "/var/run/docker.sock",
19+ "/run/podman/podman.sock",
20+}
21+
22+// DetectRuntimeSocket detects the container runtime socket to use.
23+// If explicit is non-empty, it is used directly. Otherwise, DOCKER_HOST is
24+// checked, then well-known socket paths are probed.
25+func DetectRuntimeSocket(explicit string) DetectionResult {
26+ if explicit != "" {
27+ return DetectionResult{
28+ SocketPath: explicit,
29+ Source: "explicit",
30+ RuntimeName: guessRuntimeName(explicit),
31+ }
32+ }
33+
34+ dockerHost := os.Getenv("DOCKER_HOST")
35+ if dockerHost != "" {
36+ if path, ok := parseUnixSocketPath(dockerHost); ok {
37+ return DetectionResult{
38+ SocketPath: path,
39+ Source: "DOCKER_HOST",
40+ RuntimeName: guessRuntimeName(path),
41+ }
42+ }
43+ return DetectionResult{
44+ Source: "DOCKER_HOST_passthrough",
45+ }
46+ }
47+
48+ for _, path := range defaultSocketPaths {
49+ if socketResponds(path) {
50+ return DetectionResult{
51+ SocketPath: path,
52+ Source: "probed",
53+ RuntimeName: guessRuntimeName(path),
54+ }
55+ }
56+ }
57+
58+ xdg := os.Getenv("XDG_RUNTIME_DIR")
59+ if xdg != "" {
60+ podmanPath := xdg + "/podman/podman.sock"
61+ if socketResponds(podmanPath) {
62+ return DetectionResult{
63+ SocketPath: podmanPath,
64+ Source: "probed",
65+ RuntimeName: "podman",
66+ }
67+ }
68+ }
69+
70+ return DetectionResult{Source: "none"}
71+}
72+
73+func parseUnixSocketPath(value string) (string, bool) {
74+ if strings.HasPrefix(value, "unix://") {
75+ return strings.TrimPrefix(value, "unix://"), true
76+ }
77+ if strings.HasPrefix(value, "/") {
78+ return value, true
79+ }
80+ return "", false
81+}
82+
83+func socketResponds(path string) bool {
84+ conn, err := net.DialTimeout("unix", path, 2*time.Second)
85+ if err != nil {
86+ return false
87+ }
88+ conn.Close()
89+ return true
90+}
91+
92+func guessRuntimeName(path string) string {
93+ if strings.Contains(path, "podman") {
94+ return "podman"
95+ }
96+ return "docker"
97+}
A internal/adapters/out/docker/detect_test.go +63 -0
@@ -0,0 +1,63 @@
1+package docker
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDetectRuntimeSocket_ExplicitPathTakesPrecedence(t *testing.T) {
11+ result := DetectRuntimeSocket("/custom/sock")
12+ assert.Equal(t, "/custom/sock", result.SocketPath)
13+ assert.Equal(t, "explicit", result.Source)
14+}
15+
16+func TestDetectRuntimeSocket_FallsBackToDockerHost(t *testing.T) {
17+ t.Setenv("DOCKER_HOST", "unix:///tmp/test-docker.sock")
18+ result := DetectRuntimeSocket("")
19+ assert.Equal(t, "/tmp/test-docker.sock", result.SocketPath)
20+ assert.Equal(t, "DOCKER_HOST", result.Source)
21+}
22+
23+func TestDetectRuntimeSocket_EmptyDockerHostIgnored(t *testing.T) {
24+ t.Setenv("DOCKER_HOST", "")
25+ result := DetectRuntimeSocket("")
26+ assert.NotEqual(t, "DOCKER_HOST", result.Source)
27+}
28+
29+func TestDetectRuntimeSocket_NonUnixDockerHostPassedThrough(t *testing.T) {
30+ t.Setenv("DOCKER_HOST", "tcp://localhost:2375")
31+ result := DetectRuntimeSocket("")
32+ assert.Equal(t, "", result.SocketPath)
33+ assert.Equal(t, "DOCKER_HOST_passthrough", result.Source)
34+}
35+
36+func TestParseUnixSocketPath(t *testing.T) {
37+ tests := []struct {
38+ input string
39+ path string
40+ ok bool
41+ }{
42+ {"unix:///var/run/docker.sock", "/var/run/docker.sock", true},
43+ {"unix:///run/podman/podman.sock", "/run/podman/podman.sock", true},
44+ {"tcp://localhost:2375", "", false},
45+ {"/var/run/docker.sock", "/var/run/docker.sock", true},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.input, func(t *testing.T) {
49+ path, ok := parseUnixSocketPath(tt.input)
50+ assert.Equal(t, tt.ok, ok)
51+ if ok {
52+ assert.Equal(t, tt.path, path)
53+ }
54+ })
55+ }
56+}
57+
58+func TestNewRuntimeWithSocket_UsesExplicitHost(t *testing.T) {
59+ rt, err := NewRuntimeWithSocket("/nonexistent/test.sock")
60+ require.NoError(t, err)
61+ require.NotNil(t, rt)
62+ assert.Contains(t, rt.client.DaemonHost(), "/nonexistent/test.sock")
63+}
@@ -0,0 +1,63 @@
1+package docker
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDetectRuntimeSocket_ExplicitPathTakesPrecedence(t *testing.T) {
11+ result := DetectRuntimeSocket("/custom/sock")
12+ assert.Equal(t, "/custom/sock", result.SocketPath)
13+ assert.Equal(t, "explicit", result.Source)
14+}
15+
16+func TestDetectRuntimeSocket_FallsBackToDockerHost(t *testing.T) {
17+ t.Setenv("DOCKER_HOST", "unix:///tmp/test-docker.sock")
18+ result := DetectRuntimeSocket("")
19+ assert.Equal(t, "/tmp/test-docker.sock", result.SocketPath)
20+ assert.Equal(t, "DOCKER_HOST", result.Source)
21+}
22+
23+func TestDetectRuntimeSocket_EmptyDockerHostIgnored(t *testing.T) {
24+ t.Setenv("DOCKER_HOST", "")
25+ result := DetectRuntimeSocket("")
26+ assert.NotEqual(t, "DOCKER_HOST", result.Source)
27+}
28+
29+func TestDetectRuntimeSocket_NonUnixDockerHostPassedThrough(t *testing.T) {
30+ t.Setenv("DOCKER_HOST", "tcp://localhost:2375")
31+ result := DetectRuntimeSocket("")
32+ assert.Equal(t, "", result.SocketPath)
33+ assert.Equal(t, "DOCKER_HOST_passthrough", result.Source)
34+}
35+
36+func TestParseUnixSocketPath(t *testing.T) {
37+ tests := []struct {
38+ input string
39+ path string
40+ ok bool
41+ }{
42+ {"unix:///var/run/docker.sock", "/var/run/docker.sock", true},
43+ {"unix:///run/podman/podman.sock", "/run/podman/podman.sock", true},
44+ {"tcp://localhost:2375", "", false},
45+ {"/var/run/docker.sock", "/var/run/docker.sock", true},
46+ }
47+ for _, tt := range tests {
48+ t.Run(tt.input, func(t *testing.T) {
49+ path, ok := parseUnixSocketPath(tt.input)
50+ assert.Equal(t, tt.ok, ok)
51+ if ok {
52+ assert.Equal(t, tt.path, path)
53+ }
54+ })
55+ }
56+}
57+
58+func TestNewRuntimeWithSocket_UsesExplicitHost(t *testing.T) {
59+ rt, err := NewRuntimeWithSocket("/nonexistent/test.sock")
60+ require.NoError(t, err)
61+ require.NotNil(t, rt)
62+ assert.Contains(t, rt.client.DaemonHost(), "/nonexistent/test.sock")
63+}
M internal/adapters/out/docker/runtime.go +71 -0
@@ -81,6 +81,20 @@ func NewRuntime() (*Runtime, error) {
8181 }, nil
8282 }
8383
84+// NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
85+func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
86+ host := "unix://" + socketPath
87+ cli, err := client.NewClientWithOpts(
88+ client.WithHost(host),
89+ client.WithAPIVersionNegotiation(),
90+ )
91+ if err != nil {
92+ return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
93+ }
94+
95+ return &Runtime{client: cli}, nil
96+}
97+
8498 // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
8599 func NewRuntimeWithClient(cli *client.Client) *Runtime {
86100 return &Runtime{
@@ -1053,6 +1067,63 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo
10531067 return nil
10541068 }
10551069
1070+// ListVolumes returns all named volumes with their usage status.
1071+func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1072+ volumeList, err := r.client.VolumeList(ctx, volume.ListOptions{})
1073+ if err != nil {
1074+ return nil, fmt.Errorf("failed to list volumes: %w", err)
1075+ }
1076+
1077+ // Get all containers to determine volume usage
1078+ containers, err := r.client.ContainerList(ctx, container.ListOptions{All: true})
1079+ if err != nil {
1080+ return nil, fmt.Errorf("failed to list containers for volume usage: %w", err)
1081+ }
1082+
1083+ // Build a map of volume name -> container names
1084+ volumeUsers := make(map[string][]string)
1085+ for _, c := range containers {
1086+ for _, m := range c.Mounts {
1087+ if m.Type == "volume" {
1088+ name := ""
1089+ if len(c.Names) > 0 {
1090+ name = strings.TrimPrefix(c.Names[0], "/")
1091+ }
1092+ volumeUsers[m.Name] = append(volumeUsers[m.Name], name)
1093+ }
1094+ }
1095+ }
1096+
1097+ var result []*domain.VolumeInfo
1098+ for _, v := range volumeList.Volumes {
1099+ if v == nil {
1100+ continue
1101+ }
1102+
1103+ var size int64
1104+ if v.UsageData != nil {
1105+ size = v.UsageData.Size
1106+ }
1107+
1108+ created, _ := time.Parse(time.RFC3339, v.CreatedAt)
1109+
1110+ users := volumeUsers[v.Name]
1111+ info := &domain.VolumeInfo{
1112+ Name: v.Name,
1113+ Driver: v.Driver,
1114+ MountPoint: v.Mountpoint,
1115+ Size: size,
1116+ CreatedAt: created,
1117+ InUse: len(users) > 0,
1118+ Containers: users,
1119+ Labels: v.Labels,
1120+ }
1121+ result = append(result, info)
1122+ }
1123+
1124+ return result, nil
1125+}
1126+
10561127 // InspectImageEnv gets the environment variables declared in the image.
10571128 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
10581129 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -81,6 +81,20 @@ func NewRuntime() (*Runtime, error) {
81 }, nil81 }, nil
82 }82 }
83 83
84+// NewRuntimeWithSocket creates a Docker runtime targeting a specific socket path.
85+func NewRuntimeWithSocket(socketPath string) (*Runtime, error) {
86+ host := "unix://" + socketPath
87+ cli, err := client.NewClientWithOpts(
88+ client.WithHost(host),
89+ client.WithAPIVersionNegotiation(),
90+ )
91+ if err != nil {
92+ return nil, fmt.Errorf("failed to create Docker client for %s: %w", socketPath, err)
93+ }
94+
95+ return &Runtime{client: cli}, nil
96+}
97+
84 // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).98 // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing).
85 func NewRuntimeWithClient(cli *client.Client) *Runtime {99 func NewRuntimeWithClient(cli *client.Client) *Runtime {
86 return &Runtime{100 return &Runtime{
@@ -1053,6 +1067,63 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo
1053 return nil1067 return nil
1054 }1068 }
1055 1069
1070+// ListVolumes returns all named volumes with their usage status.
1071+func (r *Runtime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1072+ volumeList, err := r.client.VolumeList(ctx, volume.ListOptions{})
1073+ if err != nil {
1074+ return nil, fmt.Errorf("failed to list volumes: %w", err)
1075+ }
1076+
1077+ // Get all containers to determine volume usage
1078+ containers, err := r.client.ContainerList(ctx, container.ListOptions{All: true})
1079+ if err != nil {
1080+ return nil, fmt.Errorf("failed to list containers for volume usage: %w", err)
1081+ }
1082+
1083+ // Build a map of volume name -> container names
1084+ volumeUsers := make(map[string][]string)
1085+ for _, c := range containers {
1086+ for _, m := range c.Mounts {
1087+ if m.Type == "volume" {
1088+ name := ""
1089+ if len(c.Names) > 0 {
1090+ name = strings.TrimPrefix(c.Names[0], "/")
1091+ }
1092+ volumeUsers[m.Name] = append(volumeUsers[m.Name], name)
1093+ }
1094+ }
1095+ }
1096+
1097+ var result []*domain.VolumeInfo
1098+ for _, v := range volumeList.Volumes {
1099+ if v == nil {
1100+ continue
1101+ }
1102+
1103+ var size int64
1104+ if v.UsageData != nil {
1105+ size = v.UsageData.Size
1106+ }
1107+
1108+ created, _ := time.Parse(time.RFC3339, v.CreatedAt)
1109+
1110+ users := volumeUsers[v.Name]
1111+ info := &domain.VolumeInfo{
1112+ Name: v.Name,
1113+ Driver: v.Driver,
1114+ MountPoint: v.Mountpoint,
1115+ Size: size,
1116+ CreatedAt: created,
1117+ InUse: len(users) > 0,
1118+ Containers: users,
1119+ Labels: v.Labels,
1120+ }
1121+ result = append(result, info)
1122+ }
1123+
1124+ return result, nil
1125+}
1126+
1056 // InspectImageEnv gets the environment variables declared in the image.1127 // InspectImageEnv gets the environment variables declared in the image.
1057 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {1128 func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) {
1058 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1129 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/adapters/out/filesystem/blob.go +58 -0
@@ -12,6 +12,7 @@ import (
1212 "path/filepath"
1313 "strings"
1414 "sync"
15+ "time"
1516
1617 "github.com/bnema/zerowrap"
1718 "github.com/google/uuid"
@@ -414,6 +415,63 @@ func (s *BlobStorage) CancelBlobUpload(uuid string) error {
414415 return nil
415416 }
416417
418+// CleanupStaleUploads removes upload files older than maxAge.
419+func (s *BlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
420+ uploadsDir := filepath.Join(s.rootDir, "uploads")
421+
422+ entries, err := os.ReadDir(uploadsDir)
423+ if err != nil {
424+ if os.IsNotExist(err) {
425+ return 0, 0, nil
426+ }
427+ return 0, 0, fmt.Errorf("failed to read uploads directory: %w", err)
428+ }
429+
430+ cutoff := time.Now().Add(-maxAge)
431+ var removed int
432+ var bytesReclaimed int64
433+
434+ for _, entry := range entries {
435+ if entry.IsDir() {
436+ continue
437+ }
438+
439+ info, err := entry.Info()
440+ if err != nil {
441+ s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to get file info for stale upload")
442+ continue
443+ }
444+
445+ if info.ModTime().Before(cutoff) {
446+ path := filepath.Join(uploadsDir, entry.Name())
447+ size := info.Size()
448+
449+ mu := s.getUploadLock(entry.Name())
450+ mu.Lock()
451+
452+ if err := os.Remove(path); err != nil {
453+ mu.Unlock()
454+ s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove stale upload")
455+ continue
456+ }
457+
458+ mu.Unlock()
459+ s.cleanupUploadLock(entry.Name())
460+ removed++
461+ bytesReclaimed += size
462+
463+ s.log.Info().
464+ Str(zerowrap.FieldLayer, "adapter").
465+ Str(zerowrap.FieldAdapter, "filesystem").
466+ Str("uuid", entry.Name()).
467+ Int64("size", size).
468+ Msg("removed stale upload")
469+ }
470+ }
471+
472+ return removed, bytesReclaimed, nil
473+}
474+
417475 // Helper methods for path generation with security validation
418476
419477 func (s *BlobStorage) getBlobPath(digest string) (string, error) {
@@ -12,6 +12,7 @@ import (
12 "path/filepath"12 "path/filepath"
13 "strings"13 "strings"
14 "sync"14 "sync"
15+ "time"
15 16
16 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
17 "github.com/google/uuid"18 "github.com/google/uuid"
@@ -414,6 +415,63 @@ func (s *BlobStorage) CancelBlobUpload(uuid string) error {
414 return nil415 return nil
415 }416 }
416 417
418+// CleanupStaleUploads removes upload files older than maxAge.
419+func (s *BlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
420+ uploadsDir := filepath.Join(s.rootDir, "uploads")
421+
422+ entries, err := os.ReadDir(uploadsDir)
423+ if err != nil {
424+ if os.IsNotExist(err) {
425+ return 0, 0, nil
426+ }
427+ return 0, 0, fmt.Errorf("failed to read uploads directory: %w", err)
428+ }
429+
430+ cutoff := time.Now().Add(-maxAge)
431+ var removed int
432+ var bytesReclaimed int64
433+
434+ for _, entry := range entries {
435+ if entry.IsDir() {
436+ continue
437+ }
438+
439+ info, err := entry.Info()
440+ if err != nil {
441+ s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to get file info for stale upload")
442+ continue
443+ }
444+
445+ if info.ModTime().Before(cutoff) {
446+ path := filepath.Join(uploadsDir, entry.Name())
447+ size := info.Size()
448+
449+ mu := s.getUploadLock(entry.Name())
450+ mu.Lock()
451+
452+ if err := os.Remove(path); err != nil {
453+ mu.Unlock()
454+ s.log.Warn().Err(err).Str("file", entry.Name()).Msg("failed to remove stale upload")
455+ continue
456+ }
457+
458+ mu.Unlock()
459+ s.cleanupUploadLock(entry.Name())
460+ removed++
461+ bytesReclaimed += size
462+
463+ s.log.Info().
464+ Str(zerowrap.FieldLayer, "adapter").
465+ Str(zerowrap.FieldAdapter, "filesystem").
466+ Str("uuid", entry.Name()).
467+ Int64("size", size).
468+ Msg("removed stale upload")
469+ }
470+ }
471+
472+ return removed, bytesReclaimed, nil
473+}
474+
417 // Helper methods for path generation with security validation475 // Helper methods for path generation with security validation
418 476
419 func (s *BlobStorage) getBlobPath(digest string) (string, error) {477 func (s *BlobStorage) getBlobPath(digest string) (string, error) {
A internal/adapters/out/filesystem/blob_cleanup_test.go +80 -0
@@ -0,0 +1,80 @@
1+package filesystem
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestBlobStorage_CleanupStaleUploads_RemovesOldFiles(t *testing.T) {
15+ tmpDir := t.TempDir()
16+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
17+ require.NoError(t, err)
18+
19+ uploadsDir := filepath.Join(tmpDir, "uploads")
20+ stalePath := filepath.Join(uploadsDir, "stale-uuid-1234")
21+ require.NoError(t, os.WriteFile(stalePath, make([]byte, 1024), 0600))
22+ staleTime := time.Now().Add(-48 * time.Hour)
23+ require.NoError(t, os.Chtimes(stalePath, staleTime, staleTime))
24+
25+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
26+ require.NoError(t, err)
27+ assert.Equal(t, 1, removed)
28+ assert.Equal(t, int64(1024), bytes)
29+ assert.NoFileExists(t, stalePath)
30+}
31+
32+func TestBlobStorage_CleanupStaleUploads_PreservesRecentFiles(t *testing.T) {
33+ tmpDir := t.TempDir()
34+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
35+ require.NoError(t, err)
36+
37+ uploadsDir := filepath.Join(tmpDir, "uploads")
38+ recentPath := filepath.Join(uploadsDir, "recent-uuid-5678")
39+ require.NoError(t, os.WriteFile(recentPath, make([]byte, 512), 0600))
40+
41+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
42+ require.NoError(t, err)
43+ assert.Equal(t, 0, removed)
44+ assert.Equal(t, int64(0), bytes)
45+ assert.FileExists(t, recentPath)
46+}
47+
48+func TestBlobStorage_CleanupStaleUploads_EmptyUploadsDir(t *testing.T) {
49+ tmpDir := t.TempDir()
50+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
51+ require.NoError(t, err)
52+
53+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
54+ require.NoError(t, err)
55+ assert.Equal(t, 0, removed)
56+ assert.Equal(t, int64(0), bytes)
57+}
58+
59+func TestBlobStorage_CleanupStaleUploads_MixedAges(t *testing.T) {
60+ tmpDir := t.TempDir()
61+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
62+ require.NoError(t, err)
63+
64+ uploadsDir := filepath.Join(tmpDir, "uploads")
65+
66+ stalePath := filepath.Join(uploadsDir, "stale-uuid")
67+ require.NoError(t, os.WriteFile(stalePath, make([]byte, 2048), 0600))
68+ staleTime := time.Now().Add(-72 * time.Hour)
69+ require.NoError(t, os.Chtimes(stalePath, staleTime, staleTime))
70+
71+ recentPath := filepath.Join(uploadsDir, "recent-uuid")
72+ require.NoError(t, os.WriteFile(recentPath, make([]byte, 512), 0600))
73+
74+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
75+ require.NoError(t, err)
76+ assert.Equal(t, 1, removed)
77+ assert.Equal(t, int64(2048), bytes)
78+ assert.NoFileExists(t, stalePath)
79+ assert.FileExists(t, recentPath)
80+}
@@ -0,0 +1,80 @@
1+package filesystem
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestBlobStorage_CleanupStaleUploads_RemovesOldFiles(t *testing.T) {
15+ tmpDir := t.TempDir()
16+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
17+ require.NoError(t, err)
18+
19+ uploadsDir := filepath.Join(tmpDir, "uploads")
20+ stalePath := filepath.Join(uploadsDir, "stale-uuid-1234")
21+ require.NoError(t, os.WriteFile(stalePath, make([]byte, 1024), 0600))
22+ staleTime := time.Now().Add(-48 * time.Hour)
23+ require.NoError(t, os.Chtimes(stalePath, staleTime, staleTime))
24+
25+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
26+ require.NoError(t, err)
27+ assert.Equal(t, 1, removed)
28+ assert.Equal(t, int64(1024), bytes)
29+ assert.NoFileExists(t, stalePath)
30+}
31+
32+func TestBlobStorage_CleanupStaleUploads_PreservesRecentFiles(t *testing.T) {
33+ tmpDir := t.TempDir()
34+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
35+ require.NoError(t, err)
36+
37+ uploadsDir := filepath.Join(tmpDir, "uploads")
38+ recentPath := filepath.Join(uploadsDir, "recent-uuid-5678")
39+ require.NoError(t, os.WriteFile(recentPath, make([]byte, 512), 0600))
40+
41+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
42+ require.NoError(t, err)
43+ assert.Equal(t, 0, removed)
44+ assert.Equal(t, int64(0), bytes)
45+ assert.FileExists(t, recentPath)
46+}
47+
48+func TestBlobStorage_CleanupStaleUploads_EmptyUploadsDir(t *testing.T) {
49+ tmpDir := t.TempDir()
50+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
51+ require.NoError(t, err)
52+
53+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
54+ require.NoError(t, err)
55+ assert.Equal(t, 0, removed)
56+ assert.Equal(t, int64(0), bytes)
57+}
58+
59+func TestBlobStorage_CleanupStaleUploads_MixedAges(t *testing.T) {
60+ tmpDir := t.TempDir()
61+ bs, err := NewBlobStorage(tmpDir, zerowrap.Default())
62+ require.NoError(t, err)
63+
64+ uploadsDir := filepath.Join(tmpDir, "uploads")
65+
66+ stalePath := filepath.Join(uploadsDir, "stale-uuid")
67+ require.NoError(t, os.WriteFile(stalePath, make([]byte, 2048), 0600))
68+ staleTime := time.Now().Add(-72 * time.Hour)
69+ require.NoError(t, os.Chtimes(stalePath, staleTime, staleTime))
70+
71+ recentPath := filepath.Join(uploadsDir, "recent-uuid")
72+ require.NoError(t, os.WriteFile(recentPath, make([]byte, 512), 0600))
73+
74+ removed, bytes, err := bs.CleanupStaleUploads(24 * time.Hour)
75+ require.NoError(t, err)
76+ assert.Equal(t, 1, removed)
77+ assert.Equal(t, int64(2048), bytes)
78+ assert.NoFileExists(t, stalePath)
79+ assert.FileExists(t, recentPath)
80+}
M internal/app/kernel.go +4 -0
@@ -23,6 +23,7 @@ type Kernel struct {
2323 registrySvc in.RegistryService
2424 healthSvc in.HealthService
2525 logSvc in.LogService
26+ volumeSvc in.VolumeService
2627 cleanup func()
2728 }
2829
@@ -56,6 +57,7 @@ func NewKernel(configPath string) (*Kernel, error) {
5657 registrySvc: svc.registrySvc,
5758 healthSvc: svc.healthSvc,
5859 logSvc: svc.logSvc,
60+ volumeSvc: svc.volumeSvc,
5961 cleanup: cleanup,
6062 }, nil
6163 } else {
@@ -106,4 +108,6 @@ func (k *Kernel) Health() in.HealthService { return k.healthSvc }
106108
107109 func (k *Kernel) Logs() in.LogService { return k.logSvc }
108110
111+func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc }
112+
109113 func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }
@@ -23,6 +23,7 @@ type Kernel struct {
23 registrySvc in.RegistryService23 registrySvc in.RegistryService
24 healthSvc in.HealthService24 healthSvc in.HealthService
25 logSvc in.LogService25 logSvc in.LogService
26+ volumeSvc in.VolumeService
26 cleanup func()27 cleanup func()
27 }28 }
28 29
@@ -56,6 +57,7 @@ func NewKernel(configPath string) (*Kernel, error) {
56 registrySvc: svc.registrySvc,57 registrySvc: svc.registrySvc,
57 healthSvc: svc.healthSvc,58 healthSvc: svc.healthSvc,
58 logSvc: svc.logSvc,59 logSvc: svc.logSvc,
60+ volumeSvc: svc.volumeSvc,
59 cleanup: cleanup,61 cleanup: cleanup,
60 }, nil62 }, nil
61 } else {63 } else {
@@ -106,4 +108,6 @@ func (k *Kernel) Health() in.HealthService { return k.healthSvc }
106 108
107 func (k *Kernel) Logs() in.LogService { return k.logSvc }109 func (k *Kernel) Logs() in.LogService { return k.logSvc }
108 110
111+func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc }
112+
109 func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }113 func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled }
M internal/app/run.go +50 -9
@@ -74,6 +74,7 @@ import (
7474 "github.com/bnema/gordon/internal/usecase/proxy"
7575 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
7676 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
77+ volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
7778
7879 // Pkg
7980 "github.com/bnema/gordon/pkg/bytesize"
@@ -184,6 +185,7 @@ type services struct {
184185 healthSvc *health.Service
185186 logSvc *logs.Service
186187 imageSvc *images.Service
188+ volumeSvc *volumesSvc.Service
187189 proxySvc *proxy.Service
188190 authSvc *auth.Service
189191 authHandler *authhandler.Handler
@@ -452,7 +454,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
452454 var err error
453455
454456 // Create output adapters
455- if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {
457+ runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))
458+ if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
456459 return nil, err
457460 }
458461
@@ -506,6 +509,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
506509
507510 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
508511 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
512+ svc.volumeSvc = volumesSvc.NewService(svc.runtime)
509513
510514 injectTelemetryMetrics(cfg, svc, log)
511515
@@ -550,7 +554,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
550554 log,
551555 svc.backupSvc,
552556 svc.imageSvc,
553- )
557+ ).WithVolumeService(svc.volumeSvc)
554558
555559 return svc, nil
556560 }
@@ -625,19 +629,53 @@ func resolveLogFilePath(cfg Config) string {
625629 return filepath.Join(dataDir, "logs", "gordon.log")
626630 }
627631
628-// createOutputAdapters creates the Docker runtime and event bus.
629-func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {
630- runtime, err := docker.NewRuntime()
632+// resolveRuntimeConfig converts a server.runtime config value to a socket path.
633+// "auto" or "" means auto-detect; any other value is treated as an explicit socket path.
634+// URI schemes (unix://, tcp://) are stripped so callers receive a bare path.
635+func resolveRuntimeConfig(value string) string {
636+ if value == "" || value == "auto" {
637+ return ""
638+ }
639+ if strings.HasPrefix(value, "unix://") {
640+ return strings.TrimPrefix(value, "unix://")
641+ }
642+ return value
643+}
644+
645+// createOutputAdapters creates the container runtime and event bus.
646+func createOutputAdapters(ctx context.Context, log zerowrap.Logger, runtimeSocket string) (*docker.Runtime, *eventbus.InMemory, error) {
647+ detection := docker.DetectRuntimeSocket(runtimeSocket)
648+
649+ var runtime *docker.Runtime
650+ var err error
651+
652+ switch detection.Source {
653+ case "none":
654+ return nil, nil, fmt.Errorf("no container runtime found: checked Docker socket, Podman socket, DOCKER_HOST env var. Install Docker or Podman, or set server.runtime in config")
655+ case "DOCKER_HOST_passthrough":
656+ detection.RuntimeName = "docker"
657+ runtime, err = docker.NewRuntime()
658+ default:
659+ if detection.SocketPath != "" {
660+ runtime, err = docker.NewRuntimeWithSocket(detection.SocketPath)
661+ } else {
662+ runtime, err = docker.NewRuntime()
663+ }
664+ }
631665 if err != nil {
632- return nil, nil, log.WrapErr(err, "failed to create Docker runtime")
666+ return nil, nil, log.WrapErr(err, "failed to create container runtime")
633667 }
634668
635669 if err := runtime.Ping(ctx); err != nil {
636- return nil, nil, log.WrapErr(err, "Docker is not available")
670+ return nil, nil, log.WrapErr(err, fmt.Sprintf("container runtime not available (detected: %s via %s)", detection.RuntimeName, detection.Source))
637671 }
638672
639- dockerVersion, _ := runtime.Version(ctx)
640- log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")
673+ runtimeVersion, _ := runtime.Version(ctx)
674+ log.Info().
675+ Str("runtime", detection.RuntimeName).
676+ Str("version", runtimeVersion).
677+ Str("source", detection.Source).
678+ Msg("container runtime initialized")
641679
642680 eventBus := eventbus.NewInMemory(100, log)
643681
@@ -1865,6 +1903,8 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo
18651903 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
18661904 Int("registry_tags_removed", report.Registry.TagsRemoved).
18671905 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1906+ Int("registry_uploads_removed", report.Registry.UploadsRemoved).
1907+ Int64("registry_upload_bytes_reclaimed", report.Registry.UploadSpaceReclaimed).
18681908 Msg("scheduled image prune complete")
18691909 return nil
18701910 },
@@ -2306,6 +2346,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
23062346 v.SetDefault("server.tls_cert_file", "")
23072347 v.SetDefault("server.tls_key_file", "")
23082348 v.SetDefault("server.data_dir", DefaultDataDir())
2349+ v.SetDefault("server.runtime", "auto")
23092350 v.SetDefault("logging.level", "info")
23102351 v.SetDefault("logging.format", "console")
23112352 v.SetDefault("logging.file.enabled", false)
@@ -74,6 +74,7 @@ import (
74 "github.com/bnema/gordon/internal/usecase/proxy"74 "github.com/bnema/gordon/internal/usecase/proxy"
75 registrySvc "github.com/bnema/gordon/internal/usecase/registry"75 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
76 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"76 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
77+ volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
77 78
78 // Pkg79 // Pkg
79 "github.com/bnema/gordon/pkg/bytesize"80 "github.com/bnema/gordon/pkg/bytesize"
@@ -184,6 +185,7 @@ type services struct {
184 healthSvc *health.Service185 healthSvc *health.Service
185 logSvc *logs.Service186 logSvc *logs.Service
186 imageSvc *images.Service187 imageSvc *images.Service
188+ volumeSvc *volumesSvc.Service
187 proxySvc *proxy.Service189 proxySvc *proxy.Service
188 authSvc *auth.Service190 authSvc *auth.Service
189 authHandler *authhandler.Handler191 authHandler *authhandler.Handler
@@ -452,7 +454,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
452 var err error454 var err error
453 455
454 // Create output adapters456 // Create output adapters
455- if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log); err != nil {457+ runtimeSocket := resolveRuntimeConfig(v.GetString("server.runtime"))
458+ if svc.runtime, svc.eventBus, err = createOutputAdapters(ctx, log, runtimeSocket); err != nil {
456 return nil, err459 return nil, err
457 }460 }
458 461
@@ -506,6 +509,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
506 509
507 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)510 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
508 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)511 svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log)
512+ svc.volumeSvc = volumesSvc.NewService(svc.runtime)
509 513
510 injectTelemetryMetrics(cfg, svc, log)514 injectTelemetryMetrics(cfg, svc, log)
511 515
@@ -550,7 +554,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
550 log,554 log,
551 svc.backupSvc,555 svc.backupSvc,
552 svc.imageSvc,556 svc.imageSvc,
553- )557+ ).WithVolumeService(svc.volumeSvc)
554 558
555 return svc, nil559 return svc, nil
556 }560 }
@@ -625,19 +629,53 @@ func resolveLogFilePath(cfg Config) string {
625 return filepath.Join(dataDir, "logs", "gordon.log")629 return filepath.Join(dataDir, "logs", "gordon.log")
626 }630 }
627 631
628-// createOutputAdapters creates the Docker runtime and event bus.632+// resolveRuntimeConfig converts a server.runtime config value to a socket path.
629-func createOutputAdapters(ctx context.Context, log zerowrap.Logger) (*docker.Runtime, *eventbus.InMemory, error) {633+// "auto" or "" means auto-detect; any other value is treated as an explicit socket path.
630- runtime, err := docker.NewRuntime()634+// URI schemes (unix://, tcp://) are stripped so callers receive a bare path.
635+func resolveRuntimeConfig(value string) string {
636+ if value == "" || value == "auto" {
637+ return ""
638+ }
639+ if strings.HasPrefix(value, "unix://") {
640+ return strings.TrimPrefix(value, "unix://")
641+ }
642+ return value
643+}
644+
645+// createOutputAdapters creates the container runtime and event bus.
646+func createOutputAdapters(ctx context.Context, log zerowrap.Logger, runtimeSocket string) (*docker.Runtime, *eventbus.InMemory, error) {
647+ detection := docker.DetectRuntimeSocket(runtimeSocket)
648+
649+ var runtime *docker.Runtime
650+ var err error
651+
652+ switch detection.Source {
653+ case "none":
654+ return nil, nil, fmt.Errorf("no container runtime found: checked Docker socket, Podman socket, DOCKER_HOST env var. Install Docker or Podman, or set server.runtime in config")
655+ case "DOCKER_HOST_passthrough":
656+ detection.RuntimeName = "docker"
657+ runtime, err = docker.NewRuntime()
658+ default:
659+ if detection.SocketPath != "" {
660+ runtime, err = docker.NewRuntimeWithSocket(detection.SocketPath)
661+ } else {
662+ runtime, err = docker.NewRuntime()
663+ }
664+ }
631 if err != nil {665 if err != nil {
632- return nil, nil, log.WrapErr(err, "failed to create Docker runtime")666+ return nil, nil, log.WrapErr(err, "failed to create container runtime")
633 }667 }
634 668
635 if err := runtime.Ping(ctx); err != nil {669 if err := runtime.Ping(ctx); err != nil {
636- return nil, nil, log.WrapErr(err, "Docker is not available")670+ return nil, nil, log.WrapErr(err, fmt.Sprintf("container runtime not available (detected: %s via %s)", detection.RuntimeName, detection.Source))
637 }671 }
638 672
639- dockerVersion, _ := runtime.Version(ctx)673+ runtimeVersion, _ := runtime.Version(ctx)
640- log.Info().Str("docker_version", dockerVersion).Msg("Docker runtime initialized")674+ log.Info().
675+ Str("runtime", detection.RuntimeName).
676+ Str("version", runtimeVersion).
677+ Str("source", detection.Source).
678+ Msg("container runtime initialized")
641 679
642 eventBus := eventbus.NewInMemory(100, log)680 eventBus := eventbus.NewInMemory(100, log)
643 681
@@ -1865,6 +1903,8 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo
1865 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).1903 Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed).
1866 Int("registry_tags_removed", report.Registry.TagsRemoved).1904 Int("registry_tags_removed", report.Registry.TagsRemoved).
1867 Int("registry_blobs_removed", report.Registry.BlobsRemoved).1905 Int("registry_blobs_removed", report.Registry.BlobsRemoved).
1906+ Int("registry_uploads_removed", report.Registry.UploadsRemoved).
1907+ Int64("registry_upload_bytes_reclaimed", report.Registry.UploadSpaceReclaimed).
1868 Msg("scheduled image prune complete")1908 Msg("scheduled image prune complete")
1869 return nil1909 return nil
1870 },1910 },
@@ -2306,6 +2346,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
2306 v.SetDefault("server.tls_cert_file", "")2346 v.SetDefault("server.tls_cert_file", "")
2307 v.SetDefault("server.tls_key_file", "")2347 v.SetDefault("server.tls_key_file", "")
2308 v.SetDefault("server.data_dir", DefaultDataDir())2348 v.SetDefault("server.data_dir", DefaultDataDir())
2349+ v.SetDefault("server.runtime", "auto")
2309 v.SetDefault("logging.level", "info")2350 v.SetDefault("logging.level", "info")
2310 v.SetDefault("logging.format", "console")2351 v.SetDefault("logging.format", "console")
2311 v.SetDefault("logging.file.enabled", false)2352 v.SetDefault("logging.file.enabled", false)
A internal/app/run_runtime_test.go +23 -0
@@ -0,0 +1,23 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestResolveRuntimeConfig_ExplicitPathPassedThrough(t *testing.T) {
10+ assert.Equal(t, "/custom/path.sock", resolveRuntimeConfig("/custom/path.sock"))
11+}
12+
13+func TestResolveRuntimeConfig_AutoReturnsEmpty(t *testing.T) {
14+ assert.Equal(t, "", resolveRuntimeConfig("auto"))
15+}
16+
17+func TestResolveRuntimeConfig_EmptyReturnsEmpty(t *testing.T) {
18+ assert.Equal(t, "", resolveRuntimeConfig(""))
19+}
20+
21+func TestResolveRuntimeConfig_StripsUnixScheme(t *testing.T) {
22+ assert.Equal(t, "/var/run/docker.sock", resolveRuntimeConfig("unix:///var/run/docker.sock"))
23+}
@@ -0,0 +1,23 @@
1+package app
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestResolveRuntimeConfig_ExplicitPathPassedThrough(t *testing.T) {
10+ assert.Equal(t, "/custom/path.sock", resolveRuntimeConfig("/custom/path.sock"))
11+}
12+
13+func TestResolveRuntimeConfig_AutoReturnsEmpty(t *testing.T) {
14+ assert.Equal(t, "", resolveRuntimeConfig("auto"))
15+}
16+
17+func TestResolveRuntimeConfig_EmptyReturnsEmpty(t *testing.T) {
18+ assert.Equal(t, "", resolveRuntimeConfig(""))
19+}
20+
21+func TestResolveRuntimeConfig_StripsUnixScheme(t *testing.T) {
22+ assert.Equal(t, "/var/run/docker.sock", resolveRuntimeConfig("unix:///var/run/docker.sock"))
23+}
A internal/boundaries/in/mocks/mock_volume_service.go +177 -0
@@ -0,0 +1,177 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockVolumeService creates a new instance of MockVolumeService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockVolumeService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockVolumeService {
20+ mock := &MockVolumeService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockVolumeService is an autogenerated mock type for the VolumeService type
29+type MockVolumeService struct {
30+ mock.Mock
31+}
32+
33+type MockVolumeService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockVolumeService) EXPECT() *MockVolumeService_Expecter {
38+ return &MockVolumeService_Expecter{mock: &_m.Mock}
39+}
40+
41+// ListVolumes provides a mock function for the type MockVolumeService
42+func (_mock *MockVolumeService) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ListVolumes")
47+ }
48+
49+ var r0 []*domain.VolumeInfo
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.VolumeInfo, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.VolumeInfo); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).([]*domain.VolumeInfo)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62+ r1 = returnFunc(ctx)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockVolumeService_ListVolumes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumes'
70+type MockVolumeService_ListVolumes_Call struct {
71+ *mock.Call
72+}
73+
74+// ListVolumes is a helper method to define mock.On call
75+// - ctx context.Context
76+func (_e *MockVolumeService_Expecter) ListVolumes(ctx interface{}) *MockVolumeService_ListVolumes_Call {
77+ return &MockVolumeService_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
78+}
79+
80+func (_c *MockVolumeService_ListVolumes_Call) Run(run func(ctx context.Context)) *MockVolumeService_ListVolumes_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ run(
87+ arg0,
88+ )
89+ })
90+ return _c
91+}
92+
93+func (_c *MockVolumeService_ListVolumes_Call) Return(volumeInfos []*domain.VolumeInfo, err error) *MockVolumeService_ListVolumes_Call {
94+ _c.Call.Return(volumeInfos, err)
95+ return _c
96+}
97+
98+func (_c *MockVolumeService_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.VolumeInfo, error)) *MockVolumeService_ListVolumes_Call {
99+ _c.Call.Return(run)
100+ return _c
101+}
102+
103+// PruneVolumes provides a mock function for the type MockVolumeService
104+func (_mock *MockVolumeService) PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error) {
105+ ret := _mock.Called(ctx, dryRun)
106+
107+ if len(ret) == 0 {
108+ panic("no return value specified for PruneVolumes")
109+ }
110+
111+ var r0 *domain.VolumePruneReport
112+ var r1 []*domain.VolumeInfo
113+ var r2 error
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error)); ok {
115+ return returnFunc(ctx, dryRun)
116+ }
117+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) *domain.VolumePruneReport); ok {
118+ r0 = returnFunc(ctx, dryRun)
119+ } else {
120+ if ret.Get(0) != nil {
121+ r0 = ret.Get(0).(*domain.VolumePruneReport)
122+ }
123+ }
124+ if returnFunc, ok := ret.Get(1).(func(context.Context, bool) []*domain.VolumeInfo); ok {
125+ r1 = returnFunc(ctx, dryRun)
126+ } else {
127+ if ret.Get(1) != nil {
128+ r1 = ret.Get(1).([]*domain.VolumeInfo)
129+ }
130+ }
131+ if returnFunc, ok := ret.Get(2).(func(context.Context, bool) error); ok {
132+ r2 = returnFunc(ctx, dryRun)
133+ } else {
134+ r2 = ret.Error(2)
135+ }
136+ return r0, r1, r2
137+}
138+
139+// MockVolumeService_PruneVolumes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PruneVolumes'
140+type MockVolumeService_PruneVolumes_Call struct {
141+ *mock.Call
142+}
143+
144+// PruneVolumes is a helper method to define mock.On call
145+// - ctx context.Context
146+// - dryRun bool
147+func (_e *MockVolumeService_Expecter) PruneVolumes(ctx interface{}, dryRun interface{}) *MockVolumeService_PruneVolumes_Call {
148+ return &MockVolumeService_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, dryRun)}
149+}
150+
151+func (_c *MockVolumeService_PruneVolumes_Call) Run(run func(ctx context.Context, dryRun bool)) *MockVolumeService_PruneVolumes_Call {
152+ _c.Call.Run(func(args mock.Arguments) {
153+ var arg0 context.Context
154+ if args[0] != nil {
155+ arg0 = args[0].(context.Context)
156+ }
157+ var arg1 bool
158+ if args[1] != nil {
159+ arg1 = args[1].(bool)
160+ }
161+ run(
162+ arg0,
163+ arg1,
164+ )
165+ })
166+ return _c
167+}
168+
169+func (_c *MockVolumeService_PruneVolumes_Call) Return(volumePruneReport *domain.VolumePruneReport, volumeInfos []*domain.VolumeInfo, err error) *MockVolumeService_PruneVolumes_Call {
170+ _c.Call.Return(volumePruneReport, volumeInfos, err)
171+ return _c
172+}
173+
174+func (_c *MockVolumeService_PruneVolumes_Call) RunAndReturn(run func(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error)) *MockVolumeService_PruneVolumes_Call {
175+ _c.Call.Return(run)
176+ return _c
177+}
@@ -0,0 +1,177 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockVolumeService creates a new instance of MockVolumeService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockVolumeService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockVolumeService {
20+ mock := &MockVolumeService{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockVolumeService is an autogenerated mock type for the VolumeService type
29+type MockVolumeService struct {
30+ mock.Mock
31+}
32+
33+type MockVolumeService_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockVolumeService) EXPECT() *MockVolumeService_Expecter {
38+ return &MockVolumeService_Expecter{mock: &_m.Mock}
39+}
40+
41+// ListVolumes provides a mock function for the type MockVolumeService
42+func (_mock *MockVolumeService) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ListVolumes")
47+ }
48+
49+ var r0 []*domain.VolumeInfo
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.VolumeInfo, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.VolumeInfo); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).([]*domain.VolumeInfo)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62+ r1 = returnFunc(ctx)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockVolumeService_ListVolumes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumes'
70+type MockVolumeService_ListVolumes_Call struct {
71+ *mock.Call
72+}
73+
74+// ListVolumes is a helper method to define mock.On call
75+// - ctx context.Context
76+func (_e *MockVolumeService_Expecter) ListVolumes(ctx interface{}) *MockVolumeService_ListVolumes_Call {
77+ return &MockVolumeService_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
78+}
79+
80+func (_c *MockVolumeService_ListVolumes_Call) Run(run func(ctx context.Context)) *MockVolumeService_ListVolumes_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ run(
87+ arg0,
88+ )
89+ })
90+ return _c
91+}
92+
93+func (_c *MockVolumeService_ListVolumes_Call) Return(volumeInfos []*domain.VolumeInfo, err error) *MockVolumeService_ListVolumes_Call {
94+ _c.Call.Return(volumeInfos, err)
95+ return _c
96+}
97+
98+func (_c *MockVolumeService_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.VolumeInfo, error)) *MockVolumeService_ListVolumes_Call {
99+ _c.Call.Return(run)
100+ return _c
101+}
102+
103+// PruneVolumes provides a mock function for the type MockVolumeService
104+func (_mock *MockVolumeService) PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error) {
105+ ret := _mock.Called(ctx, dryRun)
106+
107+ if len(ret) == 0 {
108+ panic("no return value specified for PruneVolumes")
109+ }
110+
111+ var r0 *domain.VolumePruneReport
112+ var r1 []*domain.VolumeInfo
113+ var r2 error
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error)); ok {
115+ return returnFunc(ctx, dryRun)
116+ }
117+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) *domain.VolumePruneReport); ok {
118+ r0 = returnFunc(ctx, dryRun)
119+ } else {
120+ if ret.Get(0) != nil {
121+ r0 = ret.Get(0).(*domain.VolumePruneReport)
122+ }
123+ }
124+ if returnFunc, ok := ret.Get(1).(func(context.Context, bool) []*domain.VolumeInfo); ok {
125+ r1 = returnFunc(ctx, dryRun)
126+ } else {
127+ if ret.Get(1) != nil {
128+ r1 = ret.Get(1).([]*domain.VolumeInfo)
129+ }
130+ }
131+ if returnFunc, ok := ret.Get(2).(func(context.Context, bool) error); ok {
132+ r2 = returnFunc(ctx, dryRun)
133+ } else {
134+ r2 = ret.Error(2)
135+ }
136+ return r0, r1, r2
137+}
138+
139+// MockVolumeService_PruneVolumes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PruneVolumes'
140+type MockVolumeService_PruneVolumes_Call struct {
141+ *mock.Call
142+}
143+
144+// PruneVolumes is a helper method to define mock.On call
145+// - ctx context.Context
146+// - dryRun bool
147+func (_e *MockVolumeService_Expecter) PruneVolumes(ctx interface{}, dryRun interface{}) *MockVolumeService_PruneVolumes_Call {
148+ return &MockVolumeService_PruneVolumes_Call{Call: _e.mock.On("PruneVolumes", ctx, dryRun)}
149+}
150+
151+func (_c *MockVolumeService_PruneVolumes_Call) Run(run func(ctx context.Context, dryRun bool)) *MockVolumeService_PruneVolumes_Call {
152+ _c.Call.Run(func(args mock.Arguments) {
153+ var arg0 context.Context
154+ if args[0] != nil {
155+ arg0 = args[0].(context.Context)
156+ }
157+ var arg1 bool
158+ if args[1] != nil {
159+ arg1 = args[1].(bool)
160+ }
161+ run(
162+ arg0,
163+ arg1,
164+ )
165+ })
166+ return _c
167+}
168+
169+func (_c *MockVolumeService_PruneVolumes_Call) Return(volumePruneReport *domain.VolumePruneReport, volumeInfos []*domain.VolumeInfo, err error) *MockVolumeService_PruneVolumes_Call {
170+ _c.Call.Return(volumePruneReport, volumeInfos, err)
171+ return _c
172+}
173+
174+func (_c *MockVolumeService_PruneVolumes_Call) RunAndReturn(run func(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error)) *MockVolumeService_PruneVolumes_Call {
175+ _c.Call.Return(run)
176+ return _c
177+}
A internal/boundaries/in/volumes.go +17 -0
@@ -0,0 +1,17 @@
1+package in
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// VolumeService defines volume listing and prune operations.
10+type VolumeService interface {
11+ // ListVolumes returns all volumes with usage status.
12+ ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
13+
14+ // PruneVolumes removes volumes not mounted by any container.
15+ // If dryRun is true, returns what would be removed without removing.
16+ PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error)
17+}
@@ -0,0 +1,17 @@
1+package in
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+)
8+
9+// VolumeService defines volume listing and prune operations.
10+type VolumeService interface {
11+ // ListVolumes returns all volumes with usage status.
12+ ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
13+
14+ // PruneVolumes removes volumes not mounted by any container.
15+ // If dryRun is true, returns what would be removed without removing.
16+ PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error)
17+}
M internal/boundaries/out/mocks/mock_blob_storage.go +67 -0
@@ -6,6 +6,7 @@ package mocks
66
77 import (
88 "io"
9+ "time"
910
1011 mock "github.com/stretchr/testify/mock"
1112 )
@@ -211,6 +212,72 @@ func (_c *MockBlobStorage_CancelBlobUpload_Call) RunAndReturn(run func(uuid stri
211212 return _c
212213 }
213214
215+// CleanupStaleUploads provides a mock function for the type MockBlobStorage
216+func (_mock *MockBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
217+ ret := _mock.Called(maxAge)
218+
219+ if len(ret) == 0 {
220+ panic("no return value specified for CleanupStaleUploads")
221+ }
222+
223+ var r0 int
224+ var r1 int64
225+ var r2 error
226+ if returnFunc, ok := ret.Get(0).(func(time.Duration) (int, int64, error)); ok {
227+ return returnFunc(maxAge)
228+ }
229+ if returnFunc, ok := ret.Get(0).(func(time.Duration) int); ok {
230+ r0 = returnFunc(maxAge)
231+ } else {
232+ r0 = ret.Get(0).(int)
233+ }
234+ if returnFunc, ok := ret.Get(1).(func(time.Duration) int64); ok {
235+ r1 = returnFunc(maxAge)
236+ } else {
237+ r1 = ret.Get(1).(int64)
238+ }
239+ if returnFunc, ok := ret.Get(2).(func(time.Duration) error); ok {
240+ r2 = returnFunc(maxAge)
241+ } else {
242+ r2 = ret.Error(2)
243+ }
244+ return r0, r1, r2
245+}
246+
247+// MockBlobStorage_CleanupStaleUploads_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupStaleUploads'
248+type MockBlobStorage_CleanupStaleUploads_Call struct {
249+ *mock.Call
250+}
251+
252+// CleanupStaleUploads is a helper method to define mock.On call
253+// - maxAge time.Duration
254+func (_e *MockBlobStorage_Expecter) CleanupStaleUploads(maxAge interface{}) *MockBlobStorage_CleanupStaleUploads_Call {
255+ return &MockBlobStorage_CleanupStaleUploads_Call{Call: _e.mock.On("CleanupStaleUploads", maxAge)}
256+}
257+
258+func (_c *MockBlobStorage_CleanupStaleUploads_Call) Run(run func(maxAge time.Duration)) *MockBlobStorage_CleanupStaleUploads_Call {
259+ _c.Call.Run(func(args mock.Arguments) {
260+ var arg0 time.Duration
261+ if args[0] != nil {
262+ arg0 = args[0].(time.Duration)
263+ }
264+ run(
265+ arg0,
266+ )
267+ })
268+ return _c
269+}
270+
271+func (_c *MockBlobStorage_CleanupStaleUploads_Call) Return(removed int, bytesReclaimed int64, err error) *MockBlobStorage_CleanupStaleUploads_Call {
272+ _c.Call.Return(removed, bytesReclaimed, err)
273+ return _c
274+}
275+
276+func (_c *MockBlobStorage_CleanupStaleUploads_Call) RunAndReturn(run func(maxAge time.Duration) (int, int64, error)) *MockBlobStorage_CleanupStaleUploads_Call {
277+ _c.Call.Return(run)
278+ return _c
279+}
280+
214281 // DeleteBlob provides a mock function for the type MockBlobStorage
215282 func (_mock *MockBlobStorage) DeleteBlob(digest string) error {
216283 ret := _mock.Called(digest)
@@ -6,6 +6,7 @@ package mocks
6 6
7 import (7 import (
8 "io"8 "io"
9+ "time"
9 10
10 mock "github.com/stretchr/testify/mock"11 mock "github.com/stretchr/testify/mock"
11 )12 )
@@ -211,6 +212,72 @@ func (_c *MockBlobStorage_CancelBlobUpload_Call) RunAndReturn(run func(uuid stri
211 return _c212 return _c
212 }213 }
213 214
215+// CleanupStaleUploads provides a mock function for the type MockBlobStorage
216+func (_mock *MockBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
217+ ret := _mock.Called(maxAge)
218+
219+ if len(ret) == 0 {
220+ panic("no return value specified for CleanupStaleUploads")
221+ }
222+
223+ var r0 int
224+ var r1 int64
225+ var r2 error
226+ if returnFunc, ok := ret.Get(0).(func(time.Duration) (int, int64, error)); ok {
227+ return returnFunc(maxAge)
228+ }
229+ if returnFunc, ok := ret.Get(0).(func(time.Duration) int); ok {
230+ r0 = returnFunc(maxAge)
231+ } else {
232+ r0 = ret.Get(0).(int)
233+ }
234+ if returnFunc, ok := ret.Get(1).(func(time.Duration) int64); ok {
235+ r1 = returnFunc(maxAge)
236+ } else {
237+ r1 = ret.Get(1).(int64)
238+ }
239+ if returnFunc, ok := ret.Get(2).(func(time.Duration) error); ok {
240+ r2 = returnFunc(maxAge)
241+ } else {
242+ r2 = ret.Error(2)
243+ }
244+ return r0, r1, r2
245+}
246+
247+// MockBlobStorage_CleanupStaleUploads_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanupStaleUploads'
248+type MockBlobStorage_CleanupStaleUploads_Call struct {
249+ *mock.Call
250+}
251+
252+// CleanupStaleUploads is a helper method to define mock.On call
253+// - maxAge time.Duration
254+func (_e *MockBlobStorage_Expecter) CleanupStaleUploads(maxAge interface{}) *MockBlobStorage_CleanupStaleUploads_Call {
255+ return &MockBlobStorage_CleanupStaleUploads_Call{Call: _e.mock.On("CleanupStaleUploads", maxAge)}
256+}
257+
258+func (_c *MockBlobStorage_CleanupStaleUploads_Call) Run(run func(maxAge time.Duration)) *MockBlobStorage_CleanupStaleUploads_Call {
259+ _c.Call.Run(func(args mock.Arguments) {
260+ var arg0 time.Duration
261+ if args[0] != nil {
262+ arg0 = args[0].(time.Duration)
263+ }
264+ run(
265+ arg0,
266+ )
267+ })
268+ return _c
269+}
270+
271+func (_c *MockBlobStorage_CleanupStaleUploads_Call) Return(removed int, bytesReclaimed int64, err error) *MockBlobStorage_CleanupStaleUploads_Call {
272+ _c.Call.Return(removed, bytesReclaimed, err)
273+ return _c
274+}
275+
276+func (_c *MockBlobStorage_CleanupStaleUploads_Call) RunAndReturn(run func(maxAge time.Duration) (int, int64, error)) *MockBlobStorage_CleanupStaleUploads_Call {
277+ _c.Call.Return(run)
278+ return _c
279+}
280+
214 // DeleteBlob provides a mock function for the type MockBlobStorage281 // DeleteBlob provides a mock function for the type MockBlobStorage
215 func (_mock *MockBlobStorage) DeleteBlob(digest string) error {282 func (_mock *MockBlobStorage) DeleteBlob(digest string) error {
216 ret := _mock.Called(digest)283 ret := _mock.Called(digest)
M internal/boundaries/out/mocks/mock_container_runtime.go +62 -0
@@ -1590,6 +1590,68 @@ func (_c *MockContainerRuntime_ListNetworks_Call) RunAndReturn(run func(ctx cont
15901590 return _c
15911591 }
15921592
1593+// ListVolumes provides a mock function for the type MockContainerRuntime
1594+func (_mock *MockContainerRuntime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1595+ ret := _mock.Called(ctx)
1596+
1597+ if len(ret) == 0 {
1598+ panic("no return value specified for ListVolumes")
1599+ }
1600+
1601+ var r0 []*domain.VolumeInfo
1602+ var r1 error
1603+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.VolumeInfo, error)); ok {
1604+ return returnFunc(ctx)
1605+ }
1606+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.VolumeInfo); ok {
1607+ r0 = returnFunc(ctx)
1608+ } else {
1609+ if ret.Get(0) != nil {
1610+ r0 = ret.Get(0).([]*domain.VolumeInfo)
1611+ }
1612+ }
1613+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1614+ r1 = returnFunc(ctx)
1615+ } else {
1616+ r1 = ret.Error(1)
1617+ }
1618+ return r0, r1
1619+}
1620+
1621+// MockContainerRuntime_ListVolumes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumes'
1622+type MockContainerRuntime_ListVolumes_Call struct {
1623+ *mock.Call
1624+}
1625+
1626+// ListVolumes is a helper method to define mock.On call
1627+// - ctx context.Context
1628+func (_e *MockContainerRuntime_Expecter) ListVolumes(ctx interface{}) *MockContainerRuntime_ListVolumes_Call {
1629+ return &MockContainerRuntime_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
1630+}
1631+
1632+func (_c *MockContainerRuntime_ListVolumes_Call) Run(run func(ctx context.Context)) *MockContainerRuntime_ListVolumes_Call {
1633+ _c.Call.Run(func(args mock.Arguments) {
1634+ var arg0 context.Context
1635+ if args[0] != nil {
1636+ arg0 = args[0].(context.Context)
1637+ }
1638+ run(
1639+ arg0,
1640+ )
1641+ })
1642+ return _c
1643+}
1644+
1645+func (_c *MockContainerRuntime_ListVolumes_Call) Return(volumeInfos []*domain.VolumeInfo, err error) *MockContainerRuntime_ListVolumes_Call {
1646+ _c.Call.Return(volumeInfos, err)
1647+ return _c
1648+}
1649+
1650+func (_c *MockContainerRuntime_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.VolumeInfo, error)) *MockContainerRuntime_ListVolumes_Call {
1651+ _c.Call.Return(run)
1652+ return _c
1653+}
1654+
15931655 // NetworkExists provides a mock function for the type MockContainerRuntime
15941656 func (_mock *MockContainerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
15951657 ret := _mock.Called(ctx, name)
@@ -1590,6 +1590,68 @@ func (_c *MockContainerRuntime_ListNetworks_Call) RunAndReturn(run func(ctx cont
1590 return _c1590 return _c
1591 }1591 }
1592 1592
1593+// ListVolumes provides a mock function for the type MockContainerRuntime
1594+func (_mock *MockContainerRuntime) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
1595+ ret := _mock.Called(ctx)
1596+
1597+ if len(ret) == 0 {
1598+ panic("no return value specified for ListVolumes")
1599+ }
1600+
1601+ var r0 []*domain.VolumeInfo
1602+ var r1 error
1603+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]*domain.VolumeInfo, error)); ok {
1604+ return returnFunc(ctx)
1605+ }
1606+ if returnFunc, ok := ret.Get(0).(func(context.Context) []*domain.VolumeInfo); ok {
1607+ r0 = returnFunc(ctx)
1608+ } else {
1609+ if ret.Get(0) != nil {
1610+ r0 = ret.Get(0).([]*domain.VolumeInfo)
1611+ }
1612+ }
1613+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1614+ r1 = returnFunc(ctx)
1615+ } else {
1616+ r1 = ret.Error(1)
1617+ }
1618+ return r0, r1
1619+}
1620+
1621+// MockContainerRuntime_ListVolumes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListVolumes'
1622+type MockContainerRuntime_ListVolumes_Call struct {
1623+ *mock.Call
1624+}
1625+
1626+// ListVolumes is a helper method to define mock.On call
1627+// - ctx context.Context
1628+func (_e *MockContainerRuntime_Expecter) ListVolumes(ctx interface{}) *MockContainerRuntime_ListVolumes_Call {
1629+ return &MockContainerRuntime_ListVolumes_Call{Call: _e.mock.On("ListVolumes", ctx)}
1630+}
1631+
1632+func (_c *MockContainerRuntime_ListVolumes_Call) Run(run func(ctx context.Context)) *MockContainerRuntime_ListVolumes_Call {
1633+ _c.Call.Run(func(args mock.Arguments) {
1634+ var arg0 context.Context
1635+ if args[0] != nil {
1636+ arg0 = args[0].(context.Context)
1637+ }
1638+ run(
1639+ arg0,
1640+ )
1641+ })
1642+ return _c
1643+}
1644+
1645+func (_c *MockContainerRuntime_ListVolumes_Call) Return(volumeInfos []*domain.VolumeInfo, err error) *MockContainerRuntime_ListVolumes_Call {
1646+ _c.Call.Return(volumeInfos, err)
1647+ return _c
1648+}
1649+
1650+func (_c *MockContainerRuntime_ListVolumes_Call) RunAndReturn(run func(ctx context.Context) ([]*domain.VolumeInfo, error)) *MockContainerRuntime_ListVolumes_Call {
1651+ _c.Call.Return(run)
1652+ return _c
1653+}
1654+
1593 // NetworkExists provides a mock function for the type MockContainerRuntime1655 // NetworkExists provides a mock function for the type MockContainerRuntime
1594 func (_mock *MockContainerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {1656 func (_mock *MockContainerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) {
1595 ret := _mock.Called(ctx, name)1657 ret := _mock.Called(ctx, name)
M internal/boundaries/out/runtime.go +1 -0
@@ -54,6 +54,7 @@ type ContainerRuntime interface {
5454 VolumeExists(ctx context.Context, volumeName string) (bool, error)
5555 CreateVolume(ctx context.Context, volumeName string) error
5656 RemoveVolume(ctx context.Context, volumeName string, force bool) error
57+ ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
5758
5859 // Environment inspection
5960 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
@@ -54,6 +54,7 @@ type ContainerRuntime interface {
54 VolumeExists(ctx context.Context, volumeName string) (bool, error)54 VolumeExists(ctx context.Context, volumeName string) (bool, error)
55 CreateVolume(ctx context.Context, volumeName string) error55 CreateVolume(ctx context.Context, volumeName string) error
56 RemoveVolume(ctx context.Context, volumeName string, force bool) error56 RemoveVolume(ctx context.Context, volumeName string, force bool) error
57+ ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error)
57 58
58 // Environment inspection59 // Environment inspection
59 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)60 InspectImageEnv(ctx context.Context, imageRef string) ([]string, error)
M internal/boundaries/out/storage.go +4 -0
@@ -39,6 +39,10 @@ type BlobStorage interface {
3939
4040 // CancelBlobUpload cancels an in-progress upload.
4141 CancelBlobUpload(uuid string) error
42+
43+ // CleanupStaleUploads removes upload files older than maxAge.
44+ // Returns the number of uploads removed and total bytes reclaimed.
45+ CleanupStaleUploads(maxAge time.Duration) (removed int, bytesReclaimed int64, err error)
4246 }
4347
4448 // ManifestStorage defines the contract for manifest storage operations.
@@ -39,6 +39,10 @@ type BlobStorage interface {
39 39
40 // CancelBlobUpload cancels an in-progress upload.40 // CancelBlobUpload cancels an in-progress upload.
41 CancelBlobUpload(uuid string) error41 CancelBlobUpload(uuid string) error
42+
43+ // CleanupStaleUploads removes upload files older than maxAge.
44+ // Returns the number of uploads removed and total bytes reclaimed.
45+ CleanupStaleUploads(maxAge time.Duration) (removed int, bytesReclaimed int64, err error)
42 }46 }
43 47
44 // ManifestStorage defines the contract for manifest storage operations.48 // ManifestStorage defines the contract for manifest storage operations.
M internal/domain/images.go +4 -0
@@ -45,6 +45,10 @@ type RegistryPruneResult struct {
4545 BlobsRemoved int
4646 // SpaceReclaimed is reported in bytes.
4747 SpaceReclaimed int64
48+ // UploadsRemoved is the number of stale upload files removed.
49+ UploadsRemoved int
50+ // UploadSpaceReclaimed is bytes recovered from stale uploads.
51+ UploadSpaceReclaimed int64
4852 }
4953
5054 // ImagePruneReport aggregates runtime and registry cleanup results.
@@ -45,6 +45,10 @@ type RegistryPruneResult struct {
45 BlobsRemoved int45 BlobsRemoved int
46 // SpaceReclaimed is reported in bytes.46 // SpaceReclaimed is reported in bytes.
47 SpaceReclaimed int6447 SpaceReclaimed int64
48+ // UploadsRemoved is the number of stale upload files removed.
49+ UploadsRemoved int
50+ // UploadSpaceReclaimed is bytes recovered from stale uploads.
51+ UploadSpaceReclaimed int64
48 }52 }
49 53
50 // ImagePruneReport aggregates runtime and registry cleanup results.54 // ImagePruneReport aggregates runtime and registry cleanup results.
A internal/domain/volumes.go +24 -0
@@ -0,0 +1,24 @@
1+package domain
2+
3+import "time"
4+
5+// VolumeInfo describes a named volume and its usage status.
6+type VolumeInfo struct {
7+ Name string
8+ Driver string
9+ MountPoint string
10+ Size int64
11+ CreatedAt time.Time
12+ // InUse is true when at least one container mounts this volume.
13+ InUse bool
14+ // Containers lists the names of containers using this volume.
15+ Containers []string
16+ // Labels are key-value metadata attached to the volume.
17+ Labels map[string]string
18+}
19+
20+// VolumePruneReport summarizes a volume prune operation.
21+type VolumePruneReport struct {
22+ VolumesRemoved int
23+ SpaceReclaimed int64
24+}
@@ -0,0 +1,24 @@
1+package domain
2+
3+import "time"
4+
5+// VolumeInfo describes a named volume and its usage status.
6+type VolumeInfo struct {
7+ Name string
8+ Driver string
9+ MountPoint string
10+ Size int64
11+ CreatedAt time.Time
12+ // InUse is true when at least one container mounts this volume.
13+ InUse bool
14+ // Containers lists the names of containers using this volume.
15+ Containers []string
16+ // Labels are key-value metadata attached to the volume.
17+ Labels map[string]string
18+}
19+
20+// VolumePruneReport summarizes a volume prune operation.
21+type VolumePruneReport struct {
22+ VolumesRemoved int
23+ SpaceReclaimed int64
24+}
M internal/usecase/images/service.go +9 -0
@@ -249,6 +249,15 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
249249 report.Registry.BlobsRemoved++
250250 }
251251
252+ // Clean up stale uploads (abandoned push operations)
253+ uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)
254+ if err != nil {
255+ log.Warn().Err(err).Msg("failed to clean up stale uploads, continuing")
256+ } else {
257+ report.Registry.UploadsRemoved = uploadsRemoved
258+ report.Registry.UploadSpaceReclaimed = uploadBytes
259+ }
260+
252261 return report, nil
253262 }
254263
@@ -249,6 +249,15 @@ func (s *Service) PruneRegistry(ctx context.Context, keepLast int) (domain.Image
249 report.Registry.BlobsRemoved++249 report.Registry.BlobsRemoved++
250 }250 }
251 251
252+ // Clean up stale uploads (abandoned push operations)
253+ uploadsRemoved, uploadBytes, err := s.blobStorage.CleanupStaleUploads(24 * time.Hour)
254+ if err != nil {
255+ log.Warn().Err(err).Msg("failed to clean up stale uploads, continuing")
256+ } else {
257+ report.Registry.UploadsRemoved = uploadsRemoved
258+ report.Registry.UploadSpaceReclaimed = uploadBytes
259+ }
260+
252 return report, nil261 return report, nil
253 }262 }
254 263
M internal/usecase/images/service_test.go +32 -0
@@ -581,6 +581,22 @@ func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) {
581581 assert.Contains(t, err.Error(), "docker daemon unavailable")
582582 }
583583
584+func TestService_PruneRegistry_CleansUpStaleUploads(t *testing.T) {
585+ manifestStorage := newFakeManifestStorage()
586+ blobStorage := &fakeCleanupBlobStorage{
587+ fakeBlobStorage: &fakeBlobStorage{},
588+ uploadsRemoved: 3,
589+ uploadBytes: 1024 * 1024,
590+ }
591+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
592+
593+ report, err := svc.PruneRegistry(context.Background(), 5)
594+ require.NoError(t, err)
595+ assert.Equal(t, 3, report.Registry.UploadsRemoved)
596+ assert.Equal(t, int64(1024*1024), report.Registry.UploadSpaceReclaimed)
597+ assert.Equal(t, 24*time.Hour, blobStorage.capturedMaxAge)
598+}
599+
584600 func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {
585601 manifestStorage := newFakeManifestStorage()
586602 manifestStorage.repositories = []string{"gordon/api"}
@@ -650,6 +666,18 @@ type fakeBlobStorage struct {
650666 deletedBlobs []string
651667 }
652668
669+type fakeCleanupBlobStorage struct {
670+ *fakeBlobStorage
671+ uploadsRemoved int
672+ uploadBytes int64
673+ capturedMaxAge time.Duration
674+}
675+
676+func (f *fakeCleanupBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
677+ f.capturedMaxAge = maxAge
678+ return f.uploadsRemoved, f.uploadBytes, nil
679+}
680+
653681 func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
654682 return f.listDetails, f.listErr
655683 }
@@ -773,6 +801,10 @@ func (f *fakeBlobStorage) DeleteBlob(digest string) error {
773801 return nil
774802 }
775803
804+func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {
805+ return 0, 0, nil
806+}
807+
776808 func TestSplitRepoTag(t *testing.T) {
777809 tests := []struct {
778810 name string
@@ -581,6 +581,22 @@ func TestService_Prune_DanglingOnlyRuntimeFailureReturnsError(t *testing.T) {
581 assert.Contains(t, err.Error(), "docker daemon unavailable")581 assert.Contains(t, err.Error(), "docker daemon unavailable")
582 }582 }
583 583
584+func TestService_PruneRegistry_CleansUpStaleUploads(t *testing.T) {
585+ manifestStorage := newFakeManifestStorage()
586+ blobStorage := &fakeCleanupBlobStorage{
587+ fakeBlobStorage: &fakeBlobStorage{},
588+ uploadsRemoved: 3,
589+ uploadBytes: 1024 * 1024,
590+ }
591+ svc := NewService(&fakeRuntime{}, manifestStorage, blobStorage, zerowrap.Default())
592+
593+ report, err := svc.PruneRegistry(context.Background(), 5)
594+ require.NoError(t, err)
595+ assert.Equal(t, 3, report.Registry.UploadsRemoved)
596+ assert.Equal(t, int64(1024*1024), report.Registry.UploadSpaceReclaimed)
597+ assert.Equal(t, 24*time.Hour, blobStorage.capturedMaxAge)
598+}
599+
584 func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {600 func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) {
585 manifestStorage := newFakeManifestStorage()601 manifestStorage := newFakeManifestStorage()
586 manifestStorage.repositories = []string{"gordon/api"}602 manifestStorage.repositories = []string{"gordon/api"}
@@ -650,6 +666,18 @@ type fakeBlobStorage struct {
650 deletedBlobs []string666 deletedBlobs []string
651 }667 }
652 668
669+type fakeCleanupBlobStorage struct {
670+ *fakeBlobStorage
671+ uploadsRemoved int
672+ uploadBytes int64
673+ capturedMaxAge time.Duration
674+}
675+
676+func (f *fakeCleanupBlobStorage) CleanupStaleUploads(maxAge time.Duration) (int, int64, error) {
677+ f.capturedMaxAge = maxAge
678+ return f.uploadsRemoved, f.uploadBytes, nil
679+}
680+
653 func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {681 func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) {
654 return f.listDetails, f.listErr682 return f.listDetails, f.listErr
655 }683 }
@@ -773,6 +801,10 @@ func (f *fakeBlobStorage) DeleteBlob(digest string) error {
773 return nil801 return nil
774 }802 }
775 803
804+func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) {
805+ return 0, 0, nil
806+}
807+
776 func TestSplitRepoTag(t *testing.T) {808 func TestSplitRepoTag(t *testing.T) {
777 tests := []struct {809 tests := []struct {
778 name string810 name string
A internal/usecase/volumes/service.go +74 -0
@@ -0,0 +1,74 @@
1+// Package volumes implements volume management operations.
2+package volumes
3+
4+import (
5+ "context"
6+ "fmt"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// Service implements the VolumeService interface.
15+type Service struct {
16+ runtime out.ContainerRuntime
17+}
18+
19+// NewService creates a new volume service.
20+func NewService(runtime out.ContainerRuntime) *Service {
21+ return &Service{runtime: runtime}
22+}
23+
24+// ListVolumes returns all volumes with usage status.
25+func (s *Service) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
26+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
27+ zerowrap.FieldLayer: "usecase",
28+ zerowrap.FieldUseCase: "ListVolumes",
29+ })
30+
31+ vols, err := s.runtime.ListVolumes(ctx)
32+ if err != nil {
33+ return nil, fmt.Errorf("failed to list volumes: %w", err)
34+ }
35+ return vols, nil
36+}
37+
38+// PruneVolumes removes volumes not mounted by any container.
39+// Returns a report and the list of volumes that were (or would be) removed.
40+func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error) {
41+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42+ zerowrap.FieldLayer: "usecase",
43+ zerowrap.FieldUseCase: "PruneVolumes",
44+ "dry_run": dryRun,
45+ })
46+ log := zerowrap.FromCtx(ctx)
47+
48+ vols, err := s.runtime.ListVolumes(ctx)
49+ if err != nil {
50+ return nil, nil, fmt.Errorf("failed to list volumes: %w", err)
51+ }
52+
53+ report := &domain.VolumePruneReport{}
54+ var removed []*domain.VolumeInfo
55+
56+ for _, vol := range vols {
57+ if vol.InUse {
58+ continue
59+ }
60+
61+ if !dryRun {
62+ if err := s.runtime.RemoveVolume(ctx, vol.Name, false); err != nil {
63+ log.Warn().Err(err).Str("volume", vol.Name).Msg("failed to remove volume, skipping")
64+ continue
65+ }
66+ }
67+
68+ removed = append(removed, vol)
69+ report.VolumesRemoved++
70+ report.SpaceReclaimed += vol.Size
71+ }
72+
73+ return report, removed, nil
74+}
@@ -0,0 +1,74 @@
1+// Package volumes implements volume management operations.
2+package volumes
3+
4+import (
5+ "context"
6+ "fmt"
7+
8+ "github.com/bnema/zerowrap"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+// Service implements the VolumeService interface.
15+type Service struct {
16+ runtime out.ContainerRuntime
17+}
18+
19+// NewService creates a new volume service.
20+func NewService(runtime out.ContainerRuntime) *Service {
21+ return &Service{runtime: runtime}
22+}
23+
24+// ListVolumes returns all volumes with usage status.
25+func (s *Service) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
26+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
27+ zerowrap.FieldLayer: "usecase",
28+ zerowrap.FieldUseCase: "ListVolumes",
29+ })
30+
31+ vols, err := s.runtime.ListVolumes(ctx)
32+ if err != nil {
33+ return nil, fmt.Errorf("failed to list volumes: %w", err)
34+ }
35+ return vols, nil
36+}
37+
38+// PruneVolumes removes volumes not mounted by any container.
39+// Returns a report and the list of volumes that were (or would be) removed.
40+func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error) {
41+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42+ zerowrap.FieldLayer: "usecase",
43+ zerowrap.FieldUseCase: "PruneVolumes",
44+ "dry_run": dryRun,
45+ })
46+ log := zerowrap.FromCtx(ctx)
47+
48+ vols, err := s.runtime.ListVolumes(ctx)
49+ if err != nil {
50+ return nil, nil, fmt.Errorf("failed to list volumes: %w", err)
51+ }
52+
53+ report := &domain.VolumePruneReport{}
54+ var removed []*domain.VolumeInfo
55+
56+ for _, vol := range vols {
57+ if vol.InUse {
58+ continue
59+ }
60+
61+ if !dryRun {
62+ if err := s.runtime.RemoveVolume(ctx, vol.Name, false); err != nil {
63+ log.Warn().Err(err).Str("volume", vol.Name).Msg("failed to remove volume, skipping")
64+ continue
65+ }
66+ }
67+
68+ removed = append(removed, vol)
69+ report.VolumesRemoved++
70+ report.SpaceReclaimed += vol.Size
71+ }
72+
73+ return report, removed, nil
74+}
A internal/usecase/volumes/service_test.go +108 -0
@@ -0,0 +1,108 @@
1+package volumes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func TestService_ListVolumes(t *testing.T) {
16+ runtime := outmocks.NewMockContainerRuntime(t)
17+
18+ vols := []*domain.VolumeInfo{
19+ {Name: "vol1", InUse: true, Containers: []string{"web"}},
20+ {Name: "vol2", InUse: false},
21+ }
22+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
23+
24+ svc := NewService(runtime)
25+ result, err := svc.ListVolumes(context.Background())
26+ require.NoError(t, err)
27+ assert.Len(t, result, 2)
28+}
29+
30+func TestService_PruneVolumes_RemovesOrphaned(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+
33+ vols := []*domain.VolumeInfo{
34+ {Name: "vol-in-use", InUse: true, Containers: []string{"web"}},
35+ {Name: "vol-orphaned", InUse: false, Size: 1024},
36+ }
37+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
38+ runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil)
39+
40+ svc := NewService(runtime)
41+ report, removed, err := svc.PruneVolumes(context.Background(), false)
42+ require.NoError(t, err)
43+ assert.Equal(t, 1, report.VolumesRemoved)
44+ assert.Equal(t, int64(1024), report.SpaceReclaimed)
45+ assert.Len(t, removed, 1)
46+ assert.Equal(t, "vol-orphaned", removed[0].Name)
47+}
48+
49+func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
50+ runtime := outmocks.NewMockContainerRuntime(t)
51+
52+ vols := []*domain.VolumeInfo{
53+ {Name: "vol-orphaned", InUse: false, Size: 2048},
54+ }
55+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
56+
57+ svc := NewService(runtime)
58+ report, removed, err := svc.PruneVolumes(context.Background(), true)
59+ require.NoError(t, err)
60+ assert.Equal(t, 1, report.VolumesRemoved)
61+ assert.Equal(t, int64(2048), report.SpaceReclaimed)
62+ assert.Len(t, removed, 1)
63+}
64+
65+func TestService_PruneVolumes_AllInUse(t *testing.T) {
66+ runtime := outmocks.NewMockContainerRuntime(t)
67+
68+ vols := []*domain.VolumeInfo{
69+ {Name: "vol1", InUse: true, Containers: []string{"web"}},
70+ }
71+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
72+
73+ svc := NewService(runtime)
74+ report, removed, err := svc.PruneVolumes(context.Background(), false)
75+ require.NoError(t, err)
76+ assert.Equal(t, 0, report.VolumesRemoved)
77+ assert.Empty(t, removed)
78+}
79+
80+func TestService_ListVolumes_PropagatesError(t *testing.T) {
81+ runtime := outmocks.NewMockContainerRuntime(t)
82+ runtime.EXPECT().ListVolumes(context.Background()).Return(nil, fmt.Errorf("connection refused"))
83+
84+ svc := NewService(runtime)
85+ _, err := svc.ListVolumes(context.Background())
86+ require.Error(t, err)
87+ assert.Contains(t, err.Error(), "connection refused")
88+}
89+
90+func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) {
91+ runtime := outmocks.NewMockContainerRuntime(t)
92+
93+ vols := []*domain.VolumeInfo{
94+ {Name: "vol-fail", InUse: false, Size: 1024},
95+ {Name: "vol-ok", InUse: false, Size: 2048},
96+ }
97+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
98+ runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use"))
99+ runtime.EXPECT().RemoveVolume(context.Background(), "vol-ok", false).Return(nil)
100+
101+ svc := NewService(runtime)
102+ report, removed, err := svc.PruneVolumes(context.Background(), false)
103+ require.NoError(t, err)
104+ assert.Equal(t, 1, report.VolumesRemoved)
105+ assert.Equal(t, int64(2048), report.SpaceReclaimed)
106+ assert.Len(t, removed, 1)
107+ assert.Equal(t, "vol-ok", removed[0].Name)
108+}
@@ -0,0 +1,108 @@
1+package volumes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func TestService_ListVolumes(t *testing.T) {
16+ runtime := outmocks.NewMockContainerRuntime(t)
17+
18+ vols := []*domain.VolumeInfo{
19+ {Name: "vol1", InUse: true, Containers: []string{"web"}},
20+ {Name: "vol2", InUse: false},
21+ }
22+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
23+
24+ svc := NewService(runtime)
25+ result, err := svc.ListVolumes(context.Background())
26+ require.NoError(t, err)
27+ assert.Len(t, result, 2)
28+}
29+
30+func TestService_PruneVolumes_RemovesOrphaned(t *testing.T) {
31+ runtime := outmocks.NewMockContainerRuntime(t)
32+
33+ vols := []*domain.VolumeInfo{
34+ {Name: "vol-in-use", InUse: true, Containers: []string{"web"}},
35+ {Name: "vol-orphaned", InUse: false, Size: 1024},
36+ }
37+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
38+ runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil)
39+
40+ svc := NewService(runtime)
41+ report, removed, err := svc.PruneVolumes(context.Background(), false)
42+ require.NoError(t, err)
43+ assert.Equal(t, 1, report.VolumesRemoved)
44+ assert.Equal(t, int64(1024), report.SpaceReclaimed)
45+ assert.Len(t, removed, 1)
46+ assert.Equal(t, "vol-orphaned", removed[0].Name)
47+}
48+
49+func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
50+ runtime := outmocks.NewMockContainerRuntime(t)
51+
52+ vols := []*domain.VolumeInfo{
53+ {Name: "vol-orphaned", InUse: false, Size: 2048},
54+ }
55+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
56+
57+ svc := NewService(runtime)
58+ report, removed, err := svc.PruneVolumes(context.Background(), true)
59+ require.NoError(t, err)
60+ assert.Equal(t, 1, report.VolumesRemoved)
61+ assert.Equal(t, int64(2048), report.SpaceReclaimed)
62+ assert.Len(t, removed, 1)
63+}
64+
65+func TestService_PruneVolumes_AllInUse(t *testing.T) {
66+ runtime := outmocks.NewMockContainerRuntime(t)
67+
68+ vols := []*domain.VolumeInfo{
69+ {Name: "vol1", InUse: true, Containers: []string{"web"}},
70+ }
71+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
72+
73+ svc := NewService(runtime)
74+ report, removed, err := svc.PruneVolumes(context.Background(), false)
75+ require.NoError(t, err)
76+ assert.Equal(t, 0, report.VolumesRemoved)
77+ assert.Empty(t, removed)
78+}
79+
80+func TestService_ListVolumes_PropagatesError(t *testing.T) {
81+ runtime := outmocks.NewMockContainerRuntime(t)
82+ runtime.EXPECT().ListVolumes(context.Background()).Return(nil, fmt.Errorf("connection refused"))
83+
84+ svc := NewService(runtime)
85+ _, err := svc.ListVolumes(context.Background())
86+ require.Error(t, err)
87+ assert.Contains(t, err.Error(), "connection refused")
88+}
89+
90+func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) {
91+ runtime := outmocks.NewMockContainerRuntime(t)
92+
93+ vols := []*domain.VolumeInfo{
94+ {Name: "vol-fail", InUse: false, Size: 1024},
95+ {Name: "vol-ok", InUse: false, Size: 2048},
96+ }
97+ runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
98+ runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use"))
99+ runtime.EXPECT().RemoveVolume(context.Background(), "vol-ok", false).Return(nil)
100+
101+ svc := NewService(runtime)
102+ report, removed, err := svc.PruneVolumes(context.Background(), false)
103+ require.NoError(t, err)
104+ assert.Equal(t, 1, report.VolumesRemoved)
105+ assert.Equal(t, int64(2048), report.SpaceReclaimed)
106+ assert.Len(t, removed, 1)
107+ assert.Equal(t, "vol-ok", removed[0].Name)
108+}

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