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}