v3.4.0 593c1c3

plan_test.go RawHistoryFolder

1package pruneguard
2
3import (
4	"strings"
5	"testing"
6	"time"
7
8	"github.com/bnema/gordon/internal/domain"
9)
10
11func digest(seed string) string {
12	repeated := strings.Repeat(seed, 64)
13	return "sha256:" + repeated[:64]
14}
15
16func tagKey(repository, tag string) string {
17	return domain.RegistryTagRef{Repository: repository, Tag: tag}.Key()
18}
19
20func TestClassifyResource(t *testing.T) {
21	tests := []struct {
22		name   string
23		labels map[string]string
24		want   Provenance
25	}{
26		{"nil", nil, ProvenanceUnmanaged},
27		{"foreign", map[string]string{"com.example.role": "db"}, ProvenanceUnmanaged},
28		{"managed only", map[string]string{domain.LabelManaged: "true"}, ProvenanceLegacyManaged},
29		{"managed false", map[string]string{domain.LabelManaged: "false"}, ProvenanceUnmanaged},
30		{"service", map[string]string{domain.LabelService: "srv"}, ProvenanceServiceManaged},
31		{"service name", map[string]string{domain.LabelServiceName: "srv"}, ProvenanceServiceManaged},
32		{
33			"app wins over service",
34			map[string]string{domain.LabelApp: "shop", domain.LabelService: "srv", domain.LabelManaged: "true"},
35			ProvenanceAppOwned,
36		},
37	}
38	for _, tc := range tests {
39		t.Run(tc.name, func(t *testing.T) {
40			if got := ClassifyResource(tc.labels); got != tc.want {
41				t.Fatalf("ClassifyResource(%v) = %v, want %v", tc.labels, got, tc.want)
42			}
43		})
44	}
45}
46
47func TestPlanRegistryTagsRetentionWindow(t *testing.T) {
48	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
49	tags := make([]RegistryTagCandidate, 0, 6)
50	tags = append(tags, candidate("app", "latest", base.Add(6*time.Hour), digest("a")))
51	for index := 0; index < 5; index++ {
52		tags = append(tags, candidate("app", "v"+string(rune('1'+index)), base.Add(time.Duration(index)*time.Hour), digest(string(rune('b'+index)))))
53	}
54
55	verdicts := PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: emptySnapshot(), KeepLast: 3, Complete: true})
56	byTag := indexVerdicts(t, verdicts)
57
58	if byTag["latest"] != domain.PruneVerdictProtected {
59		t.Fatalf("latest = %v, want protected", byTag["latest"])
60	}
61	for _, tag := range []string{"v5", "v4", "v3"} {
62		if byTag[tag] != domain.PruneVerdictProtected {
63			t.Fatalf("%s = %v, want protected (inside latest + 3)", tag, byTag[tag])
64		}
65	}
66	for _, tag := range []string{"v1", "v2"} {
67		if byTag[tag] != domain.PruneVerdictEligible {
68			t.Fatalf("%s = %v, want eligible (outside latest + 3)", tag, byTag[tag])
69		}
70	}
71
72	// Output order is deterministic regardless of input order.
73	shuffled := []RegistryTagCandidate{tags[3], tags[0], tags[5], tags[1], tags[4], tags[2]}
74	again := PlanRegistryTags(RegistryPlanInput{Tags: shuffled, Snapshot: emptySnapshot(), KeepLast: 3, Complete: true})
75	for index := range verdicts {
76		if verdicts[index].Ref != again[index].Ref {
77			t.Fatalf("output order changed with input order at %d: %v vs %v", index, verdicts[index].Ref, again[index].Ref)
78		}
79	}
80}
81
82func TestPlanRegistryTagsLatestCountsSeparately(t *testing.T) {
83	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
84	tags := []RegistryTagCandidate{
85		candidate("app", "latest", base, digest("a")),
86		candidate("app", "v1", base.Add(time.Hour), digest("b")),
87	}
88	verdicts := PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: emptySnapshot(), KeepLast: 1, Complete: true})
89	byTag := indexVerdicts(t, verdicts)
90	if byTag["latest"] != domain.PruneVerdictProtected || byTag["v1"] != domain.PruneVerdictProtected {
91		t.Fatalf("latest + 1 must retain both tags, got %v", byTag)
92	}
93}
94
95func TestPlanRegistryTagsEqualTimestampsAreDeterministic(t *testing.T) {
96	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
97	tags := []RegistryTagCandidate{
98		candidate("app", "b", base, digest("a")),
99		candidate("app", "a", base, digest("b")),
100	}
101	verdicts := PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: emptySnapshot(), KeepLast: 1, Complete: true})
102	byTag := indexVerdicts(t, verdicts)
103	// Tag ordering descending resolves the tie: "b" is kept.
104	if byTag["b"] != domain.PruneVerdictProtected || byTag["a"] != domain.PruneVerdictEligible {
105		t.Fatalf("equal timestamps must resolve deterministically, got %v", byTag)
106	}
107}
108
109func TestPlanRegistryTagsProtectedRootsSurviveRetention(t *testing.T) {
110	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
111	oldDigest := digest("a")
112	tags := []RegistryTagCandidate{
113		candidate("app", "latest", base.Add(10*time.Hour), digest("f")),
114		candidate("app", "v9", base.Add(9*time.Hour), digest("e")),
115		candidate("app", "v8", base.Add(8*time.Hour), digest("d")),
116		candidate("app", "v7", base.Add(7*time.Hour), digest("c")),
117		candidate("app", "v1", base.Add(time.Hour), oldDigest),
118	}
119
120	snapshot := &domain.PruneProtectionSnapshot{Roots: []domain.ProtectionRoot{
121		{Kind: domain.ProtectionDesiredRevision, Ref: tagKey("app", "v1"), Owner: "app"},
122	}}
123	verdicts := PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: snapshot, KeepLast: 3, Complete: true})
124
125	var v1 domain.RegistryTagVerdict
126	for _, verdict := range verdicts {
127		if verdict.Ref.Tag == "v1" {
128			v1 = verdict
129		}
130	}
131	if v1.Verdict != domain.PruneVerdictProtected {
132		t.Fatalf("protected v1 = %v, want protected", v1.Verdict)
133	}
134	if len(v1.Reasons) != 1 || v1.Reasons[0] != domain.PruneReasonProtectedDesiredRevision {
135		t.Fatalf("v1 reasons = %v, want the desired-revision root", v1.Reasons)
136	}
137
138	// The same protection reached through the digest must also hold.
139	snapshot = &domain.PruneProtectionSnapshot{Roots: []domain.ProtectionRoot{
140		{Kind: domain.ProtectionActiveService, Ref: oldDigest, Owner: "app/web"},
141	}}
142	verdicts = PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: snapshot, KeepLast: 3, Complete: true})
143	for _, verdict := range verdicts {
144		if verdict.Ref.Tag == "v1" && verdict.Verdict != domain.PruneVerdictProtected {
145			t.Fatalf("digest-protected v1 = %v, want protected", verdict.Verdict)
146		}
147	}
148}
149
150func TestPlanRegistryTagsFailClosed(t *testing.T) {
151	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
152	tags := []RegistryTagCandidate{
153		candidate("app", "latest", base.Add(10*time.Hour), digest("f")),
154		candidate("app", "v9", base.Add(9*time.Hour), digest("e")),
155		candidate("app", "v1", base.Add(time.Hour), digest("a")),
156		candidate("app", "v2", base.Add(2*time.Hour), digest("b")),
157	}
158	tags[3].ManifestKnown = false
159
160	verdicts := PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: emptySnapshot(), KeepLast: 1, Complete: true})
161	byTag := indexVerdicts(t, verdicts)
162	if byTag["v2"] != domain.PruneVerdictUnknown {
163		t.Fatalf("unreadable manifest tag = %v, want unknown", byTag["v2"])
164	}
165	if byTag["v1"] != domain.PruneVerdictEligible {
166		t.Fatalf("readable out-of-window tag = %v, want eligible", byTag["v1"])
167	}
168
169	verdicts = PlanRegistryTags(RegistryPlanInput{Tags: tags, KeepLast: 1, Complete: false})
170	byTag = indexVerdicts(t, verdicts)
171	if byTag["v1"] != domain.PruneVerdictUnknown {
172		t.Fatalf("incomplete registry inventory = %v, want unknown", byTag["v1"])
173	}
174	// Protection still wins over incompleteness.
175	snapshot := &domain.PruneProtectionSnapshot{Roots: []domain.ProtectionRoot{
176		{Kind: domain.ProtectionPinned, Ref: tagKey("app", "v1")},
177	}}
178	verdicts = PlanRegistryTags(RegistryPlanInput{Tags: tags, Snapshot: snapshot, KeepLast: 1, Complete: false})
179	byTag = indexVerdicts(t, verdicts)
180	if byTag["v1"] != domain.PruneVerdictProtected {
181		t.Fatalf("pinned tag under incomplete inventory = %v, want protected", byTag["v1"])
182	}
183}
184
185func TestPlanRegistryTagsKeepLastZeroSkips(t *testing.T) {
186	tags := []RegistryTagCandidate{candidate("app", "v1", time.Now(), digest("a"))}
187	for _, keepLast := range []int{0, -1} {
188		if verdicts := PlanRegistryTags(RegistryPlanInput{Tags: tags, KeepLast: keepLast, Complete: true}); verdicts != nil {
189			t.Fatalf("KeepLast=%d produced %d verdicts, want none", keepLast, len(verdicts))
190		}
191	}
192}
193
194func TestPlanRegistryTagsRejectsInvalidIdentity(t *testing.T) {
195	verdicts := PlanRegistryTags(RegistryPlanInput{
196		Tags:     []RegistryTagCandidate{{Ref: domain.RegistryTagRef{}, ManifestKnown: true}},
197		Snapshot: emptySnapshot(),
198		KeepLast: 1,
199		Complete: true,
200	})
201	if len(verdicts) != 1 || verdicts[0].Verdict != domain.PruneVerdictUnknown {
202		t.Fatalf("invalid tag identity = %+v, want unknown", verdicts)
203	}
204}
205
206func TestPlanRuntimeImagesMixedBatch(t *testing.T) {
207	activeID := "sha256:active"
208	stoppedID := "sha256:stopped"
209	danglingID := "sha256:dangling"
210	foreignID := "sha256:foreign"
211	legacyID := "sha256:legacy"
212	serviceID := "sha256:service"
213	taggedID := "sha256:tagged"
214
215	images := []domain.RuntimeImage{
216		{ID: activeID, Labels: appLabels("shop", "web")},
217		{ID: stoppedID, Labels: appLabels("shop", "web")},
218		{ID: danglingID, Labels: appLabels("shop", "web")},
219		{ID: foreignID},
220		{ID: legacyID, Labels: map[string]string{domain.LabelManaged: "true"}},
221		{ID: serviceID, Labels: map[string]string{domain.LabelService: "edge"}},
222		{ID: taggedID, RepoTags: []string{"app:latest"}, Labels: appLabels("shop", "web")},
223	}
224	containers := []domain.RuntimeContainerUse{
225		{ContainerID: "c1", ImageID: activeID, Running: true},
226		{ContainerID: "c2", ImageID: stoppedID},
227	}
228
229	verdicts := PlanRuntimeImages(RuntimePlanInput{Images: images, Containers: containers, Snapshot: &domain.PruneProtectionSnapshot{
230		ImageClaims: []domain.ImageClaim{{Reference: danglingID, App: "shop", State: domain.VolumeClaimReleased}},
231	}, Complete: true})
232	byID := indexImageVerdicts(t, verdicts)
233
234	expect := map[string]domain.PruneVerdict{
235		activeID:   domain.PruneVerdictProtected,
236		stoppedID:  domain.PruneVerdictProtected,
237		danglingID: domain.PruneVerdictEligible,
238		foreignID:  domain.PruneVerdictProtected,
239		legacyID:   domain.PruneVerdictProtected,
240		serviceID:  domain.PruneVerdictProtected,
241		taggedID:   domain.PruneVerdictProtected,
242	}
243	for id, want := range expect {
244		if byID[id] != want {
245			t.Fatalf("%s = %v, want %v", id, byID[id], want)
246		}
247	}
248	if byID[stoppedID] != domain.PruneVerdictProtected {
249		t.Fatal("stopped container must protect its image")
250	}
251	if got := byID[foreignID]; got != domain.PruneVerdictProtected {
252		t.Fatalf("unmanaged dangling image = %v, want protected", got)
253	}
254}
255
256// TestPlanRuntimeImagesForgedLabelsNeverAuthorizeDeletion proves image
257// labels are hints only: a dangling image that carries app ownership labels
258// but has no durable ownership record stays unknown, never eligible.
259func TestPlanRuntimeImagesForgedLabelsNeverAuthorizeDeletion(t *testing.T) {
260	forged := "sha256:forged"
261	verdicts := PlanRuntimeImages(RuntimePlanInput{
262		Images: []domain.RuntimeImage{{
263			ID:     forged,
264			Labels: map[string]string{domain.LabelApp: "shop", domain.LabelAppID: "shop-uuid", domain.LabelManaged: "true"},
265		}},
266		Snapshot: emptySnapshot(),
267		Complete: true,
268	})
269
270	require := verdicts[0]
271	if require.Verdict != domain.PruneVerdictUnknown {
272		t.Fatalf("forged app labels = %v, want unknown", require.Verdict)
273	}
274	if require.Reasons[0] != domain.PruneReasonUnknownProvenance {
275		t.Fatalf("forged app label reason = %v", require.Reasons)
276	}
277}
278
279// TestPlanRuntimeImagesAnyMatchingClaimProtects proves an attached claim
280// protects an image even when a released claim for the same identity also
281// exists, in either order: one live owner is enough to forbid deletion.
282func TestPlanRuntimeImagesAnyMatchingClaimProtects(t *testing.T) {
283	const identity = "sha256:shared"
284	images := []domain.RuntimeImage{{ID: identity, Labels: map[string]string{domain.LabelApp: "shop"}}}
285	orders := [][]domain.ImageClaim{
286		{
287			{Reference: identity, App: "shop", State: domain.VolumeClaimReleased},
288			{Reference: identity, App: "shop", State: domain.VolumeClaimAttached},
289		},
290		{
291			{Reference: identity, App: "shop", State: domain.VolumeClaimAttached},
292			{Reference: identity, App: "shop", State: domain.VolumeClaimReleased},
293		},
294	}
295	for index, claims := range orders {
296		verdicts := PlanRuntimeImages(RuntimePlanInput{
297			Images:   images,
298			Snapshot: &domain.PruneProtectionSnapshot{ImageClaims: claims},
299			Complete: true,
300		})
301		if verdicts[0].Verdict != domain.PruneVerdictProtected {
302			t.Fatalf("order %d: image = %v, want protected while an attached claim exists", index, verdicts[0].Verdict)
303		}
304	}
305}
306
307// TestPlanRuntimeImagesReleasedClaimIsEligible proves a durable released
308// claim with no container use and a complete inventory is eligible.
309func TestPlanRuntimeImagesReleasedClaimIsEligible(t *testing.T) {
310	released := "sha256:released"
311	verdicts := PlanRuntimeImages(RuntimePlanInput{
312		Images:   []domain.RuntimeImage{{ID: released, Labels: map[string]string{domain.LabelApp: "shop"}}},
313		Snapshot: &domain.PruneProtectionSnapshot{ImageClaims: []domain.ImageClaim{{Reference: released, App: "shop", State: domain.VolumeClaimReleased}}},
314		Complete: true,
315	})
316	if verdicts[0].Verdict != domain.PruneVerdictEligible {
317		t.Fatalf("released image = %v, want eligible", verdicts[0].Verdict)
318	}
319}
320
321// TestPlanRuntimeImagesAttachedClaimProtects proves an attached durable
322// claim protects an otherwise dangling image.
323func TestPlanRuntimeImagesAttachedClaimProtects(t *testing.T) {
324	attached := "sha256:attached"
325	verdicts := PlanRuntimeImages(RuntimePlanInput{
326		Images:   []domain.RuntimeImage{{ID: attached, Labels: map[string]string{domain.LabelApp: "shop"}}},
327		Snapshot: &domain.PruneProtectionSnapshot{ImageClaims: []domain.ImageClaim{{Reference: attached, App: "shop", State: domain.VolumeClaimAttached}}},
328		Complete: true,
329	})
330	if verdicts[0].Verdict != domain.PruneVerdictProtected {
331		t.Fatalf("attached image = %v, want protected", verdicts[0].Verdict)
332	}
333}
334
335func TestPlanRuntimeImagesProtectionRootsAndUnknowns(t *testing.T) {
336	pinnedID := "sha256:pinned"
337	danglingID := "sha256:dangling"
338
339	images := []domain.RuntimeImage{
340		{ID: pinnedID, Labels: appLabels("shop", "web")},
341		{ID: danglingID, Labels: appLabels("shop", "web")},
342	}
343	snapshot := &domain.PruneProtectionSnapshot{Roots: []domain.ProtectionRoot{
344		{Kind: domain.ProtectionRecoveryInhibition, Ref: pinnedID, Owner: "shop"},
345	}}
346
347	verdicts := PlanRuntimeImages(RuntimePlanInput{Images: images, Snapshot: snapshot, Complete: true})
348	byID := indexImageVerdicts(t, verdicts)
349	if byID[pinnedID] != domain.PruneVerdictProtected {
350		t.Fatalf("recovery-inhibited image = %v, want protected", byID[pinnedID])
351	}
352	if len(verdicts) != 2 || verdicts[1].Reasons[0] != domain.PruneReasonProtectedRecoveryInhibition {
353		t.Fatalf("reasons = %v, want the recovery-inhibition root", verdicts[0].Reasons)
354	}
355
356	// An unresolvable container protects every candidate: its image may
357	// be any of them.
358	unknown := PlanRuntimeImages(RuntimePlanInput{
359		Images:     images[1:],
360		Containers: []domain.RuntimeContainerUse{{ContainerID: "c9", ImageUnknown: true}},
361		Complete:   true,
362	})
363	if unknown[0].Verdict != domain.PruneVerdictUnknown {
364		t.Fatalf("unknown container image = %v, want unknown", unknown[0].Verdict)
365	}
366	if unknown[0].Reasons[0] != domain.PruneReasonUnknownContainerUse {
367		t.Fatalf("unknown container reason = %v", unknown[0].Reasons)
368	}
369
370	incomplete := PlanRuntimeImages(RuntimePlanInput{Images: images[1:], Complete: false})
371	if incomplete[0].Verdict != domain.PruneVerdictUnknown {
372		t.Fatalf("incomplete runtime inventory = %v, want unknown", incomplete[0].Verdict)
373	}
374}
375
376func TestPlanRuntimeImagesProtectsUnknownIdentity(t *testing.T) {
377	verdicts := PlanRuntimeImages(RuntimePlanInput{
378		Images:   []domain.RuntimeImage{{ID: "<none>"}},
379		Complete: true,
380	})
381	if len(verdicts) != 1 || verdicts[0].Verdict != domain.PruneVerdictUnknown {
382		t.Fatalf("placeholder image identity = %+v, want unknown", verdicts)
383	}
384}
385
386func TestPlanVolumesProtectsEverythingExceptReleased(t *testing.T) {
387	released := "gordon-shop-web-data"
388	attached := "gordon-shop-api-data"
389	retained := "gordon-old-data"
390	legacy := "gordon-legacy-data"
391	serviceVolume := "gordon-edge-data"
392	foreign := "pgdata"
393	labelled := "gordon-mystery-data"
394	inUse := "gordon-shop-cache"
395
396	volumes := []*domain.VolumeInfo{
397		{Name: released, Labels: appLabels("shop", "web")},
398		{Name: attached, Labels: appLabels("shop", "api")},
399		{Name: retained, Labels: appLabels("old", "web")},
400		{Name: legacy, Labels: map[string]string{domain.LabelManaged: "true"}},
401		{Name: serviceVolume, Labels: map[string]string{domain.LabelService: "edge"}},
402		{Name: foreign},
403		{Name: labelled, Labels: appLabels("ghost", "web")},
404		{Name: inUse, Labels: appLabels("shop", "web"), InUse: true, Containers: []string{"c1"}},
405	}
406
407	snapshot := &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{
408		{Name: released, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased},
409		{Name: attached, App: "shop", AppID: "shop-uuid", Service: "api", State: domain.VolumeClaimAttached},
410		{Name: retained, App: "old", AppID: "old-uuid", Service: "web", State: domain.VolumeClaimRetained},
411		{Name: inUse, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimAttached},
412	}}
413
414	verdicts := PlanVolumes(VolumePlanInput{Volumes: volumes, Snapshot: snapshot, Complete: true})
415	byName := indexVolumeVerdicts(t, verdicts)
416
417	expect := map[string]domain.PruneVerdict{
418		released:      domain.PruneVerdictEligible,
419		attached:      domain.PruneVerdictProtected,
420		retained:      domain.PruneVerdictProtected,
421		legacy:        domain.PruneVerdictProtected,
422		serviceVolume: domain.PruneVerdictProtected,
423		foreign:       domain.PruneVerdictProtected,
424		labelled:      domain.PruneVerdictUnknown,
425		inUse:         domain.PruneVerdictProtected,
426	}
427	for name, want := range expect {
428		if byName[name] != want {
429			t.Fatalf("%s = %v, want %v", name, byName[name], want)
430		}
431	}
432
433	// A retained record is never reinterpreted as released, even when
434	// the labels look released-compatible.
435	if got := byName[retained]; got != domain.PruneVerdictProtected {
436		t.Fatalf("retained volume = %v, want protected", got)
437	}
438}
439
440func TestPlanVolumesReleasedRequiresAgreeingLabels(t *testing.T) {
441	name := "gordon-shop-web-data"
442	claim := domain.VolumeClaim{Name: name, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased}
443
444	tests := []struct {
445		name   string
446		labels map[string]string
447		want   domain.PruneVerdict
448	}{
449		{"labels agree by name", map[string]string{domain.LabelManaged: "true", domain.LabelApp: "shop", domain.LabelAppID: "shop-uuid", domain.LabelAppService: "web"}, domain.PruneVerdictEligible},
450		{"labels agree by uuid", map[string]string{domain.LabelManaged: "true", domain.LabelApp: "shop-uuid", domain.LabelAppID: "shop-uuid"}, domain.PruneVerdictEligible},
451		{"missing managed marker", map[string]string{domain.LabelApp: "shop", domain.LabelAppID: "shop-uuid"}, domain.PruneVerdictUnknown},
452		{"missing app label", map[string]string{domain.LabelManaged: "true", domain.LabelAppID: "shop-uuid"}, domain.PruneVerdictUnknown},
453		{"contradicting app", map[string]string{domain.LabelManaged: "true", domain.LabelApp: "other", domain.LabelAppID: "shop-uuid"}, domain.PruneVerdictUnknown},
454		{"contradicting service", map[string]string{domain.LabelManaged: "true", domain.LabelApp: "shop", domain.LabelAppID: "shop-uuid", domain.LabelAppService: "api"}, domain.PruneVerdictUnknown},
455		{"no labels", nil, domain.PruneVerdictUnknown},
456		{"missing incarnation stamp", map[string]string{domain.LabelManaged: "true", domain.LabelApp: "shop", domain.LabelAppService: "web"}, domain.PruneVerdictUnknown},
457	}
458	for _, tc := range tests {
459		t.Run(tc.name, func(t *testing.T) {
460			verdicts := PlanVolumes(VolumePlanInput{
461				Volumes:  []*domain.VolumeInfo{{Name: name, Labels: tc.labels}},
462				Snapshot: &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{claim}},
463				Complete: true,
464			})
465			if verdicts[0].Verdict != tc.want {
466				t.Fatalf("verdict = %v (%v), want %v", verdicts[0].Verdict, verdicts[0].Reasons, tc.want)
467			}
468		})
469	}
470}
471
472func TestPlanVolumesFailClosedOnIncompleteOwnership(t *testing.T) {
473	name := "gordon-shop-web-data"
474	volume := &domain.VolumeInfo{Name: name, Labels: appLabels("shop", "web")}
475	claim := domain.VolumeClaim{Name: name, App: "shop", AppID: "shop-uuid", Service: "web", State: domain.VolumeClaimReleased}
476
477	snapshot := &domain.PruneProtectionSnapshot{
478		VolumeClaims: []domain.VolumeClaim{claim},
479		Gaps: []domain.InventoryGap{{
480			Source: domain.InventorySourceOwnership,
481			Reason: domain.PruneReasonUnknownInventory,
482			Detail: "unreadable ownership record",
483		}},
484	}
485	verdicts := PlanVolumes(VolumePlanInput{Volumes: []*domain.VolumeInfo{volume}, Snapshot: snapshot, Complete: true})
486	if verdicts[0].Verdict != domain.PruneVerdictUnknown {
487		t.Fatalf("released volume under incomplete ownership = %v, want unknown", verdicts[0].Verdict)
488	}
489
490	// A nil snapshot proves nothing: it is incomplete by definition.
491	verdicts = PlanVolumes(VolumePlanInput{Volumes: []*domain.VolumeInfo{volume}, Complete: true})
492	if verdicts[0].Verdict == domain.PruneVerdictEligible {
493		t.Fatalf("nil snapshot made a volume eligible: %+v", verdicts[0])
494	}
495
496	verdicts = PlanVolumes(VolumePlanInput{
497		Volumes:  []*domain.VolumeInfo{volume},
498		Snapshot: &domain.PruneProtectionSnapshot{VolumeClaims: []domain.VolumeClaim{claim}},
499		Complete: false,
500	})
501	if verdicts[0].Verdict != domain.PruneVerdictUnknown {
502		t.Fatalf("incomplete runtime inventory = %v, want unknown", verdicts[0].Verdict)
503	}
504}
505
506func TestPlanVolumesZeroDeletionIsSuccess(t *testing.T) {
507	volumes := []*domain.VolumeInfo{
508		{Name: "pgdata"},
509		{Name: "gordon-shop-data", Labels: appLabels("shop", "web"), InUse: true},
510	}
511	verdicts := PlanVolumes(VolumePlanInput{Volumes: volumes, Snapshot: &domain.PruneProtectionSnapshot{}, Complete: true})
512	if len(verdicts) != 2 {
513		t.Fatalf("expected one verdict per volume, got %d", len(verdicts))
514	}
515	for _, verdict := range verdicts {
516		if verdict.Verdict == domain.PruneVerdictEligible {
517			t.Fatalf("volume %q unexpectedly eligible", verdict.Ref.Name)
518		}
519		if len(verdict.Reasons) == 0 {
520			t.Fatalf("volume %q carries no reason", verdict.Ref.Name)
521		}
522	}
523}
524
525func TestPlanVolumesOrderIsDeterministic(t *testing.T) {
526	volumes := []*domain.VolumeInfo{{Name: "c"}, {Name: "a"}, {Name: "b"}}
527	first := PlanVolumes(VolumePlanInput{Volumes: volumes, Complete: true})
528	second := PlanVolumes(VolumePlanInput{Volumes: []*domain.VolumeInfo{volumes[1], volumes[2], volumes[0]}, Complete: true})
529	for index := range first {
530		if first[index].Ref != second[index].Ref {
531			t.Fatalf("order changed at %d: %v vs %v", index, first[index].Ref, second[index].Ref)
532		}
533	}
534}
535
536func TestPlansProduceValidPlanEntries(t *testing.T) {
537	// Every verdict the planners emit must survive plan validation.
538	plan := &domain.PrunePlan{}
539	plan.Tags = PlanRegistryTags(RegistryPlanInput{
540		Tags: []RegistryTagCandidate{
541			candidate("app", "latest", time.Now(), digest("a")),
542			candidate("app", "v1", time.Now().Add(-time.Hour), digest("b")),
543		},
544		Snapshot: emptySnapshot(),
545		KeepLast: 1,
546		Complete: true,
547	})
548	plan.Images = PlanRuntimeImages(RuntimePlanInput{
549		Images: []domain.RuntimeImage{
550			{ID: "sha256:one", Labels: appLabels("app", "web")},
551			{ID: "sha256:two"},
552		},
553		Snapshot: emptySnapshot(),
554		Complete: true,
555	})
556	plan.Volumes = PlanVolumes(VolumePlanInput{
557		Volumes:  []*domain.VolumeInfo{{Name: "pgdata"}, {Name: "gordon-app-data", Labels: appLabels("app", "web")}},
558		Snapshot: &domain.PruneProtectionSnapshot{},
559		Complete: true,
560	})
561	if err := plan.Validate(); err != nil {
562		t.Fatalf("planner output failed plan validation: %v", err)
563	}
564	if len(plan.EligibleTags()) != 0 {
565		t.Fatalf("latest + 1 must retain every tag, got %v", plan.EligibleTags())
566	}
567}
568
569func candidate(repository, tag string, modTime time.Time, manifestDigest string) RegistryTagCandidate {
570	return RegistryTagCandidate{
571		Ref:           domain.RegistryTagRef{Repository: repository, Tag: tag},
572		Digest:        manifestDigest,
573		ModTime:       modTime,
574		ManifestKnown: true,
575	}
576}
577
578// emptySnapshot is a complete snapshot that protects nothing.
579func emptySnapshot() *domain.PruneProtectionSnapshot {
580	return &domain.PruneProtectionSnapshot{}
581}
582
583func appLabels(app, service string) map[string]string {
584	return map[string]string{
585		domain.LabelManaged:    "true",
586		domain.LabelApp:        app,
587		domain.LabelAppID:      app + "-uuid",
588		domain.LabelAppService: service,
589	}
590}
591
592func indexVerdicts(t *testing.T, verdicts []domain.RegistryTagVerdict) map[string]domain.PruneVerdict {
593	t.Helper()
594	out := make(map[string]domain.PruneVerdict, len(verdicts))
595	for _, verdict := range verdicts {
596		out[verdict.Ref.Tag] = verdict.Verdict
597	}
598	return out
599}
600
601func indexImageVerdicts(t *testing.T, verdicts []domain.RuntimeImageVerdict) map[string]domain.PruneVerdict {
602	t.Helper()
603	out := make(map[string]domain.PruneVerdict, len(verdicts))
604	for _, verdict := range verdicts {
605		out[verdict.Ref.ID] = verdict.Verdict
606	}
607	return out
608}
609
610func indexVolumeVerdicts(t *testing.T, verdicts []domain.VolumeVerdict) map[string]domain.PruneVerdict {
611	t.Helper()
612	out := make(map[string]domain.PruneVerdict, len(verdicts))
613	for _, verdict := range verdicts {
614		out[verdict.Ref.Name] = verdict.Verdict
615	}
616	return out
617}

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