package hostops import ( "bytes" "context" "encoding/json" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The console move grant (ruling J17, claim Plan 10). On a host-claim // console the cut, abort and retry doors write the move journal on the // record. None of them may run under an app permit, a lifecycle grant or a // callback: each is one typed ControlWriter transition, chosen here from a // closed action set, bound to the exact claim, registration and record the // door observed, and checked again at its final conditional write. // ConsoleMoveAction names one console-initiated move record transition. type ConsoleMoveAction string const ( // ConsoleMoveCut is provision.MoveGuardedCut: ready to cutting/requested. ConsoleMoveCut ConsoleMoveAction = "cut" // ConsoleMoveAbort is provision.MoveGuardedAbort (ยง7.11). ConsoleMoveAbort ConsoleMoveAction = "abort" // ConsoleMoveRetry is provision.MoveRetry: stuck back to its recovery. ConsoleMoveRetry ConsoleMoveAction = "retry" ) // ConsoleMoveObservation is what a door read: values only, never authority. // ConsoleMove rereads and compares every field. type ConsoleMoveObservation struct { Account, App, AppID, Host, Incarnation, OpID string ClaimETag, RecordETag string RecordSeq uint64 } // ObserveConsoleMove reads the claim and record a door is about to act on. // The claim must hold this account and app's instance under a move op whose // admission id is its own id, and the record must journal that same move. func ObserveConsoleMove(ctx context.Context, s release.Store, account, app, host string) (ConsoleMoveObservation, error) { if s == nil || account == "" || app == "" || host == "" { return ConsoleMoveObservation{}, ErrIdentity } o, err := hostclaim.Read(ctx, s, host) if err != nil { return ConsoleMoveObservation{}, err } if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" { return ConsoleMoveObservation{}, ErrIdentity } c, h := o.Claim, o.Claim.Holder if h.Family != "instance" || h.Key != provision.RecordKey(account, app, host) || h.Account != account || h.App != app || c.Op.ID == "" || c.Op.Intent["admission_id"] != c.Op.ID || c.Op.Intent["app_id"] == "" { return ConsoleMoveObservation{}, ErrIdentity } r, _, seq, err := consoleMoveRecord(ctx, s, h, c.Op.ID) if err != nil { return ConsoleMoveObservation{}, err } return ConsoleMoveObservation{Account: account, App: app, AppID: c.Op.Intent["app_id"], Host: host, Incarnation: h.Incarnation, OpID: c.Op.ID, ClaimETag: o.ETag, RecordETag: r.ETag, RecordSeq: seq}, nil } // consoleMoveRecord reads the holder's record and requires it to journal // move id for the holder's incarnation. func consoleMoveRecord(ctx context.Context, s release.Store, h *hostclaim.Holder, id string) (Record, provision.Record, uint64, error) { if h == nil || h.Family != "instance" { return Record{}, provision.Record{}, 0, ErrIdentity } r, err := ReadRecord(ctx, s, h.Key) if err != nil { return Record{}, provision.Record{}, 0, err } if !matches(r, h) { return Record{}, provision.Record{}, 0, ErrIdentity } _, seq, err := recordSequence(r.Body) if err != nil { return Record{}, provision.Record{}, 0, err } rec, err := provision.DecodeRecord(r.Body) if err != nil { return Record{}, provision.Record{}, 0, ErrIdentity } if rec.Move == nil || rec.Move.ID != id || rec.Move.Incarnation != h.Incarnation { return Record{}, provision.Record{}, 0, ErrIdentity } return r, rec, seq, nil } // consoleMoveTransition maps the closed action set to its provision // transition and the claim operation it is legal under: cut and abort // before the seal (moving, phase ""), retry under a recovery (restoring or // reverting, where the record's stuck lives). func consoleMoveTransition(a ConsoleMoveAction, g provision.MoveGuard, op hostclaim.Op) (func(*provision.Record) error, error) { switch a { case ConsoleMoveCut, ConsoleMoveAbort: if op.Kind != hostclaim.KindMoving || op.Phase != hostclaim.PhaseMoving { return nil, hostclaim.ErrWrongState } if a == ConsoleMoveCut { return provision.MoveGuardedCut(g), nil } return provision.MoveGuardedAbort(g), nil case ConsoleMoveRetry: if op.Kind != hostclaim.KindRestoring && op.Kind != hostclaim.KindReverting { return nil, hostclaim.ErrWrongState } return provision.MoveRetryRecovery(g), nil } return nil, ErrIdentity } // consoleMoveClaim rereads the claim and holds it to the observation: the // exact ETag, this account, app and incarnation, the move op under its own // admission id and app id, and the exact move registration (closed // admission and an expired registration still drain an accepted move). func (w *ControlWriter) consoleMoveClaim(ctx context.Context, o ConsoleMoveObservation) (hostclaim.Observed, error) { obs, err := hostclaim.Read(ctx, w.raw, o.Host) if err != nil { return hostclaim.Observed{}, err } if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { return hostclaim.Observed{}, ErrIdentity } c, h := obs.Claim, obs.Claim.Holder if !operationHomeMatches(w.homeRegion, c) { return hostclaim.Observed{}, ErrIdentity } if h.Family != "instance" || h.Key != provision.RecordKey(o.Account, o.App, o.Host) || h.Account != o.Account || h.App != o.App || h.Incarnation != o.Incarnation || c.Op.ID != o.OpID || c.Op.Intent["admission_id"] != o.OpID || c.Op.Intent["app_id"] != o.AppID { return hostclaim.Observed{}, ErrIdentity } if obs.ETag != o.ClaimETag { return hostclaim.Observed{}, hostclaim.ErrConflict } if err := moveAdmission(ctx, w.raw, o.Host, c); err != nil { return hostclaim.Observed{}, err } return obs, nil } // ConsoleMove applies one console move action to the record the observation // names. The transition is chosen here, never supplied; it runs against the // exact record (ETag and record_seq) the door read, and the claim and the // move registration are read again immediately before the conditional Put, // whose base is that record. Nothing but the record is written. A refusal // from the provision transition (provision.ErrMoveConflict) is returned // unchanged for the door's recorded sentence. func (w *ControlWriter) ConsoleMove(ctx context.Context, o ConsoleMoveObservation, action ConsoleMoveAction) (*provision.Record, error) { if err := w.ready(); err != nil { return nil, err } if o.Account == "" || o.App == "" || o.AppID == "" || o.Host == "" || o.Incarnation == "" || o.OpID == "" || o.ClaimETag == "" || o.RecordETag == "" { return nil, ErrIdentity } switch action { case ConsoleMoveCut, ConsoleMoveAbort, ConsoleMoveRetry: default: return nil, ErrIdentity } obs, err := w.consoleMoveClaim(ctx, o) if err != nil { return nil, err } h := obs.Claim.Holder mutate, err := consoleMoveTransition(action, provision.MoveGuard{ID: o.OpID, Incarnation: o.Incarnation}, obs.Claim.Op) if err != nil { return nil, err } r, rec, seq, err := consoleMoveRecord(ctx, w.raw, h, o.OpID) if err != nil { return nil, err } if r.ETag != o.RecordETag || seq != o.RecordSeq { return nil, release.ErrCASConflict } next, err := provision.DecodeRecord(r.Body) if err != nil { return nil, ErrIdentity } if err := mutate(&next); err != nil { return nil, err } if next.Move != nil { next.Move.UpdatedAt = w.now().UTC() } if next.Account != rec.Account || next.App != rec.App || next.Host != rec.Host || !next.CreatedAt.Equal(rec.CreatedAt) || next.RecordSeq != rec.RecordSeq || !bytes.Equal(next.Maintenance, rec.Maintenance) { return nil, ErrIdentity } body, err := json.Marshal(next) if err != nil { return nil, err } if err := CheckCreateFreeze(ctx, w.raw, w.pub); err != nil { return nil, err } // The final check: the same claim, the same registration, just before // the Put whose If-Match is the observed record. if _, err := w.consoleMoveClaim(ctx, o); err != nil { return nil, err } _, written, err := provision.WriteRecord(provision.WithRecordSequence(ctx), w.raw, h.Key, r.Body, body, o.RecordETag) if err != nil { return nil, sequenceError(err) } next.RecordSeq = written return &next, nil }