package hostops import ( "context" "errors" "github.com/carlosframework/platform/internal/park" "sync/atomic" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // RegistrationMove is the admission registration kind of a claim-mode move. const RegistrationMove = "move" // RegisterMove mints the exact "move" registration for the serving claim // observation o: this host, this incarnation, this claim ETag and gen, at // the admission's current epoch. The handle is the only capability that can // begin the move (BeginClaimMove); knowing the registration id is not enough. // // The registration id is the move id: judgeRegistration keeps a start whose // claim operation carries its id ("an accepted start owns the host"), so the // registration outlives its TTL exactly as long as the move (or its C7 // conversion, which keeps the op id) holds the claim. Any other id would be // drained 90 seconds in, and every later record write would be refused. func (a ProductionAuthority) RegisterMove(ctx context.Context, o hostclaim.Observed, appID, moveID string) (*StartHandle, error) { if a.Control == nil || a.Now == nil || a.Store == nil { return nil, ErrContract } if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || o.Claim.Holder.Family != "instance" || o.Claim.Op.Kind != hostclaim.KindServing { return nil, ErrPending } if !provision.ValidMoveID(moveID) { return nil, ErrIdentity } h := o.Claim.Holder admission, err := ReadAdmission(ctx, a.Store, h.Account, h.App, appID) if err != nil { return nil, err } id := moveID controller, err := operationToken() if err != nil { return nil, err } owner := RegistrationOwner{Console: a.ConsoleID, Enrollment: a.Enrollment, Controller: controller} reg := Registration{Kind: RegistrationMove, Host: o.Host, Owner: owner, AppID: appID, Epoch: admission.Value.Epoch, Claim: SavedObservation(o), Incarnation: h.Incarnation, CreatedAt: h.CreatedAt} return a.Control.RegisterStart(ctx, h.Account, h.App, appID, id, reg, a.Now()) } // BeginClaimMove is the claim-mode request door's start CAS (K2 under claim // Plan 5 Task 7). It persists, beside the move's own "source", "target" and // "to", the canonical app id and the id of h's writer-minted "move" // registration (which is the move id, see RegisterMove), and both boxes' // enrollments from the signed roster. The source is the claim's executor; // no enrollment is caller input, and the target and region are bound to // the roster. The admission is reread inside the transition, so the last // admission read precedes the one claim CAS, which itself runs under h's // lifecycle grant (the data boundary rereads admission again at the Put). func BeginClaimMove(ctx context.Context, s release.Store, d hostactors.Directory, h *StartHandle, o hostclaim.Observed, id, by, target, to string, now time.Time, homeNames ...string) (hostclaim.Observed, error) { if h == nil || h.w == nil || s == nil || o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || id == "" || id != h.id || now.IsZero() { return hostclaim.Observed{}, ErrIdentity } home := "" if len(homeNames) > 1 { return hostclaim.Observed{}, ErrIdentity } if len(homeNames) == 1 { home = homeNames[0] } if home != "" && park.PlacementRegion(home) != home { return hostclaim.Observed{}, ErrIdentity } holder := o.Claim.Holder if holder.Family != "instance" || holder.Account != h.account || holder.App != h.app { return hostclaim.Observed{}, ErrIdentity } source := o.Claim.Op.Exec roster, err := d.Read(ctx) if err != nil { return hostclaim.Observed{}, err } if !roster.Roster.MatchesHome(home) { return hostclaim.Observed{}, ErrIdentity } src, sourceOK := roster.Roster.Boxes[source] dst, targetOK := roster.Roster.Boxes[target] if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region == "" { return hostclaim.Observed{}, ErrIdentity } // Lookup also refuses a box with a termination fence. for box, enrollment := range map[string]string{source: src.Enrollment, target: dst.Enrollment} { actor, err := d.Lookup(ctx, box) if err != nil { return hostclaim.Observed{}, err } if actor.Enrollment != enrollment { return hostclaim.Observed{}, ErrIdentity } } // The record is the region authority: the source's roster region must be // the record's, which is what C7's conversion later checks against the // fence binding. rec, err := provision.GetRecord(ctx, s, holder.Account, holder.App, o.Host) if err != nil { return hostclaim.Observed{}, err } inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) if err != nil || inc != holder.Incarnation || !ClaimMoveRosterPlaces(roster.Roster, source, target, rec.Region, to, home) || rec.Move != nil { return hostclaim.Observed{}, ErrIdentity } admitted := hostclaim.MoveAdmission{HomeRegion: home, AppID: h.appID, AdmissionID: h.id, SourceEnrollment: src.Enrollment, TargetEnrollment: dst.Enrollment} return hostclaim.Update(h.Lifecycle(ctx), s, o, func(c *hostclaim.Claim) error { if err := liveMoveRegistration(ctx, s, h, o); err != nil { return err } if err := hostclaim.BeginAdmittedMove(id, by, source, target, park.PlacementRegion(to), now, admitted)(c); err != nil { return err } if home != "" { c.Op.Intent["home_region"] = home } return nil }) } // liveMoveRegistration: h's registration is still the exact open "move" // registration for this observation, at the current epoch, and live. func liveMoveRegistration(ctx context.Context, s release.Store, h *StartHandle, o hostclaim.Observed) error { a, err := ReadAdmission(ctx, s, h.account, h.app, h.appID) if err != nil { return err } v := a.Value r, ok := v.Registrations[h.id] if !ok || r.Kind != RegistrationMove || r.Owner != h.owner || r.AppID != h.appID || r.Epoch != h.epoch || v.Epoch != h.epoch || v.State != "open" || v.Op != nil || r.Host != o.Host || r.Incarnation == "" || r.Incarnation != o.Claim.Holder.Incarnation || r.Claim != SavedObservation(o) || !RegistrationLive(r, h.w.now()) { return ErrPending } return nil } // StartClaimMoveRecord journals the move on the record after BeginClaimMove, // as one issued effect of the same registration. The lifecycle record writer // binds the write to the moving claim's exact ETag and rereads admission. // Every record Put is If-Match on the record ETag it was decided from. When // one fails after it was issued, the effect is uncertain and // ResolveMoveStart, with this same handle, settles it. func StartClaimMoveRecord(ctx context.Context, s release.Store, h *StartHandle, moving hostclaim.Observed, p provision.MoveStart) (*provision.Record, error) { if h == nil || h.w == nil || moving.Absent || moving.Claim == nil || moving.Claim.Holder == nil { return nil, ErrIdentity } c := moving.Claim if c.Op.Kind != hostclaim.KindMoving || c.Op.Phase != hostclaim.PhaseMoving || p.ID != c.Op.ID || p.Incarnation != c.Holder.Incarnation || !park.PlacementMatches(c.Op.Intent["home_region"], p.To, c.Op.Intent["to"]) || c.Op.Intent["admission_id"] != h.id || c.Op.Intent["app_id"] != h.appID { return nil, ErrIdentity } var rec *provision.Record var callErr error _, err := runRecordEffect(ctx, h, Effect{ID: p.ID, Resource: c.Holder.Key, Action: moveStartAction, Inputs: map[string]string{"move_id": p.ID, "incarnation": p.Incarnation, "to": p.To}}, func(ctx context.Context) (string, error) { // The handle keeps every If-Match ETag this start's record Puts // carry, so ResolveMoveStart can fence one whose answer was lost. puts := &moveStartPuts{id: p.ID, incarnation: p.Incarnation, key: c.Holder.Key, holder: *c.Holder} h.moveStart.puts.Store(puts) rec, callErr = setLifecycleRecordMove(ctx, moveStartRecorder{s: s, puts: puts, capture: func(ctx context.Context, seq uint64) error { return h.captureMoveStartBase(ctx, seq) }}, moving, provision.MoveStartTransition(p)) if callErr != nil { return "", callErr } return p.ID, nil }) if callErr != nil { return nil, callErr } if err != nil { return nil, err } if rec == nil { // A replay of an effect already complete runs no call: read the result. if rec, err = provision.GetRecord(ctx, s, c.Holder.Account, c.Holder.App, moving.Host); err != nil { return nil, err } if rec.Move == nil || rec.Move.ID != p.ID || rec.Move.Incarnation != p.Incarnation { return nil, ErrIdentity } } return rec, nil } // runRecordEffect runs call as one issued effect of h's registration: the // data boundary admits the record Put only under that effect's permit. A // call refused before any write reached a raw sink settles "not-applied" // (as RunAppMutation does), so a refusal never leaves an uncertain effect // that blocks every later effect on the same record. func runRecordEffect(ctx context.Context, h *StartHandle, e Effect, call func(context.Context) (string, error)) (string, error) { issued := new(atomic.Bool) return h.Runner(h.w.now).Run(ctx, e, func(ctx context.Context) (string, error) { outer, _ := ctx.Value(issuedMutationKey{}).([]*atomic.Bool) ctx = context.WithValue(ctx, issuedMutationKey{}, append(append([]*atomic.Bool(nil), outer...), issued)) result, err := call(ctx) if err != nil && !issued.Load() { return "", errors.Join(ErrNotApplied, err) } return result, err }) }