carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/carlosframework/platform/internal/activator"
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/hostwrite"
18 "github.com/carlosframework/platform/internal/lease"
19 "github.com/carlosframework/platform/internal/moves"
20 "github.com/carlosframework/platform/internal/moves/archive"
21 "github.com/carlosframework/platform/internal/objstore"
22 "github.com/carlosframework/platform/internal/provision"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26func stagedKeys(h *phase1Harness, prefix string) []string {
27 var keys []string
28 for k := range h.f.buckets["data-uk"] {
29 if strings.HasPrefix(k, prefix) {
30 keys = append(keys, strings.TrimPrefix(k, prefix))
31 }
32 }
33 sort.Strings(keys)
34 return keys
35}
36
37// The whole preparing round on the new layout: the replica lane holds the
38// claim's generation flat under replica/<level>/, move.json is there, the
39// archive is staged, and the move is not promoted until a passing, fresh
40// target report exists; the target's own pass writes one, and the next
41// source round promotes, pinning the inputs the target checked.
42func TestPhase1PrepareAndPromote(t *testing.T) {
43 h := newPhase1Harness(t)
44 h.start(t, "manual")
45
46 h.pass(t)
47 if got := h.read(t, p1Host).Move.State; got != provision.MovePreparing {
48 t.Fatalf("after the first round: %s, want preparing", got)
49 }
50 lane := moves.StagingPrefix(p1Host, p1ID)
51 keys := stagedKeys(h, lane)
52 want := []string{"move.json", "replica/0000/0000000000000001-0000000000000002.ltx", "replica/0001/0000000000000001-0000000000000002.ltx"}
53 for _, k := range want {
54 if !containsString(keys, k) {
55 t.Fatalf("the lane lacks %s: %v", k, keys)
56 }
57 }
58 for _, k := range keys {
59 if strings.HasPrefix(k, "replica/") && !containsString(want, k) {
60 t.Fatalf("the lane holds %s, outside the claim's generation", k)
61 }
62 }
63 sr := h.sourceReport(t)
64 if sr == nil || sr.Archive == nil || sr.FilesPlain == "" || sr.Release != "v7" || !sr.S1.OK || !sr.S2.OK || sr.Env.Digest == "" || sr.CrossRate <= 0 {
65 t.Fatalf("source report: %+v", sr)
66 }
67 // No target report: still preparing, however many rounds.
68 h.pass(t)
69 if got := h.read(t, p1Host).Move.State; got != provision.MovePreparing {
70 t.Fatalf("promoted without a target report: %s", got)
71 }
72
73 h.targetPass(t, h.targetCfg())
74 tr := h.targetReport(t)
75 if ok, why := tr.Passing(); !ok {
76 t.Fatalf("the target's report does not pass: %s (%+v)", why, tr.Checks)
77 }
78 h.pass(t)
79 got := h.read(t, p1Host)
80 if got.Move.State != provision.MoveReady || got.Move.Inputs == nil || got.Move.Inputs.FilesPlain != sr.FilesPlain || got.Move.Inputs.Release != "v7" || got.Move.Inputs.EnvReport != sr.Env.Digest {
81 t.Fatalf("after a passing target report: %+v inputs %+v", got.Move, got.Move.Inputs)
82 }
83}
84
85// A stale target report after ready demotes nothing (Review Focus 5): the
86// worker keeps mirroring a ready move and never writes it back.
87func TestPhase1StaleReportAfterReadyDemotesNothing(t *testing.T) {
88 h := newPhase1Harness(t)
89 h.start(t, "manual")
90 h.pass(t)
91 h.targetPass(t, h.targetCfg())
92 h.pass(t)
93 if got := h.read(t, p1Host).Move.State; got != provision.MoveReady {
94 t.Fatalf("not ready: %s", got)
95 }
96 h.clock.Advance(2 * moves.ReportFreshness)
97 h.pass(t)
98 h.pass(t)
99 if got := h.read(t, p1Host).Move.State; got != provision.MoveReady {
100 t.Fatalf("a stale report demoted a ready move to %s", got)
101 }
102}
103
104// With the switch off, an id'd record is inert: no lease, no staging write,
105// no claim write, no CAS, and one log line however many passes run. The
106// exec backend's Strict stays nil and Phase1 false, so every spawn and
107// restore is main's.
108func TestPhase1IsInertWithTheSwitchOff(t *testing.T) {
109 h := newPhase1Harness(t)
110 before := h.start(t, "auto")
111 claimBefore := h.claim(t)
112 h.cfg.Enabled = false
113 logs := captureLog(t)
114 h.pass(t)
115 h.pass(t)
116 if n := len(h.f.stagingCalls()); n != 0 || h.lease.acquired != 0 {
117 t.Fatalf("switch off: %d staging requests, %d leases", n, h.lease.acquired)
118 }
119 for _, op := range h.f.ops {
120 if strings.HasPrefix(op, "copy") || strings.Contains(op, "moves/") {
121 t.Fatalf("switch off: %s", op)
122 }
123 }
124 if got := h.read(t, p1Host); got.Move.State != before.Move.State || !got.Move.UpdatedAt.Equal(before.Move.UpdatedAt) {
125 t.Fatalf("switch off: the record changed: %+v", got.Move)
126 }
127 if c := h.claim(t); c.Gen != claimBefore.Gen {
128 t.Fatalf("switch off: the claim was written (gen %d → %d)", claimBefore.Gen, c.Gen)
129 }
130 if r, _ := readSourceReport(context.Background(), h.s, before); r != nil {
131 t.Fatal("switch off: a source report was written")
132 }
133 if n := strings.Count(logs(), "is off on this box"); n != 1 {
134 t.Fatalf("switch off: %d log lines, want 1:\n%s", n, logs())
135 }
136
137 // eb.Strict: nil with the switch off, set with it on.
138 prev := armedMovesWorker.Load()
139 t.Cleanup(func() { armedMovesWorker.Store(prev) })
140 for _, on := range []bool{false, true} {
141 v := ""
142 if on {
143 v = "on"
144 }
145 t.Setenv(moves.SwitchEnv, v)
146 eb := &activator.ExecBackend{}
147 armMovesPass(activator.Config{}, t.TempDir(), nil, nil, eb, hostwrite.Config{}, ClaimProviders{}, nil, nil)
148 if (eb.Strict != nil) != on {
149 t.Fatalf("switch %v: eb.Strict set = %v", on, eb.Strict != nil)
150 }
151 if eb.Phase1 != on {
152 t.Fatalf("switch %v: eb.Phase1 = %v", on, eb.Phase1)
153 }
154 }
155}
156
157// Without a deployment bucket there is no shared place for the staging
158// lease (round-5 finding 11): the round writes S1 and does nothing else.
159func TestPhase1WithoutADeploymentBucketRefuses(t *testing.T) {
160 h := newPhase1Harness(t)
161 h.start(t, "manual")
162 h.cfg.StagingLease, h.cfg.DeployBucket = nil, ""
163 gen := h.claim(t).Gen
164 h.pass(t)
165 if n := len(h.f.stagingCalls()); n != 0 {
166 t.Fatalf("%d staging requests", n)
167 }
168 if h.claim(t).Gen != gen {
169 t.Fatal("the claim was written")
170 }
171 if sr := h.sourceReport(t); sr == nil || sr.S1.OK || sr.S1.Reason != s1NoDeployBucket {
172 t.Fatalf("source report: %+v", sr)
173 }
174}
175
176// Review Focus 2: the source's own lost-response PUT of move.json and a
177// foreign object look alike, and the source may not read the lane. With
178// the ledger lost and move.json present, the put answers ErrExists, the
179// round records "present" and goes on, and nothing reads the target lane.
180func TestStagingMoveJSONReplayAfterLostLedger(t *testing.T) {
181 h := newPhase1Harness(t)
182 h.start(t, "manual")
183 h.f.store("data-uk", moves.MoveJSONKey(p1Host, p1ID), fakeMoveObject{lease.MirrorObject{ETag: "x", Size: 2}, "{}"})
184 h.vs.deny = func(op, bucket, key string) error {
185 if bucket == "data-uk" && strings.HasPrefix(key, "moves/") {
186 return errors.New("AccessDenied")
187 }
188 return nil
189 }
190 h.pass(t)
191 if got := h.ledger(t).MoveJSONVersion; got != moveJSONPresent {
192 t.Fatalf("ledger move_json_version = %q, want %q", got, moveJSONPresent)
193 }
194 if keys := stagedKeys(h, moves.ReplicaPrefix(p1Host, p1ID)); len(keys) != 2 {
195 t.Fatalf("the round did not go on: %v", keys)
196 }
197 if calls := h.vs.callsOn(moves.StagingPrefix(p1Host, p1ID)); len(calls) != 0 {
198 t.Fatalf("the source read the target lane: %v", calls)
199 }
200}
201
202// rewriteClaimOp edits p1Host's claim op in place, as another writer would.
203func rewriteClaimOp(t *testing.T, s release.Store, edit func(*hostclaim.Op)) {
204 t.Helper()
205 obs, err := hostclaim.Read(context.Background(), s, p1Host)
206 if err != nil {
207 t.Fatal(err)
208 }
209 if _, err := hostclaim.Update(context.Background(), s, obs, func(c *hostclaim.Claim) error { edit(&c.Op); return nil }); err != nil {
210 t.Fatal(err)
211 }
212}
213
214// Every transition is guarded by op identity: a replacement move written
215// between the round's read and its CAS is ErrMoveConflict, and the round
216// ends without advancing it.
217func TestRoundNeverAdvancesAReplacementMove(t *testing.T) {
218 ctx := context.Background()
219 h := newPhase1Harness(t)
220 h.start(t, "auto")
221 h.pass(t)
222 h.targetPass(t, h.targetCfg())
223 // Between the next round's read and its promotion, the record's move is
224 // replaced by another in the same state.
225 h.f.failAt = func(op, key string) error {
226 h.f.failAt = nil
227 _, err := provision.SetRecordMove(ctx, h.s, "bab", "app", p1Host, func(rec *provision.Record) error {
228 rec.Move.ID = p1ID2
229 return nil
230 })
231 return err
232 }
233 h.f.putSized("data-ie", "instances/"+p1Host+"/"+h.inc+"/g-cur/0000/0000000000000003-0000000000000003.ltx", "cur-c", 10)
234 h.pass(t)
235 got := h.read(t, p1Host)
236 if got.Move.ID != p1ID2 || got.Move.State != provision.MovePreparing || got.Move.Inputs != nil {
237 t.Fatalf("the replacement move was advanced: %+v", got.Move)
238 }
239}
240
241// The source copy is bound to the claim's location and generation (round-3
242// finding 5): from a scoped location with two generations only g-cur's
243// level files reach the lane; from a legacy root location with g-x/ beside
244// its level directories, only the level directories do.
245func TestReplicaSourceSelectsTheClaimsGeneration(t *testing.T) {
246 h := newPhase1Harness(t)
247 h.start(t, "manual")
248 c := h.claim(t)
249 prefix, _ := moves.ReplicaSource(*c.Holder)
250 if want := "instances/" + p1Host + "/" + h.inc + "/g-cur/"; prefix != want {
251 t.Fatalf("prefix %q, want %q", prefix, want)
252 }
253 h.pass(t)
254 got := stagedKeys(h, moves.ReplicaPrefix(p1Host, p1ID))
255 if want := []string{"0000/0000000000000001-0000000000000002.ltx", "0001/0000000000000001-0000000000000002.ltx"}; strings.Join(got, ",") != strings.Join(want, ",") {
256 t.Fatalf("scoped location staged %v, want %v", got, want)
257 }
258
259 // The legacy root location.
260 h2 := newPhase1Harness(t)
261 h2.start(t, "manual")
262 rewriteP1Claim(t, h2.s, func(c *hostclaim.Claim) {
263 c.Holder.Location, c.Holder.Generation = "instances/"+p1Host, hostclaim.RootGeneration
264 })
265 root := "instances/" + p1Host + "/"
266 h2.f.putSized("data-ie", root+"0000/0000000000000001-0000000000000005.ltx", "root-a", 10)
267 h2.f.putSized("data-ie", root+"0009/0000000000000001-0000000000000005.ltx", "root-snap", 10)
268 h2.f.putSized("data-ie", root+"g-x/0000/0000000000000001-0000000000000001.ltx", "gx", 10)
269 h2.pass(t)
270 got = stagedKeys(h2, moves.ReplicaPrefix(p1Host, p1ID))
271 if want := []string{"0000/0000000000000001-0000000000000005.ltx", "0009/0000000000000001-0000000000000005.ltx"}; strings.Join(got, ",") != strings.Join(want, ",") {
272 t.Fatalf("root location staged %v, want %v", got, want)
273 }
274}
275
276func rewriteP1Claim(t *testing.T, s release.Store, edit func(*hostclaim.Claim)) {
277 t.Helper()
278 obs, err := hostclaim.Read(context.Background(), s, p1Host)
279 if err != nil {
280 t.Fatal(err)
281 }
282 if _, err := hostclaim.Update(context.Background(), s, obs, func(c *hostclaim.Claim) error { edit(c); return nil }); err != nil {
283 t.Fatal(err)
284 }
285}
286
287// Every staging write goes through the guard: with the lease too close to
288// its end, the round issues no staging request of any class (move.json,
289// the replica copy, the archive) and nothing reaches the target bucket.
290// And for every point at which the lease turns unsafe, the requests issued
291// are exactly the checks that passed: no class signs without one.
292func TestEveryStagingWriteIsGuarded(t *testing.T) {
293 t.Parallel()
294 h := newPhase1Harness(t)
295 h.start(t, "manual")
296 h.lease.until = h.clock.now.Add(moves.SignatureLife) // inside SignatureLife + skew
297 h.pass(t)
298 if calls := h.f.stagingCalls(); len(calls) != 0 {
299 t.Fatalf("guarded requests issued past the bound: %+v", calls)
300 }
301 if keys := stagedKeys(h, "moves/"); len(keys) != 0 {
302 t.Fatalf("the target bucket was written: %v", keys)
303 }
304
305 // A full first round with a safe lease: every class, each signed at
306 // the guard's check time, every write to the target bucket guarded.
307 full := newPhase1Harness(t)
308 full.start(t, "manual")
309 full.pass(t)
310 calls := full.f.stagingCalls()
311 ops := map[string]bool{}
312 for _, c := range calls {
313 ops[c.Op] = true
314 if c.SignAt.IsZero() || c.Bucket != "data-uk" {
315 t.Fatalf("unguarded or misdirected request: %+v", c)
316 }
317 }
318 if !ops["put-if-absent"] || !ops["copy"] || !ops["put"] {
319 t.Fatalf("not every class went through the guard: %v", ops)
320 }
321 if full.lease.reads != len(calls) {
322 t.Fatalf("%d guard checks for %d requests", full.lease.reads, len(calls))
323 }
324 for _, op := range full.f.ops {
325 if strings.Contains(op, "data-uk/") && !strings.HasPrefix(op, "copy ") {
326 t.Fatalf("an unguarded write reached the target bucket: %s", op)
327 }
328 }
329 for k := 1; k < len(calls); k++ {
330 hk := newPhase1Harness(t)
331 hk.start(t, "manual")
332 hk.lease.unsafeAfter = k
333 hk.pass(t)
334 if n := len(hk.f.stagingCalls()); n != k {
335 t.Fatalf("lease unsafe after %d checks: %d requests issued", k, n)
336 }
337 }
338}
339
340// The box-step refusals, written into S1 with nothing staged: a legacy
341// record with the switch on (logged, never touched), a nested row, a
342// sidecar environment file, every refused registry column, a non-instance
343// or non-hibernating row (F14), and a bound private store.
344func TestPreparingRefusesLegacyNestedAndRefusedColumns(t *testing.T) {
345 ctx := context.Background()
346 t.Run("legacy record", func(t *testing.T) {
347 h := newPhase1Harness(t)
348 h.record(t, p1Host, provision.MoveRequest("uk", time.Now()))
349 logs := captureLog(t)
350 h.pass(t)
351 if len(h.f.ops) != 0 || len(h.f.stagingCalls()) != 0 || h.read(t, p1Host).Move.State != provision.MovePreparing {
352 t.Fatalf("a legacy record was driven: %v", h.f.ops)
353 }
354 if !strings.Contains(logs(), legacyMoveSentence) {
355 t.Fatalf("no MV-N7 log line:\n%s", logs())
356 }
357 })
358 for name, tc := range map[string]struct {
359 edit func(*phase1Harness)
360 want string
361 }{
362 "nested": {func(h *phase1Harness) { h.route.DBPath = filepath.Join(h.cfg.DataDir, "instances", "x", p1Host+".db") }, s1Nested},
363 "sidecar": {func(h *phase1Harness) { writeFileForTest(filepath.Join(h.cfg.DataDir, p1Host+"-sidecar.env"), "X=1") }, s1Sidecar(p1Host)},
364 "not instance": {func(h *phase1Harness) { h.route.Kind = "service" }, s1NotExec(p1Host)},
365 "platform": {func(h *phase1Harness) { h.route.Platform = true }, s1NotExec(p1Host)},
366 "no hibernate": {func(h *phase1Harness) { h.route.Hibernate = false }, s1NoHibernate(p1Host)},
367 "caps": {func(h *phase1Harness) { h.route.Caps = "x" }, s1Column(p1Host, "caps")},
368 "unit": {func(h *phase1Harness) { h.route.Unit = "x" }, s1Column(p1Host, "unit")},
369 "backing": {func(h *phase1Harness) { h.route.Backing = "unit" }, s1Column(p1Host, "backing")},
370 "tunnel": {func(h *phase1Harness) { h.route.Tunnel = "x" }, s1Column(p1Host, "tunnel")},
371 "fleet box": {func(h *phase1Harness) { h.route.FleetBox = "x" }, s1Column(p1Host, "fleet_box")},
372 "pool": {func(h *phase1Harness) { h.route.Pool = "x" }, s1Column(p1Host, "pool")},
373 "canary": {func(h *phase1Harness) { h.route.Canary = true }, s1Column(p1Host, "canary")},
374 "addr": {func(h *phase1Harness) { h.route.Addr = "x:1" }, s1Column(p1Host, "addr")},
375 "addrs": {func(h *phase1Harness) { h.route.Addrs = "x:1" }, s1Column(p1Host, "addrs")},
376 "health": {func(h *phase1Harness) { h.route.Health = "/h" }, s1Column(p1Host, "health")},
377 "replica": {func(h *phase1Harness) { h.route.Replica = true }, s1Column(p1Host, "replica")},
378 "replicas": {func(h *phase1Harness) { h.route.Replicas = "uk" }, s1Column(p1Host, "replicas")},
379 "private storage": {func(h *phase1Harness) {
380 // The record has no Environment, so its store hangs off its
381 // slug (provision.InstanceStoreEnv).
382 if _, err := h.s.Put(ctx, objstore.KeyIn("bab", "app", "s"), []byte(`{"environment":"s"}`), ""); err != nil {
383 panic(err)
384 }
385 }, s1PrivateStorage(p1Host)},
386 } {
387 t.Run(name, func(t *testing.T) {
388 h := newPhase1Harness(t)
389 h.start(t, "manual")
390 tc.edit(h)
391 h.pass(t)
392 if calls := h.f.stagingCalls(); len(calls) != 0 {
393 t.Fatalf("staged despite the refusal: %+v", calls)
394 }
395 sr := h.sourceReport(t)
396 if sr == nil || sr.S1.OK || sr.S1.Reason != tc.want {
397 t.Fatalf("S1 = %+v, want %q", sr, tc.want)
398 }
399 })
400 }
401}
402
403// K12 and T9: the claim's touched gains the target region and box before
404// the first staging write, from a claim that touched neither.
405func TestPreparingTouchesTheTargetRegionAndBoxFirst(t *testing.T) {
406 h := newPhase1Harness(t)
407 h.start(t, "manual")
408 if c := h.claim(t); containsString(c.Touched.Regions, "uk") || containsString(c.Touched.Boxes, "uk-box") {
409 t.Fatalf("the fixture already touched uk: %+v", c.Touched)
410 }
411 var touched *hostclaim.Touched
412 h.f.failAt = func(op, key string) error {
413 if touched == nil {
414 c := h.claim(t)
415 touched = &c.Touched
416 }
417 return nil
418 }
419 h.pass(t)
420 if touched == nil {
421 t.Fatal("no staging write happened")
422 }
423 if !containsString(touched.Regions, "uk") || !containsString(touched.Boxes, "uk-box") {
424 t.Fatalf("at the first staging write touched = %+v", *touched)
425 }
426}
427
428// A failed copy is retried only by calling again: the lane asks the guard
429// again before the retry, and a lease past the bound by then ends the round
430// with no second request.
431func TestStagingWriterRetriesWithANewCheck(t *testing.T) {
432 h := newPhase1Harness(t)
433 h.start(t, "manual")
434 // The first copy fails, and by its retry the lease is too close to
435 // its end.
436 copies := 0
437 h.f.failAt = func(op, key string) error {
438 if op != "copy" {
439 return nil
440 }
441 copies++
442 if copies == 1 {
443 h.lease.until = h.clock.now.Add(time.Minute)
444 return errors.New("injected 503")
445 }
446 return nil
447 }
448 logs := captureLog(t)
449 h.pass(t)
450 if copies != 1 {
451 t.Fatalf("%d copy requests reached the store, want 1", copies)
452 }
453 if !strings.Contains(logs(), "retrying once") || !strings.Contains(logs(), moves.ErrLeaseUnsafe.Error()) {
454 t.Fatalf("the retry did not go back to the guard:\n%s", logs())
455 }
456
457 // With the lease safe, the retry's request is signed at a new check.
458 h2 := newPhase1Harness(t)
459 h2.start(t, "manual")
460 var signs []time.Time
461 failed := false
462 h2.f.failAt = func(op, key string) error {
463 if op != "copy" {
464 return nil
465 }
466 calls := h2.f.at
467 signs = append(signs, calls[len(calls)-1].SignAt)
468 if !failed {
469 failed = true
470 return errors.New("injected 503")
471 }
472 return nil
473 }
474 h2.pass(t)
475 if len(signs) < 2 || !signs[1].After(signs[0]) {
476 t.Fatalf("the retry reused the first signature time: %v", signs)
477 }
478}
479
480// The source report is replaced each round (PutReplace), never refused as
481// an existing object.
482func TestSourceReportIsWrittenEachRound(t *testing.T) {
483 h := newPhase1Harness(t)
484 h.start(t, "manual")
485 h.pass(t)
486 first := h.sourceReport(t)
487 h.probe = moves.Check{Reason: s1NoAnswer(p1Host)}
488 h.pass(t)
489 second := h.sourceReport(t)
490 if first == nil || second == nil || !second.At.After(first.At) || second.S1.OK || second.S1.Reason != s1NoAnswer(p1Host) {
491 t.Fatalf("first %+v second %+v", first, second)
492 }
493}
494
495// The archive is rebuilt only when the plaintext digest changes.
496func TestArchiveIsRebuiltOnlyWhenPlainChanges(t *testing.T) {
497 h := newPhase1Harness(t)
498 h.start(t, "manual")
499 sealedPuts := func() int {
500 n := 0
501 for _, c := range h.f.stagingCalls() {
502 if c.Op == "put" && strings.HasSuffix(c.Key, ".sealed") {
503 n++
504 }
505 }
506 return n
507 }
508 h.pass(t)
509 h.pass(t)
510 if n := sealedPuts(); n != 1 {
511 t.Fatalf("%d archives staged over two unchanged rounds, want 1", n)
512 }
513 first := h.ledger(t).ArchivePlain
514 if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host, "upload.txt"), []byte("changed"), 0o644); err != nil {
515 t.Fatal(err)
516 }
517 h.pass(t)
518 if n := sealedPuts(); n != 2 {
519 t.Fatalf("%d archives staged after a change, want 2", n)
520 }
521 if l := h.ledger(t); l.ArchivePlain == first || l.ArchiveVersions == nil || l.ArchiveVersions.SealedVersion == "" || l.ArchiveVersions.ManifestVersion == "" {
522 t.Fatalf("ledger after the change: %+v", l)
523 }
524 // A file that changes under the walk (archive.ErrChanged), while the
525 // digest is taken or while the archive is built, after an earlier
526 // archive passed T5: the round ends before readiness, publishes no
527 // source report, and never promotes on the archive the target checked
528 // before (Task 14 review finding 2).
529 for _, stage := range []string{"digest", "build"} {
530 t.Run("changed during the "+stage, func(t *testing.T) {
531 h2 := newPhase1Harness(t)
532 h2.start(t, "manual")
533 h2.pass(t)
534 h2.targetPass(t, h2.targetCfg())
535 if f := failing(h2.targetReport(t)); f[moves.CheckArchive] != "" {
536 t.Fatalf("the first archive did not pass T5: %v", f)
537 }
538 before := h2.sourceReport(t)
539 if err := os.WriteFile(filepath.Join(h2.cfg.DataDir, p1Host, "upload.txt"), []byte("changing"), 0o644); err != nil {
540 t.Fatal(err)
541 }
542 changed := fmt.Errorf("%w: upload.txt", archive.ErrChanged)
543 prevDigest, prevBuild := archivePlainDigest, archiveBuild
544 t.Cleanup(func() { archivePlainDigest, archiveBuild = prevDigest, prevBuild })
545 if stage == "digest" {
546 archivePlainDigest = func(string, string) (string, int64, int, error) { return "", 0, 0, changed }
547 } else {
548 archiveBuild = func(string, string, io.Writer) (string, []archive.Entry, int64, error) { return "", nil, 0, changed }
549 }
550 h2.pass(t)
551 if after := h2.sourceReport(t); !after.At.Equal(before.At) {
552 t.Fatalf("a retried round published a source report (%v, was %v)", after.At, before.At)
553 }
554 if got := h2.read(t, p1Host).Move.State; got != provision.MovePreparing {
555 t.Fatalf("a retried round promoted on the old archive: %s", got)
556 }
557 if l := h2.ledger(t); l.ArchivePlain != before.FilesPlain {
558 t.Fatalf("the ledger moved to %s", l.ArchivePlain)
559 }
560 archivePlainDigest, archiveBuild = prevDigest, prevBuild
561 h2.pass(t)
562 if sr := h2.sourceReport(t); sr.FilesPlain == before.FilesPlain || h2.read(t, p1Host).Move.State != provision.MovePreparing {
563 t.Fatalf("after the retry: files %s, state %s", sr.FilesPlain, h2.read(t, p1Host).Move.State)
564 }
565 })
566 }
567
568 // An archive this move can never carry is a standing S1 refusal.
569 if err := os.Symlink("/etc/passwd", filepath.Join(h.cfg.DataDir, p1Host, "link")); err != nil {
570 t.Fatal(err)
571 }
572 h.pass(t)
573 if sr := h.sourceReport(t); sr.S1.OK || !strings.Contains(sr.S1.Reason, "keeps a file this move cannot copy") {
574 t.Fatalf("S1 = %+v", sr.S1)
575 }
576}
577
578// cut == "auto": from ready the source itself marks the cut requested, for
579// a holder at the root location (the only one the cut accepts, ruling
580// T5-lease; TestScopedAutomaticMoveDoesNotLoop covers the other).
581func TestAutoCutMarksCuttingFromReady(t *testing.T) {
582 h := newPhase1Harness(t)
583 h.start(t, "auto")
584 rootHolder(t, h)
585 h.pass(t)
586 h.targetPass(t, h.targetCfg())
587 h.pass(t)
588 got := h.read(t, p1Host)
589 if got.Move.State != provision.MoveCutting || got.Move.CutPhase != provision.CutRequested || got.Move.Inputs == nil {
590 t.Fatalf("auto cut: %+v", got.Move)
591 }
592 // A manual move stays ready.
593 h2 := newPhase1Harness(t)
594 h2.start(t, "manual")
595 h2.pass(t)
596 h2.targetPass(t, h2.targetCfg())
597 h2.pass(t)
598 h2.pass(t)
599 if got := h2.read(t, p1Host).Move; got.State != provision.MoveReady {
600 t.Fatalf("manual cut: %+v", got)
601 }
602}
603
604// Promotion pins only the release the target checked (Task 14 review
605// finding 3): after the target checks v7, a source that moves to v8 does
606// not promote until the target has checked v8, and then pins v8.
607func TestPromotionNeedsTheReleaseTheTargetChecked(t *testing.T) {
608 h := newPhase1Harness(t)
609 h.start(t, "manual")
610 h.pass(t)
611 h.targetPass(t, h.targetCfg())
612 if r := h.targetReport(t); r.Inputs.Release != "v7" {
613 t.Fatalf("the target checked %q", r.Inputs.Release)
614 }
615 pointLiveForTest(t, h.optDir, p1Host, "v8")
616 h.pass(t)
617 if got := h.read(t, p1Host).Move; got.State != provision.MovePreparing || got.Inputs != nil {
618 t.Fatalf("promoted with v8 pinned on a v7 check: %+v", got)
619 }
620 h.targetPass(t, h.targetCfg())
621 h.pass(t)
622 if got := h.read(t, p1Host).Move; got.State != provision.MoveReady || got.Inputs == nil || got.Inputs.Release != "v8" {
623 t.Fatalf("after the target checked v8: %+v", got)
624 }
625}
626
627// S1 asks the path the instances manifest declares. A manifest that cannot
628// be read fails S1, even though "/" would answer (Task 14 review finding
629// 5); "/" is used only when the manifest was read and declares no path.
630func TestS1ReadsTheDeclaredReadinessPath(t *testing.T) {
631 ctx := context.Background()
632 h := newPhase1Harness(t)
633 h.start(t, "manual")
634 h.pass(t)
635 if sr := h.sourceReport(t); !sr.S1.OK || len(h.probePaths) != 1 || h.probePaths[0] != "/" {
636 t.Fatalf("no declared path: S1 %+v, probed %v", sr.S1, h.probePaths)
637 }
638 if err := provision.PutManifest(ctx, h.s, "bab", "app", provision.Manifest{Schema: 1, Moves: &provision.MovesManifest{ReadyPath: "/health"}}); err != nil {
639 t.Fatal(err)
640 }
641 h.pass(t)
642 if got := h.probePaths[len(h.probePaths)-1]; got != "/health" {
643 t.Fatalf("probed %q, want the declared /health", got)
644 }
645 // The manifest no longer parses: S1 fails, and "/" is never asked.
646 _, etag, err := h.s.Get(ctx, provision.ManifestKey("bab", "app"))
647 if err != nil {
648 t.Fatal(err)
649 }
650 if _, err := h.s.Put(ctx, provision.ManifestKey("bab", "app"), []byte("{not json"), etag); err != nil {
651 t.Fatal(err)
652 }
653 asked := len(h.probePaths)
654 h.pass(t)
655 if sr := h.sourceReport(t); sr.S1.OK || sr.S1.Reason != s1NoReadyPath {
656 t.Fatalf("S1 with an unreadable manifest: %+v", sr.S1)
657 }
658 if len(h.probePaths) != asked {
659 t.Fatalf("the instance was probed at %v with no readable manifest", h.probePaths[asked:])
660 }
661}
662
663// Archive errors reach S1 only as recorded sentences, chosen by errors.Is
664// and the path the error carries as data; the raw error text, which may
665// name anything, goes to the log alone (ruling T15-sinks).
666func TestArchiveErrorsReachS1AsRecordedSentences(t *testing.T) {
667 secret := "EIO reading /data/other-tenant/secret"
668 for name, tc := range map[string]struct {
669 err error
670 want string
671 }{
672 "special file": {&archive.PathError{Err: archive.ErrSpecialFile, Path: "link"}, s1SpecialFile(p1Host, "link")},
673 "over cap": {fmt.Errorf("walk: %w", archive.ErrOverCap), s1OverCap(p1Host)},
674 "too many": {archive.ErrTooManyEntries, s1TooManyEntries},
675 "long path": {&archive.PathError{Err: archive.ErrBadPath, Path: "deep"}, s1LongPath("deep")},
676 "special, no path": {fmt.Errorf("%w: %s", archive.ErrSpecialFile, secret), s1ArchiveFailed},
677 "bad path, no path": {fmt.Errorf("%w: %s", archive.ErrBadPath, secret), s1ArchiveFailed},
678 "unrecognised": {errors.New(secret), s1ArchiveFailed},
679 } {
680 t.Run(name, func(t *testing.T) {
681 h := newPhase1Harness(t)
682 h.start(t, "manual")
683 prev := archivePlainDigest
684 t.Cleanup(func() { archivePlainDigest = prev })
685 archivePlainDigest = func(string, string) (string, int64, int, error) { return "", 0, 0, tc.err }
686 logs := captureLog(t)
687 h.pass(t)
688 sr := h.sourceReport(t)
689 if sr == nil || sr.S1.OK || sr.S1.Reason != tc.want {
690 t.Fatalf("S1 = %+v, want %q", sr, tc.want)
691 }
692 if strings.Contains(sr.S1.Reason, "EIO") || strings.Contains(sr.S1.Reason, "other-tenant") {
693 t.Fatalf("the raw error reached S1: %q", sr.S1.Reason)
694 }
695 if !strings.Contains(logs(), tc.err.Error()) {
696 t.Fatalf("the raw error was not logged:\n%s", logs())
697 }
698 })
699 }
700}
701
702// MV-N79 and MV-N81 are the archive's own texts, written from data.
703func TestArchiveSentencesMatchTheArchiveRows(t *testing.T) {
704 if s1TooManyEntries != archive.ErrTooManyEntries.Error() {
705 t.Fatalf("%q vs %q", s1TooManyEntries, archive.ErrTooManyEntries.Error())
706 }
707 // A relative path over MaxPathLen, made one directory at a time
708 // through os.Root so no single call meets the OS path limit.
709 files := t.TempDir()
710 long := strings.Repeat("d", 200)
711 cur, err := os.OpenRoot(files)
712 if err != nil {
713 t.Fatal(err)
714 }
715 for depth := 0; depth*(len(long)+1) <= archive.MaxPathLen; depth++ {
716 if err := cur.Mkdir(long, 0o755); err != nil {
717 t.Fatal(err)
718 }
719 next, err := cur.OpenRoot(long)
720 cur.Close()
721 if err != nil {
722 t.Fatal(err)
723 }
724 cur = next
725 }
726 cur.Close()
727 _, _, _, err = archive.PlainDigest(files, "")
728 var pe *archive.PathError
729 if !errors.As(err, &pe) || !errors.Is(err, archive.ErrBadPath) || err.Error() != s1LongPath(pe.Path) {
730 t.Fatalf("err %v; s1LongPath %q", err, s1LongPath(pe.Path))
731 }
732}
733
734// readyAuto drives a move to a persisted ready (cut manual, so the
735// promotion does not cut in the same pass), then makes it automatic: the
736// state a crash between the promotion and the cut leaves behind.
737func readyAuto(t *testing.T) *phase1Harness {
738 t.Helper()
739 h := newPhase1Harness(t)
740 h.start(t, "manual")
741 rootHolder(t, h) // an automatic cut is requested only for a root holder (ruling T5-lease)
742 h.pass(t)
743 h.targetPass(t, h.targetCfg())
744 h.pass(t)
745 if got := h.read(t, p1Host).Move.State; got != provision.MoveReady {
746 t.Fatalf("the fixture did not reach ready: %s", got)
747 }
748 if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error {
749 rec.Move.Cut = "auto"
750 return nil
751 }); err != nil {
752 t.Fatal(err)
753 }
754 return h
755}
756
757// The re-review's regression: from a persisted ready, automatic move, a
758// round whose archive fails with an unrecognised error publishes MV-N151
759// and requests no cut. Without the failure, the same round does.
760func TestAnUnknownArchiveErrorRequestsNoCut(t *testing.T) {
761 h := readyAuto(t)
762 prev := archivePlainDigest
763 t.Cleanup(func() { archivePlainDigest = prev })
764 archivePlainDigest = func(string, string) (string, int64, int, error) { return "", 0, 0, errors.New("EIO") }
765 h.pass(t)
766 if got := h.read(t, p1Host).Move.State; got != provision.MoveReady {
767 t.Fatalf("an unknown archive error requested the cut: %s", got)
768 }
769 if sr := h.sourceReport(t); sr.S1.OK || sr.S1.Reason != s1ArchiveFailed {
770 t.Fatalf("S1 = %+v", sr.S1)
771 }
772 archivePlainDigest = prev
773 h.pass(t)
774 if got := h.read(t, p1Host).Move; got.State != provision.MoveCutting {
775 t.Fatalf("a clean round did not request the cut: %+v", got)
776 }
777}
778
779// The invariant: a round requests a cut only when this round's own source
780// report passed every check and was published. A failure injected into
781// each source check, retry or error of the round leaves a ready,
782// automatic move ready.
783func TestOnlyAPassingRoundRequestsTheCut(t *testing.T) {
784 ctx := context.Background()
785 for name, inject := range map[string]func(t *testing.T, h *phase1Harness){
786 "none (the control)": nil,
787 "S1 the instance does not answer": func(t *testing.T, h *phase1Harness) {
788 h.probe = moves.Check{Reason: s1NoAnswer(p1Host)}
789 },
790 "S1 the manifest cannot be read": func(t *testing.T, h *phase1Harness) {
791 _, etag, _ := h.s.Get(ctx, provision.ManifestKey("bab", "app"))
792 if _, err := h.s.Put(ctx, provision.ManifestKey("bab", "app"), []byte("{"), etag); err != nil {
793 t.Fatal(err)
794 }
795 },
796 "S1 no probe on this box": func(t *testing.T, h *phase1Harness) { h.cfg.ReadyProbe = nil },
797 "S1 no secrets key": func(t *testing.T, h *phase1Harness) { h.cfg.SecretsKey = "" },
798 "S1 a refused column": func(t *testing.T, h *phase1Harness) { h.route.Canary = true },
799 "S1 a special file": func(t *testing.T, h *phase1Harness) {
800 if err := os.Symlink("/etc/passwd", filepath.Join(h.cfg.DataDir, p1Host, "link")); err != nil {
801 t.Fatal(err)
802 }
803 },
804 "S2 the next spawn is not strict": func(t *testing.T, h *phase1Harness) {
805 h.strict = moves.Check{Reason: "S2: the next spawn of this host is not strict"}
806 },
807 "S2 no strict hook": func(t *testing.T, h *phase1Harness) { h.cfg.StrictNext = nil },
808 "the archive changed (retry)": func(t *testing.T, h *phase1Harness) {
809 prev := archivePlainDigest
810 t.Cleanup(func() { archivePlainDigest = prev })
811 archivePlainDigest = func(string, string) (string, int64, int, error) { return "", 0, 0, archive.ErrChanged }
812 },
813 "the launch environment cannot be composed": func(t *testing.T, h *phase1Harness) {
814 h.cfg.LaunchEnv = func(string) (map[string]string, map[string]bool, error) { return nil, nil, errors.New("no row") }
815 },
816 "a staging write fails": func(t *testing.T, h *phase1Harness) {
817 h.f.putSized("data-ie", "instances/"+p1Host+"/"+h.inc+"/g-cur/0000/0000000000000003-0000000000000003.ltx", "new", 10)
818 h.f.failAt = func(string, string) error { return errors.New("injected 503") }
819 },
820 "the staging lease is too close to its end": func(t *testing.T, h *phase1Harness) {
821 h.f.putSized("data-ie", "instances/"+p1Host+"/"+h.inc+"/g-cur/0000/0000000000000003-0000000000000003.ltx", "new", 10)
822 h.lease.until = h.clock.now.Add(time.Minute)
823 },
824 "no deployment bucket": func(t *testing.T, h *phase1Harness) { h.cfg.StagingLease = nil },
825 "the claim is not the move's": func(t *testing.T, h *phase1Harness) {
826 rewriteClaimOp(t, h.s, func(op *hostclaim.Op) { op.Phase = hostclaim.PhaseAborting })
827 },
828 } {
829 t.Run(name, func(t *testing.T) {
830 h := readyAuto(t)
831 if inject != nil {
832 inject(t, h)
833 }
834 h.pass(t)
835 got := h.read(t, p1Host).Move.State
836 if inject == nil && got != provision.MoveCutting {
837 t.Fatalf("the control round did not request the cut: %s", got)
838 }
839 if inject != nil && got != provision.MoveReady {
840 t.Fatalf("a failing round requested the cut: %s", got)
841 }
842 })
843 }
844}
845
846// One strict setter (ruling F34/G9): a host is strict on every box that is a
847// participant of its Phase 1 move. The source stays strict after the flip
848// (spec §5.3): its record's region is the target by then, but Move.From
849// still names it until the close CAS clears the move.
850func TestSetStrictCoversEveryParticipant(t *testing.T) {
851 const host = "a.go.example.com"
852 move := func(id, from, to string) *provision.MoveState {
853 return &provision.MoveState{ID: id, From: from, To: to, State: provision.MoveCutting}
854 }
855 cases := []struct {
856 name string
857 box string
858 region string // the record's region
859 move *provision.MoveState
860 want bool
861 }{
862 {"source before the flip", "ie", "ie", move(p1ID, "ie", "ca"), true},
863 {"source after the flip", "ie", "ca", move(p1ID, "ie", "ca"), true},
864 {"target before the flip", "ca", "ie", move(p1ID, "ie", "ca"), true},
865 {"target after the flip", "ca", "ca", move(p1ID, "ie", "ca"), true},
866 {"a box that is neither", "us", "ca", move(p1ID, "ie", "ca"), false},
867 {"the source once the move closed", "ie", "ca", nil, false},
868 {"a legacy move names no one", "ie", "ca", move("", "ie", "ca"), false},
869 {"a legacy move on the target", "ca", "ie", move("", "ie", "ca"), false},
870 {"a record with no move", "ie", "ie", nil, false},
871 }
872 for _, tc := range cases {
873 t.Run(tc.name, func(t *testing.T) {
874 w := &movesWorker{cfg: moveCfg{Region: tc.box}}
875 w.setStrict([]provision.Record{{Host: host, Region: tc.region, Move: tc.move}})
876 if got := w.strictHost(host); got != tc.want {
877 t.Fatalf("strictHost = %v, want %v", got, tc.want)
878 }
879 if w.strictHost("other.example.com") {
880 t.Fatal("an unrelated host is strict")
881 }
882 })
883 }
884}
885