package host import ( "context" "crypto/ed25519" "crypto/rand" "encoding/json" "errors" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfence" "github.com/carlosframework/platform/internal/hostrelocate" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/termfence" "github.com/superfly/ltx" ) // landingWorld is a landing claim at probed whose target dst is // terminated (its fence in the roster's bucket), the authoritative record // of its move (committed or still landing) and a signed roster in which // wrk, the converting worker, is as change leaves it. type landingWorld struct { store release.Store directory hostactors.Directory obs hostclaim.Observed now time.Time enrollment map[string]string } func newLandingWorld(t *testing.T, committed bool, change func(*hostactors.Roster)) landingWorld { t.Helper() return newLandingWorldHome(t, committed, change, "") } // newLandingWorldHome is newLandingWorld with the landing into the home // when home is the fleet's home region (ie): the signed roster designates // it, the move is admitted under it (MoveAdmission.HomeRegion, its "to" // park.HomeRegion, as BeginClaimMove writes it) and the record's To is the // home's placement code "". func newLandingWorldHome(t *testing.T, committed bool, change func(*hostactors.Roster), home string) landingWorld { t.Helper() ctx := t.Context() const host, id = "shop.example", "mv_abcdefghijklmnopqrstuvwxyz" w := landingWorld{store: release.NewDirStore(t.TempDir()), now: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC), enrollment: map[string]string{"src": strings.Repeat("b", 32), "dst": strings.Repeat("c", 32), "spa": strings.Repeat("d", 32), "wrk": strings.Repeat("e", 32)}} pub, key, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123", AWSRegion: "eu-west-1"}, "uk": {Account: "123", AWSRegion: "eu-west-2"}}, Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} for box, enr := range w.enrollment { roster.Boxes[box] = hostactors.Box{Enrollment: enr, Namespace: "platform", BoxID: box, Label: box, Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + box}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} } roster.HomeRegion = home if change != nil { change(&roster) } raw, err := release.SignRootEnvelope(release.RootSigner(key), roster) if err != nil { t.Fatal(err) } if _, err := w.store.Put(ctx, hostactors.Key, raw, ""); err != nil { t.Fatal(err) } w.directory = hostactors.Directory{Store: w.store, PublicKey: pub, Bucket: "bucket"} w.obs = landingAtProbedHome(t, w.store, host, id, w.now, w.enrollment, home) dst := roster.Boxes["dst"] fence, _ := json.Marshal(termfence.Record{V: 1, Box: "dst", Enrollment: dst.Enrollment, Cloud: dst.Cloud, State: "terminated", ObservedAt: w.now.Add(-time.Minute).Format(time.RFC3339Nano), Via: termfence.ViaDescribe, RequestID: "req", WrittenBy: termfence.WrittenBy{Box: "spa", Enrollment: w.enrollment["spa"]}}) if _, err := w.store.Put(ctx, hostfence.FenceKey(dst.Enrollment), fence, ""); err != nil { t.Fatal(err) } created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) state, committedAt, flipped := provision.MoveLanding, (*time.Time)(nil), w.now.Add(-90*time.Minute) if committed { at := w.now.Add(-30 * time.Minute) state, committedAt = provision.MoveCommitted, &at } to := "ie" if home != "" { to = "" // the home's placement code } record := provision.Record{Schema: 1, Host: host, Slug: "shop", App: "jam", Account: "bab", Region: to, Channel: "stable", CreatedAt: created, Move: &provision.MoveState{ID: id, Incarnation: w.obs.Claim.Holder.Incarnation, From: "uk", To: to, State: state, CommittedAt: committedAt, FlippedAt: &flipped}} if err := provision.CreateRecord(ctx, w.store, record); err != nil { t.Fatal(err) } return w } // homeWorker is worker with the box's verified home binding. func (w landingWorld) homeWorker(enrollment, region, home string) hostrelocate.Worker { x := w.worker(enrollment, region) x.HomeRegion = home return x } // worker is the converting worker: wrk, running as enrollment, in region. func (w landingWorld) worker(enrollment, region string) hostrelocate.Worker { now := func() time.Time { return w.now } return hostrelocate.Worker{Claims: w.store, Directory: w.directory, Fences: hostfence.ClaimFenceAuthority{Directory: w.directory, Now: now}, Discharge: func(context.Context, hostactors.Box, termfence.Record) *hostclaim.LocalDischarge { return nil }, Self: termfence.WrittenBy{Box: "wrk", Enrollment: enrollment}, Region: region, NewID: newClaimID, Now: now} } // J3: the pass converts a landing whose target box is terminated into a // failover relocation when the move committed, through // CommittedLandingRecords over the authoritative record; a move that has // not committed is moves' case, and nothing is written. func TestRelocatePassConvertsAFencedCommittedLanding(t *testing.T) { for _, committed := range []bool{true, false} { name := "uncommitted" if committed { name = "committed" } t.Run(name, func(t *testing.T) { ctx := t.Context() w := newLandingWorld(t, committed, nil) if err := convertFencedLanding(w.worker(w.enrollment["wrk"], "ie"), w.store)(ctx, w.store, w.obs); err != nil { t.Fatalf("convert: %v", err) } after, err := hostclaim.Read(ctx, w.store, w.obs.Host) if err != nil { t.Fatal(err) } if !committed { if after.ETag != w.obs.ETag || after.Claim.Op.Kind != hostclaim.KindLanding { t.Fatalf("an uncommitted landing was written: %+v", after.Claim.Op) } return } c := after.Claim if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Intent["source_box"] != "dst" || c.Op.Journal["conversion"] != "committed-landing" { t.Fatalf("not converted: %+v", c.Op) } }) } } // Astra round 6, finding 4: the conversion is the worker's, under Step's // own checks of the caller. A worker whose roster entry is another // enrollment (revoked and re-enrolled), one that is not active, one that // is fenced, or one enrolled outside the region it claims, is refused // with ErrNotWorker; a worker of another region leaves the landing alone. // None of them writes the claim, though the move committed and its target // is fenced. func TestRelocatePassConversionRefusesAnUnauthorizedWorker(t *testing.T) { t.Parallel() type tc struct { name string change func(*hostactors.Roster) enrollment, region string want error } moved := func(state, region string) func(*hostactors.Roster) { return func(r *hostactors.Roster) { b := r.Boxes["wrk"] if state != "" { b.State = state } if region != "" { b.Region = region } r.Boxes["wrk"] = b } } for _, c := range []tc{ {name: "a revoked enrollment", enrollment: strings.Repeat("f", 32), region: "ie", want: hostrelocate.ErrNotWorker}, {name: "a retired worker", change: moved("retired", ""), region: "ie", want: hostrelocate.ErrNotWorker}, {name: "enrolled in another region", change: moved("", "uk"), region: "ie", want: hostrelocate.ErrNotWorker}, {name: "a worker of another region", change: moved("", "uk"), region: "uk"}, } { t.Run(c.name, func(t *testing.T) { ctx := t.Context() w := newLandingWorld(t, true, c.change) if c.enrollment == "" { c.enrollment = w.enrollment["wrk"] } err := convertFencedLanding(w.worker(c.enrollment, c.region), w.store)(ctx, w.store, w.obs) if c.want == nil && err != nil || c.want != nil && !errors.Is(err, c.want) { t.Fatalf("got %v, want %v", err, c.want) } after, err := hostclaim.Read(ctx, w.store, w.obs.Host) if err != nil { t.Fatal(err) } if after.ETag != w.obs.ETag || after.Claim.Op.Kind != hostclaim.KindLanding { t.Fatalf("an unauthorized worker wrote the claim: %+v", after.Claim.Op) } }) } t.Run("a fenced worker", func(t *testing.T) { ctx := t.Context() w := newLandingWorld(t, true, nil) o, err := w.directory.Read(ctx) if err != nil { t.Fatal(err) } wrk := o.Roster.Boxes["wrk"] fence, _ := json.Marshal(termfence.Record{V: 1, Box: "wrk", Enrollment: wrk.Enrollment, Cloud: wrk.Cloud, State: "terminated", ObservedAt: w.now.Add(-time.Minute).Format(time.RFC3339Nano), Via: termfence.ViaDescribe, RequestID: "req", WrittenBy: termfence.WrittenBy{Box: "spa", Enrollment: w.enrollment["spa"]}}) if _, err := w.store.Put(ctx, hostfence.FenceKey(wrk.Enrollment), fence, ""); err != nil { t.Fatal(err) } if err := convertFencedLanding(w.worker(w.enrollment["wrk"], "ie"), w.store)(ctx, w.store, w.obs); !errors.Is(err, hostrelocate.ErrNotWorker) { t.Fatalf("got %v", err) } if after, err := hostclaim.Read(ctx, w.store, w.obs.Host); err != nil || after.ETag != w.obs.ETag { t.Fatalf("a fenced worker wrote the claim: %v", err) } }) } // landingAtProbed is a landing claim as moves leaves it at probed: the // move from src to dst flipped, the target's probe admitted. Its seal is a // real one, so moves.CommitScopes reads it. func landingAtProbed(t *testing.T, store release.Store, host, id string, now time.Time, enrollment map[string]string) hostclaim.Observed { t.Helper() return landingAtProbedHome(t, store, host, id, now, enrollment, "") } // landingAtProbedHome is landingAtProbed into the home when home is set: // the move is admitted under the home binding with "to" park.HomeRegion. func landingAtProbedHome(t *testing.T, store release.Store, host, id string, now time.Time, enrollment map[string]string, home string) hostclaim.Observed { t.Helper() ctx := t.Context() created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) nh := hostclaim.NewHolder{Family: "instance", Key: provision.RecordKey("bab", "jam", host), Account: "bab", App: "jam", Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created} body := []byte(`{"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) o, err := hostclaim.Read(ctx, store, host) if err != nil { t.Fatal(err) } step := func(tr func(*hostclaim.Claim) error) { t.Helper() if o, err = hostclaim.Update(ctx, store, o, tr); err != nil { t.Fatal(err) } } seal := moves.Seal{V: moves.SealVersion, N: 7, FromDB: true, SrcCovered: true, SealedAt: now.Add(-2 * time.Hour), LeaseUntil: now.Add(-time.Hour), Replica: []moves.SealedObject{{Key: "moves/" + host + "/" + id + "/replica/0009/" + ltx.FormatFilename(1, 7), VersionID: "v1", Size: 100}}} step(hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment["src"]})) step(hostclaim.CompleteCreate("c1", "src")) step(hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true})) to := "ie" if home != "" { to = park.HomeRegion } step(hostclaim.BeginAdmittedMove(id, "ops", "src", "dst", to, now.Add(-3*time.Hour), hostclaim.MoveAdmission{HomeRegion: home, AppID: "app-canonical", AdmissionID: id, SourceEnrollment: enrollment["src"], TargetEnrollment: enrollment["dst"]})) step(hostclaim.ExecToNone(id, "src", "moves/"+host+"/"+id+"/stopped-source.json", hostclaim.JournalEvidenceSource)) step(hostclaim.SetJournal(id, map[string]string{hostclaim.JournalSeal: seal.Encode(), hostclaim.JournalCutSession: "1"})) step(hostclaim.SetMovePhase(id, hostclaim.KindMoving, hostclaim.PhaseMoving, hostclaim.PhaseSealed)) step(hostclaim.Flip(id, "dst", o.Claim.Holder.Stamp, o.Claim.Holder.Generation)) for _, p := range [][2]string{{"checking", "parking"}, {"parking", "building"}, {"building", "published"}, {"published", "local"}, {"local", "configured"}, {"configured", "restored"}, {"restored", "probing"}, {"probing", "probed"}} { step(hostclaim.SetMovePhase(id, hostclaim.KindLanding, p[0], p[1])) } step(func(c *hostclaim.Claim) error { h := c.Holder h.Session++ h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Region: "ie", PredecessorRegion: "ie", Session: h.Session, Box: "dst", Generation: "gen-landed", Predecessor: h.Generation, Mode: "cold", Coverage: "sealed"}) h.Generation, h.WriterBox = "gen-landed", "dst" return nil }) return o } // Main's home-region parks name a move into the home by park.HomeRegion // ("to") while the roster, the fence binding and the region worker name it // by its roster name (ie). Under the binding the box and the move agree on // (the signed roster's home), the home's worker converts the committed // landing: the relocation's region is the roster identity (as // ConvertMovingToRelocating's is), the move's home binding is kept, and the // commit's parks are queued in engine regions exactly as moves' commit // queues them (the source's location in uk, the staging lane in // park.HomeRegion). A box with no binding, or a binding the roster does not // designate, writes nothing. func TestRelocatePassConvertsACommittedLandingIntoTheHome(t *testing.T) { ctx := t.Context() w := newLandingWorldHome(t, true, nil, "ie") if err := convertFencedLanding(w.homeWorker(w.enrollment["wrk"], "ie", "ie"), w.store)(ctx, w.store, w.obs); err != nil { t.Fatalf("convert: %v", err) } after, err := hostclaim.Read(ctx, w.store, w.obs.Host) if err != nil { t.Fatal(err) } c := after.Claim if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseStarting || c.Op.Intent["source_box"] != "dst" || c.Op.Journal["conversion"] != "committed-landing" { t.Fatalf("not converted: %+v", c.Op) } if c.Op.Intent["region"] != "ie" || c.Op.Intent["home_region"] != "ie" || c.Op.Intent["move_to_region"] != park.HomeRegion || c.Op.Journal["move_from_region"] != "uk" { t.Fatalf("intent %v journal from %q", c.Op.Intent, c.Op.Journal["move_from_region"]) } parks := map[string]int{} for _, o := range c.Obligations { if o.Kind == "park-replica" { parks[o.Region+" "+o.Owner]++ } } if parks["uk region:uk"] != 1 || parks[park.HomeRegion+" region:"+park.HomeRegion] != 1 || len(parks) != 2 { t.Fatalf("the commit's parks: %v", parks) } for _, tc := range []struct { name string change func(*hostactors.Roster) home string want error }{ {name: "the box holds no home binding", want: park.ErrUnsafe}, {name: "the roster designates no home", change: func(r *hostactors.Roster) { r.HomeRegion = "" }, home: "ie", want: hostrelocate.ErrNotWorker}, } { t.Run(tc.name, func(t *testing.T) { w := newLandingWorldHome(t, true, tc.change, "ie") err := convertFencedLanding(w.homeWorker(w.enrollment["wrk"], "ie", tc.home), w.store)(ctx, w.store, w.obs) if !errors.Is(err, tc.want) { t.Fatalf("got %v, want %v", err, tc.want) } if after, err := hostclaim.Read(ctx, w.store, w.obs.Host); err != nil || after.ETag != w.obs.ETag { t.Fatalf("the claim was written: %v", err) } }) } } // CommittedLandingRecords answers the record's placement codes as engine // regions (park.PlacementRegion), the regions moves' commit queues its // parks in: the home's "" is park.HomeRegion, and a code that is no // placement refuses. func TestCommittedLandingRecordsAnswersEngineRegions(t *testing.T) { ctx := t.Context() for _, tc := range []struct { name, from, to string wantFrom, wantTo string refused bool }{ {name: "named", from: "uk", to: "ie", wantFrom: "uk", wantTo: "ie"}, {name: "into the home", from: "uk", to: "", wantFrom: "uk", wantTo: park.HomeRegion}, {name: "out of the home", from: "", to: "uk", wantFrom: park.HomeRegion, wantTo: "uk"}, {name: "not a placement code", from: "UK", to: "ie", refused: true}, } { t.Run(tc.name, func(t *testing.T) { w := newLandingWorld(t, true, nil) c := w.obs.Claim read := func(ctx context.Context, account, app, host string) (*provision.Record, error) { rec, err := provision.GetRecord(ctx, w.store, account, app, host) if err != nil { return nil, err } rec.Move.From, rec.Move.To = tc.from, tc.to return rec, nil } got, err := CommittedLandingRecords{Records: read}.CheckLandingCommitted(ctx, w.obs, c.Op.ID) if tc.refused { if !errors.Is(err, hostclaim.ErrWrongState) { t.Fatalf("got %+v, %v", got, err) } return } if err != nil || got.FromRegion != tc.wantFrom || got.ToRegion != tc.wantTo { t.Fatalf("got %+v, %v", got, err) } }) } }