package host import ( "context" "errors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "os/exec" "testing" ) func TestPlan11Fix1RelocationAdmissionHooks(t *testing.T) { for _, writer := range []string{"source", "", "target"} { t.Run(writer, func(t *testing.T) { calls := []string{} w := &ClaimWakeAssembly{Stop: func(context.Context, hostclaim.Observed) error { calls = append(calls, "local-stop"); return nil }} local := func(context.Context, hostclaim.Observed, hostclaim.Observed) error { calls = append(calls, "local-after") return nil } reloc := ClaimRelocationOwners{BeforeAdmission: func(context.Context, hostclaim.Observed) error { calls = append(calls, "relocation-before") return nil }, AfterAdmission: func(context.Context, hostclaim.Observed, hostclaim.Observed) error { calls = append(calls, "relocation-after") return nil }} wireClaimAdmission(w, "target", local, claimCrossBoxHandover{}, &reloc, nil) o := plan11Relocating("probing") o.Claim.Op.Exec = "target" o.Claim.Holder.WriterBox = writer if err := w.stopPredecessor(t.Context(), o); err != nil { t.Fatal(err) } if err := w.AfterAdmission(t.Context(), o, o); err != nil { t.Fatal(err) } want := []string{"relocation-before", "relocation-after"} if writer == "target" { want = []string{"local-stop", "local-after"} } if len(calls) != 2 || calls[0] != want[0] || calls[1] != want[1] { t.Fatalf("calls=%v want=%v", calls, want) } }) } } func TestPlan11Fix1CollectRelocationStops(t *testing.T) { for _, phase := range []string{"releasing", "probing", "serving"} { t.Run(phase, func(t *testing.T) { f := newRecoveryFixture(t) c := f.controller("boot-1", "controller-1", 4101, "start-1") c.admit(t, f) o, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(cl *hostclaim.Claim) error { cl.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: "reloc", Phase: phase, Exec: "box-1", Participants: []string{"box-0", "box-1"}, Intent: map[string]string{"region": "ie", "source_box": "box-1", "target_box": "box-1"}} if phase == "releasing" { return hostclaim.QueueRelocationRelease("reloc", "box-1", "ie", "retirement")(cl) } return nil }) if err != nil { t.Fatal(err) } local, err := c.assembly.localStopIdentity(t.Context()) if err != nil { t.Fatal(err) } seen := false sentinel := errors.New("collected") c.assembly.collectStop = func(_ context.Context, req StopRequest, _ StopDrainer) (StopResult, error) { seen = true want := hostguard.WriterStopObligation(o.Claim.Holder.Session) if phase == "releasing" { want = "relocation-release" } if req.ObligationID != want { t.Errorf("obligation=%q want=%q", req.ObligationID, want) } return StopResult{}, sentinel } c.wake.Continue = hostclaim.SessionContinues _, err = c.assembly.collectCapturedStop(t.Context(), o, local, claimLaunchOwner{BootID: local.BootID, ControllerID: local.ControllerID}, phase != "releasing") if !seen || !errors.Is(err, sentinel) { t.Fatalf("relocation stop never collected: %v", err) } }) } } func TestPlan11Fix1RelocationComposedAdmission(t *testing.T) { f := newRecoveryFixture(t) if _, err := hostclaim.Update(t.Context(), f.claims, f.read(), func(c *hostclaim.Claim) error { c.Op = hostclaim.Op{ID: "reloc", Kind: hostclaim.KindRelocating, Phase: "probing", Exec: "box-1", Participants: []string{"source", "box-1"}, Intent: map[string]string{"source_box": "source", "target_box": "box-1", "region": "ie"}} return nil }); err != nil { t.Fatal(err) } c := f.controller("boot-1", "controller-1", 4101, "start-1") c.wake.Inputs = plan9D2Inputs{recoveryInputs{f}} c.wake.Continue = hostclaim.SessionContinues c.wake.Operations = &plan9D2Attempts{} c.wake.ProbeReady = func(context.Context, *exec.Cmd, registry.Route, ClaimLaunchInputs, string) error { return nil } before, after := 0, 0 refusal := errors.New("release proof refused") owners := ClaimRelocationOwners{ BeforeAdmission: func(context.Context, hostclaim.Observed) error { before++; return refusal }, AfterAdmission: func(_ context.Context, prior, admitted hostclaim.Observed) error { after++ if admitted.Claim.Holder.WriterBox != "box-1" || admitted.Claim.Holder.Session <= prior.Claim.Holder.Session { t.Fatal("handover did not receive admitted writer") } return nil }, } wireClaimAdmission(c.wake, "box-1", func(context.Context, hostclaim.Observed, hostclaim.Observed) error { t.Fatal("remote predecessor reached local handover") return nil }, claimCrossBoxHandover{}, &owners, nil) prior := f.read() if err := c.wake.stopPredecessor(t.Context(), prior); !errors.Is(err, refusal) { t.Fatalf("release refusal lost: %v", err) } refusal = nil if err := c.wake.stopPredecessor(t.Context(), prior); err != nil { t.Fatal(err) } live, cancel := context.WithCancel(t.Context()) defer cancel() done := make(chan struct{}) go func() { <-live.Done(); close(done) }() c.wake.pending[recoveryHost] = &claimWakeAttempt{observed: prior, held: recoveryHeld{}, ctx: live, cancel: cancel, done: done} policy := recoveryPolicy policy.Probe = true admitted, err := c.runtime.Admit(t.Context(), registry.Route{Host: recoveryHost}, prior, policy) if err != nil { t.Fatal(err) } if before != 2 || after != 1 || len(f.requests) != 0 { t.Fatalf("before=%d after=%d local stops=%d", before, after, len(f.requests)) } if admitted.Claim.Op.Kind != hostclaim.KindRelocating { t.Fatal("lost relocation") } } func TestPlan11Fix1RelocationRequiresHandoverOwners(t *testing.T) { for _, missing := range []string{"before", "after"} { var calls []string owners := plan11RelocationOwners(&calls) if missing == "before" { owners.BeforeAdmission = nil } else { owners.AfterAdmission = nil } if _, err := claimRelocationC1(plan11Env("1", "1"), true, owners); err == nil { t.Fatalf("accepted missing %s hook", missing) } } }