package hostclaim import ( "context" "encoding/json" "github.com/carlosframework/platform/internal/hostops/fencerelease" "github.com/carlosframework/platform/internal/regionname" "github.com/carlosframework/platform/internal/release" "strings" "time" ) const journalFencedTargetReleased = "fenced_target_released" // VerifyMoveTargetFence supplies a target-bound capability without writing the claim. func VerifyMoveTargetFence(ctx context.Context, s release.Store, o Observed, ref FenceRef, authority FenceAuthority) (ReturnFence, error) { if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" { return ReturnFence{}, ErrStaleOp } c := o.Claim if (c.Op.Kind != KindLanding && c.Op.Kind != KindRestoring) || c.Op.Phase == PhaseCommitted || c.Op.ID == "" || c.Op.Intent["target"] == c.Op.Intent["source"] || c.Op.Intent["target_enrollment"] != ref.Enrollment { return ReturnFence{}, ErrWrongState } f, err := VerifyFence(ctx, s, authority, ref) if err != nil { return ReturnFence{}, err } if f.Box() != c.Op.Intent["target"] || f.Region() != c.Op.Intent["to"] { return ReturnFence{}, ErrFence } raw, err := json.Marshal(ref) if err != nil { return ReturnFence{}, err } at, err := time.Parse(time.RFC3339Nano, f.record.ObservedAt) if err != nil || at.IsZero() { return ReturnFence{}, ErrFence } return ReturnFence{op: c.Op.ID, box: f.Box(), ref: string(raw), observedAt: f.record.ObservedAt}, nil } // FencedTargetRelease has the same scopes as loss release, with verified fence authority. type FencedTargetRelease struct { Token fencerelease.Token OpID, RollbackID, Target, TargetRegion, SourceRegion string Location, Lane ParkScope ReturnLane *ParkScope NewID func() string Fence ReturnFence } // ReleaseFencedTarget declares and consumes the fence in one CAS, including all parks. func ReleaseFencedTarget(r FencedTargetRelease) func(*Claim) error { return func(c *Claim) error { if c == nil || c.Holder == nil || !r.Token.Consume(c.Host, c.Holder.Incarnation, c.Gen, r.OpID, r.RollbackID, r.Target) { return ErrFence } return releaseFencedTarget(r)(c) } } func releaseFencedTarget(r FencedTargetRelease) func(*Claim) error { return func(c *Claim) error { if err := RequireOp(r.OpID)(c); err != nil { return err } if c.Holder == nil || !rollbackIDShape.MatchString(r.RollbackID) || r.Target == "" || r.NewID == nil || r.SourceRegion == "" || r.SourceRegion == r.TargetRegion { return ErrMalformed } f := r.Fence if f.op != r.OpID || f.box != r.Target || f.ref == "" || c.Op.Intent["target"] != r.Target || c.Op.Intent["source"] == r.Target || c.Op.Intent["to"] != r.TargetRegion { return ErrFence } var ref FenceRef if json.Unmarshal([]byte(f.ref), &ref) != nil || c.Op.Intent["target_enrollment"] != ref.Enrollment { return ErrFence } if old := c.Op.Journal["fence."+r.Target]; old != "" && old != f.ref { return ErrFence } if c.Op.Kind == KindRestoring && c.Op.Intent["rollback"] == r.RollbackID && c.Op.Journal[journalFencedTargetReleased] == f.ref { return nil } if c.Op.Exec != r.Target && c.Op.Exec != "" && c.Op.Exec != "none" { return ErrWrongState } at, err := time.Parse(time.RFC3339Nano, f.observedAt) if err != nil || at.IsZero() || !r.Location.EvidenceAt.Equal(at) || !r.Lane.EvidenceAt.Equal(at) || (r.ReturnLane != nil && !r.ReturnLane.EvidenceAt.Equal(at)) { return ErrFence } loss := AutomaticLoss{ID: automaticLossID(c.Op.ID, r.Target, ref), Box: r.Target, Enrollment: ref.Enrollment, Fence: ref, ObservedAt: f.observedAt, Op: c.Op.ID, Kind: c.Op.Kind, Phase: c.Op.Phase, Role: "target", Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp, Timers: TimersUnknown} if c.Holder.WriterBox == r.Target { loss.Session, loss.Generation = c.Holder.Session, c.Holder.Generation } for i := len(c.Holder.Admissions) - 1; i >= 0; i-- { a := c.Holder.Admissions[i] if a.Box == r.Target { loss.Session, loss.Generation, loss.LastAdmission = a.Session, a.Generation, &a break } } owned := map[ObligationRef]bool{} for _, ob := range c.Obligations { if ob.Op == r.OpID && (ob.Kind == "park-generation" || ob.Kind == "park-replica") && ob.Owner == "box:"+r.Target { owned[ObligationRef{ID: ob.ID, Op: ob.Op, Retirement: ob.Retirement}] = true } } return inOneWrite(c, func(c *Claim) error { setJournal(c, "fence."+r.Target, f.ref) c.Op.Exec = "none" c.Op.Participants = removeString(c.Op.Participants, r.Target) return releaseTargetParks(c, LostTargetRelease{OpID: r.OpID, RollbackID: r.RollbackID, Target: r.Target, TargetRegion: r.TargetRegion, SourceRegion: r.SourceRegion, Location: r.Location, Lane: r.Lane, ReturnLane: r.ReturnLane, NewID: r.NewID}) }, func(c *Claim) error { for i := range c.Obligations { o := &c.Obligations[i] if owned[ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}] { o.Owner = "region:" + o.Region loss.Reassigned = append(loss.Reassigned, ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}) } } if !stringPresent(c.Lost, r.Target) { c.Lost = append(c.Lost, r.Target) } if !lossRefPresent(c, loss.ID) { c.LossRefs = append(c.LossRefs, loss.ID) c.EvidenceRefs = append(c.EvidenceRefs, loss) } setJournal(c, journalFencedTargetReleased, f.ref) return nil }) } } // FencedTargetReleased is the durable marker, checked without creating another CAS. func FencedTargetReleased(c *Claim, fence ReturnFence) bool { ref, ok := ConsumedMoveTargetFence(c) var expected FenceRef return ok && c.Op.ID == fence.op && c.Op.Intent["target"] == fence.box && json.Unmarshal([]byte(fence.ref), &expected) == nil && ref == expected } // ConsumedMoveTargetFence reads the claim's completed revocation, not live roster authority. func ConsumedMoveTargetFence(c *Claim) (FenceRef, bool) { if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring { return FenceRef{}, false } target := c.Op.Intent["target"] raw := c.Op.Journal["fence."+target] var ref FenceRef if raw == "" || raw != c.Op.Journal[journalFencedTargetReleased] || json.Unmarshal([]byte(raw), &ref) != nil || !validFenceRef(ref) || ref.Enrollment != c.Op.Intent["target_enrollment"] || c.Op.Exec == target || stringPresent(c.Op.Participants, target) || !stringPresent(c.Lost, target) { return FenceRef{}, false } for _, l := range c.EvidenceRefs { if l.ID == automaticLossID(c.Op.ID, target, ref) && l.Op == c.Op.ID && l.Box == target && l.Fence == ref && l.Incarnation == c.Holder.Incarnation && lossRefPresent(c, l.ID) { return ref, true } } return FenceRef{}, false } // LostMoveTargetParked requires revocation and completion of every target chore. func LostMoveTargetParked(c *Claim, region string) bool { if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring || region == "" || !regionname.SameRegion(c.Op.Intent["home_region"], region, c.Op.Intent["to"]) { return false } target := c.Op.Intent["target"] if target == "" || !stringPresent(c.Lost, target) || c.Op.Exec == target || stringPresent(c.Op.Participants, target) { return false } for _, o := range c.Obligations { if o.Op == c.Op.ID && (o.Box == target || o.Owner == "box:"+target || regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) || (strings.HasPrefix(o.Owner, "region:") && regionname.SameRegion(c.Op.Intent["home_region"], strings.TrimPrefix(o.Owner, "region:"), region))) { return false } } return true } func (f ReturnFence) ObservedAt() time.Time { at, _ := time.Parse(time.RFC3339Nano, f.observedAt) return at }