carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The on-demand scan drain under host claims (scan-revival claims design).
4//
5// These drive objstore.DrainApp, RebuildQueue and CollectScanResults through
6// the enabled console's own boundary store and guarded scanner, and read the
7// canonical admission afterwards. What they pin is the operation SHAPE, which
8// no wrapper call count can: one short exclusive operation per scan group, a
9// group that completes is durable on its own, a failure before any effect
10// reopens the app, an uncertain Scan holds exactly the operation it ran under,
11// known outcomes are results and not failures, housekeeping opens an
12// operation only when there is something to delete, and nothing is deleted or
13// scanned while another operation owns the app.
14
15import (
16 "context"
17 "errors"
18 "sync"
19 "testing"
20 "time"
21
22 "github.com/carlosframework/platform/internal/hostops"
23 "github.com/carlosframework/platform/internal/objstore"
24)
25
26// scanClaimWorld is one enabled console with bab/titogo granted for scanning.
27// Items are staged through the RAW bucket at chosen times (world staging, not
28// a door under test); the drain runs through the boundary.
29type scanClaimWorld struct {
30 cx *claimCase
31 bucket string
32 appID string
33 ref claimAppRef
34}
35
36func newScanClaimWorld(t *testing.T) *scanClaimWorld {
37 t.Helper()
38 cx := newClaimCase(t)
39 cx.scanGranted(t)
40 rec, _, err := objstore.Get(context.Background(), cx.rel, "bab", "titogo")
41 if err != nil || rec == nil || rec.Provisioned == nil {
42 t.Fatalf("granted store record %+v %v", rec, err)
43 }
44 ref := claimAppRef{Account: "bab", App: "titogo"}
45 return &scanClaimWorld{cx: cx, bucket: rec.Provisioned.Bucket, appID: cx.appIDs[ref], ref: ref}
46}
47
48// seed puts key in the fake engine's view of the bucket and returns its version.
49func (w *scanClaimWorld) seed(t *testing.T, key string) string {
50 t.Helper()
51 w.cx.scanFake.PutObject(w.bucket, key, `"etag-`+key+`"`, time.Now().Add(-time.Hour), 10)
52 _, obj, err := w.cx.scanFake.Status(context.Background(), w.bucket, key)
53 if err != nil {
54 t.Fatal(err)
55 }
56 return obj.Version()
57}
58
59// enqueue stages one item at a chosen time through the raw bucket, so groups
60// order the way the test says rather than by the millisecond.
61func (w *scanClaimWorld) enqueue(t *testing.T, key string, at time.Time) {
62 t.Helper()
63 if _, err := objstore.Enqueue(context.Background(), w.cx.rel, w.cx.srv.scanQueue, "bab", "titogo",
64 &objstore.QueueItem{Key: key, By: "test"}, at); err != nil {
65 t.Fatalf("stage queue item %s: %v", key, err)
66 }
67}
68
69func (w *scanClaimWorld) drain(t *testing.T) ([]objstore.Drained, error) {
70 t.Helper()
71 done, _, err := objstore.DrainApp(context.Background(), w.cx.srv.cfg.Releases, w.cx.srv.cfg.StoreScanner,
72 w.cx.srv.scanQueue, "bab", "titogo", scanDrainPerApp, time.Now())
73 return done, err
74}
75
76func (w *scanClaimWorld) admission(t *testing.T) hostops.AppAdmission {
77 t.Helper()
78 a, err := hostops.ReadCanonicalAdmission(context.Background(), w.cx.rel, "bab", "titogo")
79 if err != nil {
80 t.Fatalf("read admission: %v", err)
81 }
82 return a.Value
83}
84
85func (w *scanClaimWorld) queued(t *testing.T) []string {
86 t.Helper()
87 keys, err := w.cx.rel.List(context.Background(), objstore.QueuePrefix("bab", "titogo"))
88 if err != nil {
89 t.Fatal(err)
90 }
91 return keys
92}
93
94func (w *scanClaimWorld) results(t *testing.T) []string {
95 t.Helper()
96 keys, err := w.cx.rel.List(context.Background(), objstore.ScanResultsPrefix("bab", "titogo"))
97 if err != nil {
98 t.Fatal(err)
99 }
100 return keys
101}
102
103// effectsOf returns the app-mutation effects recorded for one action.
104func effectsOf(a hostops.AppAdmission, action string) []hostops.Effect {
105 var out []hostops.Effect
106 for _, e := range a.Effects {
107 if e.Resource == hostops.OpAppMutation && e.Action == action {
108 out = append(out, e)
109 }
110 }
111 return out
112}
113
114func effectStates(es []hostops.Effect) map[string]int {
115 out := map[string]int{}
116 for _, e := range es {
117 out[e.State]++
118 }
119 return out
120}
121
122// requireOpen fails unless the app is open with no operation and no effect
123// left issued or uncertain.
124func requireOpen(t *testing.T, a hostops.AppAdmission, when string) {
125 t.Helper()
126 if a.State != "open" || a.Op != nil {
127 t.Fatalf("%s: admission state=%q op=%+v, want open with no operation", when, a.State, a.Op)
128 }
129 for id, e := range a.Effects {
130 if e.State != "complete" {
131 t.Fatalf("%s: effect %s (%s) is %q, want complete", when, id, e.Action, e.State)
132 }
133 }
134}
135
136// TestClaimScanDrainUncertainScanHoldsOnlyItsGroup: object A carries a verdict
137// already (no engine call), object B's Scan fails after the guard marked it
138// issued. A's items are gone and A is in done; B's item survives; the app is
139// held under B's objstore.DrainGroup operation with exactly one uncertain
140// effect, and A's own effect is complete under a different operation. The hold
141// is the boundary's honest ruling about a backend call with no known outcome
142// (pinned, not hidden), and a second drain refuses before it lists, scans or
143// deletes anything.
144func TestClaimScanDrainUncertainScanHoldsOnlyItsGroup(t *testing.T) {
145 w := newScanClaimWorld(t)
146 versionA := w.seed(t, "a.txt")
147 w.seed(t, "b.txt")
148 w.cx.scanFake.Put(w.bucket, "a.txt", versionA, objstore.VerdictClean)
149 base := time.Now().Add(-time.Minute)
150 w.enqueue(t, "a.txt", base)
151 w.enqueue(t, "b.txt", base.Add(time.Second))
152 errBoom := errors.New("clamd went away mid-scan")
153 w.cx.scanFake.Fail("Scan", errBoom)
154
155 done, err := w.drain(t)
156 if !errors.Is(err, errBoom) {
157 t.Fatalf("drain err = %v, want the engine failure", err)
158 }
159 if len(done) != 1 || done[0].Key != "a.txt" || done[0].Verdict != objstore.VerdictClean {
160 t.Fatalf("done = %+v, want A's verdict alone", done)
161 }
162 left := w.queued(t)
163 if len(left) != 1 {
164 t.Fatalf("queue holds %v, want only B's item", left)
165 }
166 if n := w.cx.scanFake.Scans("b.txt"); n != 0 {
167 t.Fatalf("B was scanned %d time(s) after its Scan failed", n)
168 }
169
170 a := w.admission(t)
171 if a.State != "closed" || a.Op == nil || a.Op.Kind != hostops.OpAppMutation || a.Op.Intent["action"] != "objstore.DrainGroup" {
172 t.Fatalf("admission state=%q op=%+v, want closed under objstore.DrainGroup", a.State, a.Op)
173 }
174 groups := effectsOf(a, "objstore.DrainGroup")
175 if got := effectStates(groups); got["uncertain"] != 1 || got["complete"] != 1 || len(groups) != 2 {
176 t.Fatalf("DrainGroup effects by state = %v (%d), want one complete (A) and one uncertain (B)", got, len(groups))
177 }
178 for _, e := range groups {
179 switch e.State {
180 case "uncertain":
181 if e.OperationID != a.Op.ID {
182 t.Errorf("the uncertain effect belongs to operation %s, but the app is held under %s", e.OperationID, a.Op.ID)
183 }
184 case "complete":
185 if e.OperationID == a.Op.ID {
186 t.Error("A's completed effect is recorded under B's held operation: the groups shared one operation")
187 }
188 }
189 }
190 if n := len(effectsOf(a, "objstore.DrainApp")); n != 0 {
191 t.Fatalf("%d whole-page objstore.DrainApp effect(s) recorded", n)
192 }
193
194 // Held means held: the next drain refuses before any read of the object.
195 done, err = w.drain(t)
196 if !errors.Is(err, hostops.ErrPending) || len(done) != 0 {
197 t.Fatalf("second drain = %+v, %v; want ErrPending and no outcome", done, err)
198 }
199 if n := w.cx.scanFake.Scans("b.txt"); n != 0 {
200 t.Fatalf("B was scanned %d time(s) while the app was held", n)
201 }
202 if got := w.queued(t); len(got) != 1 {
203 t.Fatalf("queue = %v after the refused drain, want B's item untouched", got)
204 }
205}
206
207// TestClaimScanDrainFailureBeforeEffectReopens: B's Status (a read) fails,
208// so nothing was issued under B's operation. It settles not-applied, the app
209// reopens, B's item is kept, and the next drain scans it.
210func TestClaimScanDrainFailureBeforeEffectReopens(t *testing.T) {
211 w := newScanClaimWorld(t)
212 w.seed(t, "a.txt")
213 w.seed(t, "b.txt")
214 base := time.Now().Add(-time.Minute)
215 w.enqueue(t, "a.txt", base)
216 w.enqueue(t, "b.txt", base.Add(time.Second))
217 errRead := errors.New("HeadObject: 500")
218 // Arm the failure for the SECOND Status call only: A's must succeed.
219 var calls int
220 var mu sync.Mutex
221 w.cx.scanFake.OnStatus(func() {
222 mu.Lock()
223 defer mu.Unlock()
224 calls++
225 if calls == 2 {
226 w.cx.scanFake.Fail("Status", errRead)
227 }
228 })
229
230 done, err := w.drain(t)
231 if !errors.Is(err, errRead) {
232 t.Fatalf("drain err = %v, want the read failure", err)
233 }
234 if len(done) != 1 || done[0].Key != "a.txt" || !done[0].Scanned {
235 t.Fatalf("done = %+v, want A scanned", done)
236 }
237 if got := w.queued(t); len(got) != 1 {
238 t.Fatalf("queue = %v, want B's item kept", got)
239 }
240 a := w.admission(t)
241 requireOpen(t, a, "after a read failure")
242 groups := effectsOf(a, "objstore.DrainGroup")
243 var notApplied int
244 for _, e := range groups {
245 if e.ResultReference == "not-applied" {
246 notApplied++
247 }
248 }
249 if len(groups) != 2 || notApplied != 1 {
250 t.Fatalf("DrainGroup effects = %+v, want A joined and B not-applied", groups)
251 }
252
253 w.cx.scanFake.OnStatus(nil)
254 done, err = w.drain(t)
255 if err != nil || len(done) != 1 || done[0].Key != "b.txt" || !done[0].Scanned {
256 t.Fatalf("retry = %+v, %v; want B scanned", done, err)
257 }
258 if got := w.queued(t); len(got) != 0 {
259 t.Fatalf("queue = %v after the retry, want empty", got)
260 }
261 requireOpen(t, w.admission(t), "after the retry")
262}
263
264// TestClaimScanDrainKnownOutcomesAreNotFailures: an object that is gone, a
265// consent withdrawn after the engine answered, and a deployment disabled after
266// the engine answered are results of a completed effect. None holds the app;
267// the queue is dropped (with the results) for the permanent refusal and kept
268// for the reversible one.
269func TestClaimScanDrainKnownOutcomesAreNotFailures(t *testing.T) {
270 t.Run("object gone", func(t *testing.T) {
271 w := newScanClaimWorld(t)
272 w.enqueue(t, "missing.txt", time.Now().Add(-time.Minute))
273 done, err := w.drain(t)
274 if err != nil {
275 t.Fatalf("drain: %v", err)
276 }
277 if len(done) != 1 || !done[0].Gone || done[0].Key != "missing.txt" {
278 t.Fatalf("done = %+v, want one Gone outcome", done)
279 }
280 if got := w.queued(t); len(got) != 0 {
281 t.Fatalf("queue = %v, want the gone object's item deleted", got)
282 }
283 a := w.admission(t)
284 requireOpen(t, a, "after a gone object")
285 if n := len(effectsOf(a, "objstore.DrainGroup")); n != 1 {
286 t.Fatalf("%d DrainGroup effects, want one", n)
287 }
288 })
289
290 // Consent withdrawn between the per-object check and the result write:
291 // the engine ran (unrecordable, so the drain goes to write a result),
292 // the re-read says the store may no longer be scanned. The group's
293 // operation completes, and the queue and results are dropped under
294 // objstore.DropQueue.
295 t.Run("consent withdrawn after the scan", func(t *testing.T) {
296 w := newScanClaimWorld(t)
297 w.seed(t, "a.txt")
298 w.seed(t, "b.txt")
299 w.cx.scanFake.Unrecordable("a.txt")
300 base := time.Now().Add(-time.Minute)
301 w.enqueue(t, "a.txt", base)
302 w.enqueue(t, "b.txt", base.Add(time.Second))
303 if err := objstore.WriteScanResult(context.Background(), w.cx.rel, "bab", "titogo",
304 &objstore.ScanResult{Key: "old.txt", Verdict: "clean", Version: "v1", At: time.Now()}); err != nil {
305 t.Fatal(err)
306 }
307 var once sync.Once
308 w.cx.scanFake.OnStatus(func() {
309 once.Do(func() {
310 ctx := context.Background()
311 rec, etag, err := objstore.Get(ctx, w.cx.rel, "bab", "titogo")
312 if err != nil || rec == nil {
313 t.Errorf("re-read record: %+v %v", rec, err)
314 return
315 }
316 rec.Scanning = nil
317 if _, err := objstore.Put(ctx, w.cx.rel, "bab", "titogo", rec, etag); err != nil {
318 t.Errorf("revoke: %v", err)
319 }
320 })
321 })
322
323 done, err := w.drain(t)
324 if !errors.Is(err, objstore.ErrQueueNotPermitted) {
325 t.Fatalf("drain err = %v, want ErrQueueNotPermitted", err)
326 }
327 if len(done) != 0 {
328 t.Fatalf("done = %+v for a store that may not be scanned", done)
329 }
330 if n := w.cx.scanFake.Scans("a.txt"); n != 1 {
331 t.Fatalf("A scanned %d time(s), want once (the outcome is about an answer the engine gave)", n)
332 }
333 if n := w.cx.scanFake.Scans("b.txt"); n != 0 {
334 t.Fatalf("B scanned %d time(s) after consent was withdrawn", n)
335 }
336 if got := w.queued(t); len(got) != 0 {
337 t.Fatalf("queue = %v, want dropped", got)
338 }
339 if got := w.results(t); len(got) != 0 {
340 t.Fatalf("results = %v, want dropped with the queue", got)
341 }
342 a := w.admission(t)
343 requireOpen(t, a, "after consent was withdrawn")
344 if n := len(effectsOf(a, "objstore.DropQueue")); n != 1 {
345 t.Fatalf("%d DropQueue effects, want one", n)
346 }
347 if got := effectStates(effectsOf(a, "objstore.DrainGroup")); got["complete"] != 1 || len(got) != 1 {
348 t.Fatalf("DrainGroup effects by state = %v, want one complete", got)
349 }
350 })
351
352 t.Run("deployment disabled after the scan", func(t *testing.T) {
353 w := newScanClaimWorld(t)
354 w.seed(t, "a.txt")
355 w.seed(t, "b.txt")
356 w.cx.scanFake.Unrecordable("a.txt")
357 base := time.Now().Add(-time.Minute)
358 w.enqueue(t, "a.txt", base)
359 w.enqueue(t, "b.txt", base.Add(time.Second))
360 var once sync.Once
361 w.cx.scanFake.OnStatus(func() {
362 once.Do(func() { disableDeploymentScanning(t, w.cx.consoleFixture) })
363 })
364
365 done, err := w.drain(t)
366 if !errors.Is(err, objstore.ErrScanningDisabled) {
367 t.Fatalf("drain err = %v, want ErrScanningDisabled", err)
368 }
369 if len(done) != 0 {
370 t.Fatalf("done = %+v while the deployment is disabled", done)
371 }
372 if got := w.queued(t); len(got) != 2 {
373 t.Fatalf("queue = %v, want both items kept for the next enabled tick", got)
374 }
375 a := w.admission(t)
376 requireOpen(t, a, "after the deployment was disabled")
377 if got := effectStates(effectsOf(a, "objstore.DrainGroup")); got["complete"] != 1 || len(got) != 1 {
378 t.Fatalf("DrainGroup effects by state = %v, want one complete", got)
379 }
380 if n := len(effectsOf(a, "objstore.DropQueue")); n != 0 {
381 t.Fatalf("%d DropQueue effects for a reversible refusal", n)
382 }
383 })
384}
385
386// TestClaimScanDrainOneOperationPerGroup: three objects, three complete
387// objstore.DrainGroup effects under three distinct operations, none uncertain,
388// and the app open afterwards.
389func TestClaimScanDrainOneOperationPerGroup(t *testing.T) {
390 w := newScanClaimWorld(t)
391 base := time.Now().Add(-time.Minute)
392 for i, key := range []string{"a.txt", "b.txt", "c.txt"} {
393 w.seed(t, key)
394 w.enqueue(t, key, base.Add(time.Duration(i)*time.Second))
395 }
396 done, err := w.drain(t)
397 if err != nil || len(done) != 3 {
398 t.Fatalf("drain = %+v, %v; want three outcomes", done, err)
399 }
400 a := w.admission(t)
401 requireOpen(t, a, "after a full page")
402 groups := effectsOf(a, "objstore.DrainGroup")
403 ops := map[string]bool{}
404 for _, e := range groups {
405 if e.State != "complete" {
406 t.Errorf("effect %s is %q", e.ID, e.State)
407 }
408 ops[e.OperationID] = true
409 }
410 if len(groups) != 3 || len(ops) != 3 {
411 t.Fatalf("%d DrainGroup effects under %d operations, want 3 and 3", len(groups), len(ops))
412 }
413 if got := w.queued(t); len(got) != 0 {
414 t.Fatalf("queue = %v, want empty", got)
415 }
416}
417
418// TestClaimScanHousekeepingOpensOperationsOnlyWithWork: the collects open no
419// operation when nothing is expired, and exactly one when something is.
420func TestClaimScanHousekeepingOpensOperationsOnlyWithWork(t *testing.T) {
421 ctx := context.Background()
422 t.Run("results collect", func(t *testing.T) {
423 w := newScanClaimWorld(t)
424 if err := objstore.WriteScanResult(ctx, w.cx.rel, "bab", "titogo",
425 &objstore.ScanResult{Key: "a.txt", Verdict: "clean", Version: "v1", At: time.Now()}); err != nil {
426 t.Fatal(err)
427 }
428 before := w.admission(t)
429 n, err := objstore.CollectScanResults(ctx, w.cx.srv.cfg.Releases, "bab", "titogo", time.Now().Add(-objstore.QueueTTL))
430 if err != nil || n != 0 {
431 t.Fatalf("collect of a fresh result = %d, %v", n, err)
432 }
433 after := w.admission(t)
434 if after.Revision != before.Revision || len(after.Effects) != len(before.Effects) {
435 t.Fatalf("admission moved from rev %d (%d effects) to rev %d (%d effects) for a collect with nothing to delete",
436 before.Revision, len(before.Effects), after.Revision, len(after.Effects))
437 }
438 n, err = objstore.CollectScanResults(ctx, w.cx.srv.cfg.Releases, "bab", "titogo", time.Now().Add(time.Hour))
439 if err != nil || n != 1 {
440 t.Fatalf("collect of an expired result = %d, %v", n, err)
441 }
442 if got := w.results(t); len(got) != 0 {
443 t.Fatalf("results = %v, want collected", got)
444 }
445 a := w.admission(t)
446 requireOpen(t, a, "after the collect")
447 if n := len(effectsOf(a, "objstore.CollectScanResults")); n != 1 {
448 t.Fatalf("%d CollectScanResults effects, want one", n)
449 }
450 })
451
452 t.Run("drain collect", func(t *testing.T) {
453 w := newScanClaimWorld(t)
454 w.seed(t, "a.txt")
455 w.enqueue(t, "a.txt", time.Now().Add(-time.Minute))
456 done, err := w.drain(t)
457 if err != nil || len(done) != 1 {
458 t.Fatalf("drain = %+v, %v", done, err)
459 }
460 a := w.admission(t)
461 if n := len(effectsOf(a, "objstore.DrainCollect")); n != 0 {
462 t.Fatalf("%d DrainCollect effects for a page with nothing stale", n)
463 }
464
465 w.seed(t, "b.txt")
466 w.enqueue(t, "expired.txt", time.Now().Add(-objstore.QueueTTL-time.Hour))
467 w.enqueue(t, "b.txt", time.Now().Add(-time.Minute))
468 done, err = w.drain(t)
469 if err != nil || len(done) != 1 || done[0].Key != "b.txt" {
470 t.Fatalf("drain = %+v, %v; want B alone", done, err)
471 }
472 if got := w.queued(t); len(got) != 0 {
473 t.Fatalf("queue = %v, want the expired item collected and B drained", got)
474 }
475 a = w.admission(t)
476 requireOpen(t, a, "after the collect")
477 if got := effectStates(effectsOf(a, "objstore.DrainCollect")); got["complete"] != 1 || len(got) != 1 {
478 t.Fatalf("DrainCollect effects by state = %v, want exactly one complete", got)
479 }
480 })
481
482 t.Run("rebuild collect", func(t *testing.T) {
483 w := newScanClaimWorld(t)
484 w.enqueue(t, "a.txt", time.Now().Add(-time.Minute))
485 before := w.admission(t)
486 fresh := objstore.NewQueueIndex()
487 live, err := objstore.RebuildQueue(ctx, w.cx.srv.cfg.Releases, fresh, "bab", "titogo", time.Now())
488 if err != nil || live != 1 || fresh.Len() != 1 {
489 t.Fatalf("rebuild = live %d, indexed %d, %v", live, fresh.Len(), err)
490 }
491 after := w.admission(t)
492 if after.Revision != before.Revision || len(after.Effects) != len(before.Effects) {
493 t.Fatalf("admission moved for a rebuild with nothing to collect: rev %d -> %d", before.Revision, after.Revision)
494 }
495
496 w.enqueue(t, "expired.txt", time.Now().Add(-objstore.QueueTTL-time.Hour))
497 fresh = objstore.NewQueueIndex()
498 live, err = objstore.RebuildQueue(ctx, w.cx.srv.cfg.Releases, fresh, "bab", "titogo", time.Now())
499 if err != nil || live != 1 || fresh.Len() != 1 {
500 t.Fatalf("rebuild with an expired item = live %d, indexed %d, %v", live, fresh.Len(), err)
501 }
502 if got := w.queued(t); len(got) != 1 {
503 t.Fatalf("queue = %v, want the live item alone", got)
504 }
505 a := w.admission(t)
506 requireOpen(t, a, "after the rebuild collect")
507 if got := effectStates(effectsOf(a, "objstore.RebuildCollect")); got["complete"] != 1 || len(got) != 1 {
508 t.Fatalf("RebuildCollect effects by state = %v, want exactly one complete", got)
509 }
510 })
511}
512
513// TestClaimScanDrainRefusesUnderAnotherOwner: with the app closed under a
514// foreign app mutation or a purge, the drain refuses before any object is
515// read and deletes nothing. And while a DrainGroup is live (the engine is
516// mid-Status), a competing exclusive begin on the app refuses: the writeSidecar
517// shaped race the queue comments describe is closed by construction.
518func TestClaimScanDrainRefusesUnderAnotherOwner(t *testing.T) {
519 ctx := context.Background()
520 refused := func(t *testing.T, w *scanClaimWorld, what string) {
521 t.Helper()
522 done, err := w.drain(t)
523 if !errors.Is(err, hostops.ErrPending) || len(done) != 0 {
524 t.Fatalf("%s: drain = %+v, %v; want ErrPending and nothing done", what, done, err)
525 }
526 if n := w.cx.scanFake.Scans("a.txt"); n != 0 {
527 t.Fatalf("%s: the object was scanned %d time(s)", what, n)
528 }
529 if got := w.queued(t); len(got) != 1 {
530 t.Fatalf("%s: queue = %v, want the item untouched", what, got)
531 }
532 }
533
534 t.Run("foreign exclusive", func(t *testing.T) {
535 w := newScanClaimWorld(t)
536 w.seed(t, "a.txt")
537 w.enqueue(t, "a.txt", time.Now().Add(-time.Minute))
538 restore := w.cx.foreign(t, w.ref)
539 refused(t, w, "foreign")
540 restore()
541 done, err := w.drain(t)
542 if err != nil || len(done) != 1 {
543 t.Fatalf("drain once reopened = %+v, %v", done, err)
544 }
545 })
546
547 t.Run("purge", func(t *testing.T) {
548 w := newScanClaimWorld(t)
549 w.seed(t, "a.txt")
550 w.enqueue(t, "a.txt", time.Now().Add(-time.Minute))
551 a := w.admission(t)
552 a.Revision++
553 a.State = "closed"
554 a.Op = &hostops.AppOperation{ID: "purge", Kind: hostops.OpPurging, AppID: a.AppID, Phase: "draining",
555 Intent: map[string]string{"account": "bab", "app": "titogo"}}
556 w.cx.putAdmission(t, "bab", "titogo", a)
557 refused(t, w, "purge")
558 })
559
560 t.Run("competing begin while a group is live", func(t *testing.T) {
561 w := newScanClaimWorld(t)
562 w.seed(t, "a.txt")
563 w.enqueue(t, "a.txt", time.Now().Add(-time.Minute))
564 var once sync.Once
565 var competing error
566 var held *hostops.AppAdmission
567 w.cx.scanFake.OnStatus(func() {
568 once.Do(func() {
569 obs, err := hostops.ReadCanonicalAdmission(ctx, w.cx.rel, "bab", "titogo")
570 if err != nil {
571 t.Errorf("read admission mid-scan: %v", err)
572 return
573 }
574 held = &obs.Value
575 _, competing = w.cx.srv.cfg.ClaimAuthority.Control.BeginExclusive(ctx, "bab", "titogo", w.appID,
576 hostops.AppOperation{ID: "competing", Kind: hostops.OpAppMutation, AppID: w.appID, Phase: "issued",
577 Intent: map[string]string{"action": "competing"}}, claimTestOwner)
578 })
579 })
580 done, err := w.drain(t)
581 if err != nil || len(done) != 1 {
582 t.Fatalf("drain = %+v, %v", done, err)
583 }
584 if held == nil || held.State != "closed" || held.Op == nil || held.Op.Intent["action"] != "objstore.DrainGroup" {
585 t.Fatalf("mid-Status admission = %+v, want closed under objstore.DrainGroup", held)
586 }
587 if !errors.Is(competing, hostops.ErrPending) {
588 t.Fatalf("competing BeginExclusive during the scan = %v, want ErrPending", competing)
589 }
590 requireOpen(t, w.admission(t), "after the drain")
591 })
592}
593