| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "github.com/carlosframework/platform/internal/hostops/fencerelease" |
| 7 | "github.com/carlosframework/platform/internal/regionname" |
| 8 | "github.com/carlosframework/platform/internal/release" |
| 9 | "strings" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | const journalFencedTargetReleased = "fenced_target_released" |
| 14 | |
| 15 | // VerifyMoveTargetFence supplies a target-bound capability without writing the claim. |
| 16 | func VerifyMoveTargetFence(ctx context.Context, s release.Store, o Observed, ref FenceRef, authority FenceAuthority) (ReturnFence, error) { |
| 17 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" { |
| 18 | return ReturnFence{}, ErrStaleOp |
| 19 | } |
| 20 | c := o.Claim |
| 21 | 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 { |
| 22 | return ReturnFence{}, ErrWrongState |
| 23 | } |
| 24 | f, err := VerifyFence(ctx, s, authority, ref) |
| 25 | if err != nil { |
| 26 | return ReturnFence{}, err |
| 27 | } |
| 28 | if f.Box() != c.Op.Intent["target"] || f.Region() != c.Op.Intent["to"] { |
| 29 | return ReturnFence{}, ErrFence |
| 30 | } |
| 31 | raw, err := json.Marshal(ref) |
| 32 | if err != nil { |
| 33 | return ReturnFence{}, err |
| 34 | } |
| 35 | at, err := time.Parse(time.RFC3339Nano, f.record.ObservedAt) |
| 36 | if err != nil || at.IsZero() { |
| 37 | return ReturnFence{}, ErrFence |
| 38 | } |
| 39 | return ReturnFence{op: c.Op.ID, box: f.Box(), ref: string(raw), observedAt: f.record.ObservedAt}, nil |
| 40 | } |
| 41 | |
| 42 | // FencedTargetRelease has the same scopes as loss release, with verified fence authority. |
| 43 | type FencedTargetRelease struct { |
| 44 | Token fencerelease.Token |
| 45 | OpID, RollbackID, Target, TargetRegion, SourceRegion string |
| 46 | Location, Lane ParkScope |
| 47 | ReturnLane *ParkScope |
| 48 | NewID func() string |
| 49 | Fence ReturnFence |
| 50 | } |
| 51 | |
| 52 | // ReleaseFencedTarget declares and consumes the fence in one CAS, including all parks. |
| 53 | func ReleaseFencedTarget(r FencedTargetRelease) func(*Claim) error { |
| 54 | return func(c *Claim) error { |
| 55 | if c == nil || c.Holder == nil || !r.Token.Consume(c.Host, c.Holder.Incarnation, c.Gen, r.OpID, r.RollbackID, r.Target) { |
| 56 | return ErrFence |
| 57 | } |
| 58 | return releaseFencedTarget(r)(c) |
| 59 | } |
| 60 | } |
| 61 | func releaseFencedTarget(r FencedTargetRelease) func(*Claim) error { |
| 62 | return func(c *Claim) error { |
| 63 | if err := RequireOp(r.OpID)(c); err != nil { |
| 64 | return err |
| 65 | } |
| 66 | if c.Holder == nil || !rollbackIDShape.MatchString(r.RollbackID) || r.Target == "" || r.NewID == nil || r.SourceRegion == "" || r.SourceRegion == r.TargetRegion { |
| 67 | return ErrMalformed |
| 68 | } |
| 69 | f := r.Fence |
| 70 | 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 { |
| 71 | return ErrFence |
| 72 | } |
| 73 | var ref FenceRef |
| 74 | if json.Unmarshal([]byte(f.ref), &ref) != nil || c.Op.Intent["target_enrollment"] != ref.Enrollment { |
| 75 | return ErrFence |
| 76 | } |
| 77 | if old := c.Op.Journal["fence."+r.Target]; old != "" && old != f.ref { |
| 78 | return ErrFence |
| 79 | } |
| 80 | if c.Op.Kind == KindRestoring && c.Op.Intent["rollback"] == r.RollbackID && c.Op.Journal[journalFencedTargetReleased] == f.ref { |
| 81 | return nil |
| 82 | } |
| 83 | if c.Op.Exec != r.Target && c.Op.Exec != "" && c.Op.Exec != "none" { |
| 84 | return ErrWrongState |
| 85 | } |
| 86 | at, err := time.Parse(time.RFC3339Nano, f.observedAt) |
| 87 | if err != nil || at.IsZero() || !r.Location.EvidenceAt.Equal(at) || !r.Lane.EvidenceAt.Equal(at) || (r.ReturnLane != nil && !r.ReturnLane.EvidenceAt.Equal(at)) { |
| 88 | return ErrFence |
| 89 | } |
| 90 | 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} |
| 91 | if c.Holder.WriterBox == r.Target { |
| 92 | loss.Session, loss.Generation = c.Holder.Session, c.Holder.Generation |
| 93 | } |
| 94 | for i := len(c.Holder.Admissions) - 1; i >= 0; i-- { |
| 95 | a := c.Holder.Admissions[i] |
| 96 | if a.Box == r.Target { |
| 97 | loss.Session, loss.Generation, loss.LastAdmission = a.Session, a.Generation, &a |
| 98 | break |
| 99 | } |
| 100 | } |
| 101 | owned := map[ObligationRef]bool{} |
| 102 | for _, ob := range c.Obligations { |
| 103 | if ob.Op == r.OpID && (ob.Kind == "park-generation" || ob.Kind == "park-replica") && ob.Owner == "box:"+r.Target { |
| 104 | owned[ObligationRef{ID: ob.ID, Op: ob.Op, Retirement: ob.Retirement}] = true |
| 105 | } |
| 106 | } |
| 107 | return inOneWrite(c, func(c *Claim) error { |
| 108 | setJournal(c, "fence."+r.Target, f.ref) |
| 109 | c.Op.Exec = "none" |
| 110 | c.Op.Participants = removeString(c.Op.Participants, r.Target) |
| 111 | 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}) |
| 112 | }, func(c *Claim) error { |
| 113 | for i := range c.Obligations { |
| 114 | o := &c.Obligations[i] |
| 115 | if owned[ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}] { |
| 116 | o.Owner = "region:" + o.Region |
| 117 | loss.Reassigned = append(loss.Reassigned, ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement}) |
| 118 | } |
| 119 | } |
| 120 | if !stringPresent(c.Lost, r.Target) { |
| 121 | c.Lost = append(c.Lost, r.Target) |
| 122 | } |
| 123 | if !lossRefPresent(c, loss.ID) { |
| 124 | c.LossRefs = append(c.LossRefs, loss.ID) |
| 125 | c.EvidenceRefs = append(c.EvidenceRefs, loss) |
| 126 | } |
| 127 | setJournal(c, journalFencedTargetReleased, f.ref) |
| 128 | return nil |
| 129 | }) |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // FencedTargetReleased is the durable marker, checked without creating another CAS. |
| 134 | func FencedTargetReleased(c *Claim, fence ReturnFence) bool { |
| 135 | ref, ok := ConsumedMoveTargetFence(c) |
| 136 | var expected FenceRef |
| 137 | return ok && c.Op.ID == fence.op && c.Op.Intent["target"] == fence.box && json.Unmarshal([]byte(fence.ref), &expected) == nil && ref == expected |
| 138 | } |
| 139 | |
| 140 | // ConsumedMoveTargetFence reads the claim's completed revocation, not live roster authority. |
| 141 | func ConsumedMoveTargetFence(c *Claim) (FenceRef, bool) { |
| 142 | if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring { |
| 143 | return FenceRef{}, false |
| 144 | } |
| 145 | target := c.Op.Intent["target"] |
| 146 | raw := c.Op.Journal["fence."+target] |
| 147 | var ref FenceRef |
| 148 | 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) { |
| 149 | return FenceRef{}, false |
| 150 | } |
| 151 | for _, l := range c.EvidenceRefs { |
| 152 | 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) { |
| 153 | return ref, true |
| 154 | } |
| 155 | } |
| 156 | return FenceRef{}, false |
| 157 | } |
| 158 | |
| 159 | // LostMoveTargetParked requires revocation and completion of every target chore. |
| 160 | func LostMoveTargetParked(c *Claim, region string) bool { |
| 161 | if c == nil || c.Holder == nil || c.Op.Kind != KindRestoring || region == "" || !regionname.SameRegion(c.Op.Intent["home_region"], region, c.Op.Intent["to"]) { |
| 162 | return false |
| 163 | } |
| 164 | target := c.Op.Intent["target"] |
| 165 | if target == "" || !stringPresent(c.Lost, target) || c.Op.Exec == target || stringPresent(c.Op.Participants, target) { |
| 166 | return false |
| 167 | } |
| 168 | for _, o := range c.Obligations { |
| 169 | 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))) { |
| 170 | return false |
| 171 | } |
| 172 | } |
| 173 | return true |
| 174 | } |
| 175 | |
| 176 | func (f ReturnFence) ObservedAt() time.Time { |
| 177 | at, _ := time.Parse(time.RFC3339Nano, f.observedAt) |
| 178 | return at |
| 179 | } |
| 180 | |