v3.4.0 593c1c3

service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close