| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/hostclaim" |
| 9 | "github.com/carlosframework/platform/internal/provision" |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | // The console move grant (ruling J17, claim Plan 10). On a host-claim |
| 14 | // console the cut, abort and retry doors write the move journal on the |
| 15 | // record. None of them may run under an app permit, a lifecycle grant or a |
| 16 | // callback: each is one typed ControlWriter transition, chosen here from a |
| 17 | // closed action set, bound to the exact claim, registration and record the |
| 18 | // door observed, and checked again at its final conditional write. |
| 19 | |
| 20 | // ConsoleMoveAction names one console-initiated move record transition. |
| 21 | type ConsoleMoveAction string |
| 22 | |
| 23 | const ( |
| 24 | // ConsoleMoveCut is provision.MoveGuardedCut: ready to cutting/requested. |
| 25 | ConsoleMoveCut ConsoleMoveAction = "cut" |
| 26 | // ConsoleMoveAbort is provision.MoveGuardedAbort (§7.11). |
| 27 | ConsoleMoveAbort ConsoleMoveAction = "abort" |
| 28 | // ConsoleMoveRetry is provision.MoveRetry: stuck back to its recovery. |
| 29 | ConsoleMoveRetry ConsoleMoveAction = "retry" |
| 30 | ) |
| 31 | |
| 32 | // ConsoleMoveObservation is what a door read: values only, never authority. |
| 33 | // ConsoleMove rereads and compares every field. |
| 34 | type ConsoleMoveObservation struct { |
| 35 | Account, App, AppID, Host, Incarnation, OpID string |
| 36 | ClaimETag, RecordETag string |
| 37 | RecordSeq uint64 |
| 38 | } |
| 39 | |
| 40 | // ObserveConsoleMove reads the claim and record a door is about to act on. |
| 41 | // The claim must hold this account and app's instance under a move op whose |
| 42 | // admission id is its own id, and the record must journal that same move. |
| 43 | func ObserveConsoleMove(ctx context.Context, s release.Store, account, app, host string) (ConsoleMoveObservation, error) { |
| 44 | if s == nil || account == "" || app == "" || host == "" { |
| 45 | return ConsoleMoveObservation{}, ErrIdentity |
| 46 | } |
| 47 | o, err := hostclaim.Read(ctx, s, host) |
| 48 | if err != nil { |
| 49 | return ConsoleMoveObservation{}, err |
| 50 | } |
| 51 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.ETag == "" { |
| 52 | return ConsoleMoveObservation{}, ErrIdentity |
| 53 | } |
| 54 | c, h := o.Claim, o.Claim.Holder |
| 55 | 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"] == "" { |
| 56 | return ConsoleMoveObservation{}, ErrIdentity |
| 57 | } |
| 58 | r, _, seq, err := consoleMoveRecord(ctx, s, h, c.Op.ID) |
| 59 | if err != nil { |
| 60 | return ConsoleMoveObservation{}, err |
| 61 | } |
| 62 | return ConsoleMoveObservation{Account: account, App: app, AppID: c.Op.Intent["app_id"], Host: host, Incarnation: h.Incarnation, OpID: c.Op.ID, |
| 63 | ClaimETag: o.ETag, RecordETag: r.ETag, RecordSeq: seq}, nil |
| 64 | } |
| 65 | |
| 66 | // consoleMoveRecord reads the holder's record and requires it to journal |
| 67 | // move id for the holder's incarnation. |
| 68 | func consoleMoveRecord(ctx context.Context, s release.Store, h *hostclaim.Holder, id string) (Record, provision.Record, uint64, error) { |
| 69 | if h == nil || h.Family != "instance" { |
| 70 | return Record{}, provision.Record{}, 0, ErrIdentity |
| 71 | } |
| 72 | r, err := ReadRecord(ctx, s, h.Key) |
| 73 | if err != nil { |
| 74 | return Record{}, provision.Record{}, 0, err |
| 75 | } |
| 76 | if !matches(r, h) { |
| 77 | return Record{}, provision.Record{}, 0, ErrIdentity |
| 78 | } |
| 79 | _, seq, err := recordSequence(r.Body) |
| 80 | if err != nil { |
| 81 | return Record{}, provision.Record{}, 0, err |
| 82 | } |
| 83 | rec, err := provision.DecodeRecord(r.Body) |
| 84 | if err != nil { |
| 85 | return Record{}, provision.Record{}, 0, ErrIdentity |
| 86 | } |
| 87 | if rec.Move == nil || rec.Move.ID != id || rec.Move.Incarnation != h.Incarnation { |
| 88 | return Record{}, provision.Record{}, 0, ErrIdentity |
| 89 | } |
| 90 | return r, rec, seq, nil |
| 91 | } |
| 92 | |
| 93 | // consoleMoveTransition maps the closed action set to its provision |
| 94 | // transition and the claim operation it is legal under: cut and abort |
| 95 | // before the seal (moving, phase ""), retry under a recovery (restoring or |
| 96 | // reverting, where the record's stuck lives). |
| 97 | func consoleMoveTransition(a ConsoleMoveAction, g provision.MoveGuard, op hostclaim.Op) (func(*provision.Record) error, error) { |
| 98 | switch a { |
| 99 | case ConsoleMoveCut, ConsoleMoveAbort: |
| 100 | if op.Kind != hostclaim.KindMoving || op.Phase != hostclaim.PhaseMoving { |
| 101 | return nil, hostclaim.ErrWrongState |
| 102 | } |
| 103 | if a == ConsoleMoveCut { |
| 104 | return provision.MoveGuardedCut(g), nil |
| 105 | } |
| 106 | return provision.MoveGuardedAbort(g), nil |
| 107 | case ConsoleMoveRetry: |
| 108 | if op.Kind != hostclaim.KindRestoring && op.Kind != hostclaim.KindReverting { |
| 109 | return nil, hostclaim.ErrWrongState |
| 110 | } |
| 111 | return provision.MoveRetryRecovery(g), nil |
| 112 | } |
| 113 | return nil, ErrIdentity |
| 114 | } |
| 115 | |
| 116 | // consoleMoveClaim rereads the claim and holds it to the observation: the |
| 117 | // exact ETag, this account, app and incarnation, the move op under its own |
| 118 | // admission id and app id, and the exact move registration (closed |
| 119 | // admission and an expired registration still drain an accepted move). |
| 120 | func (w *ControlWriter) consoleMoveClaim(ctx context.Context, o ConsoleMoveObservation) (hostclaim.Observed, error) { |
| 121 | obs, err := hostclaim.Read(ctx, w.raw, o.Host) |
| 122 | if err != nil { |
| 123 | return hostclaim.Observed{}, err |
| 124 | } |
| 125 | if obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| 126 | return hostclaim.Observed{}, ErrIdentity |
| 127 | } |
| 128 | c, h := obs.Claim, obs.Claim.Holder |
| 129 | if !operationHomeMatches(w.homeRegion, c) { |
| 130 | return hostclaim.Observed{}, ErrIdentity |
| 131 | } |
| 132 | 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 || |
| 133 | c.Op.Intent["admission_id"] != o.OpID || c.Op.Intent["app_id"] != o.AppID { |
| 134 | return hostclaim.Observed{}, ErrIdentity |
| 135 | } |
| 136 | if obs.ETag != o.ClaimETag { |
| 137 | return hostclaim.Observed{}, hostclaim.ErrConflict |
| 138 | } |
| 139 | if err := moveAdmission(ctx, w.raw, o.Host, c); err != nil { |
| 140 | return hostclaim.Observed{}, err |
| 141 | } |
| 142 | return obs, nil |
| 143 | } |
| 144 | |
| 145 | // ConsoleMove applies one console move action to the record the observation |
| 146 | // names. The transition is chosen here, never supplied; it runs against the |
| 147 | // exact record (ETag and record_seq) the door read, and the claim and the |
| 148 | // move registration are read again immediately before the conditional Put, |
| 149 | // whose base is that record. Nothing but the record is written. A refusal |
| 150 | // from the provision transition (provision.ErrMoveConflict) is returned |
| 151 | // unchanged for the door's recorded sentence. |
| 152 | func (w *ControlWriter) ConsoleMove(ctx context.Context, o ConsoleMoveObservation, action ConsoleMoveAction) (*provision.Record, error) { |
| 153 | if err := w.ready(); err != nil { |
| 154 | return nil, err |
| 155 | } |
| 156 | if o.Account == "" || o.App == "" || o.AppID == "" || o.Host == "" || o.Incarnation == "" || o.OpID == "" || o.ClaimETag == "" || o.RecordETag == "" { |
| 157 | return nil, ErrIdentity |
| 158 | } |
| 159 | switch action { |
| 160 | case ConsoleMoveCut, ConsoleMoveAbort, ConsoleMoveRetry: |
| 161 | default: |
| 162 | return nil, ErrIdentity |
| 163 | } |
| 164 | obs, err := w.consoleMoveClaim(ctx, o) |
| 165 | if err != nil { |
| 166 | return nil, err |
| 167 | } |
| 168 | h := obs.Claim.Holder |
| 169 | mutate, err := consoleMoveTransition(action, provision.MoveGuard{ID: o.OpID, Incarnation: o.Incarnation}, obs.Claim.Op) |
| 170 | if err != nil { |
| 171 | return nil, err |
| 172 | } |
| 173 | r, rec, seq, err := consoleMoveRecord(ctx, w.raw, h, o.OpID) |
| 174 | if err != nil { |
| 175 | return nil, err |
| 176 | } |
| 177 | if r.ETag != o.RecordETag || seq != o.RecordSeq { |
| 178 | return nil, release.ErrCASConflict |
| 179 | } |
| 180 | next, err := provision.DecodeRecord(r.Body) |
| 181 | if err != nil { |
| 182 | return nil, ErrIdentity |
| 183 | } |
| 184 | if err := mutate(&next); err != nil { |
| 185 | return nil, err |
| 186 | } |
| 187 | if next.Move != nil { |
| 188 | next.Move.UpdatedAt = w.now().UTC() |
| 189 | } |
| 190 | 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) { |
| 191 | return nil, ErrIdentity |
| 192 | } |
| 193 | body, err := json.Marshal(next) |
| 194 | if err != nil { |
| 195 | return nil, err |
| 196 | } |
| 197 | if err := CheckCreateFreeze(ctx, w.raw, w.pub); err != nil { |
| 198 | return nil, err |
| 199 | } |
| 200 | // The final check: the same claim, the same registration, just before |
| 201 | // the Put whose If-Match is the observed record. |
| 202 | if _, err := w.consoleMoveClaim(ctx, o); err != nil { |
| 203 | return nil, err |
| 204 | } |
| 205 | _, written, err := provision.WriteRecord(provision.WithRecordSequence(ctx), w.raw, h.Key, r.Body, body, o.RecordETag) |
| 206 | if err != nil { |
| 207 | return nil, sequenceError(err) |
| 208 | } |
| 209 | next.RecordSeq = written |
| 210 | return &next, nil |
| 211 | } |
| 212 | |