package park import ( "context" "errors" "testing" "time" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/lease" ) func TestPlan10MovesSweepRequiresPriorRetirement(t *testing.T) { for _, tc := range []string{"missing", "zero", "equal", "later", "host", "etag", "move", "incarnation", "provider-error", "mutation", "valid"} { t.Run(tc, func(t *testing.T) { f := newFixture(t) obs, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) if err != nil { t.Fatal(err) } obs, err = hostclaim.Update(t.Context(), f.e.Claims, obs, func(c *hostclaim.Claim) error { c.Obligations = nil c.Op = hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"} return nil }) if err != nil { t.Fatal(err) } f.b.Put("data", "moves/"+f.w.Host+"/old/staging", []byte("old")) listing := f.e.Now() listedAt := listing.Add(time.Second) ticks := 0 f.e.Now = func() time.Time { ticks++ if ticks == 1 { return listing } return listedAt } f.e.View = func(_ context.Context, o hostclaim.Observed) (View, error) { return View{ClaimETag: o.ETag, Region: "ie", AuthoritativeRegion: "uk"}, nil } calls := 0 if tc != "missing" { f.e.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, versions []lease.ObjectVersion) (MoveSweepProof, error) { calls++ if o.ETag != obs.ETag || len(versions) != 1 { t.Fatal("proof not bound to claim and complete census") } p := MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "old", Incarnation: f.ob.Park.Incarnation, RetiredAt: listing.Add(-time.Second)} switch tc { case "zero": p.RetiredAt = time.Time{} case "equal": p.RetiredAt = listing case "later": p.RetiredAt = listing.Add(time.Second) case "host": p.Host = "other.example" case "etag": p.ClaimETag = "other" case "move": p.MoveID = "" case "incarnation": p.Incarnation = "wrong" case "mutation": o.Claim.Holder.Account = "foreign" o.Claim.Lost = []string{"unrelated"} case "provider-error": return p, ErrPending } return p, nil } } _, err = f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit) fresh, readErr := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) if readErr != nil { t.Fatal(readErr) } if tc != "valid" && tc != "mutation" { if fresh.ETag != obs.ETag { t.Fatalf("unsafe proof queued: err=%v claim=%+v", err, fresh.Claim) } return } if !errors.Is(err, ErrPending) || calls != 1 || len(fresh.Claim.Obligations) != 1 { t.Fatalf("valid proof: calls=%d err=%v claim=%+v", calls, err, fresh.Claim) } ob := fresh.Claim.Obligations[0] if ob.Park.Location != "moves/"+f.w.Host || !ob.Park.EvidenceAt.Equal(listedAt) { t.Fatalf("scope does not bind listing time: %+v", ob) } if fresh.Claim.Holder.Account != obs.Claim.Holder.Account || len(fresh.Claim.Lost) != len(obs.Claim.Lost) { t.Fatal("proof provider mutated CAS input") } // A retry keeps the queued bound; it does not restart the horizon. f.e.Now = func() time.Time { return listing.Add(20 * time.Minute) } if err := f.e.Run(t.Context(), Work{Host: f.w.Host, ID: ob.ID, Op: ob.Op}); err != nil { t.Fatal(err) } }) } } func TestPlan10MovesSweepNoMoveOperation(t *testing.T) { for _, kind := range []string{hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring, hostclaim.KindRelocating} { t.Run(kind, func(t *testing.T) { p := MoveSweepProof{Host: "app.example", ClaimETag: "tag", MoveID: "old", Incarnation: "inc", RetiredAt: time.Unix(1, 0)} o := hostclaim.Observed{Host: p.Host, ETag: p.ClaimETag, Claim: &hostclaim.Claim{Op: hostclaim.Op{Kind: kind, ID: "active"}}} if err := p.Check(o, p.Incarnation, time.Unix(2, 0)); !errors.Is(err, ErrUnsafe) { t.Fatalf("%s accepted: %v", kind, err) } }) } } // Existing retirement tests advance the fake clock after listing so they can // exercise completion. The precondition tests above keep the horizon pending. func allowFixtureMoveSweep(f *fixture) { listing := f.e.Now() calls := 0 f.e.Now = func() time.Time { calls++ if calls <= 2 { return listing } return listing.Add(20 * time.Minute) } f.e.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, _ []lease.ObjectVersion) (MoveSweepProof, error) { return MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "retired", Incarnation: f.ob.Park.Incarnation, RetiredAt: listing.Add(-time.Second)}, nil } } func TestPlan10MovesSweepPreservesInstanceEvidence(t *testing.T) { f := newFixture(t) f.seed() o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) if err != nil { t.Fatal(err) } _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Obligations = nil; return hostclaim.FreeAfterPark("op")(c) }) if err != nil { t.Fatal(err) } f.b.Put("data", "moves/"+f.w.Host+"/retired/staging", []byte("staged")) listedAt := f.e.Now() f.e.VerifyMovesSweep = func(_ context.Context, o hostclaim.Observed, _ []lease.ObjectVersion) (MoveSweepProof, error) { return MoveSweepProof{Host: o.Host, ClaimETag: o.ETag, MoveID: "retired", Incarnation: f.ob.Park.Incarnation, RetiredAt: listedAt.Add(-time.Second)}, nil } scopes := map[string]time.Time{} f.e.Authorize = func(_ context.Context, _ hostclaim.Observed, ob hostclaim.Obligation) error { scopes[ob.Park.Location] = ob.Park.EvidenceAt return nil } _, err = f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.ob.Park.EvidenceAt, f.ob.Park.RequestLimit) if !errors.Is(err, ErrPending) { t.Fatal(err) } if !scopes[f.ob.Park.Location].Equal(f.ob.Park.EvidenceAt) || !scopes["moves/"+f.w.Host].Equal(listedAt) { t.Fatalf("evidence mixed: %+v", scopes) } } func TestPlan10MovesSweepUnavailableProofKeepsOtherCandidates(t *testing.T) { for _, unavailable := range []string{"nil", "error", "invalid"} { t.Run(unavailable, func(t *testing.T) { f := newFixture(t) f.seed() o, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) if err != nil { t.Fatal(err) } _, err = hostclaim.Update(t.Context(), f.e.Claims, o, func(c *hostclaim.Claim) error { c.Obligations = nil; return hostclaim.FreeAfterPark("op")(c) }) if err != nil { t.Fatal(err) } f.b.Put("data", "moves/"+f.w.Host+"/retired/staging", []byte("staged")) if unavailable != "nil" { f.e.VerifyMovesSweep = func(context.Context, hostclaim.Observed, []lease.ObjectVersion) (MoveSweepProof, error) { if unavailable == "error" { return MoveSweepProof{}, ErrPending } return MoveSweepProof{}, nil } } d, err := f.e.Sweep(t.Context(), f.w.Host, f.ob.Park.Incarnation, f.e.Now(), f.ob.Park.RequestLimit) if !errors.Is(err, ErrPending) { t.Fatalf("unrelated candidates not queued: %v", err) } if len(d.Candidates) == 0 { t.Fatal("all candidates dropped") } fresh, err := hostclaim.Read(t.Context(), f.e.Claims, f.w.Host) if err != nil { t.Fatal(err) } if len(fresh.Claim.Obligations) == 0 { t.Fatal("nothing queued") } for _, ob := range fresh.Claim.Obligations { if ob.Park.Location == "moves/"+f.w.Host { t.Fatal("moves residue queued without proof") } } }) } }