package hostops import ( "context" "crypto/rand" "encoding/json" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostops/internal/fencerelease" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // FencedTargetOutcome is what one ReleaseFencedTarget step found or wrote, // as values, never authority. VerifiedDirty is set only with ErrPending, and // only after the fence verified: the target released dirty, which the grant // leaves for J4. Every other ErrPending (a withdrawn registration, a spent // grant) leaves it unset. Transition is the step's record write, named as the // move log names it (moves.TransitionOf over the record before and after the // write), and ClaimRolledBack that the step's claim write rolled a landing // claim back: the caller logs the write from this evidence, never from a // later read (Transition's rule). type FencedTargetOutcome struct { VerifiedDirty bool Transition *moves.Transition ClaimRolledBack bool } // ReleaseFencedTarget advances one record-first recovery step per grant: // record decision, claim release with parks, then record release. Each pass // verifies unconsumed authority; completion uses the durable claim evidence. func (g *MoveRecoveryGrant) ReleaseFencedTarget(ctx context.Context, ref hostclaim.FenceRef) (FencedTargetOutcome, error) { var out FencedTargetOutcome err := g.releaseFencedTarget(ctx, ref, &out) return out, err } func (g *MoveRecoveryGrant) releaseFencedTarget(ctx context.Context, ref hostclaim.FenceRef, out *FencedTargetOutcome) error { if g == nil || g.state == nil { return ErrPending } g.state.mu.Lock() defer g.state.mu.Unlock() o, record, rec, err := g.current(ctx) if err != nil { return err } m := rec.Move if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation { return ErrIdentity } guard := provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation} writeRecord := func() error { body, err := g.prepareRecord(ctx, rec) if err != nil { return err } if _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, record.Holder.Key, record.Body, body, record.ETag); err != nil { return err } if before, err := provision.DecodeRecord(record.Body); err == nil { if t, ok := moves.TransitionOf(before, rec); ok { out.Transition = &t } } return nil } if consumed, ok := hostclaim.ConsumedMoveTargetFence(o.Claim); ok { if consumed != ref || m.Rollback == nil || m.State != provision.MoveRollingBack || rec.Region != m.From { return ErrIdentity } rb := m.Rollback if (rb.TargetPhase == provision.RbTargetReleased || rb.TargetPhase == provision.RbTargetCleaned && rb.TargetParkedAt != nil) && !rb.Dirty { if rb.LossRef == ref.Key && rb.Evidence == ref.Key { return nil } return g.verifyReleasedTarget(ctx, o, rec) } if rb.LossRef != ref.Key { return ErrIdentity } if err := provision.MoveRollbackLossCommitted(guard)(&rec); err != nil { return err } return writeRecord() } fence, err := hostclaim.VerifyMoveTargetFence(ctx, g.w.raw, o, ref, MoveFenceRegions{Authority: g.authority, HomeRegion: o.Claim.Op.Intent["home_region"]}) if err != nil { return err } if m.State == provision.MoveLanding { if rec.Region != m.To { return ErrIdentity } id, err := moves.NewRollbackID(rand.Reader) if err != nil { return err } if err := provision.MoveRollBack(guard, id, provision.RollbackTargetFenced, g.w.now())(&rec); err != nil { return err } } else if m.State != provision.MoveRollingBack || m.Rollback == nil || rec.Region != m.From { return ErrIdentity } rb := m.Rollback if rb.Dirty { out.VerifiedDirty = true return ErrPending } // J4: a dead dirty target cannot build a return lane. released := rb.TargetPhase == provision.RbTargetReleased && !rb.Dirty if released { if err := g.verifyReleasedTarget(ctx, o, rec); err != nil { return err } if o.Claim.Op.Kind != hostclaim.KindRestoring || o.Claim.Op.Phase != hostclaim.PhaseReleasing || o.Claim.Op.Exec != o.Claim.Op.Intent["target"] { return ErrPending } } else if rb.TargetPhase != provision.RbTargetLost || rb.LossRef != ref.Key { if err := provision.MoveRollbackTargetLost(guard, ref.Key, g.w.now())(&rec); err != nil { return err } return writeRecord() } h := o.Claim.Holder // The record's codes become engine regions (park.PlacementRegion): a // move out of home names its source park.HomeRegion, never "", which // the claim refuses (home-region parks, part 1). r := hostclaim.FencedTargetRelease{OpID: m.ID, RollbackID: rb.ID, Target: o.Claim.Op.Intent["target"], TargetRegion: park.PlacementRegion(m.To), SourceRegion: park.PlacementRegion(m.From), Location: moves.TargetLocationScope(*h, fence.ObservedAt()), Lane: moves.StagingScope(o.Host, m.ID, h.Incarnation, fence.ObservedAt()), NewID: moveLossNewID(ref.SHA256), Fence: fence} if err := CheckCreateFreeze(ctx, g.w.raw, g.w.pub); err != nil { return err } // Fence and roster reads may have raced a new record. Bind both ETag and sequence again. if _, _, _, err := g.current(ctx); err != nil { return err } g.state.active = false landing := false _, err = hostclaim.Update(ctx, g.w.raw, o, func(c *hostclaim.Claim) error { record, err := ReadRecord(ctx, g.w.raw, h.Key) if err != nil { return err } _, seq, err := recordSequence(record.Body) if err != nil { return err } if record.ETag != g.o.RecordETag || seq != g.o.RecordSeq { return release.ErrCASConflict } if err := moveAdmission(ctx, g.w.raw, o.Host, c); err != nil { return err } r.Token = fencerelease.New(c.Host, c.Holder.Incarnation, c.Gen, r.OpID, r.RollbackID, r.Target) defer r.Token.Revoke() landing = c.Op.Kind == hostclaim.KindLanding return hostclaim.ReleaseFencedTarget(r)(c) }) out.ClaimRolledBack = err == nil && landing return err } // The record-first release can outlive its target. Require the canonical complete report. func (g *MoveRecoveryGrant) verifyReleasedTarget(ctx context.Context, o hostclaim.Observed, rec provision.Record) error { m, h := rec.Move, o.Claim.Holder if m == nil || m.Rollback == nil || m.Rollback.Dirty || (m.Rollback.TargetPhase != provision.RbTargetReleased && (m.Rollback.TargetPhase != provision.RbTargetCleaned || m.Rollback.TargetParkedAt == nil)) { return ErrIdentity } key := moves.StoppedKey(moves.ReportPrefix(h.Account, h.App, o.Host, m.ID), "target") if m.Rollback.Evidence != key { return ErrIdentity } raw, _, err := g.w.raw.Get(ctx, key) if err != nil { return err } var report moves.StoppedReport if json.Unmarshal(raw, &report) != nil || !report.Complete() || report.Role != "target" || report.Box != o.Claim.Op.Intent["target"] || report.Host != o.Host || report.OpID != m.ID || report.Incarnation != h.Incarnation { return ErrIdentity } return nil } // StampLostMoveTargetParked uses the same expiring, record-sequenced grant as closure. func (g *MoveRecoveryGrant) StampLostMoveTargetParked(ctx context.Context) error { if g == nil || g.state == nil { return ErrPending } g.state.mu.Lock() defer g.state.mu.Unlock() o, r, rec, err := g.current(ctx) if err != nil { return err } m := rec.Move if m == nil || m.ID != g.o.OpID || m.Incarnation != g.o.Incarnation || m.Rollback == nil || !hostclaim.LostMoveTargetParked(o.Claim, park.PlacementRegion(m.To)) { return ErrPending } if m.Rollback.TargetParkedAt != nil { return nil } if err := provision.MoveLostTargetParked(provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation}, m.Rollback.ID, g.w.now())(&rec); err != nil { return err } body, err := g.prepareRecord(ctx, rec) if err != nil { return err } _, _, err = provision.WriteRecord(provision.WithRecordSequence(ctx), g.w.raw, r.Holder.Key, r.Body, body, r.ETag) return err }