carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostops
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "errors"
9 "io"
10 "math"
11 "os"
12 "reflect"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/hostclaim"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// Lifecycle claims (control/hosts/<host>.json) are the claim engine's CAS
21// root. The built Tasks 2-6 engine writes them through the data handle, so
22// the data boundary cannot refuse them outright. It admits a claim write
23// only under a lifecycle grant carried in ctx, and validates the exact
24// old->new claim against the grant's scope. A grant is minted only by a
25// writer-issued handle (its own app/operation) or by RecoveryPass (stale
26// operations and frozen-backfill adoption only). A handler holding
27// Config.Releases and no grant cannot write any claim.
28
29type lifecycleGrantKey struct{}
30
31type lifecycleGrant struct {
32 kind string // "app" or "recovery"
33 account, app, appID string
34 operation string
35 // exclusive: the grant is an OperationHandle's (its op must still hold
36 // admission); otherwise a StartHandle's (its registration must be live
37 // at the handle's epoch with the handle's owner).
38 exclusive bool
39 owner RegistrationOwner
40 epoch uint64
41 w *ControlWriter
42 // registration and incarnation are a StartHandle's registration kind
43 // and saved holder incarnation. A reset-wake-retry grant admits only
44 // the exact ResetWakeRetry transition for that incarnation (review R2).
45 registration, incarnation string
46}
47
48// Lifecycle returns ctx carrying this registration's claim grant. The grant
49// is checked against admission on every claim write: it dies with the
50// registration.
51func (h *StartHandle) Lifecycle(ctx context.Context) context.Context {
52 if h.kind == RegistrationIngest {
53 return ctx
54 }
55 return withLifecycleGrant(ctx, lifecycleGrant{kind: "app", account: h.account, app: h.app, appID: h.appID, operation: h.id, owner: h.owner, epoch: h.epoch, w: h.w, registration: h.kind, incarnation: h.incarnation})
56}
57
58// Lifecycle returns ctx carrying this operation's claim grant. The grant is
59// checked against admission on every claim write: it dies with the op.
60func (h *OperationHandle) Lifecycle(ctx context.Context) context.Context {
61 return withLifecycleGrant(ctx, lifecycleGrant{kind: "app", account: h.account, app: h.app, appID: h.appID, operation: h.id, exclusive: true, owner: h.owner, w: h.w})
62}
63
64// grantLive re-reads admission: an app grant authorizes nothing once its
65// registration lapsed or was drained, or once its operation ended.
66func grantLive(ctx context.Context, raw release.Store, g lifecycleGrant) (bool, error) {
67 a, err := ReadAdmission(ctx, raw, g.account, g.app, g.appID)
68 if errors.Is(err, os.ErrNotExist) {
69 return false, nil
70 }
71 if err != nil {
72 return false, err
73 }
74 v := a.Value
75 if g.exclusive {
76 return v.State == "closed" && v.Op != nil && v.Op.ID == g.operation && v.Op.AppID == g.appID, nil
77 }
78 r, ok := v.Registrations[g.operation]
79 return ok && r.Owner == g.owner && r.AppID == g.appID && r.Epoch == g.epoch && v.Epoch == g.epoch && RegistrationLive(r, g.w.now()), nil
80}
81
82func withLifecycleGrant(ctx context.Context, g lifecycleGrant) context.Context {
83 inherited, _ := ctx.Value(lifecycleGrantKey{}).([]lifecycleGrant)
84 return context.WithValue(ctx, lifecycleGrantKey{}, append(append([]lifecycleGrant(nil), inherited...), g))
85}
86
87// RecoveryPass runs the built recovery pass with a recovery grant. That
88// grant only lets a stale operation advance or clear (never install a new
89// holder or a new operation) and lets frozen backfill adopt an absent
90// claim for an existing matching record.
91func (w *ControlWriter) RecoveryPass(ctx context.Context, r Recovery) error {
92 return w.RunRecovery(ctx, r.Pass)
93}
94
95// RunRecovery runs one console recovery body (frozen-backfill reconcile and
96// the recovery pass) under the recovery grant. The grant is constrained by
97// recoveryGrantAllows, so a caller gains nothing beyond what recovery itself
98// may do: advance or clear a stale operation, or adopt for a signed plan.
99func (w *ControlWriter) RunRecovery(ctx context.Context, body func(context.Context) error) error {
100 if err := w.ready(); err != nil {
101 return err
102 }
103 if body == nil {
104 return ErrContract
105 }
106 scope := &moveRecoveryPassScope{}
107 scope.live.Store(true)
108 defer scope.live.Store(false)
109 ctx = context.WithValue(ctx, moveRecoveryPassKey{}, scope)
110 return body(withLifecycleGrant(ctx, lifecycleGrant{kind: "recovery", w: w}))
111}
112
113// decodeClaim is strict (unknown fields and trailing data refuse) and
114// validates the decoded claim, as hostclaim.Read does.
115func decodeClaim(b []byte) (*hostclaim.Claim, error) {
116 if len(b) == 0 {
117 return nil, nil
118 }
119 dec := json.NewDecoder(bytes.NewReader(b))
120 dec.DisallowUnknownFields()
121 var c hostclaim.Claim
122 if err := dec.Decode(&c); err != nil {
123 return nil, err
124 }
125 if _, err := dec.Token(); err != io.EOF {
126 return nil, ErrIdentity
127 }
128 if err := c.Validate(); err != nil {
129 return nil, err
130 }
131 return &c, nil
132}
133
134// claimTransitionShape is review P1-1's per-write validation: same host as
135// the key, generation exactly old+1 (1 from absence), same schema version.
136func claimTransitionShape(host string, old, next *hostclaim.Claim) bool {
137 if next.Host != host {
138 return false
139 }
140 if old == nil {
141 return next.Gen == 1
142 }
143 return old.Host == host && next.V == old.V && old.Gen < math.MaxUint64 && next.Gen == old.Gen+1
144}
145
146func holderOf(c *hostclaim.Claim) *hostclaim.Holder {
147 if c == nil {
148 return nil
149 }
150 return c.Holder
151}
152func opOf(c *hostclaim.Claim) hostclaim.Op {
153 if c == nil {
154 return hostclaim.Op{}
155 }
156 return c.Op
157}
158
159// authorizeClaimWrite is the classifier's lifecycle-claim rule.
160func authorizeClaimWrite(ctx context.Context, raw release.Store, host string, t MutationTarget) error {
161 grants, _ := ctx.Value(lifecycleGrantKey{}).([]lifecycleGrant)
162 if len(grants) == 0 {
163 return ErrPending
164 }
165 old, err := decodeClaim(t.Before)
166 if err != nil {
167 return ErrIdentity
168 }
169 next, err := decodeClaim(t.After)
170 if err != nil || next == nil {
171 return ErrIdentity
172 }
173 if !claimTransitionShape(host, old, next) ||
174 (old == nil && next.Origins != nil) ||
175 (old != nil && !reflect.DeepEqual(old.Origins, next.Origins)) {
176 return ErrIdentity
177 }
178 for _, g := range grants {
179 if g.w == nil || !operationHomeMatches(g.w.homeRegion, old) || !operationHomeMatches(g.w.homeRegion, next) {
180 continue
181 }
182 switch g.kind {
183 case "app":
184 if !appGrantAllows(g, old, next) || g.registration == RegistrationWakeRetryReset && !resetWakeRetryTransition(g.incarnation, old, next) {
185 continue
186 }
187 if live, err := grantLive(ctx, raw, g); err != nil {
188 return err
189 } else if live {
190 return nil
191 }
192 case "recovery":
193 if ok, err := recoveryGrantAllows(ctx, raw, g.w, host, old, next); err != nil {
194 return err
195 } else if ok {
196 return nil
197 }
198 }
199 }
200 return ErrPending
201}
202
203func sameApp(h *hostclaim.Holder, g lifecycleGrant) bool {
204 return h != nil && h.Account == g.account && h.App == g.app
205}
206
207func appGrantAllows(g lifecycleGrant, old, next *hostclaim.Claim) bool {
208 oh, nh := holderOf(old), holderOf(next)
209 // Installing (or replacing) a holder: only this grant's own app.
210 if nh != nil && (oh == nil || oh.Incarnation != nh.Incarnation) && !sameApp(nh, g) {
211 return false
212 }
213 if oh != nil && !sameApp(oh, g) {
214 // A foreign holder is never replaced or removed by an app grant
215 // (v2 let a write that merely NAMED its own op ID in next replace
216 // it), and only an op this grant already owns on that claim may be
217 // advanced or cleared. Installing this grant's op over a foreign
218 // holder is refused.
219 if nh == nil || nh.Account != oh.Account || nh.App != oh.App || nh.Incarnation != oh.Incarnation {
220 return false
221 }
222 return opOf(old).ID == g.operation && (opOf(next).ID == g.operation || opOf(next).ID == "")
223 }
224 if oh == nil {
225 // No holder: never overwrite another operation's op (a foreign
226 // purge fence or creating op); stale ops are recovery's.
227 if id := opOf(old).ID; id != "" && id != g.operation {
228 return false
229 }
230 }
231 return sameApp(oh, g) || sameApp(nh, g) || opOf(old).ID == g.operation || opOf(next).ID == g.operation
232}
233
234// resetWakeRetryTransition reports whether old->next is exactly what
235// hostclaim.ResetWakeRetry writes for the registered holder incarnation:
236// the same claim with gen advanced and the holder's wake-retry episode
237// replaced by a fresh open episode that carries the operator request.
238func resetWakeRetryTransition(incarnation string, old, next *hostclaim.Claim) bool {
239 oh, nh := holderOf(old), holderOf(next)
240 if incarnation == "" || oh == nil || nh == nil || oh.Incarnation != incarnation || oh.WakeRetry == nil || nh.WakeRetry == nil {
241 return false
242 }
243 r := nh.WakeRetry
244 if r.EpisodeID == "" || r.EpisodeID == oh.WakeRetry.EpisodeID || r.State != "open" || r.ResetRequest == "" ||
245 r.AttemptsReserved != 0 || !r.NotBefore.IsZero() || r.Attempt != nil {
246 return false
247 }
248 want, holder := *old, *oh
249 holder.WakeRetry = r
250 want.Holder, want.Gen = &holder, next.Gen
251 a, err := json.Marshal(&want)
252 if err != nil {
253 return false
254 }
255 b, err := json.Marshal(next)
256 return err == nil && bytes.Equal(a, b)
257}
258
259func recoveryGrantAllows(ctx context.Context, raw release.Store, w *ControlWriter, host string, old, next *hostclaim.Claim) (bool, error) {
260 oh, nh := holderOf(old), holderOf(next)
261 if old != nil {
262 if moveRecoveryKind(old.Op.Kind) {
263 return false, nil
264 }
265 op := old.Op
266 stale := op.Kind == hostclaim.KindParking && len(old.Obligations) == 0 ||
267 op.Kind != "" && op.Kind != hostclaim.KindServing && !op.StartedAt.IsZero() && w.now().After(op.StartedAt.Add(5*time.Minute))
268 if stale && (nh == nil || (oh != nil && oh.Incarnation == nh.Incarnation)) && (next.Op.ID == op.ID || next.Op.ID == "") {
269 return true, nil
270 }
271 }
272 // Frozen-backfill adoption: an absent or free claim gains the holder of an
273 // existing matching record, only while a signed plan is adopting.
274 if nh == nil || (old != nil && (oh != nil || old.Op.Kind != "")) {
275 return false, nil
276 }
277 rawPlan, _, err := raw.Get(ctx, BackfillKey)
278 if errors.Is(err, os.ErrNotExist) {
279 return false, nil
280 }
281 if err != nil {
282 return false, err
283 }
284 var plan BackfillPlan
285 if release.VerifyRootEnvelope(w.pub, rawPlan, &plan) != nil || plan.Phase != "adopting" {
286 return false, nil
287 }
288 if !strings.HasPrefix(nh.Key, "apps/"+nh.Account+"/"+nh.App+"/") {
289 return false, ErrIdentity
290 }
291 r, err := ReadRecord(ctx, raw, nh.Key)
292 if errors.Is(err, os.ErrNotExist) {
293 return false, nil
294 }
295 if err != nil {
296 return false, err
297 }
298 return r.Holder.Host == host && matches(r, nh), nil
299}
300
301// authorizeSignedControl admits a root-signed control object (the roster,
302// the backfill plan) only with a valid root envelope.
303func authorizeSignedControl(pub ed25519.PublicKey, t MutationTarget) error {
304 if t.Method != "Put" {
305 return ErrIdentity
306 }
307 var v json.RawMessage
308 if release.VerifyRootEnvelope(pub, t.After, &v) != nil {
309 return ErrIdentity
310 }
311 return nil
312}
313