carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "bytes"
5 "crypto/ed25519"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "os"
13 "slices"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/hostclaim"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21const (
22 doneOne = "one.shop.example.com"
23 doneTwo = "two.shop.example.com"
24)
25
26// doneOtherKey signs the plan a wrong signer wrote. Fixed, so a mutation
27// check can name it.
28var doneOtherKey = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{9}, ed25519.SeedSize))
29
30// keyless re-signs the roster with these boxes' census keys removed: the dev
31// roster's shape, where sophie has no key.
32func (a *activation) keyless(refs ...string) {
33 a.t.Helper()
34 for _, ref := range refs {
35 box := a.roster.Boxes[ref]
36 box.CensusPublicKey = nil
37 a.roster.Boxes[ref] = box
38 }
39 _, tag, err := a.f.store.Get(a.ctx, "control/host-actors.json")
40 if err != nil {
41 a.t.Fatal(err)
42 }
43 raw, err := release.SignRootEnvelope(a.sg, a.roster)
44 if err != nil {
45 a.t.Fatal(err)
46 }
47 if _, err := a.f.store.Put(a.ctx, "control/host-actors.json", raw, tag); err != nil {
48 a.t.Fatal(err)
49 }
50}
51
52// collecting writes backfill-collect's plan over the records (no box, no
53// region), edited by edit when it is not nil, signed by signer.
54func (a *activation) collecting(signer release.Signer, edit func(*BackfillPlan)) string {
55 a.t.Helper()
56 p := BackfillPlan{V: 1, ID: "backfill", Phase: "collecting", DirectoryRevision: 1, CreatedAt: a.f.now, Hosts: map[string]BackfillHost{}}
57 for _, r := range a.records {
58 p.Hosts[r.Holder.Host] = BackfillHost{Key: r.Holder.Key, ETag: r.ETag, Acquisition: r.Holder.Acquisition}
59 }
60 if edit != nil {
61 edit(&p)
62 }
63 raw, err := release.SignRootEnvelope(signer, p)
64 if err != nil {
65 a.t.Fatal(err)
66 }
67 _, before, err := a.f.store.Get(a.ctx, BackfillKey)
68 if err != nil && !errors.Is(err, os.ErrNotExist) {
69 a.t.Fatal(err)
70 }
71 tag, err := a.f.store.Put(a.ctx, BackfillKey, raw, before)
72 if err != nil {
73 a.t.Fatal(err)
74 }
75 return tag
76}
77
78// emptyReport is a box's complete census report for the reservation, as
79// the migration census writes it on an empty box.
80func (a *activation) emptyReport(ref string) CensusReport {
81 return CensusReport{Box: ref, Enrollment: a.roster.Boxes[ref].Enrollment, BootID: "boot", Reservation: "backfill", DirectoryRevision: 1,
82 CapturedAt: a.f.now, Complete: true, Installations: []CensusInstallation{}, Seeds: &CensusSeeds{Journaled: []string{}, Gated: []string{}}}
83}
84
85// rawReport signs report with key and writes it at the box's report key,
86// bypassing PublishCensusReport's own checks, as a broken box could.
87func (a *activation) rawReport(report CensusReport, key ed25519.PrivateKey) {
88 a.t.Helper()
89 canonical, err := json.Marshal(report)
90 if err != nil {
91 a.t.Fatal(err)
92 }
93 raw, err := json.Marshal(CensusEnvelope{Report: report, Signature: ed25519.Sign(key, canonical)})
94 if err != nil {
95 a.t.Fatal(err)
96 }
97 k, err := reportKey("backfill", report.Box)
98 if err != nil {
99 a.t.Fatal(err)
100 }
101 if _, err := a.f.store.Put(a.ctx, k, raw, ""); err != nil {
102 a.t.Fatal(err)
103 }
104}
105
106func (a *activation) installation(host string) CensusInstallation {
107 a.t.Helper()
108 for _, r := range a.records {
109 if r.Holder.Host == host {
110 inc, err := r.Incarnation()
111 if err != nil {
112 a.t.Fatal(err)
113 }
114 return CensusInstallation{Host: host, Account: r.Holder.Account, App: r.Holder.App, Family: r.Holder.Family, Key: r.Holder.Key, Incarnation: inc, Route: true}
115 }
116 }
117 a.t.Fatalf("no record for %s", host)
118 return CensusInstallation{}
119}
120
121// The empty cohort: two records frozen, box-a (keyed) reports nothing,
122// box-b has no census key. Done writes done under If-Match with box-a's
123// digest only, returns box-b as unreported, and lifts the create freeze.
124// The console's adoption pass is a no-op on a done plan, adoption refuses
125// it, and a second done is refused.
126func TestBackfillDoneClosesAnEmptyCohort(t *testing.T) {
127 a := newActivation(t)
128 a.record(doneOne, nil)
129 a.record(doneTwo, nil)
130 a.keyless("box-b")
131 tag := a.collecting(a.sg, nil)
132 if err := CheckCreateFreeze(a.ctx, a.f.store, a.d.PublicKey); !errors.Is(err, ErrPending) {
133 t.Fatalf("freeze while collecting = %v", err)
134 }
135 if err := PublishCensusReport(a.ctx, a.f.store, a.d, a.emptyReport("box-a"), a.boxKeys["box-a"]); err != nil {
136 t.Fatal(err)
137 }
138 before := a.plan()
139 b := &Backfill{Store: a.f.store, Directory: a.d}
140 unreported, err := b.Done(a.ctx, a.sg, tag)
141 if err != nil || !slices.Equal(unreported, []string{"box-b"}) {
142 t.Fatalf("Done = %v, %v; want box-b unreported", unreported, err)
143 }
144 p := a.plan()
145 key, _ := reportKey("backfill", "box-a")
146 body, _, err := a.f.store.Get(a.ctx, key)
147 if err != nil {
148 t.Fatal(err)
149 }
150 sum := sha256.Sum256(body)
151 if p.Phase != "done" || len(p.Reports) != 1 || p.Reports["box-a"] != hex.EncodeToString(sum[:]) || p.ID != before.ID || !p.CreatedAt.Equal(before.CreatedAt) || p.DirectoryRevision != before.DirectoryRevision {
152 t.Fatalf("done plan = %+v", p)
153 }
154 for host, h := range before.Hosts {
155 if p.Hosts[host] != h {
156 t.Fatalf("%s changed: %+v -> %+v", host, h, p.Hosts[host])
157 }
158 }
159 if len(p.Hosts) != 2 {
160 t.Fatalf("hosts = %+v", p.Hosts)
161 }
162 if err := CheckCreateFreeze(a.ctx, a.f.store, a.d.PublicKey); err != nil {
163 t.Fatalf("freeze after done = %v", err)
164 }
165 serial := 0
166 c := NewBackfillController(a.f.store, a.d, func() time.Time { return a.f.now }, func() string { serial++; return fmt.Sprintf("op-%d", serial) })
167 if err := c.ReconcileClaims(a.ctx); err != nil {
168 t.Fatalf("adoption pass over a done plan = %v", err)
169 }
170 for _, host := range []string{doneOne, doneTwo} {
171 if obs, err := hostclaim.Read(a.ctx, a.f.store, host); err != nil || !obs.Absent {
172 t.Fatalf("%s gained a claim: %+v %v", host, obs.Claim, err)
173 }
174 }
175 if _, _, _, _, err := b.Adoption(a.ctx, a.records[0]); !errors.Is(err, ErrContract) {
176 t.Fatalf("Adoption on a done plan = %v", err)
177 }
178 _, doneTag, err := a.f.store.Get(a.ctx, BackfillKey)
179 if err != nil {
180 t.Fatal(err)
181 }
182 if _, err := b.Done(a.ctx, a.sg, doneTag); !errors.Is(err, ErrDonePhase) {
183 t.Fatalf("second Done = %v", err)
184 }
185}
186
187// The dev trial's real shape: every instance record deleted before the
188// window, so the collecting plan freezes zero hosts. Its hosts are an empty
189// object, never null, and Done accepts an empty listing against it.
190func TestBackfillDoneWithZeroHosts(t *testing.T) {
191 a := newActivation(t)
192 a.keyless("box-b")
193 tag := a.collecting(a.sg, nil)
194 raw, _, err := a.f.store.Get(a.ctx, BackfillKey)
195 if err != nil {
196 t.Fatal(err)
197 }
198 var wire struct {
199 Payload string `json:"payload"`
200 }
201 if err := json.Unmarshal(raw, &wire); err != nil {
202 t.Fatal(err)
203 }
204 payload, err := base64.StdEncoding.DecodeString(wire.Payload)
205 if err != nil || !bytes.Contains(payload, []byte(`"hosts":{}`)) {
206 t.Fatalf("zero-host plan payload = %s, %v; want \"hosts\":{}", payload, err)
207 }
208 if p := a.plan(); p.Hosts == nil || len(p.Hosts) != 0 {
209 t.Fatalf("zero-host plan hosts = %#v", p.Hosts)
210 }
211 if err := PublishCensusReport(a.ctx, a.f.store, a.d, a.emptyReport("box-a"), a.boxKeys["box-a"]); err != nil {
212 t.Fatal(err)
213 }
214 b := &Backfill{Store: a.f.store, Directory: a.d}
215 unreported, err := b.Done(a.ctx, a.sg, tag)
216 if err != nil || !slices.Equal(unreported, []string{"box-b"}) {
217 t.Fatalf("Done over zero hosts = %v, %v", unreported, err)
218 }
219 if p := a.plan(); p.Phase != "done" || p.Hosts == nil || len(p.Hosts) != 0 || len(p.Reports) != 1 {
220 t.Fatalf("done plan = %#v", p)
221 }
222 if err := CheckCreateFreeze(a.ctx, a.f.store, a.d.PublicKey); err != nil {
223 t.Fatal(err)
224 }
225 // A record that appears after a zero-host collect is refused.
226 b2 := newActivation(t)
227 b2.keyless("box-b")
228 tag = b2.collecting(b2.sg, nil)
229 if err := PublishCensusReport(b2.ctx, b2.f.store, b2.d, b2.emptyReport("box-a"), b2.boxKeys["box-a"]); err != nil {
230 t.Fatal(err)
231 }
232 b2.record(doneOne, nil)
233 if _, err := (&Backfill{Store: b2.f.store, Directory: b2.d}).Done(b2.ctx, b2.sg, tag); !errors.Is(err, ErrDoneRecords) {
234 t.Fatalf("Done with a record the zero-host plan does not name = %v", err)
235 }
236}
237
238// Every condition is its own refusal, and a refusal writes nothing: the
239// plan stays collecting, byte for byte, and creates stay frozen.
240func TestBackfillDoneRefusals(t *testing.T) {
241 type setup struct {
242 keyless []string // boxes with no census key (default box-b)
243 edit func(*BackfillPlan) // the collecting plan
244 signer bool // sign the plan with doneOtherKey
245 reports func(a *activation) // default: box-a's empty report
246 after func(a *activation) // changes after the reports
247 tag func(t string) string // the etag Done is given
248 }
249 for name, c := range map[string]struct {
250 s setup
251 want []error
252 }{
253 "etag": {setup{tag: func(string) string { return "other" }}, []error{ErrDoneETag}},
254 "envelope": {setup{signer: true}, []error{ErrDoneEnvelope}},
255 "phase": {setup{edit: func(p *BackfillPlan) { p.Phase = "adopting"; p.Reports = map[string]string{} }}, []error{ErrDonePhase}},
256 "record etag": {setup{after: func(a *activation) {
257 r := a.records[0]
258 if _, err := a.f.store.Put(a.ctx, r.Holder.Key, append(append([]byte(nil), r.Body...), '\n'), r.ETag); err != nil {
259 a.t.Fatal(err)
260 }
261 }}, []error{ErrDoneRecords}},
262 "record added": {setup{after: func(a *activation) { a.record("three.shop.example.com", nil) }}, []error{ErrDoneRecords}},
263 "record gone": {setup{after: func(a *activation) {
264 if err := release.DeleteIfMatch(a.ctx, a.f.store, a.records[1].Holder.Key, a.records[1].ETag); err != nil {
265 a.t.Fatal(err)
266 }
267 }}, []error{ErrDoneRecords}},
268 "acquisition": {setup{edit: func(p *BackfillPlan) {
269 h := p.Hosts[doneOne]
270 h.Acquisition = "other"
271 p.Hosts[doneOne] = h
272 }}, []error{ErrDoneRecords}},
273 "claim": {setup{after: func(a *activation) {
274 key, err := hostclaim.Key(doneOne)
275 if err != nil {
276 a.t.Fatal(err)
277 }
278 if _, err := a.f.store.Put(a.ctx, key, []byte(`{}`), ""); err != nil {
279 a.t.Fatal(err)
280 }
281 }}, []error{ErrDoneClaims}},
282 "admission": {setup{after: func(a *activation) {
283 key, err := AdmissionKey("bab", "shop")
284 if err != nil {
285 a.t.Fatal(err)
286 }
287 if _, err := a.f.store.Put(a.ctx, key, []byte(`{}`), ""); err != nil {
288 a.t.Fatal(err)
289 }
290 }}, []error{ErrDoneAdmission}},
291 "no report": {setup{reports: func(*activation) {}}, []error{ErrDoneReport, os.ErrNotExist}},
292 "keyed box without a report": {setup{keyless: []string{}}, []error{ErrDoneReport, os.ErrNotExist}},
293 "no keyed box": {setup{keyless: []string{"box-a", "box-b"}, reports: func(*activation) {}}, []error{ErrDoneReport}},
294 "report signature": {setup{reports: func(a *activation) { a.rawReport(a.emptyReport("box-a"), a.boxKeys["box-b"]) }}, []error{ErrDoneReport}},
295 "report revision": {setup{reports: func(a *activation) {
296 r := a.emptyReport("box-a")
297 r.DirectoryRevision = 2
298 a.rawReport(r, a.boxKeys["box-a"])
299 }}, []error{ErrDoneReport}},
300 "report reservation": {setup{reports: func(a *activation) {
301 r := a.emptyReport("box-a")
302 r.Reservation = "other"
303 a.rawReport(r, a.boxKeys["box-a"])
304 }}, []error{ErrDoneReport}},
305 "report incomplete": {setup{reports: func(a *activation) {
306 r := a.emptyReport("box-a")
307 r.Complete = false
308 a.rawReport(r, a.boxKeys["box-a"])
309 }}, []error{ErrDoneReport}},
310 "report captured before the plan": {setup{reports: func(a *activation) {
311 r := a.emptyReport("box-a")
312 r.CapturedAt = a.f.now.Add(-time.Second)
313 if err := PublishCensusReport(a.ctx, a.f.store, a.d, r, a.boxKeys["box-a"]); err != nil {
314 a.t.Fatal(err)
315 }
316 }}, []error{ErrDoneStale}},
317 "installations": {setup{reports: func(a *activation) {
318 r := a.emptyReport("box-a")
319 r.Installations = []CensusInstallation{a.installation(doneOne)}
320 if err := PublishCensusReport(a.ctx, a.f.store, a.d, r, a.boxKeys["box-a"]); err != nil {
321 a.t.Fatal(err)
322 }
323 }}, []error{ErrDoneInstallations}},
324 "roster moved": {setup{after: func(a *activation) {
325 a.roster.Revision = 2
326 a.keyless()
327 }}, []error{ErrPending}},
328 } {
329 t.Run(name, func(t *testing.T) {
330 a := newActivation(t)
331 a.record(doneOne, nil)
332 a.record(doneTwo, nil)
333 keyless := []string{"box-b"}
334 if c.s.keyless != nil {
335 keyless = c.s.keyless
336 }
337 a.keyless(keyless...)
338 signer := a.sg
339 if c.s.signer {
340 signer = release.RootSigner(doneOtherKey)
341 }
342 tag := a.collecting(signer, c.s.edit)
343 if c.s.reports != nil {
344 c.s.reports(a)
345 } else if err := PublishCensusReport(a.ctx, a.f.store, a.d, a.emptyReport("box-a"), a.boxKeys["box-a"]); err != nil {
346 t.Fatal(err)
347 }
348 if c.s.after != nil {
349 c.s.after(a)
350 }
351 if c.s.tag != nil {
352 tag = c.s.tag(tag)
353 }
354 before, beforeTag, err := a.f.store.Get(a.ctx, BackfillKey)
355 if err != nil {
356 t.Fatal(err)
357 }
358 b := &Backfill{Store: a.f.store, Directory: a.d}
359 unreported, err := b.Done(a.ctx, a.sg, tag)
360 for _, want := range c.want {
361 if !errors.Is(err, want) {
362 t.Fatalf("Done = %v, %v; want %v", unreported, err, want)
363 }
364 }
365 after, afterTag, err := a.f.store.Get(a.ctx, BackfillKey)
366 if err != nil || !bytes.Equal(before, after) || beforeTag != afterTag {
367 t.Fatalf("a refused Done changed the plan (%v)", err)
368 }
369 if err := CheckCreateFreeze(a.ctx, a.f.store, a.d.PublicKey); err == nil {
370 t.Fatal("a refused Done lifted the create freeze")
371 }
372 })
373 }
374}
375
376// Done's copy of the census's per-box report check must accept and refuse
377// exactly what ProductionAuthority.census does (census is accepted-legacy
378// and stays untouched). Each variant is box-a's report; box-b's is valid.
379func TestDoneVerifiesReportsAsTheCensusDoes(t *testing.T) {
380 for name, c := range map[string]struct {
381 edit func(a *activation, r *CensusReport)
382 key string // signing box key (default box-a)
383 }{
384 "valid": {},
385 "valid with a host": {edit: func(a *activation, r *CensusReport) { r.Installations = []CensusInstallation{a.installation(doneOne)} }},
386 "other box's key": {key: "box-b"},
387 "other box": {edit: func(_ *activation, r *CensusReport) { r.Box = "box-b" }},
388 "other enrollment": {edit: func(_ *activation, r *CensusReport) { r.Enrollment = "x" }},
389 "other reservation": {edit: func(_ *activation, r *CensusReport) { r.Reservation = "other" }},
390 "other revision": {edit: func(_ *activation, r *CensusReport) { r.DirectoryRevision = 2 }},
391 "no boot": {edit: func(_ *activation, r *CensusReport) { r.BootID = "" }},
392 "no capture time": {edit: func(_ *activation, r *CensusReport) { r.CapturedAt = time.Time{} }},
393 "nil installations": {edit: func(_ *activation, r *CensusReport) { r.Installations = nil }},
394 "incomplete": {edit: func(_ *activation, r *CensusReport) { r.Complete = false }},
395 "unknown host": {edit: func(a *activation, r *CensusReport) {
396 i := a.installation(doneOne)
397 i.Host = "x.example.com"
398 r.Installations = []CensusInstallation{i}
399 }},
400 "host twice": {edit: func(a *activation, r *CensusReport) {
401 i := a.installation(doneOne)
402 r.Installations = []CensusInstallation{i, i}
403 }},
404 "neither route nor local": {edit: func(a *activation, r *CensusReport) {
405 i := a.installation(doneOne)
406 i.Route = false
407 r.Installations = []CensusInstallation{i}
408 }},
409 "other account": {edit: func(a *activation, r *CensusReport) {
410 i := a.installation(doneOne)
411 i.Account = "zzz"
412 r.Installations = []CensusInstallation{i}
413 }},
414 "other key": {edit: func(a *activation, r *CensusReport) {
415 i := a.installation(doneOne)
416 i.Key = "apps/x"
417 r.Installations = []CensusInstallation{i}
418 }},
419 "other family": {edit: func(a *activation, r *CensusReport) {
420 i := a.installation(doneOne)
421 i.Family = "sidecar"
422 r.Installations = []CensusInstallation{i}
423 }},
424 "other incarnation": {edit: func(a *activation, r *CensusReport) {
425 i := a.installation(doneOne)
426 i.Incarnation = "x"
427 r.Installations = []CensusInstallation{i}
428 }},
429 } {
430 t.Run(name, func(t *testing.T) {
431 a := newActivation(t)
432 a.record(doneOne, nil)
433 a.collecting(a.sg, nil)
434 if err := PublishCensusReport(a.ctx, a.f.store, a.d, a.emptyReport("box-b"), a.boxKeys["box-b"]); err != nil {
435 t.Fatal(err)
436 }
437 r := a.emptyReport("box-a")
438 if c.edit != nil {
439 c.edit(a, &r)
440 }
441 key := a.boxKeys["box-a"]
442 if c.key != "" {
443 key = a.boxKeys[c.key]
444 }
445 k, _ := reportKey("backfill", "box-a")
446 canonical, _ := json.Marshal(r)
447 raw, _ := json.Marshal(CensusEnvelope{Report: r, Signature: ed25519.Sign(key, canonical)})
448 tag, err := a.f.store.Put(a.ctx, k, raw, "")
449 if err != nil {
450 t.Fatal(err)
451 }
452 _, censusErr := ProductionAuthority{Store: a.f.store, Directory: a.d}.census(a.ctx, "", "", "backfill", 0, nil)
453 records := map[string]Record{}
454 for _, rec := range a.records {
455 records[rec.Holder.Host] = rec
456 }
457 _, doneErr := verifyCensusReport(raw, tag, "box-a", a.roster.Boxes["box-a"], "backfill", a.roster.Revision, records)
458 if (censusErr == nil) != (doneErr == nil) {
459 t.Fatalf("census = %v, Done's check = %v", censusErr, doneErr)
460 }
461 if name == "valid" || name == "valid with a host" {
462 if doneErr != nil {
463 t.Fatalf("valid report refused: %v", doneErr)
464 }
465 } else if doneErr == nil {
466 t.Fatal("variant accepted")
467 }
468 })
469 }
470}
471
472// doneCohort is the done tests' empty cohort: hosts records (zero or two),
473// box-b keyless, box-a's empty report published, the collecting plan's etag
474// returned.
475func doneCohort(t *testing.T, hosts int) (*activation, string) {
476 t.Helper()
477 a := newActivation(t)
478 for _, host := range []string{doneOne, doneTwo}[:hosts] {
479 a.record(host, nil)
480 }
481 a.keyless("box-b")
482 tag := a.collecting(a.sg, nil)
483 if err := PublishCensusReport(a.ctx, a.f.store, a.d, a.emptyReport("box-a"), a.boxKeys["box-a"]); err != nil {
484 t.Fatal(err)
485 }
486 return a, tag
487}
488
489// A record created while Done reads the reports (the first listing has
490// already passed) is caught by the second listing, immediately before the
491// write: Done refuses and writes nothing.
492func TestBackfillDoneRefusesARecordCreatedDuringReportRetrieval(t *testing.T) {
493 for _, hosts := range []int{0, 2} {
494 t.Run(fmt.Sprintf("%d hosts", hosts), func(t *testing.T) {
495 a, tag := doneCohort(t, hosts)
496 reportAt, err := reportKey("backfill", "box-a")
497 if err != nil {
498 t.Fatal(err)
499 }
500 fired := false
501 a.f.store.afterGet = func(key string) {
502 if key != reportAt {
503 return
504 }
505 a.f.store.afterGet = nil
506 fired = true
507 a.record("three.shop.example.com", nil)
508 }
509 before, beforeTag, err := a.f.store.Get(a.ctx, BackfillKey)
510 if err != nil {
511 t.Fatal(err)
512 }
513 _, err = (&Backfill{Store: a.f.store, Directory: a.d}).Done(a.ctx, a.sg, tag)
514 if !fired {
515 t.Fatal("the report was never read")
516 }
517 if !errors.Is(err, ErrDoneRecords) || errors.Is(err, ErrDoneRecordsChanged) {
518 t.Fatalf("Done = %v; want the records refusal before the write", err)
519 }
520 after, afterTag, err := a.f.store.Get(a.ctx, BackfillKey)
521 if err != nil || !bytes.Equal(before, after) || beforeTag != afterTag {
522 t.Fatalf("a refused Done changed the plan (%v)", err)
523 }
524 if err := CheckCreateFreeze(a.ctx, a.f.store, a.d.PublicKey); !errors.Is(err, ErrPending) {
525 t.Fatalf("freeze after a refused Done = %v", err)
526 }
527 })
528 }
529}
530
531// A record created after Done's write, before its third listing, is not in
532// the done plan. Done has written done and cannot take it back; it says
533// so with ErrDoneRecordsChanged (which the claims run reports under the
534// records refusal).
535func TestBackfillDoneReportsARecordCreatedAfterTheWrite(t *testing.T) {
536 for _, hosts := range []int{0, 2} {
537 t.Run(fmt.Sprintf("%d hosts", hosts), func(t *testing.T) {
538 a, tag := doneCohort(t, hosts)
539 fired := false
540 a.f.store.afterPut = func(key string) {
541 if key != BackfillKey {
542 return
543 }
544 a.f.store.afterPut = nil
545 fired = true
546 a.record("three.shop.example.com", nil)
547 }
548 _, err := (&Backfill{Store: a.f.store, Directory: a.d}).Done(a.ctx, a.sg, tag)
549 if !fired {
550 t.Fatal("Done never wrote the plan")
551 }
552 if !errors.Is(err, ErrDoneRecordsChanged) || !errors.Is(err, ErrDoneRecords) {
553 t.Fatalf("Done = %v; want ErrDoneRecordsChanged", err)
554 }
555 if p := a.plan(); p.Phase != "done" || len(p.Hosts) != hosts {
556 t.Fatalf("plan = %+v; want done over the %d frozen hosts", p, hosts)
557 }
558 })
559 }
560}
561