| 1 | package host |
| 2 | |
| 3 | import ( |
| 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 | |
| 26 | func 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. |
| 42 | func 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. |
| 87 | func 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. |
| 108 | func 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. |
| 159 | func 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. |
| 180 | func 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. |
| 203 | func 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. |
| 217 | func 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. |
| 245 | func 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 | |
| 276 | func 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. |
| 292 | func 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. |
| 344 | func 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. |
| 405 | func 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. |
| 431 | func 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. |
| 482 | func 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. |
| 496 | func 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). |
| 581 | func 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. |
| 607 | func 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. |
| 630 | func 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). |
| 666 | func 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. |
| 703 | func 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. |
| 737 | func 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. |
| 760 | func 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. |
| 783 | func 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. |
| 850 | func 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 | |