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: | |||
| 36 | 36 | HTTPProber: | |
| 37 | 37 | SecretService: | |
| 38 | 38 | LogService: | |
| 39 | + | VolumeService: | |
| 39 | 40 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | |
| 40 | 41 | # Mocked here because it abstracts Docker SDK calls that require a running | |
| 41 | 42 | # 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 running | 41 | # 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 | |||
| 175 | 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 | 186 | func TestResolveAttachmentImage_OneTarget(t *testing.T) { | |
| 179 | 187 | cp := &attachmentPushTestControlPlane{ | |
| 180 | 188 | 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 { | |||
| 55 | 55 | GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) | |
| 56 | 56 | StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) | |
| 57 | 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 | } | |
| @@ -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 { | |||
| 23 | 23 | deployCoord in.DeployCoordinator | |
| 24 | 24 | healthSvc in.HealthService | |
| 25 | 25 | logSvc in.LogService | |
| 26 | + | volumeSvc in.VolumeService | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |
| @@ -47,6 +48,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 47 | 48 | deployCoord: deployCoord, | |
| 48 | 49 | healthSvc: kernel.Health(), | |
| 49 | 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 | 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 | 586 | func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo { | |
| 536 | 587 | out := make([]remote.RouteInfo, 0, len(routes)) | |
| 537 | 588 | for _, route := range routes { | |
| @@ -23,6 +23,7 @@ type localControlPlane struct { | |||||
| 23 | deployCoord in.DeployCoordinator | 23 | deployCoord in.DeployCoordinator | ||
| 24 | healthSvc in.HealthService | 24 | healthSvc in.HealthService | ||
| 25 | logSvc in.LogService | 25 | 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) ( | |||
| 163 | 163 | func (r *remoteControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) { | |
| 164 | 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 | + | } | |
| @@ -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, | |||
| 172 | 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 | 183 | func TestValidateBuildArg(t *testing.T) { | |
| 176 | 184 | tests := []struct { | |
| 177 | 185 | 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 string | 185 | 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 | |||
| 1083 | 1083 | ||
| 1084 | 1084 | 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 | + | } | |
| @@ -1083,3 +1083,33 @@ func (c *Client) VerifyAuth(ctx context.Context) (*dto.AuthVerifyResponse, error | |||||
| 1083 | 1083 | ||||
| 1084 | return &result, nil | 1084 | 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: | |||
| 137 | 137 | networksCmd.GroupID = groupManage | |
| 138 | 138 | rootCmd.AddCommand(networksCmd) | |
| 139 | 139 | ||
| 140 | + | volumesCmd := newVolumesCmd() | |
| 141 | + | volumesCmd.GroupID = groupManage | |
| 142 | + | rootCmd.AddCommand(volumesCmd) | |
| 143 | + | ||
| 140 | 144 | autorouteCmd := newAutorouteCmd() | |
| 141 | 145 | autorouteCmd.GroupID = groupManage | |
| 142 | 146 | rootCmd.AddCommand(autorouteCmd) | |
| @@ -137,6 +137,10 @@ Commands are organized by where they run: | |||||
| 137 | networksCmd.GroupID = groupManage | 137 | 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 = groupManage | 145 | 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 { | |||
| 45 | 45 | healthSvc in.HealthService | |
| 46 | 46 | secretSvc in.SecretService | |
| 47 | 47 | logSvc in.LogService | |
| 48 | + | volumeSvc in.VolumeService | |
| 48 | 49 | registrySvc registryDeployService | |
| 49 | 50 | eventBus out.EventPublisher | |
| 50 | 51 | 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 | 173 | // RegisterRoutes registers the admin routes on the given mux. | |
| 167 | 174 | func (h *Handler) RegisterRoutes(mux *http.ServeMux) { | |
| 168 | 175 | mux.HandleFunc("/admin/", h.handleAdminRoutes) | |
| @@ -200,13 +207,15 @@ type routeHandler func(w http.ResponseWriter, r *http.Request, path string) | |||
| 200 | 207 | func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |
| 201 | 208 | // Exact match routes | |
| 202 | 209 | 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) }, | |
| 210 | 219 | } | |
| 211 | 220 | if handler, ok := exactRoutes[path]; ok { | |
| 212 | 221 | return handler, true | |
| @@ -45,6 +45,7 @@ type Handler struct { | |||||
| 45 | healthSvc in.HealthService | 45 | healthSvc in.HealthService | ||
| 46 | secretSvc in.SecretService | 46 | secretSvc in.SecretService | ||
| 47 | logSvc in.LogService | 47 | logSvc in.LogService | ||
| 48 | + | volumeSvc in.VolumeService | |||
| 48 | registrySvc registryDeployService | 49 | registrySvc registryDeployService | ||
| 49 | eventBus out.EventPublisher | 50 | eventBus out.EventPublisher | ||
| 50 | log zerowrap.Logger | 51 | 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 routes | 208 | // 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, true | 221 | 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) { | |||
| 81 | 81 | }, 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 | 98 | // NewRuntimeWithClient creates a new Docker runtime instance with a custom client (for testing). | |
| 85 | 99 | func NewRuntimeWithClient(cli *client.Client) *Runtime { | |
| 86 | 100 | return &Runtime{ | |
| @@ -1053,6 +1067,63 @@ func (r *Runtime) RemoveVolume(ctx context.Context, volumeName string, force boo | |||
| 1053 | 1067 | 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 | 1127 | // InspectImageEnv gets the environment variables declared in the image. | |
| 1057 | 1128 | func (r *Runtime) InspectImageEnv(ctx context.Context, imageRef string) ([]string, error) { | |
| 1058 | 1129 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -81,6 +81,20 @@ func NewRuntime() (*Runtime, error) { | |||||
| 81 | }, nil | 81 | }, 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 nil | 1067 | 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 ( | |||
| 12 | 12 | "path/filepath" | |
| 13 | 13 | "strings" | |
| 14 | 14 | "sync" | |
| 15 | + | "time" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/zerowrap" | |
| 17 | 18 | "github.com/google/uuid" | |
| @@ -414,6 +415,63 @@ func (s *BlobStorage) CancelBlobUpload(uuid string) error { | |||
| 414 | 415 | 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 | 475 | // Helper methods for path generation with security validation | |
| 418 | 476 | ||
| 419 | 477 | 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 nil | 415 | 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 validation | 475 | // 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 { | |||
| 23 | 23 | registrySvc in.RegistryService | |
| 24 | 24 | healthSvc in.HealthService | |
| 25 | 25 | logSvc in.LogService | |
| 26 | + | volumeSvc in.VolumeService | |
| 26 | 27 | cleanup func() | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -56,6 +57,7 @@ func NewKernel(configPath string) (*Kernel, error) { | |||
| 56 | 57 | registrySvc: svc.registrySvc, | |
| 57 | 58 | healthSvc: svc.healthSvc, | |
| 58 | 59 | logSvc: svc.logSvc, | |
| 60 | + | volumeSvc: svc.volumeSvc, | |
| 59 | 61 | cleanup: cleanup, | |
| 60 | 62 | }, nil | |
| 61 | 63 | } else { | |
| @@ -106,4 +108,6 @@ func (k *Kernel) Health() in.HealthService { return k.healthSvc } | |||
| 106 | 108 | ||
| 107 | 109 | func (k *Kernel) Logs() in.LogService { return k.logSvc } | |
| 108 | 110 | ||
| 111 | + | func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc } | |
| 112 | + | ||
| 109 | 113 | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | |
| @@ -23,6 +23,7 @@ type Kernel struct { | |||||
| 23 | registrySvc in.RegistryService | 23 | registrySvc in.RegistryService | ||
| 24 | healthSvc in.HealthService | 24 | healthSvc in.HealthService | ||
| 25 | logSvc in.LogService | 25 | 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 | }, nil | 62 | }, 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 ( | |||
| 74 | 74 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 75 | 75 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 76 | 76 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 77 | + | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | |
| 77 | 78 | ||
| 78 | 79 | // Pkg | |
| 79 | 80 | "github.com/bnema/gordon/pkg/bytesize" | |
| @@ -184,6 +185,7 @@ type services struct { | |||
| 184 | 185 | healthSvc *health.Service | |
| 185 | 186 | logSvc *logs.Service | |
| 186 | 187 | imageSvc *images.Service | |
| 188 | + | volumeSvc *volumesSvc.Service | |
| 187 | 189 | proxySvc *proxy.Service | |
| 188 | 190 | authSvc *auth.Service | |
| 189 | 191 | authHandler *authhandler.Handler | |
| @@ -452,7 +454,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 452 | 454 | var err error | |
| 453 | 455 | ||
| 454 | 456 | // 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 | 459 | 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 | 510 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 508 | 511 | svc.imageSvc = images.NewService(svc.runtime, svc.manifestStorage, svc.blobStorage, log) | |
| 512 | + | svc.volumeSvc = volumesSvc.NewService(svc.runtime) | |
| 509 | 513 | ||
| 510 | 514 | injectTelemetryMetrics(cfg, svc, log) | |
| 511 | 515 | ||
| @@ -550,7 +554,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 550 | 554 | log, | |
| 551 | 555 | svc.backupSvc, | |
| 552 | 556 | svc.imageSvc, | |
| 553 | - | ) | |
| 557 | + | ).WithVolumeService(svc.volumeSvc) | |
| 554 | 558 | ||
| 555 | 559 | return svc, nil | |
| 556 | 560 | } | |
| @@ -625,19 +629,53 @@ func resolveLogFilePath(cfg Config) string { | |||
| 625 | 629 | return filepath.Join(dataDir, "logs", "gordon.log") | |
| 626 | 630 | } | |
| 627 | 631 | ||
| 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 | + | } | |
| 631 | 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 | 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) | |
| 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") | |
| 641 | 679 | ||
| 642 | 680 | eventBus := eventbus.NewInMemory(100, log) | |
| 643 | 681 | ||
| @@ -1865,6 +1903,8 @@ func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, lo | |||
| 1865 | 1903 | Int64("runtime_reclaimed_bytes", report.Runtime.SpaceReclaimed). | |
| 1866 | 1904 | Int("registry_tags_removed", report.Registry.TagsRemoved). | |
| 1867 | 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 | 1908 | Msg("scheduled image prune complete") | |
| 1869 | 1909 | return nil | |
| 1870 | 1910 | }, | |
| @@ -2306,6 +2346,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2306 | 2346 | v.SetDefault("server.tls_cert_file", "") | |
| 2307 | 2347 | v.SetDefault("server.tls_key_file", "") | |
| 2308 | 2348 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 2349 | + | v.SetDefault("server.runtime", "auto") | |
| 2309 | 2350 | v.SetDefault("logging.level", "info") | |
| 2310 | 2351 | v.SetDefault("logging.format", "console") | |
| 2311 | 2352 | 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 | // Pkg | 79 | // 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.Service | 185 | healthSvc *health.Service | ||
| 185 | logSvc *logs.Service | 186 | logSvc *logs.Service | ||
| 186 | imageSvc *images.Service | 187 | imageSvc *images.Service | ||
| 188 | + | volumeSvc *volumesSvc.Service | |||
| 187 | proxySvc *proxy.Service | 189 | proxySvc *proxy.Service | ||
| 188 | authSvc *auth.Service | 190 | authSvc *auth.Service | ||
| 189 | authHandler *authhandler.Handler | 191 | authHandler *authhandler.Handler | ||
| @@ -452,7 +454,8 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 452 | var err error | 454 | var err error | ||
| 453 | 455 | ||||
| 454 | // Create output adapters | 456 | // 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, err | 459 | 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, nil | 559 | 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 nil | 1909 | 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 | |||
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | 8 | "io" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | mock "github.com/stretchr/testify/mock" | |
| 11 | 12 | ) | |
| @@ -211,6 +212,72 @@ func (_c *MockBlobStorage_CancelBlobUpload_Call) RunAndReturn(run func(uuid stri | |||
| 211 | 212 | 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 | 281 | // DeleteBlob provides a mock function for the type MockBlobStorage | |
| 215 | 282 | func (_mock *MockBlobStorage) DeleteBlob(digest string) error { | |
| 216 | 283 | 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 _c | 212 | 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 MockBlobStorage | 281 | // 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 | |||
| 1590 | 1590 | 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 | 1655 | // NetworkExists provides a mock function for the type MockContainerRuntime | |
| 1594 | 1656 | func (_mock *MockContainerRuntime) NetworkExists(ctx context.Context, name string) (bool, error) { | |
| 1595 | 1657 | ret := _mock.Called(ctx, name) | |
| @@ -1590,6 +1590,68 @@ func (_c *MockContainerRuntime_ListNetworks_Call) RunAndReturn(run func(ctx cont | |||||
| 1590 | return _c | 1590 | 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 MockContainerRuntime | 1655 | // 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 { | |||
| 54 | 54 | VolumeExists(ctx context.Context, volumeName string) (bool, error) | |
| 55 | 55 | CreateVolume(ctx context.Context, volumeName string) error | |
| 56 | 56 | RemoveVolume(ctx context.Context, volumeName string, force bool) error | |
| 57 | + | ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) | |
| 57 | 58 | ||
| 58 | 59 | // Environment inspection | |
| 59 | 60 | 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) error | 55 | CreateVolume(ctx context.Context, volumeName string) error | ||
| 56 | RemoveVolume(ctx context.Context, volumeName string, force bool) error | 56 | RemoveVolume(ctx context.Context, volumeName string, force bool) error | ||
| 57 | + | ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) | |||
| 57 | 58 | ||||
| 58 | // Environment inspection | 59 | // 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 { | |||
| 39 | 39 | ||
| 40 | 40 | // CancelBlobUpload cancels an in-progress upload. | |
| 41 | 41 | 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 | 48 | // 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) error | 41 | 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 { | |||
| 45 | 45 | BlobsRemoved int | |
| 46 | 46 | // SpaceReclaimed is reported in bytes. | |
| 47 | 47 | 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 | 54 | // ImagePruneReport aggregates runtime and registry cleanup results. | |
| @@ -45,6 +45,10 @@ type RegistryPruneResult struct { | |||||
| 45 | BlobsRemoved int | 45 | BlobsRemoved int | ||
| 46 | // SpaceReclaimed is reported in bytes. | 46 | // SpaceReclaimed is reported in bytes. | ||
| 47 | SpaceReclaimed int64 | 47 | 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 | |||
| 249 | 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 | 261 | return report, nil | |
| 253 | 262 | } | |
| 254 | 263 | ||
| @@ -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, nil | 261 | 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) { | |||
| 581 | 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 | 600 | func TestService_Prune_RegistryOnlySkipsRuntime(t *testing.T) { | |
| 585 | 601 | manifestStorage := newFakeManifestStorage() | |
| 586 | 602 | manifestStorage.repositories = []string{"gordon/api"} | |
| @@ -650,6 +666,18 @@ type fakeBlobStorage struct { | |||
| 650 | 666 | 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 | 681 | func (f *fakeRuntime) ListImagesDetailed(context.Context) ([]pkgruntime.ImageDetail, error) { | |
| 654 | 682 | return f.listDetails, f.listErr | |
| 655 | 683 | } | |
| @@ -773,6 +801,10 @@ func (f *fakeBlobStorage) DeleteBlob(digest string) error { | |||
| 773 | 801 | return nil | |
| 774 | 802 | } | |
| 775 | 803 | ||
| 804 | + | func (f *fakeBlobStorage) CleanupStaleUploads(_ time.Duration) (int, int64, error) { | |
| 805 | + | return 0, 0, nil | |
| 806 | + | } | |
| 807 | + | ||
| 776 | 808 | func TestSplitRepoTag(t *testing.T) { | |
| 777 | 809 | tests := []struct { | |
| 778 | 810 | 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 []string | 666 | 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.listErr | 682 | return f.listDetails, f.listErr | ||
| 655 | } | 683 | } | ||
| @@ -773,6 +801,10 @@ func (f *fakeBlobStorage) DeleteBlob(digest string) error { | |||||
| 773 | return nil | 801 | 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 string | 810 | 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 | + | } | |||