| 1 | package hostrelocate |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/rand" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "slices" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/hostactors" |
| 18 | "github.com/carlosframework/platform/internal/hostclaim" |
| 19 | "github.com/carlosframework/platform/internal/hostfence" |
| 20 | "github.com/carlosframework/platform/internal/park" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | "github.com/carlosframework/platform/internal/termfence" |
| 23 | ) |
| 24 | |
| 25 | const host = "shop.bab.oncarlos.com" |
| 26 | |
| 27 | var boxes = map[string]string{"wrk": "a", "src": "b", "dst": "c", "spa": "d", "sp2": "e", "sp3": "f"} |
| 28 | |
| 29 | func enrollment(box string) string { return strings.Repeat(boxes[box], 32) } |
| 30 | |
| 31 | type fixture struct { |
| 32 | pinErr error |
| 33 | t *testing.T |
| 34 | s release.Store |
| 35 | w Worker |
| 36 | now time.Time |
| 37 | notified int |
| 38 | sign release.Signer |
| 39 | roster hostactors.Roster |
| 40 | // leases records the lease provider's calls in order with the pinner's. |
| 41 | leases []string |
| 42 | loseLease bool |
| 43 | ineligible map[string]bool |
| 44 | } |
| 45 | |
| 46 | // fakeLease is one held replica lease; lost is closed when the test takes |
| 47 | // it away before the pin is journaled. |
| 48 | type fakeLease struct { |
| 49 | f *fixture |
| 50 | lost chan struct{} |
| 51 | } |
| 52 | |
| 53 | func (l *fakeLease) Lost() <-chan struct{} { return l.lost } |
| 54 | func (l *fakeLease) Release(context.Context) error { |
| 55 | l.f.leases = append(l.f.leases, "release") |
| 56 | return nil |
| 57 | } |
| 58 | |
| 59 | type okReleases struct{} |
| 60 | |
| 61 | func (okReleases) VerifyRelease(context.Context, hostclaim.Claim, hostclaim.Release) error { |
| 62 | return nil |
| 63 | } |
| 64 | |
| 65 | type admission struct{ registered []string } |
| 66 | |
| 67 | func (a *admission) Register(_ context.Context, _ hostclaim.Observed, o Owner, id string) (string, error) { |
| 68 | if o.Box != "wrk" || o.Enrollment != enrollment("wrk") { |
| 69 | return "", errors.New("wrong owner") |
| 70 | } |
| 71 | a.registered = append(a.registered, id) |
| 72 | return "adm-" + id, nil |
| 73 | } |
| 74 | func (a *admission) Recheck(context.Context, string) error { return nil } |
| 75 | |
| 76 | // built reports P built once the target has said so (the participant's |
| 77 | // half), as moves' install routine would leave it. |
| 78 | type built struct{} |
| 79 | |
| 80 | func (built) Verify(_ context.Context, o hostclaim.Observed) (hostclaim.PreparedAuthority, bool, error) { |
| 81 | c := o.Claim |
| 82 | if c.Op.Journal["test.built"] != c.Op.Journal["prepared"] { |
| 83 | return hostclaim.PreparedAuthority{}, false, nil |
| 84 | } |
| 85 | var pin hostclaim.RecoveryPin |
| 86 | _ = json.Unmarshal([]byte(c.Op.Journal["pin"]), &pin) |
| 87 | return hostclaim.PreparedAuthority{Generation: c.Op.Journal["prepared"], InstallManifest: "m", SHA256: strings.Repeat("c", 64), SnapshotTXID: pin.MaxTXID, |
| 88 | SourceFilesSeq: pin.FilesSeq, FilesManifestSHA256: strings.Repeat("f", 64)}, true, nil |
| 89 | } |
| 90 | |
| 91 | type pins struct{ f *fixture } |
| 92 | |
| 93 | func (p pins) Pin(_ context.Context, o hostclaim.Observed, now time.Time) (hostclaim.RecoveryPin, error) { |
| 94 | p.f.leases = append(p.f.leases, "pin") |
| 95 | if p.f.pinErr != nil { |
| 96 | return hostclaim.RecoveryPin{}, p.f.pinErr |
| 97 | } |
| 98 | var r hostclaim.Release |
| 99 | _ = json.Unmarshal([]byte(o.Claim.Op.Journal["release"]), &r) |
| 100 | return hostclaim.RecoveryPin{Key: PinKey(o.Claim.Host, o.Claim.Op.ID, o.Claim.Gen), SHA256: strings.Repeat("a", 64), Generation: o.Claim.Holder.Generation, |
| 101 | MaxTXID: max(r.FinalTXID, 40), FilesManifest: "m", FilesSeq: max(r.ManifestSeq, 7), PinnedAt: now, Release: "v1.4.2"}, nil |
| 102 | } |
| 103 | |
| 104 | func newFixture(t *testing.T) *fixture { |
| 105 | t.Helper() |
| 106 | ctx := context.Background() |
| 107 | f := &fixture{t: t, s: release.NewDirStore(t.TempDir()), now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)} |
| 108 | pub, key, err := ed25519.GenerateKey(rand.Reader) |
| 109 | if err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | f.sign = release.RootSigner(key) |
| 113 | f.roster = hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123", AWSRegion: "eu-west-1"}}, |
| 114 | Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 115 | for ref := range boxes { |
| 116 | f.roster.Boxes[ref] = hostactors.Box{Enrollment: enrollment(ref), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", |
| 117 | Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| 118 | } |
| 119 | f.writeRoster() |
| 120 | d := hostactors.Directory{Store: f.s, PublicKey: pub, Bucket: "bucket"} |
| 121 | clock := func() time.Time { return f.now } |
| 122 | n := 0 |
| 123 | f.w = Worker{Claims: f.s, Directory: d, Fences: hostfence.ClaimFenceAuthority{Directory: d, Now: clock}, Releases: okReleases{}, Admission: &admission{}, |
| 124 | Census: func(context.Context, string, []string) error { return nil }, |
| 125 | Choose: func(_ context.Context, _ hostclaim.Observed, exclude []string) (hostclaim.Target, error) { |
| 126 | for _, b := range []string{"dst", "spa", "sp2", "sp3"} { |
| 127 | if !slices.Contains(exclude, b) { |
| 128 | return hostclaim.Target{Box: b, Enrollment: enrollment(b)}, nil |
| 129 | } |
| 130 | } |
| 131 | return hostclaim.Target{}, errors.New("no eligible box") |
| 132 | }, |
| 133 | Eligible: func(_ context.Context, _ hostclaim.Observed, box string) error { |
| 134 | if f.ineligible[box] { |
| 135 | return errors.New("not eligible: " + box) |
| 136 | } |
| 137 | return nil |
| 138 | }, |
| 139 | Relocatable: func(context.Context, hostclaim.Observed) error { return nil }, |
| 140 | Leases: func(context.Context, string) (Lease, error) { |
| 141 | f.leases = append(f.leases, "acquire") |
| 142 | l := &fakeLease{f: f, lost: make(chan struct{})} |
| 143 | if f.loseLease { |
| 144 | close(l.lost) |
| 145 | } |
| 146 | return l, nil |
| 147 | }, |
| 148 | Pins: pins{f}, |
| 149 | Prepared: built{}, |
| 150 | Discharge: func(context.Context, hostactors.Box, termfence.Record) *hostclaim.LocalDischarge { return nil }, |
| 151 | Notify: func(context.Context) { f.notified++ }, |
| 152 | Self: termfence.WrittenBy{Box: "wrk", Enrollment: enrollment("wrk")}, Region: "ie", Controller: "ctl", |
| 153 | NewID: func() string { n++; return "id" + string(rune('0'+n)) }, Now: clock} |
| 154 | f.ineligible = map[string]bool{} |
| 155 | // A host serving on src, whose source admitted one cold session. |
| 156 | created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| 157 | nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| 158 | body := []byte(`{"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| 159 | o, err := hostclaim.Read(ctx, f.s, host) |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | o = f.step(o, hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment("src")})) |
| 164 | o = f.step(o, hostclaim.CompleteCreate("c1", "src")) |
| 165 | f.step(o, hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) |
| 166 | return f |
| 167 | } |
| 168 | |
| 169 | func (f *fixture) writeRoster() { |
| 170 | f.t.Helper() |
| 171 | raw, err := release.SignRootEnvelope(f.sign, f.roster) |
| 172 | if err != nil { |
| 173 | f.t.Fatal(err) |
| 174 | } |
| 175 | _, etag, err := f.s.Get(context.Background(), hostactors.Key) |
| 176 | if err != nil { |
| 177 | etag = "" |
| 178 | } |
| 179 | if _, err := f.s.Put(context.Background(), hostactors.Key, raw, etag); err != nil { |
| 180 | f.t.Fatal(err) |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | func (f *fixture) step(o hostclaim.Observed, tr func(*hostclaim.Claim) error) hostclaim.Observed { |
| 185 | f.t.Helper() |
| 186 | next, err := hostclaim.Update(context.Background(), f.s, o, tr) |
| 187 | if err != nil { |
| 188 | f.t.Fatal(err) |
| 189 | } |
| 190 | return next |
| 191 | } |
| 192 | |
| 193 | func (f *fixture) claim() hostclaim.Observed { |
| 194 | f.t.Helper() |
| 195 | o, err := hostclaim.Read(context.Background(), f.s, host) |
| 196 | if err != nil { |
| 197 | f.t.Fatal(err) |
| 198 | } |
| 199 | return o |
| 200 | } |
| 201 | |
| 202 | // participant applies a box-side transition (Plan 6b's half) to the claim. |
| 203 | func (f *fixture) participant(tr func(*hostclaim.Claim) error) { |
| 204 | f.t.Helper() |
| 205 | f.step(f.claim(), tr) |
| 206 | } |
| 207 | |
| 208 | func (f *fixture) ack(box string) { |
| 209 | f.participant(hostclaim.AcknowledgeExecution(box, hostclaim.ExecutionAcknowledgement{Op: f.claim().Claim.Op.ID, Enrollment: enrollment(box), BootID: "boot", ControllerID: "ctl", LeaseETag: "lease", Expires: f.now.Add(time.Hour)})) |
| 210 | } |
| 211 | |
| 212 | // terminate writes box's termination fence as hostfence.TerminationWorker |
| 213 | // would, observed a minute ago by wrk. |
| 214 | func (f *fixture) terminate(box string) { |
| 215 | f.t.Helper() |
| 216 | b := f.roster.Boxes[box] |
| 217 | rec := termfence.Record{V: 1, Box: box, Enrollment: b.Enrollment, Cloud: b.Cloud, State: "terminated", ObservedAt: f.now.Add(-time.Minute).Format(time.RFC3339Nano), |
| 218 | Via: termfence.ViaDescribe, RequestID: "req", WrittenBy: termfence.WrittenBy{Box: "wrk", Enrollment: enrollment("wrk")}} |
| 219 | raw, _ := json.Marshal(rec) |
| 220 | if _, err := f.s.Put(context.Background(), hostfence.FenceKey(b.Enrollment), raw, ""); err != nil { |
| 221 | f.t.Fatal(err) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func (f *fixture) worker(want error) hostclaim.Observed { |
| 226 | f.t.Helper() |
| 227 | o, err := f.w.Step(context.Background(), host) |
| 228 | if !errors.Is(err, want) { |
| 229 | f.t.Fatalf("step: %v, want %v", err, want) |
| 230 | } |
| 231 | return o |
| 232 | } |
| 233 | |
| 234 | func (f *fixture) installAndProbe(target string) { |
| 235 | f.t.Helper() |
| 236 | c := f.claim().Claim |
| 237 | id, p := c.Op.ID, c.Op.Journal["prepared"] |
| 238 | f.worker(ErrNotYet) // P is not built yet |
| 239 | f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = p; return nil }) |
| 240 | if o := f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalled { |
| 241 | f.t.Fatalf("the worker records installed once P verifies: %s", o.Claim.Op.Phase) |
| 242 | } |
| 243 | stamp := f.claim().Claim.Op.Journal["install_stamp"] |
| 244 | var n uint64 |
| 245 | _ = json.Unmarshal([]byte(stamp), &n) |
| 246 | f.participant(hostclaim.RecordLocalInstall(id, hostclaim.LocalInstall{Box: target, Enrollment: enrollment(target), InstallStamp: n, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("d", 64)})) |
| 247 | f.participant(hostclaim.ToProbing(id, f.now)) |
| 248 | o := f.claim() |
| 249 | f.participant(hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: id, ObservedPhase: hostclaim.PhaseRelocatingProbing, ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation, |
| 250 | Box: target, Mode: "cold", NewGeneration: p, KeepStamp: true, Region: "ie", PredecessorRegion: "ie"})) |
| 251 | } |
| 252 | |
| 253 | func TestWorkerDrivesADrain(t *testing.T) { |
| 254 | f := newFixture(t) |
| 255 | o, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}) |
| 256 | if err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if o.Claim.Op.Intent["admission_id"] == "" || o.Claim.Op.Intent["directory_revision"] != "1" { |
| 260 | t.Fatalf("intent: %+v", o.Claim.Op.Intent) |
| 261 | } |
| 262 | f.worker(ErrNotYet) // no acknowledgements yet |
| 263 | f.ack("src") |
| 264 | f.ack("dst") |
| 265 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseReleasing { |
| 266 | t.Fatalf("phase %s", o.Claim.Op.Phase) |
| 267 | } |
| 268 | f.worker(ErrNotYet) // the source has not released |
| 269 | h := f.claim().Claim.Holder |
| 270 | f.participant(hostclaim.JournalRelease(o.Claim.Op.ID, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 271 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhasePinning || o.Claim.Op.Exec != "none" { |
| 272 | t.Fatalf("pinning: %+v", o.Claim.Op) |
| 273 | } |
| 274 | if o = f.worker(nil); o.Claim.Op.Journal["pin"] == "" { |
| 275 | t.Fatal("no pin journaled") |
| 276 | } |
| 277 | if want := []string{"acquire", "pin", "release"}; !slices.Equal(f.leases, want) { |
| 278 | t.Fatalf("the pin was not selected and journaled under the location lease: %v", f.leases) |
| 279 | } |
| 280 | before := f.notified |
| 281 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "dst" || f.notified != before+1 { |
| 282 | t.Fatalf("installing: %+v notified %d", o.Claim.Op, f.notified-before) |
| 283 | } |
| 284 | f.installAndProbe("dst") |
| 285 | f.worker(ErrNotYet) // probing, no probe result, within the deadline |
| 286 | o = f.claim() |
| 287 | f.participant(hostclaim.RecordProbe(o.Claim.Op.ID, hostclaim.Probe{Box: "dst", Session: o.Claim.Holder.Session})) |
| 288 | f.worker(ErrNotYet) // the probe passed; its session is not stopped yet (ruling 49) |
| 289 | f.participant(hostclaim.RecordProbeStop(o.Claim.Op.ID, hostclaim.ProbeStop{Box: "dst", Session: o.Claim.Holder.Session, Receipt: "fence-1", ReceiptSHA256: strings.Repeat("a", 64)})) |
| 290 | if o = f.worker(nil); o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.Exec != "dst" { |
| 291 | t.Fatalf("serving: %+v", o.Claim.Op) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | func TestWorkerFailoverWaitsForThePinWindow(t *testing.T) { |
| 296 | f := newFixture(t) |
| 297 | ctx := context.Background() |
| 298 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); !errors.Is(err, ErrRefused) { |
| 299 | t.Fatalf("failover with no fence: %v", err) |
| 300 | } |
| 301 | f.terminate("src") |
| 302 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrRefused) { |
| 303 | t.Fatalf("drain of a fenced source: %v", err) |
| 304 | } |
| 305 | o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}) |
| 306 | if err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | if o.Claim.Op.Exec != "none" || !slices.Contains(o.Claim.Lost, "src") { |
| 310 | t.Fatalf("failover start: %+v", o.Claim.Op) |
| 311 | } |
| 312 | f.ack("dst") |
| 313 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhasePinning { |
| 314 | t.Fatalf("phase %s", o.Claim.Op.Phase) |
| 315 | } |
| 316 | // The fence was observed a minute ago, so the 30-second window is open. |
| 317 | if o = f.worker(nil); o.Claim.Op.Journal["pin"] == "" { |
| 318 | t.Fatal("not pinned") |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | func TestWorkerPinsNoEarlierThanThirtySecondsAfterTheFence(t *testing.T) { |
| 323 | f := newFixture(t) |
| 324 | ctx := context.Background() |
| 325 | f.terminate("src") |
| 326 | f.now = f.now.Add(-time.Minute + 10*time.Second) // ten seconds after the observation |
| 327 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | f.ack("dst") |
| 331 | f.worker(nil) // pinning |
| 332 | f.worker(ErrNotYet) |
| 333 | f.now = f.now.Add(20 * time.Second) |
| 334 | if o := f.worker(nil); o.Claim.Op.Journal["pin"] == "" { |
| 335 | t.Fatal("not pinned at observation plus thirty seconds") |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | func TestWorkerConvertsADrainWhoseSourceIsTerminated(t *testing.T) { |
| 340 | f := newFixture(t) |
| 341 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | f.ack("dst") |
| 345 | f.terminate("src") |
| 346 | o := f.worker(nil) |
| 347 | if o.Claim.Op.Phase != hostclaim.PhaseStarting || o.Claim.Op.Exec != "none" || slices.Contains(o.Claim.Op.Participants, "src") || !slices.Contains(o.Claim.Lost, "src") { |
| 348 | t.Fatalf("conversion: %+v lost %v", o.Claim.Op, o.Claim.Lost) |
| 349 | } |
| 350 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhasePinning { |
| 351 | t.Fatalf("the barrier now needs only the target: %s", o.Claim.Op.Phase) |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | func TestWorkerReplacesATerminatedTargetTwiceThenStops(t *testing.T) { |
| 356 | f := newFixture(t) |
| 357 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | for _, tc := range []struct{ dead, next string }{{"dst", "spa"}, {"spa", "sp2"}} { |
| 361 | f.terminate(tc.dead) |
| 362 | o := f.worker(nil) |
| 363 | if o.Claim.Op.Intent["target_box"] != tc.next || !slices.Contains(o.Claim.Lost, tc.dead) { |
| 364 | t.Fatalf("replacement of %s: %+v", tc.dead, o.Claim.Op.Intent) |
| 365 | } |
| 366 | } |
| 367 | f.terminate("sp2") |
| 368 | o := f.worker(nil) |
| 369 | if o.Claim.Op.Journal["stuck"] == "" { |
| 370 | t.Fatalf("a third replacement must stop the operation: %+v", o.Claim.Op.Journal) |
| 371 | } |
| 372 | before := o.Claim.Gen |
| 373 | if o = f.worker(nil); o.Claim.Gen != before { |
| 374 | t.Fatal("a worker moved a stuck operation") |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | func TestWorkerFailsAProbePastItsDeadline(t *testing.T) { |
| 379 | f := newFixture(t) |
| 380 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | f.ack("src") |
| 384 | f.ack("dst") |
| 385 | f.worker(nil) |
| 386 | h := f.claim().Claim.Holder |
| 387 | f.participant(hostclaim.JournalRelease(f.claim().Claim.Op.ID, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 388 | f.worker(nil) |
| 389 | f.worker(nil) |
| 390 | f.worker(nil) |
| 391 | f.installAndProbe("dst") |
| 392 | f.now = f.now.Add(ProbeDeadline + time.Second) |
| 393 | if o := f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseProbeFailed { |
| 394 | t.Fatalf("phase %s", o.Claim.Op.Phase) |
| 395 | } |
| 396 | f.worker(ErrNotYet) // the failed target has not released |
| 397 | } |
| 398 | |
| 399 | func TestWorkerRefusesWhenItIsNotAnActiveWorker(t *testing.T) { |
| 400 | f := newFixture(t) |
| 401 | b := f.roster.Boxes["wrk"] |
| 402 | b.Region = "uk" |
| 403 | f.roster.Boxes["wrk"] = b |
| 404 | f.roster.Revision++ |
| 405 | f.writeRoster() |
| 406 | before := f.claim().ETag |
| 407 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrNotWorker) { |
| 408 | t.Fatalf("start: %v", err) |
| 409 | } |
| 410 | if f.claim().ETag != before { |
| 411 | t.Fatal("a refused start wrote the claim") |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | func TestWorkerStartRefusesAnUpdateReservedForAParticipant(t *testing.T) { |
| 416 | f := newFixture(t) |
| 417 | f.roster.Activations["upd-1"] = hostactors.Reservation{ID: "upd-1", FloorSHA256: strings.Repeat("f", 64), |
| 418 | Actors: map[string]string{"box:dst": enrollment("dst")}, Releases: map[string]hostactors.Report{"box:dst": {Enrollment: enrollment("dst")}}} |
| 419 | f.roster.Revision++ |
| 420 | f.writeRoster() |
| 421 | before := f.claim().ETag |
| 422 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err == nil { |
| 423 | t.Fatal("started while an executable update is reserved for the target (H14)") |
| 424 | } |
| 425 | if f.claim().ETag != before { |
| 426 | t.Fatal("a refused start wrote the claim") |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | func TestWorkerTreatsAnUnreadableFenceAsAnErrorNeverAsNoFence(t *testing.T) { |
| 431 | f := newFixture(t) |
| 432 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 433 | t.Fatal(err) |
| 434 | } |
| 435 | f.ack("src") |
| 436 | f.ack("dst") |
| 437 | if _, err := f.s.Put(context.Background(), hostfence.FenceKey(enrollment("src")), []byte("{"), ""); err != nil { |
| 438 | t.Fatal(err) |
| 439 | } |
| 440 | before := f.claim().ETag |
| 441 | if _, err := f.w.Step(context.Background(), host); err == nil || errors.Is(err, ErrNotYet) { |
| 442 | t.Fatalf("an unreadable fence let the drain proceed: %v", err) |
| 443 | } |
| 444 | if f.claim().ETag != before { |
| 445 | t.Fatal("the claim moved") |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | func TestWorkerRefusesToJournalAPinAfterTheLeaseIsLost(t *testing.T) { |
| 450 | f := newFixture(t) |
| 451 | f.terminate("src") |
| 452 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil { |
| 453 | t.Fatal(err) |
| 454 | } |
| 455 | f.ack("dst") |
| 456 | f.worker(nil) // pinning |
| 457 | f.loseLease = true |
| 458 | o := f.worker(ErrNotYet) |
| 459 | if o.Claim.Op.Journal["pin"] != "" { |
| 460 | t.Fatal("a pin was journaled after its lease was lost: a park could have retired its versions") |
| 461 | } |
| 462 | } |
| 463 | |
| 464 | func TestWorkerStartRefusesAnIneligibleTargetAndANonRelocatableHost(t *testing.T) { |
| 465 | f := newFixture(t) |
| 466 | before := f.claim().ETag |
| 467 | f.ineligible["dst"] = true |
| 468 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err == nil { |
| 469 | t.Fatal("started on a target the eligible set does not hold") |
| 470 | } |
| 471 | f.ineligible["dst"] = false |
| 472 | shape := errors.New("unit-backed route") |
| 473 | f.w.Relocatable = func(context.Context, hostclaim.Observed) error { return shape } |
| 474 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, shape) { |
| 475 | t.Fatalf("started a non-relocatable host: %v", err) |
| 476 | } |
| 477 | if f.claim().ETag != before { |
| 478 | t.Fatal("a refused start wrote the claim") |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | func TestWorkerRefusesToDrainAHostThatNeverHadAWriter(t *testing.T) { |
| 483 | f := newFixture(t) |
| 484 | // A second host created on src and never woken: session 0. |
| 485 | ctx := context.Background() |
| 486 | const quiet = "quiet.bab.oncarlos.com" |
| 487 | nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + quiet + ".json", Account: "bab", App: "jam", Host: quiet, Region: "ie", RecordRegion: "ie", CreatedAt: time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)} |
| 488 | body := []byte(`{"host":"` + quiet + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| 489 | o, err := hostclaim.Read(ctx, f.s, quiet) |
| 490 | if err != nil { |
| 491 | t.Fatal(err) |
| 492 | } |
| 493 | o = f.step(o, hostclaim.BeginCreateOn(nh, body, "c2", "console", f.now, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment("src")})) |
| 494 | f.step(o, hostclaim.CompleteCreate("c2", "src")) |
| 495 | if _, err := f.w.Start(ctx, quiet, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrRefused) { |
| 496 | t.Fatalf("drained a never-woken host: %v", err) |
| 497 | } |
| 498 | f.terminate("src") |
| 499 | if _, err := f.w.Start(ctx, quiet, Request{Reason: hostclaim.ReasonFailover, Source: "src", Target: "dst"}); err != nil { |
| 500 | t.Fatalf("a failover of a never-woken host: %v", err) |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | func TestWorkerMarksALiveTargetsThirdReplacementStuck(t *testing.T) { |
| 505 | f := newFixture(t) |
| 506 | ctx := context.Background() |
| 507 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 508 | t.Fatal(err) |
| 509 | } |
| 510 | // Two terminated targets use the replacements; the third target is |
| 511 | // alive but fails its probe, and its release names a fourth box: stuck. |
| 512 | for _, dead := range []string{"dst", "spa"} { |
| 513 | f.terminate(dead) |
| 514 | f.worker(nil) |
| 515 | } |
| 516 | f.ack("src") |
| 517 | f.ack("sp2") |
| 518 | f.worker(nil) // releasing |
| 519 | h := f.claim().Claim.Holder |
| 520 | id := f.claim().Claim.Op.ID |
| 521 | f.participant(hostclaim.JournalRelease(id, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 522 | f.worker(nil) // pinning |
| 523 | f.worker(nil) // pin |
| 524 | f.worker(nil) // installing on sp2 |
| 525 | f.installAndProbe("sp2") |
| 526 | f.now = f.now.Add(ProbeDeadline + time.Second) |
| 527 | if o := f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseProbeFailed { |
| 528 | t.Fatalf("phase %s", o.Claim.Op.Phase) |
| 529 | } |
| 530 | h = f.claim().Claim.Holder |
| 531 | f.participant(hostclaim.JournalRelease(id, hostclaim.Release{ReleasedBy: "sp2", Enrollment: enrollment("sp2"), Generation: h.Generation, Session: h.Session, FinalTXID: 41, ManifestSeq: 7, Receipt: "r2", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 532 | if o := f.worker(nil); o.Claim.Op.Journal["stuck"] == "" || o.Claim.Op.Phase != hostclaim.PhaseProbeFailed { |
| 533 | t.Fatalf("a live target's third replacement must stop the operation: %+v", o.Claim.Op) |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | func TestWorkerMarksExhaustionStuckBeforeLookingForASpare(t *testing.T) { |
| 538 | // Two replacements used; no box is eligible for a third. The |
| 539 | // operation is stuck at once, not retried forever on "no eligible box". |
| 540 | f := newFixture(t) |
| 541 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 542 | t.Fatal(err) |
| 543 | } |
| 544 | for _, dead := range []string{"dst", "spa"} { |
| 545 | f.terminate(dead) |
| 546 | f.worker(nil) |
| 547 | } |
| 548 | f.w.Choose = func(context.Context, hostclaim.Observed, []string) (hostclaim.Target, error) { |
| 549 | return hostclaim.Target{}, errors.New("no eligible box") |
| 550 | } |
| 551 | f.terminate("sp2") |
| 552 | if o := f.worker(nil); o.Claim.Op.Journal["stuck"] == "" { |
| 553 | t.Fatalf("exhaustion with no spare was not stuck: %+v", o.Claim.Op.Journal) |
| 554 | } |
| 555 | } |
| 556 | |
| 557 | // Final review: a malformed replacements count is an error, never 0. The |
| 558 | // worker refuses before it looks for a spare, and the claim is unchanged. |
| 559 | func TestWorkerRefusesAMalformedReplacementsCount(t *testing.T) { |
| 560 | f := newFixture(t) |
| 561 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 562 | t.Fatal(err) |
| 563 | } |
| 564 | f.participant(func(c *hostclaim.Claim) error { |
| 565 | if c.Op.Journal == nil { |
| 566 | c.Op.Journal = map[string]string{} |
| 567 | } |
| 568 | c.Op.Journal["replacements"] = "two" |
| 569 | return nil |
| 570 | }) |
| 571 | chose := 0 |
| 572 | f.w.Choose = func(context.Context, hostclaim.Observed, []string) (hostclaim.Target, error) { |
| 573 | chose++ |
| 574 | return hostclaim.Target{}, errors.New("no eligible box") |
| 575 | } |
| 576 | f.now = f.now.Add(ReleaseDeadline + time.Minute) |
| 577 | f.terminate("dst") |
| 578 | before := f.claim() |
| 579 | if _, err := f.w.Step(context.Background(), host); !errors.Is(err, hostclaim.ErrRelocation) { |
| 580 | t.Fatalf("step over a malformed count: %v", err) |
| 581 | } |
| 582 | if after := f.claim(); after.ETag != before.ETag || chose != 0 { |
| 583 | t.Fatalf("the claim moved (%v) or a spare was looked for (%d)", after.ETag != before.ETag, chose) |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | func TestWorkerFencesAnExhaustedTargetThatDiedAfterItsLocalInstall(t *testing.T) { |
| 588 | f := newFixture(t) |
| 589 | ctx := context.Background() |
| 590 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 591 | t.Fatal(err) |
| 592 | } |
| 593 | for _, dead := range []string{"dst", "spa"} { |
| 594 | f.terminate(dead) |
| 595 | f.worker(nil) |
| 596 | } |
| 597 | f.ack("src") |
| 598 | f.ack("sp2") |
| 599 | f.worker(nil) // releasing |
| 600 | h := f.claim().Claim.Holder |
| 601 | id := f.claim().Claim.Op.ID |
| 602 | f.participant(hostclaim.JournalRelease(id, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 603 | f.worker(nil) // pinning |
| 604 | f.worker(nil) // pin |
| 605 | f.worker(nil) // installing on sp2 |
| 606 | c := f.claim().Claim |
| 607 | f.participant(func(c *hostclaim.Claim) error { c.Op.Journal["test.built"] = c.Op.Journal["prepared"]; return nil }) |
| 608 | f.worker(nil) // installed |
| 609 | var n uint64 |
| 610 | _ = json.Unmarshal([]byte(f.claim().Claim.Op.Journal["install_stamp"]), &n) |
| 611 | f.participant(hostclaim.RecordLocalInstall(c.Op.ID, hostclaim.LocalInstall{Box: "sp2", Enrollment: enrollment("sp2"), InstallStamp: n, FilesManifestVersion: 1, JournalSHA256: strings.Repeat("d", 64)})) |
| 612 | f.terminate("sp2") // dead between installed and its probe |
| 613 | o := f.worker(nil) |
| 614 | if o.Claim.Op.Journal["stuck"] == "" || o.Claim.Op.Phase != hostclaim.PhasePinning || o.Claim.Op.Exec != "none" || !slices.Contains(o.Claim.Lost, "sp2") { |
| 615 | t.Fatalf("an exhausted dead target was not fenced into a stuck pinning: %+v", o.Claim.Op) |
| 616 | } |
| 617 | before := o.Claim.Gen |
| 618 | if o = f.worker(nil); o.Claim.Gen != before { |
| 619 | t.Fatal("a worker moved a stuck operation") |
| 620 | } |
| 621 | } |
| 622 | |
| 623 | func TestWorkerMarksAnOverdueReleaseStuck(t *testing.T) { |
| 624 | f := newFixture(t) |
| 625 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 626 | t.Fatal(err) |
| 627 | } |
| 628 | f.ack("src") |
| 629 | f.ack("dst") |
| 630 | f.worker(nil) // releasing |
| 631 | f.now = f.now.Add(ReleaseDeadline) |
| 632 | f.worker(ErrNotYet) // at the deadline: still waiting |
| 633 | f.now = f.now.Add(time.Second) |
| 634 | o := f.worker(nil) |
| 635 | if o.Claim.Op.Journal["stuck"] == "" || o.Claim.Op.Phase != hostclaim.PhaseReleasing || o.Claim.Op.Exec != "src" { |
| 636 | t.Fatalf("a source that never released must leave the operation stuck on the source: %+v", o.Claim.Op) |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | // Final review Minor 3 (Task 7's ruling): a source removed from the roster |
| 641 | // makes every pass refuse at its fence read. The release deadline is |
| 642 | // checked before that refusal stands, so the operation goes stuck at the |
| 643 | // deadline, released or not, instead of refusing for ever. |
| 644 | func TestWorkerMarksARosterRemovedSourceStuckAtTheReleaseDeadline(t *testing.T) { |
| 645 | for _, journaled := range []bool{false, true} { |
| 646 | t.Run(fmt.Sprintf("release journaled %v", journaled), func(t *testing.T) { |
| 647 | f := newFixture(t) |
| 648 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 649 | t.Fatal(err) |
| 650 | } |
| 651 | f.ack("src") |
| 652 | f.ack("dst") |
| 653 | f.worker(nil) // releasing |
| 654 | if journaled { |
| 655 | h, id := f.claim().Claim.Holder, f.claim().Claim.Op.ID |
| 656 | f.participant(hostclaim.JournalRelease(id, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 657 | } |
| 658 | delete(f.roster.Boxes, "src") |
| 659 | f.roster.Revision++ |
| 660 | f.writeRoster() |
| 661 | f.now = f.now.Add(ReleaseDeadline) |
| 662 | if o := f.worker(ErrRefused); o.Claim.Op.Journal["stuck"] != "" { |
| 663 | t.Fatal("stuck before the deadline") |
| 664 | } |
| 665 | f.now = f.now.Add(time.Second) |
| 666 | o := f.worker(nil) |
| 667 | if o.Claim.Op.Journal["stuck"] == "" || o.Claim.Op.Phase != hostclaim.PhaseReleasing || o.Claim.Op.Exec != "src" { |
| 668 | t.Fatalf("a roster-removed source must leave the operation stuck at the release deadline: %+v", o.Claim.Op) |
| 669 | } |
| 670 | }) |
| 671 | } |
| 672 | // Outside releasing and probe-failed there is no release deadline: the |
| 673 | // refusal stands. |
| 674 | f := newFixture(t) |
| 675 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 676 | t.Fatal(err) |
| 677 | } |
| 678 | delete(f.roster.Boxes, "src") |
| 679 | f.roster.Revision++ |
| 680 | f.writeRoster() |
| 681 | f.now = f.now.Add(ReleaseDeadline + time.Hour) |
| 682 | if o := f.worker(ErrRefused); o.Claim.Op.Journal["stuck"] != "" || o.Claim.Op.Phase != hostclaim.PhaseStarting { |
| 683 | t.Fatalf("starting: %+v", o.Claim.Op) |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | // Round 5 finding 1: a spare that owes an older local retirement for the |
| 688 | // host is never offered to the chooser; the worker replaces the dead |
| 689 | // target with a clean spare (the claim refuses the other in its CAS). |
| 690 | func TestWorkerNeverReplacesATargetWithABoxOwingALocalRetirement(t *testing.T) { |
| 691 | f := newFixture(t) |
| 692 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 693 | t.Fatal(err) |
| 694 | } |
| 695 | f.participant(func(c *hostclaim.Claim) error { |
| 696 | c.Obligations = append(c.Obligations, hostclaim.Obligation{ID: "old", Op: "old-op", Kind: "park-local", Box: "spa", Retirement: "r", Owner: "box:spa"}) |
| 697 | return nil |
| 698 | }) |
| 699 | f.terminate("dst") |
| 700 | o := f.worker(nil) |
| 701 | if o.Claim.Op.Intent["target_box"] != "sp2" || slices.Contains(o.Claim.Op.Participants, "spa") { |
| 702 | t.Fatalf("replacement: %+v participants %v", o.Claim.Op.Intent, o.Claim.Op.Participants) |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | // Two-box model finding F2: a pin that cannot show the platform files |
| 707 | // never existed (StorePinner's ErrFilesUnevidenced) stops the operation |
| 708 | // for an operator instead of retrying or pinning empty. |
| 709 | func TestWorkerMarksAnUnevidencedFilesPinStuck(t *testing.T) { |
| 710 | f := newFixture(t) |
| 711 | if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 712 | t.Fatal(err) |
| 713 | } |
| 714 | f.ack("src") |
| 715 | f.ack("dst") |
| 716 | f.worker(nil) // releasing |
| 717 | h := f.claim().Claim.Holder |
| 718 | f.participant(hostclaim.JournalRelease(f.claim().Claim.Op.ID, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 719 | f.worker(nil) // pinning |
| 720 | f.pinErr = fmt.Errorf("%w: test", ErrFilesUnevidenced) |
| 721 | if o := f.worker(nil); o.Claim.Op.Journal["stuck"] == "" || o.Claim.Op.Journal["pin"] != "" { |
| 722 | t.Fatalf("an unevidenced files pin: %+v", o.Claim.Op) |
| 723 | } |
| 724 | } |
| 725 | |
| 726 | // Two-box model finding F1: a drain's target fails its probe and |
| 727 | // releases; with no spare (two boxes: the source is excluded) the worker |
| 728 | // waits, then, ReleaseDeadline after the target failed, marks the |
| 729 | // operation stuck for an operator. |
| 730 | func TestWorkerBoundsAProbeFailedWithNoSpare(t *testing.T) { |
| 731 | for _, terminated := range []bool{false, true} { |
| 732 | t.Run(fmt.Sprintf("target terminated after its release %v", terminated), func(t *testing.T) { boundsAProbeFailed(t, terminated) }) |
| 733 | } |
| 734 | } |
| 735 | |
| 736 | func boundsAProbeFailed(t *testing.T, terminated bool) { |
| 737 | f := newFixture(t) |
| 738 | ctx := context.Background() |
| 739 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| 740 | t.Fatal(err) |
| 741 | } |
| 742 | f.ack("src") |
| 743 | f.ack("dst") |
| 744 | f.worker(nil) // releasing |
| 745 | h := f.claim().Claim.Holder |
| 746 | id := f.claim().Claim.Op.ID |
| 747 | f.participant(hostclaim.JournalRelease(id, hostclaim.Release{ReleasedBy: "src", Enrollment: enrollment("src"), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 748 | f.worker(nil) // pinning |
| 749 | f.worker(nil) // pin |
| 750 | f.worker(nil) // installing on dst |
| 751 | f.installAndProbe("dst") |
| 752 | f.now = f.now.Add(ProbeDeadline + time.Second) |
| 753 | if o := f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseProbeFailed { |
| 754 | t.Fatalf("phase %s", o.Claim.Op.Phase) |
| 755 | } |
| 756 | h = f.claim().Claim.Holder |
| 757 | f.participant(hostclaim.JournalRelease(id, hostclaim.Release{ReleasedBy: "dst", Enrollment: enrollment("dst"), Generation: h.Generation, Session: h.Session, FinalTXID: 41, ManifestSeq: 7, Receipt: "r2", ReceiptSHA256: strings.Repeat("d", 64)})) |
| 758 | noSpare := errors.New("no eligible box") |
| 759 | f.w.Choose = func(context.Context, hostclaim.Observed, []string) (hostclaim.Target, error) { |
| 760 | return hostclaim.Target{}, noSpare |
| 761 | } |
| 762 | if _, err := f.w.Step(ctx, host); !errors.Is(err, noSpare) || f.claim().Claim.Op.Journal["stuck"] != "" { |
| 763 | t.Fatalf("before the deadline: %v %+v", err, f.claim().Claim.Op.Journal) |
| 764 | } |
| 765 | if terminated { |
| 766 | // Round 17 finding 2: the released target is terminated. Its |
| 767 | // fence goes to the replacement path first; with no spare that |
| 768 | // path is bounded too, and consumes the fence as it stops. |
| 769 | f.terminate("dst") |
| 770 | if _, err := f.w.Step(ctx, host); !errors.Is(err, noSpare) || f.claim().Claim.Op.Journal["stuck"] != "" { |
| 771 | t.Fatalf("a dead target, before the deadline: %v %+v", err, f.claim().Claim.Op.Journal) |
| 772 | } |
| 773 | } |
| 774 | f.now = f.now.Add(ReleaseDeadline + time.Second) |
| 775 | o := f.worker(nil) |
| 776 | if o.Claim.Op.Journal["stuck"] == "" { |
| 777 | t.Fatalf("no spare after the deadline: %+v", o.Claim.Op) |
| 778 | } |
| 779 | if terminated && (!slices.Contains(o.Claim.Lost, "dst") || o.Claim.Op.Journal["fence.dst"] == "" || o.Claim.Op.Exec != "none") { |
| 780 | t.Fatalf("the dead target's fence was not consumed: %+v lost %v", o.Claim.Op, o.Claim.Lost) |
| 781 | } |
| 782 | if !terminated && o.Claim.Op.Phase != hostclaim.PhaseProbeFailed { |
| 783 | t.Fatalf("a live failed target: %+v", o.Claim.Op) |
| 784 | } |
| 785 | } |
| 786 | |
| 787 | // preFlip is the moves door's durable pre-flip decision as |
| 788 | // ConvertMovingToRelocating reads it: the record is still in ie. |
| 789 | type preFlip struct{} |
| 790 | |
| 791 | func (preFlip) CheckMovingBeforeFlip(context.Context, hostclaim.Observed, string, hostclaim.FenceRef) (string, error) { |
| 792 | return "ie", nil |
| 793 | } |
| 794 | |
| 795 | // convertedMove begins an admitted move of host from src to "far", a box |
| 796 | // of another region that is not in this fixture's roster, terminates src |
| 797 | // (observed ten seconds before f.now), and converts the move to |
| 798 | // relocating as the moves door would. farAcks says whether the move's |
| 799 | // barrier had heard from far before the source died. |
| 800 | func convertedMove(f *fixture, farAcks bool) hostclaim.Observed { |
| 801 | f.t.Helper() |
| 802 | ctx := context.Background() |
| 803 | far := strings.Repeat("9", 32) // the move target's enrollment |
| 804 | f.step(f.claim(), hostclaim.BeginAdmittedMove("mv1", "member", "src", "far", "uk", f.now, |
| 805 | hostclaim.MoveAdmission{AppID: "app", AdmissionID: "mv1", SourceEnrollment: enrollment("src"), TargetEnrollment: far})) |
| 806 | f.ack("src") |
| 807 | if farAcks { |
| 808 | f.participant(hostclaim.AcknowledgeExecution("far", hostclaim.ExecutionAcknowledgement{Op: "mv1", Enrollment: far, BootID: "boot", ControllerID: "ctl", LeaseETag: "lease", Expires: f.now.Add(time.Hour)})) |
| 809 | } |
| 810 | f.terminate("src") |
| 811 | f.now = f.now.Add(-time.Minute + 10*time.Second) // ten seconds after the observation |
| 812 | ro, err := f.w.Directory.Read(ctx) |
| 813 | if err != nil { |
| 814 | f.t.Fatal(err) |
| 815 | } |
| 816 | ref, _, err := f.w.fence(ctx, ro, "src") |
| 817 | if err != nil || ref == nil { |
| 818 | f.t.Fatalf("fence: %v %v", ref, err) |
| 819 | } |
| 820 | o, err := hostclaim.ConvertMovingToRelocating(ctx, f.s, f.claim(), "mv1", *ref, f.w.Fences, preFlip{}) |
| 821 | if err != nil { |
| 822 | f.t.Fatal(err) |
| 823 | } |
| 824 | if o.Claim.Op.Journal["fence.src"] == "" || o.Claim.Op.Journal["fence.src.observed_at"] != "" || slices.Contains(o.Claim.Lost, "src") || !slices.Contains(o.Claim.Op.Participants, "far") { |
| 825 | f.t.Fatalf("not the converted shape this test is about: %+v lost %v", o.Claim.Op, o.Claim.Lost) |
| 826 | } |
| 827 | return o |
| 828 | } |
| 829 | |
| 830 | // Task 1's fix: a move converted to relocating binds fence.<source> with |
| 831 | // no observation time, and JournalPin refuses until FenceSource has |
| 832 | // consumed it. The worker reads the converted source as not yet lost, so |
| 833 | // its first CAS is FenceSource; the pin then waits FailoverPinDelay past |
| 834 | // the fence's observed_at, under the location lease. Task 5 fix round 1: |
| 835 | // the move's own target leaves the barrier when the same-region target is |
| 836 | // named, so one that never acknowledged (or died) holds nothing up. |
| 837 | func TestWorkerFencesAConvertedMovesSourceBeforeItPins(t *testing.T) { |
| 838 | for _, farAcks := range []bool{true, false} { |
| 839 | t.Run(fmt.Sprintf("the move target acknowledged %v", farAcks), func(t *testing.T) { |
| 840 | f := newFixture(t) |
| 841 | convertedMove(f, farAcks) |
| 842 | o := f.worker(nil) |
| 843 | if o.Claim.Op.Journal["fence.src.observed_at"] == "" || !slices.Contains(o.Claim.Lost, "src") || o.Claim.Op.Phase != hostclaim.PhasePinning || o.Claim.Op.Journal["pin"] != "" { |
| 844 | t.Fatalf("the worker's first CAS on a converted move is FenceSource: %+v lost %v", o.Claim.Op, o.Claim.Lost) |
| 845 | } |
| 846 | if o = f.worker(nil); o.Claim.Op.Intent["target_box"] != "dst" || !slices.Equal(o.Claim.Op.Participants, []string{"dst"}) { |
| 847 | t.Fatalf("the converted move names its same-region target alone: %+v", o.Claim.Op) |
| 848 | } |
| 849 | f.worker(ErrNotYet) // inside the 30-second hold |
| 850 | if len(f.leases) != 0 { |
| 851 | t.Fatalf("a lease was taken inside the hold: %v", f.leases) |
| 852 | } |
| 853 | f.now = f.now.Add(20 * time.Second) |
| 854 | if o = f.worker(nil); o.Claim.Op.Journal["pin"] == "" { |
| 855 | t.Fatal("not pinned at observation plus thirty seconds") |
| 856 | } |
| 857 | if want := []string{"acquire", "pin", "release"}; !slices.Equal(f.leases, want) { |
| 858 | t.Fatalf("the pin was not selected and journaled under the location lease: %v", f.leases) |
| 859 | } |
| 860 | f.worker(ErrNotYet) // the named target has not acknowledged |
| 861 | f.ack("dst") |
| 862 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "dst" { |
| 863 | t.Fatalf("installing: %+v", o.Claim.Op) |
| 864 | } |
| 865 | }) |
| 866 | } |
| 867 | } |
| 868 | |
| 869 | // Task 5 fix round 1, absence is never evidence: a converted move's |
| 870 | // fence.<source> is bound in the journal, so a fence object that is no |
| 871 | // longer there is an error, never "not fenced". The worker refuses the |
| 872 | // pass: it names no target and never calls the pinner. |
| 873 | func TestWorkerRefusesAConvertedMoveWhoseBoundFenceIsMissing(t *testing.T) { |
| 874 | f := newFixture(t) |
| 875 | convertedMove(f, false) |
| 876 | if err := f.s.Delete(context.Background(), hostfence.FenceKey(enrollment("src"))); err != nil { |
| 877 | t.Fatal(err) |
| 878 | } |
| 879 | before := f.claim().ETag |
| 880 | for range 3 { |
| 881 | f.now = f.now.Add(time.Minute) |
| 882 | if _, err := f.w.Step(context.Background(), host); err == nil || errors.Is(err, ErrNotYet) { |
| 883 | t.Fatalf("a missing bound fence read as no fence: %v", err) |
| 884 | } |
| 885 | } |
| 886 | if o := f.claim(); o.ETag != before || o.Claim.Op.Intent["target_box"] != "" || len(f.leases) != 0 { |
| 887 | t.Fatalf("the worker acted on a missing fence: %+v leases %v", o.Claim.Op, f.leases) |
| 888 | } |
| 889 | } |
| 890 | |
| 891 | // committedGuard is moves' record for the landing: committed, from uk |
| 892 | // (the move's source region) to ie, this worker's region. |
| 893 | type committedGuard struct{ op string } |
| 894 | |
| 895 | func (g committedGuard) CheckLandingCommitted(_ context.Context, o hostclaim.Observed, id string) (hostclaim.CommittedLanding, error) { |
| 896 | if id != g.op || o.Claim.Op.ID != g.op { |
| 897 | return hostclaim.CommittedLanding{}, hostclaim.ErrWrongState |
| 898 | } |
| 899 | h := o.Claim.Holder |
| 900 | at := time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC) |
| 901 | return hostclaim.CommittedLanding{FromRegion: "uk", ToRegion: "ie", |
| 902 | SourceScope: hostclaim.ParkScope{Location: h.Location, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}, |
| 903 | StagingScope: hostclaim.ParkScope{Location: "moves/" + host + "/" + g.op, Incarnation: h.Incarnation, EvidenceAt: at, RequestLimit: 120 * time.Second}}, nil |
| 904 | } |
| 905 | |
| 906 | // convertedLanding drives the host through an admitted move from src (in |
| 907 | // uk, for the move) to dst through the claim's own transitions to a probed |
| 908 | // landing, terminates dst, and converts it with ConvertCommittedLanding: |
| 909 | // the claim the worker then finds is the conversion's real output. |
| 910 | func (f *fixture) convertedLanding() hostclaim.Observed { |
| 911 | f.t.Helper() |
| 912 | ctx := context.Background() |
| 913 | const id = "mv_abcdefghijklmnopqrstuvwxyz" |
| 914 | o := f.claim() |
| 915 | o = f.step(o, hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", "ie", f.now, hostclaim.MoveAdmission{AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment("src"), TargetEnrollment: enrollment("dst")})) |
| 916 | o = f.step(o, hostclaim.ExecToNone(id, "src", "moves/"+host+"/"+id+"/stopped-source.json", hostclaim.JournalEvidenceSource)) |
| 917 | o = f.step(o, hostclaim.SetJournal(id, map[string]string{hostclaim.JournalSeal: `{"n":7}`, hostclaim.JournalCutSession: "1"})) |
| 918 | o = f.step(o, hostclaim.SetMovePhase(id, hostclaim.KindMoving, hostclaim.PhaseMoving, hostclaim.PhaseSealed)) |
| 919 | o = f.step(o, hostclaim.Flip(id, "dst", o.Claim.Holder.Stamp, o.Claim.Holder.Generation)) |
| 920 | for _, p := range [][2]string{{"checking", "parking"}, {"parking", "building"}, {"building", "published"}, {"published", "local"}, {"local", "configured"}, {"configured", "restored"}, {"restored", "probing"}, {"probing", "probed"}} { |
| 921 | o = f.step(o, hostclaim.SetMovePhase(id, hostclaim.KindLanding, p[0], p[1])) |
| 922 | } |
| 923 | // The probe's cold admission on dst (claim rev 6.9): its generation is |
| 924 | // holder.generation and dst the writer. |
| 925 | o = f.step(o, func(c *hostclaim.Claim) error { |
| 926 | h := c.Holder |
| 927 | h.Session++ |
| 928 | h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Region: "ie", PredecessorRegion: "uk", Session: h.Session, Box: "dst", Generation: "gen-landed", Predecessor: h.Generation, Mode: "cold", Coverage: "sealed"}) |
| 929 | h.Generation, h.WriterBox = "gen-landed", "dst" |
| 930 | return nil |
| 931 | }) |
| 932 | f.terminate("dst") |
| 933 | enr := enrollment("dst") |
| 934 | raw, _, err := f.s.Get(ctx, hostfence.FenceKey(enr)) |
| 935 | if err != nil { |
| 936 | f.t.Fatal(err) |
| 937 | } |
| 938 | sum := sha256.Sum256(raw) |
| 939 | ref := hostclaim.FenceRef{Enrollment: enr, Key: hostfence.FenceKey(enr), SHA256: hex.EncodeToString(sum[:])} |
| 940 | n := 0 |
| 941 | out, err := hostclaim.ConvertCommittedLanding(ctx, f.s, o, id, "dst", ref, f.w.Fences, committedGuard{op: id}, nil, func() string { n++; return fmt.Sprintf("mv-chore-%d", n) }) |
| 942 | if err != nil { |
| 943 | f.t.Fatal(err) |
| 944 | } |
| 945 | return out |
| 946 | } |
| 947 | |
| 948 | // J3: a committed landing converted to a failover (ConvertCommittedLanding) |
| 949 | // starts with no target. The worker names one in starting; it acknowledges |
| 950 | // before PinFailover, and the pin is selected after that, as an initial |
| 951 | // failover's is; installing then copies the pin's release (R9). The move's |
| 952 | // cleanup obligations ride through untouched. |
| 953 | func TestWorkerNamesAConvertedLandingsTargetBeforeItsPin(t *testing.T) { |
| 954 | f := newFixture(t) |
| 955 | converted := f.convertedLanding() |
| 956 | c := converted.Claim |
| 957 | if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Exec != "none" || c.Op.Intent["source_box"] != "dst" || |
| 958 | c.Op.Intent["move_source_box"] != "src" || c.Op.Journal["conversion"] != "committed-landing" || len(c.Op.Participants) != 0 || len(c.Obligations) == 0 { |
| 959 | t.Fatalf("not the conversion's output: %+v obligations %d", c.Op, len(c.Obligations)) |
| 960 | } |
| 961 | chores, _ := json.Marshal(c.Obligations) |
| 962 | o := f.worker(nil) |
| 963 | if o.Claim.Op.Phase != hostclaim.PhaseStarting || o.Claim.Op.Intent["target_box"] != "spa" || !slices.Equal(o.Claim.Op.Participants, []string{"spa"}) || o.Claim.Op.Journal["pin"] != "" { |
| 964 | t.Fatalf("named in starting: %+v", o.Claim.Op) |
| 965 | } |
| 966 | f.worker(ErrNotYet) // the target has not acknowledged: no pin yet |
| 967 | f.ack("spa") |
| 968 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhasePinning { |
| 969 | t.Fatalf("phase %s", o.Claim.Op.Phase) |
| 970 | } |
| 971 | if o = f.worker(nil); o.Claim.Op.Journal["pin"] == "" { |
| 972 | t.Fatal("not pinned") |
| 973 | } |
| 974 | if o = f.worker(nil); o.Claim.Op.Phase != hostclaim.PhaseInstalling || o.Claim.Op.Exec != "spa" || o.Claim.Op.Journal["install_release"] != "v1.4.2" { |
| 975 | t.Fatalf("installing: %+v", o.Claim.Op) |
| 976 | } |
| 977 | if after, _ := json.Marshal(o.Claim.Obligations); string(after) != string(chores) { |
| 978 | t.Fatalf("the failover changed an obligation:\n%s\n%s", after, chores) |
| 979 | } |
| 980 | } |
| 981 | |
| 982 | // Main's home-region parks: a worker in the home holds the box's verified |
| 983 | // home binding. It starts a relocation under it (the op's "home_region", |
| 984 | // which lets the claim see the home's chores under park.HomeRegion too), |
| 985 | // and only when the signed roster designates that home and the worker's |
| 986 | // region is it. |
| 987 | func TestWorkerStartsUnderItsHomeBinding(t *testing.T) { |
| 988 | ctx := context.Background() |
| 989 | t.Run("designated", func(t *testing.T) { |
| 990 | f := newFixture(t) |
| 991 | f.roster.HomeRegion = "ie" |
| 992 | f.writeRoster() |
| 993 | f.w.HomeRegion = "ie" |
| 994 | o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}) |
| 995 | if err != nil { |
| 996 | t.Fatal(err) |
| 997 | } |
| 998 | if o.Claim.Op.Intent["home_region"] != "ie" || o.Claim.Op.Intent["region"] != "ie" { |
| 999 | t.Fatalf("intent %v", o.Claim.Op.Intent) |
| 1000 | } |
| 1001 | }) |
| 1002 | t.Run("not designated", func(t *testing.T) { |
| 1003 | f := newFixture(t) |
| 1004 | f.w.HomeRegion = "ie" |
| 1005 | before := f.claim() |
| 1006 | if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrNotWorker) { |
| 1007 | t.Fatalf("got %v", err) |
| 1008 | } |
| 1009 | if after := f.claim(); after.ETag != before.ETag { |
| 1010 | t.Fatal("a refused start wrote the claim") |
| 1011 | } |
| 1012 | }) |
| 1013 | t.Run("an op bound to another home is not stepped", func(t *testing.T) { |
| 1014 | f := newFixture(t) |
| 1015 | o, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}) |
| 1016 | if err != nil { |
| 1017 | t.Fatal(err) |
| 1018 | } |
| 1019 | f.ack("src") |
| 1020 | f.ack("dst") |
| 1021 | o, err = hostclaim.Update(ctx, f.s, f.claim(), func(c *hostclaim.Claim) error { |
| 1022 | c.Op.Intent["home_region"] = "uk" |
| 1023 | return nil |
| 1024 | }) |
| 1025 | if err != nil { |
| 1026 | t.Fatal(err) |
| 1027 | } |
| 1028 | if _, err := f.w.Step(ctx, host); !errors.Is(err, park.ErrUnsafe) { |
| 1029 | t.Fatalf("got %v", err) |
| 1030 | } |
| 1031 | if after := f.claim(); after.ETag != o.ETag { |
| 1032 | t.Fatal("the step wrote the claim") |
| 1033 | } |
| 1034 | }) |
| 1035 | } |
| 1036 | |