| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "github.com/carlosframework/platform/internal/park" |
| 7 | "sync/atomic" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostactors" |
| 11 | "github.com/carlosframework/platform/internal/hostclaim" |
| 12 | "github.com/carlosframework/platform/internal/provision" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // RegistrationMove is the admission registration kind of a claim-mode move. |
| 17 | const RegistrationMove = "move" |
| 18 | |
| 19 | // RegisterMove mints the exact "move" registration for the serving claim |
| 20 | // observation o: this host, this incarnation, this claim ETag and gen, at |
| 21 | // the admission's current epoch. The handle is the only capability that can |
| 22 | // begin the move (BeginClaimMove); knowing the registration id is not enough. |
| 23 | // |
| 24 | // The registration id is the move id: judgeRegistration keeps a start whose |
| 25 | // claim operation carries its id ("an accepted start owns the host"), so the |
| 26 | // registration outlives its TTL exactly as long as the move (or its C7 |
| 27 | // conversion, which keeps the op id) holds the claim. Any other id would be |
| 28 | // drained 90 seconds in, and every later record write would be refused. |
| 29 | func (a ProductionAuthority) RegisterMove(ctx context.Context, o hostclaim.Observed, appID, moveID string) (*StartHandle, error) { |
| 30 | if a.Control == nil || a.Now == nil || a.Store == nil { |
| 31 | return nil, ErrContract |
| 32 | } |
| 33 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" || o.Claim.Holder.Family != "instance" || o.Claim.Op.Kind != hostclaim.KindServing { |
| 34 | return nil, ErrPending |
| 35 | } |
| 36 | if !provision.ValidMoveID(moveID) { |
| 37 | return nil, ErrIdentity |
| 38 | } |
| 39 | h := o.Claim.Holder |
| 40 | admission, err := ReadAdmission(ctx, a.Store, h.Account, h.App, appID) |
| 41 | if err != nil { |
| 42 | return nil, err |
| 43 | } |
| 44 | id := moveID |
| 45 | controller, err := operationToken() |
| 46 | if err != nil { |
| 47 | return nil, err |
| 48 | } |
| 49 | owner := RegistrationOwner{Console: a.ConsoleID, Enrollment: a.Enrollment, Controller: controller} |
| 50 | reg := Registration{Kind: RegistrationMove, Host: o.Host, Owner: owner, AppID: appID, Epoch: admission.Value.Epoch, Claim: SavedObservation(o), Incarnation: h.Incarnation, CreatedAt: h.CreatedAt} |
| 51 | return a.Control.RegisterStart(ctx, h.Account, h.App, appID, id, reg, a.Now()) |
| 52 | } |
| 53 | |
| 54 | // BeginClaimMove is the claim-mode request door's start CAS (K2 under claim |
| 55 | // Plan 5 Task 7). It persists, beside the move's own "source", "target" and |
| 56 | // "to", the canonical app id and the id of h's writer-minted "move" |
| 57 | // registration (which is the move id, see RegisterMove), and both boxes' |
| 58 | // enrollments from the signed roster. The source is the claim's executor; |
| 59 | // no enrollment is caller input, and the target and region are bound to |
| 60 | // the roster. The admission is reread inside the transition, so the last |
| 61 | // admission read precedes the one claim CAS, which itself runs under h's |
| 62 | // lifecycle grant (the data boundary rereads admission again at the Put). |
| 63 | 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) { |
| 64 | 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() { |
| 65 | return hostclaim.Observed{}, ErrIdentity |
| 66 | } |
| 67 | home := "" |
| 68 | if len(homeNames) > 1 { |
| 69 | return hostclaim.Observed{}, ErrIdentity |
| 70 | } |
| 71 | if len(homeNames) == 1 { |
| 72 | home = homeNames[0] |
| 73 | } |
| 74 | if home != "" && park.PlacementRegion(home) != home { |
| 75 | return hostclaim.Observed{}, ErrIdentity |
| 76 | } |
| 77 | holder := o.Claim.Holder |
| 78 | if holder.Family != "instance" || holder.Account != h.account || holder.App != h.app { |
| 79 | return hostclaim.Observed{}, ErrIdentity |
| 80 | } |
| 81 | source := o.Claim.Op.Exec |
| 82 | roster, err := d.Read(ctx) |
| 83 | if err != nil { |
| 84 | return hostclaim.Observed{}, err |
| 85 | } |
| 86 | if !roster.Roster.MatchesHome(home) { |
| 87 | return hostclaim.Observed{}, ErrIdentity |
| 88 | } |
| 89 | src, sourceOK := roster.Roster.Boxes[source] |
| 90 | dst, targetOK := roster.Roster.Boxes[target] |
| 91 | if !sourceOK || !targetOK || source == target || src.State != "active" || dst.State != "active" || src.Region == "" || dst.Region == "" { |
| 92 | return hostclaim.Observed{}, ErrIdentity |
| 93 | } |
| 94 | // Lookup also refuses a box with a termination fence. |
| 95 | for box, enrollment := range map[string]string{source: src.Enrollment, target: dst.Enrollment} { |
| 96 | actor, err := d.Lookup(ctx, box) |
| 97 | if err != nil { |
| 98 | return hostclaim.Observed{}, err |
| 99 | } |
| 100 | if actor.Enrollment != enrollment { |
| 101 | return hostclaim.Observed{}, ErrIdentity |
| 102 | } |
| 103 | } |
| 104 | // The record is the region authority: the source's roster region must be |
| 105 | // the record's, which is what C7's conversion later checks against the |
| 106 | // fence binding. |
| 107 | rec, err := provision.GetRecord(ctx, s, holder.Account, holder.App, o.Host) |
| 108 | if err != nil { |
| 109 | return hostclaim.Observed{}, err |
| 110 | } |
| 111 | inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt) |
| 112 | if err != nil || inc != holder.Incarnation || !ClaimMoveRosterPlaces(roster.Roster, source, target, rec.Region, to, home) || rec.Move != nil { |
| 113 | return hostclaim.Observed{}, ErrIdentity |
| 114 | } |
| 115 | admitted := hostclaim.MoveAdmission{HomeRegion: home, AppID: h.appID, AdmissionID: h.id, SourceEnrollment: src.Enrollment, TargetEnrollment: dst.Enrollment} |
| 116 | return hostclaim.Update(h.Lifecycle(ctx), s, o, func(c *hostclaim.Claim) error { |
| 117 | if err := liveMoveRegistration(ctx, s, h, o); err != nil { |
| 118 | return err |
| 119 | } |
| 120 | if err := hostclaim.BeginAdmittedMove(id, by, source, target, park.PlacementRegion(to), now, admitted)(c); err != nil { |
| 121 | return err |
| 122 | } |
| 123 | if home != "" { |
| 124 | c.Op.Intent["home_region"] = home |
| 125 | } |
| 126 | return nil |
| 127 | }) |
| 128 | } |
| 129 | |
| 130 | // liveMoveRegistration: h's registration is still the exact open "move" |
| 131 | // registration for this observation, at the current epoch, and live. |
| 132 | func liveMoveRegistration(ctx context.Context, s release.Store, h *StartHandle, o hostclaim.Observed) error { |
| 133 | a, err := ReadAdmission(ctx, s, h.account, h.app, h.appID) |
| 134 | if err != nil { |
| 135 | return err |
| 136 | } |
| 137 | v := a.Value |
| 138 | r, ok := v.Registrations[h.id] |
| 139 | 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 || |
| 140 | r.Host != o.Host || r.Incarnation == "" || r.Incarnation != o.Claim.Holder.Incarnation || r.Claim != SavedObservation(o) || !RegistrationLive(r, h.w.now()) { |
| 141 | return ErrPending |
| 142 | } |
| 143 | return nil |
| 144 | } |
| 145 | |
| 146 | // StartClaimMoveRecord journals the move on the record after BeginClaimMove, |
| 147 | // as one issued effect of the same registration. The lifecycle record writer |
| 148 | // binds the write to the moving claim's exact ETag and rereads admission. |
| 149 | // Every record Put is If-Match on the record ETag it was decided from. When |
| 150 | // one fails after it was issued, the effect is uncertain and |
| 151 | // ResolveMoveStart, with this same handle, settles it. |
| 152 | func StartClaimMoveRecord(ctx context.Context, s release.Store, h *StartHandle, moving hostclaim.Observed, p provision.MoveStart) (*provision.Record, error) { |
| 153 | if h == nil || h.w == nil || moving.Absent || moving.Claim == nil || moving.Claim.Holder == nil { |
| 154 | return nil, ErrIdentity |
| 155 | } |
| 156 | c := moving.Claim |
| 157 | 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"]) || |
| 158 | c.Op.Intent["admission_id"] != h.id || c.Op.Intent["app_id"] != h.appID { |
| 159 | return nil, ErrIdentity |
| 160 | } |
| 161 | var rec *provision.Record |
| 162 | var callErr error |
| 163 | _, 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) { |
| 164 | // The handle keeps every If-Match ETag this start's record Puts |
| 165 | // carry, so ResolveMoveStart can fence one whose answer was lost. |
| 166 | puts := &moveStartPuts{id: p.ID, incarnation: p.Incarnation, key: c.Holder.Key, holder: *c.Holder} |
| 167 | h.moveStart.puts.Store(puts) |
| 168 | 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)) |
| 169 | if callErr != nil { |
| 170 | return "", callErr |
| 171 | } |
| 172 | return p.ID, nil |
| 173 | }) |
| 174 | if callErr != nil { |
| 175 | return nil, callErr |
| 176 | } |
| 177 | if err != nil { |
| 178 | return nil, err |
| 179 | } |
| 180 | if rec == nil { |
| 181 | // A replay of an effect already complete runs no call: read the result. |
| 182 | if rec, err = provision.GetRecord(ctx, s, c.Holder.Account, c.Holder.App, moving.Host); err != nil { |
| 183 | return nil, err |
| 184 | } |
| 185 | if rec.Move == nil || rec.Move.ID != p.ID || rec.Move.Incarnation != p.Incarnation { |
| 186 | return nil, ErrIdentity |
| 187 | } |
| 188 | } |
| 189 | return rec, nil |
| 190 | } |
| 191 | |
| 192 | // runRecordEffect runs call as one issued effect of h's registration: the |
| 193 | // data boundary admits the record Put only under that effect's permit. A |
| 194 | // call refused before any write reached a raw sink settles "not-applied" |
| 195 | // (as RunAppMutation does), so a refusal never leaves an uncertain effect |
| 196 | // that blocks every later effect on the same record. |
| 197 | func runRecordEffect(ctx context.Context, h *StartHandle, e Effect, call func(context.Context) (string, error)) (string, error) { |
| 198 | issued := new(atomic.Bool) |
| 199 | return h.Runner(h.w.now).Run(ctx, e, func(ctx context.Context) (string, error) { |
| 200 | outer, _ := ctx.Value(issuedMutationKey{}).([]*atomic.Bool) |
| 201 | ctx = context.WithValue(ctx, issuedMutationKey{}, append(append([]*atomic.Bool(nil), outer...), issued)) |
| 202 | result, err := call(ctx) |
| 203 | if err != nil && !issued.Load() { |
| 204 | return "", errors.Join(ErrNotApplied, err) |
| 205 | } |
| 206 | return result, err |
| 207 | }) |
| 208 | } |
| 209 | |