1package volumes
2
3import (
4 "context"
5 "fmt"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"
11
12 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// appVolumeLabels are the runtime labels a stamped app volume carries.
17func appVolumeLabels(app, service string) map[string]string {
18 return map[string]string{
19 domain.LabelManaged: "true",
20 domain.LabelApp: app,
21 domain.LabelAppID: app + "-uuid",
22 domain.LabelAppService: service,
23 }
24}
25
26// newPruneService wires a volume service against a scripted snapshot and
27// runtime inventory.
28func newPruneService(
29 t *testing.T,
30 volumes []*domain.VolumeInfo,
31 snapshot *domain.PruneProtectionSnapshot,
32 inventoryGaps ...domain.InventoryGap,
33) (*Service, *outmocks.MockContainerRuntime, *outmocks.MockPruneRuntime, *outmocks.MockPruneProtectionStore, *outmocks.MockGCBarrier) {
34 t.Helper()
35 runtime := outmocks.NewMockContainerRuntime(t)
36 protection := outmocks.NewMockPruneProtectionStore(t)
37 pruneRuntime := outmocks.NewMockPruneRuntime(t)
38 barrier := outmocks.NewMockGCBarrier(t)
39
40 if snapshot == nil {
41 snapshot = &domain.PruneProtectionSnapshot{}
42 }
43 protection.EXPECT().ProtectionSnapshot(context.Background()).Return(snapshot, nil)
44 pruneRuntime.EXPECT().InventoryRuntime(context.Background()).Return(&domain.RuntimeInventory{
45 Volumes: volumes,
46 Gaps: inventoryGaps,
47 }, nil)
48
49 lease := outmocks.NewMockGCLease(t)
50 barrier.EXPECT().AcquireExclusive(context.Background()).Return(lease, nil)
51 lease.EXPECT().Release()
52
53 svc := NewService(runtime).WithPrunePorts(protection, pruneRuntime, barrier)
54 return svc, runtime, pruneRuntime, protection, barrier
55}
56
57func TestService_ListVolumes(t *testing.T) {
58 runtime := outmocks.NewMockContainerRuntime(t)
59
60 vols := []*domain.VolumeInfo{
61 {Name: "vol1", InUse: true, Containers: []string{"web"}},
62 {Name: "vol2", InUse: false},
63 }
64 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
65
66 svc := NewService(runtime)
67 result, err := svc.ListVolumes(context.Background())
68 require.NoError(t, err)
69 assert.Len(t, result, 2)
70}
71
72func TestService_ListVolumes_PropagatesError(t *testing.T) {
73 runtime := outmocks.NewMockContainerRuntime(t)
74 runtime.EXPECT().ListVolumes(context.Background()).Return(nil, fmt.Errorf("connection refused"))
75
76 svc := NewService(runtime)
77 _, err := svc.ListVolumes(context.Background())
78 require.Error(t, err)
79 assert.Contains(t, err.Error(), "connection refused")
80}
81
82// TestService_PruneVolumes_RemovesOnlyExplicitlyReleasedVolumes is the
83// central safety case: managed, app-labelled, and legacy volumes all
84// survive; only a released, label-agreeing, unused volume is removed.
85func TestService_PruneVolumes_RemovesOnlyExplicitlyReleasedVolumes(t *testing.T) {
86 const released = "gordon-shop--web--vol--data"
87 volumes := []*domain.VolumeInfo{
88 {Name: released, Size: 1024, Labels: appVolumeLabels("shop", "web")},
89 {Name: "gordon-shop--api--vol--data", Size: 200, Labels: appVolumeLabels("shop", "api")},
90 {Name: "gordon-old--web--vol--data", Size: 300, Labels: appVolumeLabels("old", "web")},
91 {Name: "gordon-legacy", Size: 400, Labels: map[string]string{domain.LabelManaged: "true"}},
92 {Name: "pgdata", Size: 500},
93 {Name: "gordon-shop--cache--vol--data", Size: 600, Labels: appVolumeLabels("shop", "web"), InUse: true},
94 }
95 snapshot := &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{
96 {Name: released, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased},
97 {Name: "gordon-shop--api--vol--data", App: "shop", AppID: "shop-uuid", Service: "api", State: domain.VolumeClaimAttached},
98 {Name: "gordon-old--web--vol--data", App: "old", AppID: "old-uuid", Service: "web", State: domain.VolumeClaimRetained},
99 {Name: "gordon-shop--cache--vol--data", App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimAttached},
100 }}
101
102 svc, _, pruneRuntime, _, _ := newPruneService(t, volumes, snapshot)
103 pruneRuntime.EXPECT().RemoveVolumeExact(context.Background(), domain.RuntimeVolumeRef{Name: released}).Return(nil)
104
105 report, removed, err := svc.PruneVolumes(context.Background(), false)
106 require.NoError(t, err)
107 assert.Equal(t, 1, report.VolumesRemoved)
108 assert.Equal(t, int64(1024), report.SpaceReclaimed)
109 require.Len(t, removed, 1)
110 assert.Equal(t, released, removed[0].Name)
111 assert.True(t, report.Plan.Applied)
112
113 protected := report.Plan.CountByVerdict(domain.PruneVerdictProtected)
114 assert.GreaterOrEqual(t, protected, 4, "attached, retained, legacy, unknown and in-use volumes must be protected")
115}
116
117func TestService_PruneVolumes_UnknownAndContradictoryVolumesSurvive(t *testing.T) {
118 volumes := []*domain.VolumeInfo{
119 // App labels but no durable record: ownership history missing.
120 {Name: "gordon-ghost--web--vol--data", Labels: appVolumeLabels("ghost", "web")},
121 // Durable released record but contradicting labels.
122 {Name: "gordon-shop--web--vol--other", Labels: appVolumeLabels("other", "web")},
123 }
124 snapshot := &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{
125 {Name: "gordon-shop--web--vol--other", App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased},
126 }}
127
128 svc, _, pruneRuntime, _, _ := newPruneService(t, volumes, snapshot)
129 pruneRuntime.EXPECT().RemoveVolumeExact(mock.Anything, mock.Anything).Maybe().Return(nil)
130
131 report, removed, err := svc.PruneVolumes(context.Background(), false)
132 require.NoError(t, err)
133 assert.Zero(t, report.VolumesRemoved)
134 assert.Empty(t, removed)
135 assert.Equal(t, 2, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
136}
137
138func TestService_PruneVolumes_DryRunPlansWithoutDeleting(t *testing.T) {
139 const released = "gordon-shop--web--vol--data"
140 volumes := []*domain.VolumeInfo{{Name: released, Size: 2048, Labels: appVolumeLabels("shop", "web")}}
141 snapshot := &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{
142 {Name: released, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased},
143 }}
144
145 svc, _, pruneRuntime, _, _ := newPruneService(t, volumes, snapshot)
146 pruneRuntime.EXPECT().RemoveVolumeExact(mock.Anything, mock.Anything).Maybe().Return(nil)
147
148 report, removed, err := svc.PruneVolumes(context.Background(), true)
149 require.NoError(t, err)
150 assert.Zero(t, report.VolumesRemoved)
151 assert.Empty(t, removed, "a dry run must report only what would be removed")
152 assert.False(t, report.Plan.Applied)
153 assert.Equal(t, 1, report.Plan.CountByVerdict(domain.PruneVerdictEligible))
154
155 // The dry-run plan and the executed plan must agree.
156 executed, _, err := svc.PruneVolumes(context.Background(), false)
157 require.NoError(t, err)
158 assert.Equal(t, report.Plan.Candidates, executed.Plan.Candidates)
159}
160
161func TestService_PruneVolumes_ZeroDeletionSucceeds(t *testing.T) {
162 volumes := []*domain.VolumeInfo{
163 {Name: "pgdata"},
164 {Name: "gordon-shop--web--vol--data", Labels: appVolumeLabels("shop", "web"), InUse: true},
165 }
166 svc, _, _, _, _ := newPruneService(t, volumes, nil)
167
168 report, removed, err := svc.PruneVolumes(context.Background(), false)
169 require.NoError(t, err, "with current metadata a zero-deletion prune is valid")
170 assert.Zero(t, report.VolumesRemoved)
171 assert.Empty(t, removed)
172 assert.False(t, report.Plan.ReclaimedKnown)
173}
174
175func TestService_PruneVolumes_FailedRemovalIsIsolated(t *testing.T) {
176 first := "gordon-a--web--vol--data"
177 second := "gordon-b--web--vol--data"
178 volumes := []*domain.VolumeInfo{
179 {Name: first, Size: 1024, Labels: appVolumeLabels("a", "web")},
180 {Name: second, Size: 2048, Labels: appVolumeLabels("b", "web")},
181 }
182 snapshot := &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{
183 {Name: first, App: "a", AppID: "a-uuid", Service: "web", State: domain.VolumeClaimReleased},
184 {Name: second, App: "b", AppID: "b-uuid", Service: "web", State: domain.VolumeClaimReleased},
185 }}
186
187 svc, _, pruneRuntime, _, _ := newPruneService(t, volumes, snapshot)
188 pruneRuntime.EXPECT().RemoveVolumeExact(context.Background(), domain.RuntimeVolumeRef{Name: first}).Return(fmt.Errorf("volume in use"))
189 pruneRuntime.EXPECT().RemoveVolumeExact(context.Background(), domain.RuntimeVolumeRef{Name: second}).Return(nil)
190
191 report, removed, err := svc.PruneVolumes(context.Background(), false)
192 require.NoError(t, err)
193 assert.Equal(t, 1, report.VolumesRemoved)
194 assert.Equal(t, int64(2048), report.SpaceReclaimed)
195 require.Len(t, removed, 1)
196 assert.Equal(t, second, removed[0].Name)
197 require.Len(t, report.Plan.Failures, 1)
198 assert.Equal(t, first, report.Plan.Failures[0].Ref)
199}
200
201func TestService_PruneVolumes_IncompleteInventoryProtectsEverything(t *testing.T) {
202 const released = "gordon-shop--web--vol--data"
203 volumes := []*domain.VolumeInfo{{Name: released, Labels: appVolumeLabels("shop", "web")}}
204 snapshot := &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{
205 {Name: released, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased},
206 }}
207 gap := domain.InventoryGap{
208 Source: domain.InventorySourceRuntimeContainers,
209 Reason: domain.PruneReasonUnknownContainerUse,
210 Detail: "list containers failed",
211 }
212
213 svc, _, _, _, _ := newPruneService(t, volumes, snapshot, gap)
214 report, removed, err := svc.PruneVolumes(context.Background(), false)
215 require.NoError(t, err)
216 assert.Empty(t, removed)
217 assert.Equal(t, 1, report.Plan.CountByVerdict(domain.PruneVerdictUnknown))
218 require.Len(t, report.Plan.Gaps, 1)
219}
220
221func TestService_PruneVolumes_RequiresPrunePorts(t *testing.T) {
222 svc := NewService(outmocks.NewMockContainerRuntime(t))
223 _, _, err := svc.PruneVolumes(context.Background(), false)
224 require.Error(t, err)
225 assert.ErrorIs(t, err, domain.ErrPruneDisabled)
226}
227
228func TestService_PruneVolumes_ProtectionSnapshotFailureFailsClosed(t *testing.T) {
229 runtime := outmocks.NewMockContainerRuntime(t)
230 protection := outmocks.NewMockPruneProtectionStore(t)
231 pruneRuntime := outmocks.NewMockPruneRuntime(t)
232 barrier := outmocks.NewMockGCBarrier(t)
233 lease := outmocks.NewMockGCLease(t)
234 barrier.EXPECT().AcquireExclusive(context.Background()).Return(lease, nil)
235 lease.EXPECT().Release()
236 protection.EXPECT().ProtectionSnapshot(context.Background()).Return(nil, fmt.Errorf("unreadable state"))
237
238 svc := NewService(runtime).WithPrunePorts(protection, pruneRuntime, barrier)
239 _, _, err := svc.PruneVolumes(context.Background(), false)
240 require.Error(t, err)
241 assert.Contains(t, err.Error(), "protection snapshot")
242}