main bac21c6

prune_test.go RawHistoryFolder

1package domain
2
3import (
4	"strings"
5	"testing"
6)
7
8func TestValidOCIDigest(t *testing.T) {
9	tests := []struct {
10		name   string
11		digest string
12		want   bool
13	}{
14		{"sha256", "sha256:" + strings.Repeat("ab", 32), true},
15		{"sha512", "sha512:" + strings.Repeat("cd", 64), true},
16		{"empty", "", false},
17		{"no algorithm", strings.Repeat("ab", 32), false},
18		{"empty encoded", "sha256:", false},
19		{"short encoded", "sha256:abcd", false},
20		{"uppercase algorithm", "SHA256:" + strings.Repeat("ab", 32), false},
21		{"non hex", "sha256:" + strings.Repeat("zz", 32), false},
22		{"space inside", "sha256:" + strings.Repeat("ab", 31) + "a ", false},
23	}
24	for _, tc := range tests {
25		t.Run(tc.name, func(t *testing.T) {
26			if got := ValidOCIDigest(tc.digest); got != tc.want {
27				t.Fatalf("ValidOCIDigest(%q) = %v, want %v", tc.digest, got, tc.want)
28			}
29		})
30	}
31}
32
33func TestRegistryTagRefValidation(t *testing.T) {
34	valid := RegistryTagRef{Repository: "app", Tag: "v1"}
35	if !valid.Valid() || valid.String() != "app:v1" || valid.Key() == "" {
36		t.Fatalf("valid registry tag ref not accepted: %+v", valid)
37	}
38
39	for _, ref := range []RegistryTagRef{
40		{},
41		{Repository: "app"},
42		{Tag: "v1"},
43		{Repository: "app", Tag: " "},
44		{Repository: "a pp", Tag: "v1"},
45	} {
46		if ref.Valid() {
47			t.Fatalf("empty or whitespace identity accepted: %+v", ref)
48		}
49		if ref.Key() != "" {
50			t.Fatalf("invalid ref produced a map key: %+v", ref)
51		}
52	}
53
54	if _, err := NewRegistryTagRef("", "v1"); err == nil {
55		t.Fatal("expected constructor to reject an empty repository")
56	}
57}
58
59func TestIdentityKeysAreUnambiguous(t *testing.T) {
60	// A repository containing a colon must not collide with the
61	// repository/tag separator used by the map key. The human display
62	// form is documented as ambiguous and never parsed back.
63	first := RegistryTagRef{Repository: "a:b", Tag: "c"}
64	second := RegistryTagRef{Repository: "a", Tag: "b:c"}
65	if first.Key() == second.Key() {
66		t.Fatalf("distinct references collided on key: %q", first.Key())
67	}
68	if first.Key() == "" || second.Key() == "" {
69		t.Fatalf("valid references must carry a canonical key: %q / %q", first.Key(), second.Key())
70	}
71}
72
73func TestRuntimeImageRefRejectsPlaceholders(t *testing.T) {
74	for _, id := range []string{"", " ", "<none>", "<none>:<none>"} {
75		if ref := (RuntimeImageRef{ID: id}); ref.Valid() {
76			t.Fatalf("placeholder image ID %q accepted", id)
77		}
78	}
79	if !(RuntimeImageRef{ID: "sha256:abc"}).Valid() {
80		t.Fatal("exact image ID rejected")
81	}
82}
83
84func TestRuntimeVolumeRefValidation(t *testing.T) {
85	if !(RuntimeVolumeRef{Name: "gordon-app-data"}).Valid() {
86		t.Fatal("exact volume name rejected")
87	}
88	for _, name := range []string{"", " ", "vol ume", "vol\tume"} {
89		if ref := (RuntimeVolumeRef{Name: name}); ref.Valid() {
90			t.Fatalf("invalid volume name %q accepted", name)
91		}
92	}
93}
94
95func TestPruneReasonCoverage(t *testing.T) {
96	reasons := PruneReasons()
97	if len(reasons) == 0 {
98		t.Fatal("no reason codes defined")
99	}
100	seen := make(map[PruneReason]struct{}, len(reasons))
101	for _, reason := range reasons {
102		if _, dup := seen[reason]; dup {
103			t.Fatalf("reason %q listed twice in canonical order", reason)
104		}
105		seen[reason] = struct{}{}
106		if !reason.Valid() {
107			t.Fatalf("reason %q has no verdict category", reason)
108		}
109		if reason.Category() == "" {
110			t.Fatalf("reason %q has an empty category", reason)
111		}
112	}
113	if len(seen) != len(pruneReasonCategory) {
114		t.Fatalf("canonical order covers %d reasons, category map has %d", len(seen), len(pruneReasonCategory))
115	}
116}
117
118func TestPruneReasonWireValuesAreStable(t *testing.T) {
119	// These strings appear in JSON and human output. If one changes,
120	// this test must change deliberately.
121	want := map[PruneReason]string{
122		PruneReasonEligibleRetention:            "eligible-retention-window",
123		PruneReasonEligibleUnreferencedBlob:     "eligible-unreferenced-blob",
124		PruneReasonEligibleDanglingRuntimeImage: "eligible-dangling-runtime-image",
125		PruneReasonEligibleReleasedVolume:       "eligible-released-volume",
126		PruneReasonProtectedLatest:              "protected-latest",
127		PruneReasonProtectedRetentionWindow:     "protected-retention-window",
128		PruneReasonProtectedDesiredRevision:     "protected-desired-revision",
129		PruneReasonProtectedActiveService:       "protected-active-service",
130		PruneReasonProtectedRecoveryInhibition:  "protected-recovery-inhibition",
131		PruneReasonProtectedApplyIntent:         "protected-apply-intent",
132		PruneReasonProtectedOperation:           "protected-operation",
133		PruneReasonProtectedOwnership:           "protected-ownership",
134		PruneReasonProtectedContainerUse:        "protected-container-use",
135		PruneReasonProtectedPendingUpload:       "protected-pending-upload",
136		PruneReasonProtectedSharedContent:       "protected-shared-content",
137		PruneReasonProtectedPinned:              "protected-pinned",
138		PruneReasonProtectedServiceManaged:      "protected-service-managed",
139		PruneReasonProtectedManagedOnly:         "protected-managed-without-ownership",
140		PruneReasonProtectedTagged:              "protected-tagged-runtime-image",
141		PruneReasonProtectedUnmanaged:           "protected-unmanaged",
142		PruneReasonUnknownIdentity:              "unknown-identity",
143		PruneReasonUnknownProvenance:            "unknown-provenance",
144		PruneReasonUnknownInventory:             "unknown-incomplete-inventory",
145		PruneReasonUnknownManifest:              "unknown-manifest-unreadable",
146		PruneReasonUnknownMediaType:             "unknown-unsupported-media-type",
147		PruneReasonUnknownContainerUse:          "unknown-container-use",
148	}
149	for reason, wire := range want {
150		if string(reason) != wire {
151			t.Fatalf("reason %q wire value changed to %q", wire, reason)
152		}
153	}
154	if len(want) != len(pruneReasonCategory) {
155		t.Fatalf("stability table covers %d reasons, %d defined", len(want), len(pruneReasonCategory))
156	}
157}
158
159func TestCanonicalReasonsIsDeterministic(t *testing.T) {
160	input := []PruneReason{
161		PruneReasonUnknownInventory,
162		PruneReasonProtectedActiveService,
163		PruneReasonProtectedLatest,
164		PruneReasonProtectedActiveService,
165	}
166	got := CanonicalReasons(input...)
167	want := []PruneReason{
168		PruneReasonProtectedLatest,
169		PruneReasonProtectedActiveService,
170		PruneReasonUnknownInventory,
171	}
172	if len(got) != len(want) {
173		t.Fatalf("CanonicalReasons returned %v, want %v", got, want)
174	}
175	for i := range want {
176		if got[i] != want[i] {
177			t.Fatalf("CanonicalReasons returned %v, want %v", got, want)
178		}
179	}
180
181	// A permuted input must produce the identical output.
182	permuted := []PruneReason{input[2], input[0], input[1], input[3]}
183	again := CanonicalReasons(permuted...)
184	for i := range want {
185		if again[i] != want[i] {
186			t.Fatalf("permuted input produced %v, want %v", again, want)
187		}
188	}
189}
190
191func TestValidateVerdictReasons(t *testing.T) {
192	if err := validateVerdictReasons(PruneVerdictEligible, []PruneReason{PruneReasonEligibleRetention}); err != nil {
193		t.Fatalf("well-formed eligible verdict rejected: %v", err)
194	}
195	cases := []struct {
196		name    string
197		verdict PruneVerdict
198		reasons []PruneReason
199	}{
200		{"undefined verdict", PruneVerdict("maybe"), []PruneReason{PruneReasonEligibleRetention}},
201		{"no reasons", PruneVerdictEligible, nil},
202		{"undefined reason", PruneVerdictEligible, []PruneReason{"made-up"}},
203		{"reason contradicts verdict", PruneVerdictEligible, []PruneReason{PruneReasonProtectedLatest}},
204		{"unknown reason under protected", PruneVerdictProtected, []PruneReason{PruneReasonUnknownIdentity}},
205	}
206	for _, tc := range cases {
207		t.Run(tc.name, func(t *testing.T) {
208			if err := validateVerdictReasons(tc.verdict, tc.reasons); err == nil {
209				t.Fatal("expected validation failure")
210			}
211		})
212	}
213}
214
215func TestPrunePlanValidateRejectsMalformedEntries(t *testing.T) {
216	plan := &PrunePlan{
217		Tags: []RegistryTagVerdict{{
218			Ref:     RegistryTagRef{Repository: "app", Tag: "v1"},
219			Verdict: PruneVerdictEligible,
220			Reasons: []PruneReason{PruneReasonEligibleRetention},
221		}},
222		Volumes: []VolumeVerdict{{
223			Ref:     RuntimeVolumeRef{Name: "gordon-app-data"},
224			Verdict: PruneVerdictProtected,
225			Reasons: []PruneReason{PruneReasonProtectedOwnership},
226		}},
227		Gaps: []InventoryGap{{
228			Source: InventorySourceRegistry,
229			Reason: PruneReasonUnknownInventory,
230			Detail: "list tags failed",
231		}},
232	}
233	if err := plan.Validate(); err != nil {
234		t.Fatalf("well-formed plan rejected: %v", err)
235	}
236
237	bad := *plan
238	bad.Tags = []RegistryTagVerdict{{Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleRetention}}}
239	if err := bad.Validate(); err == nil {
240		t.Fatal("plan with an invalid tag identity passed validation")
241	}
242
243	badGap := *plan
244	badGap.Gaps = []InventoryGap{{Source: InventorySourceRegistry, Reason: PruneReasonProtectedLatest}}
245	if err := badGap.Validate(); err == nil {
246		t.Fatal("plan with a non-unknown gap reason passed validation")
247	}
248}
249
250func TestPrunePlanEligibleAccessors(t *testing.T) {
251	plan := &PrunePlan{
252		Tags: []RegistryTagVerdict{
253			{Ref: RegistryTagRef{Repository: "app", Tag: "v1"}, Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleRetention}},
254			{Ref: RegistryTagRef{Repository: "app", Tag: "v2"}, Verdict: PruneVerdictProtected, Reasons: []PruneReason{PruneReasonProtectedActiveService}},
255		},
256		Images: []RuntimeImageVerdict{
257			{Ref: RuntimeImageRef{ID: "sha256:dead"}, Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleDanglingRuntimeImage}},
258			{Ref: RuntimeImageRef{ID: "sha256:beef"}, Verdict: PruneVerdictUnknown, Reasons: []PruneReason{PruneReasonUnknownContainerUse}},
259		},
260		Volumes: []VolumeVerdict{
261			{Ref: RuntimeVolumeRef{Name: "gordon-a-data"}, Verdict: PruneVerdictEligible, Reasons: []PruneReason{PruneReasonEligibleReleasedVolume}},
262		},
263	}
264
265	eligibleTags := plan.EligibleTags()
266	if len(eligibleTags) != 1 || eligibleTags[0].Tag != "v1" {
267		t.Fatalf("EligibleTags() = %v, want only app:v1", eligibleTags)
268	}
269	eligibleImages := plan.EligibleImages()
270	if len(eligibleImages) != 1 || eligibleImages[0].ID != "sha256:dead" {
271		t.Fatalf("EligibleImages() = %v, want only sha256:dead", eligibleImages)
272	}
273	eligibleVolumes := plan.EligibleVolumes()
274	if len(eligibleVolumes) != 1 || eligibleVolumes[0].Name != "gordon-a-data" {
275		t.Fatalf("EligibleVolumes() = %v, want only gordon-a-data", eligibleVolumes)
276	}
277	if got := plan.CountByVerdict(PruneVerdictEligible); got != 3 {
278		t.Fatalf("CountByVerdict(eligible) = %d, want 3", got)
279	}
280	if got := plan.CountByVerdict(PruneVerdictUnknown); got != 1 {
281		t.Fatalf("CountByVerdict(unknown) = %d, want 1", got)
282	}
283}
284
285func TestPruneProtectionSnapshotLookups(t *testing.T) {
286	digest := "sha256:" + strings.Repeat("ab", 32)
287	snapshot := &PruneProtectionSnapshot{
288		Roots: []ProtectionRoot{
289			{Kind: ProtectionLatest, Ref: "app\x00latest"},
290			{Kind: ProtectionActiveService, Ref: digest, Owner: "app/web"},
291			{Kind: ProtectionContainerUse, Ref: "sha256:imageid", Owner: "container-1"},
292			{Kind: ProtectionOwnership, Ref: "gordon-app-data", Owner: "app"},
293		},
294		VolumeClaims: []VolumeClaim{
295			{Name: "gordon-app-data", App: "app", Service: "web", State: VolumeClaimAttached},
296			{Name: "gordon-app-data", App: "app", Service: "web", State: VolumeClaimRetained},
297		},
298	}
299
300	if !snapshot.ProtectsDigest(digest) {
301		t.Fatal("digest root not matched")
302	}
303	if snapshot.ProtectsDigest("") {
304		t.Fatal("empty digest matched a root")
305	}
306	if snapshot.ProtectsDigest("not-a-digest") {
307		t.Fatal("invalid digest matched a root")
308	}
309	if !snapshot.ProtectsRuntimeImage("sha256:imageid", nil) {
310		t.Fatal("runtime image ID root not matched")
311	}
312	if !snapshot.ProtectsRuntimeImage("", []string{digest}) {
313		t.Fatal("repo digest root not matched")
314	}
315	if snapshot.Protects(ProtectionLatest, "other\x00latest") {
316		t.Fatal("non-matching tag root matched")
317	}
318
319	claim, ok := snapshot.VolumeClaimFor("gordon-app-data")
320	if !ok || claim.State != VolumeClaimRetained {
321		t.Fatalf("VolumeClaimFor returned %+v/%v, want the last recorded claim", claim, ok)
322	}
323	if claim.State.Protects() != true {
324		t.Fatal("retained claim must protect")
325	}
326	if VolumeClaimReleased.Protects() {
327		t.Fatal("released claim must not protect")
328	}
329	if _, ok := snapshot.VolumeClaimFor("absent"); ok {
330		t.Fatal("absent volume reported a claim")
331	}
332	if snapshot.Complete() != true {
333		t.Fatal("snapshot without gaps must be complete")
334	}
335
336	gapped := &PruneProtectionSnapshot{Gaps: []InventoryGap{{
337		Source: InventorySourceAppState,
338		Reason: PruneReasonUnknownInventory,
339		Detail: "unreadable app record",
340	}}}
341	if gapped.Complete() {
342		t.Fatal("snapshot with a valid gap reported complete")
343	}
344	if (*PruneProtectionSnapshot)(nil).Complete() {
345		t.Fatal("nil snapshot must not report complete")
346	}
347}
348
349func TestVolumeClaimValidation(t *testing.T) {
350	if !(VolumeClaim{Name: "vol", App: "app", State: VolumeClaimAttached}).Valid() {
351		t.Fatal("complete claim rejected")
352	}
353	for _, claim := range []VolumeClaim{
354		{App: "app", State: VolumeClaimAttached},
355		{Name: "vol", State: VolumeClaimAttached},
356		{Name: "vol", App: "app"},
357		{Name: "vol", App: "app", State: "gone"},
358	} {
359		if claim.Valid() {
360			t.Fatalf("incomplete claim accepted: %+v", claim)
361		}
362	}
363}
364
365func TestRuntimeInventoryCompleteness(t *testing.T) {
366	if !(&RuntimeInventory{}).Complete() {
367		t.Fatal("empty inventory must be complete")
368	}
369	if (*RuntimeInventory)(nil).Complete() {
370		t.Fatal("nil inventory must not be complete")
371	}
372	if (&RuntimeInventory{Gaps: []InventoryGap{{
373		Source: InventorySourceRuntimeImages,
374		Reason: PruneReasonUnknownInventory,
375		Detail: "list images failed",
376	}}}).Complete() {
377		t.Fatal("inventory with a gap reported complete")
378	}
379}

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