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 "reflect"
10 "slices"
11 "time"
12
13 "github.com/carlosframework/platform/internal/hostactors"
14 "github.com/carlosframework/platform/internal/hostclaim"
15 "github.com/carlosframework/platform/internal/hostfence"
16 "github.com/carlosframework/platform/internal/hostresolve"
17 "github.com/carlosframework/platform/internal/provision"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// The declare-lost grant for a move's target (claim Plan 10, J17 with J4).
22// The console's resolve doors call these two typed ControlWriter methods;
23// neither takes a store, a callback or a transition. Each builds the generic
24// hostresolve service with only the moves owner registered, for the move
25// kinds only, over a private store that never leaves the method:
26//
27// - inspection reads the raw bucket through a store that refuses every
28// write;
29// - a declaration writes through lossScopedStore, bound to the exact
30// declaration the signed challenge names (loss id, host, op, kind, box,
31// claim ETag, incarnation, account and app). It admits exactly the
32// service's acceptance, quarantine of that box, and loss record for that
33// id, the claim's declare-lost CAS and the owner's release of it, and
34// the owner's two record transitions, each recomputed from the bucket
35// and compared with the bytes offered. The move's admitted registration
36// is reread at every acceptance, quarantine, claim and record write.
37
38// lossBinding is one declaration's scope.
39type lossBinding struct {
40 lossID, host, opID, kind, box, claimETag, incarnation, account, app, bucket string
41}
42
43// moveLossService is the service a typed method runs, with the moves owner
44// for the move kinds and nothing else.
45func moveLossService(store release.Store, pub ed25519.PublicKey, signer release.Signer, bucket string, now func() time.Time) hostresolve.Service {
46 owner := &moveLossOwner{store: store, pub: pub, now: now}
47 owners := map[string]hostresolve.OperationOwner{}
48 for _, kind := range moveLossKinds {
49 owners[kind] = owner
50 }
51 return hostresolve.Service{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket}, Signer: signer, Now: now, Owners: owners}
52}
53
54func (w *ControlWriter) lossSigner(signer release.Signer) error {
55 if err := w.ready(); err != nil {
56 return err
57 }
58 if !bytes.Equal(signer.RootPublicKey(), w.pub) {
59 return ErrContract
60 }
61 return nil
62}
63
64// moveLossClaim reads the claim a door names and holds it to the door's
65// account and app and to a move kind this owner serves.
66func (w *ControlWriter) moveLossClaim(ctx context.Context, account, app, host string) (hostclaim.Observed, error) {
67 o, err := hostclaim.Read(ctx, w.raw, host)
68 if err != nil {
69 return hostclaim.Observed{}, err
70 }
71 if o.Absent || o.Claim == nil || o.Claim.Holder == nil {
72 return hostclaim.Observed{}, hostresolve.ErrResolve
73 }
74 h := o.Claim.Holder
75 if !operationHomeMatches(w.homeRegion, o.Claim) {
76 return hostclaim.Observed{}, ErrIdentity
77 }
78 if h.Family != "instance" || h.Account != account || h.App != app || h.Key != provision.RecordKey(account, app, host) {
79 return hostclaim.Observed{}, ErrIdentity
80 }
81 return o, nil
82}
83
84// InspectMoveLoss answers the signed challenge for declaring box, a move's
85// target, lost on host. Read-only.
86func (w *ControlWriter) InspectMoveLoss(ctx context.Context, signer release.Signer, bucket, actor, account, app, host, box string) (hostresolve.Inspection, error) {
87 if err := w.lossSigner(signer); err != nil {
88 return hostresolve.Inspection{}, err
89 }
90 o, err := w.moveLossClaim(ctx, account, app, host)
91 if err != nil {
92 return hostresolve.Inspection{}, err
93 }
94 if !slices.Contains(moveLossKinds, o.Claim.Op.Kind) {
95 return hostresolve.Inspection{}, hostresolve.ErrResolve
96 }
97 if err := moveAdmission(ctx, w.raw, host, o.Claim); err != nil {
98 return hostresolve.Inspection{}, err
99 }
100 return moveLossService(readOnlyStore{w.raw}, w.pub, signer, bucket, w.now).Inspect(ctx, actor, host, box)
101}
102
103// DeclareMoveLoss accepts and completes a declaration for the challenge in
104// the submission. A new declaration needs the move's exact admitted
105// registration (closed admission and an expired registration still drain
106// an accepted move); a retry of an accepted one replays it from its durable
107// records, even after its claim CAS. It answers the claim the service last
108// observed.
109func (w *ControlWriter) DeclareMoveLoss(ctx context.Context, signer release.Signer, bucket, actor, account, app, host, box string, in hostfence.Submission) (hostclaim.Observed, error) {
110 if err := w.lossSigner(signer); err != nil {
111 return hostclaim.Observed{}, err
112 }
113 var c hostfence.Challenge
114 if err := release.VerifyRootEnvelope(w.pub, in.Challenge, &c); err != nil {
115 return hostclaim.Observed{}, hostresolve.ErrResolve
116 }
117 if _, err := hostclaim.LossRecordKey(c.ID); err != nil || c.Host != host || c.Actor != actor || c.Op.ID == "" || !slices.Contains(moveLossKinds, c.Op.Kind) {
118 return hostclaim.Observed{}, hostresolve.ErrResolve
119 }
120 d := hostactors.Directory{Store: readOnlyStore{w.raw}, PublicKey: w.pub, Bucket: bucket}
121 ref, _, err := d.Resolve(ctx, box)
122 if err != nil || ref != c.Box {
123 return hostclaim.Observed{}, hostresolve.ErrResolve
124 }
125 o, err := w.moveLossClaim(ctx, account, app, host)
126 if err != nil {
127 return hostclaim.Observed{}, err
128 }
129 if o.Claim.Holder.Incarnation != c.HolderIncarnation {
130 return hostclaim.Observed{}, hostresolve.ErrResolve
131 }
132 if o.Claim.Op.ID == c.Op.ID {
133 if err := moveAdmission(ctx, w.raw, host, o.Claim); err != nil {
134 return hostclaim.Observed{}, err
135 }
136 } else if prior, err := hostclaim.ReadLossRecord(ctx, w.raw, w.pub, c.ID); err != nil || prior.Record.Host != host || prior.Record.OpID != c.Op.ID {
137 // The operation has moved on: only an accepted declaration replays.
138 return hostclaim.Observed{}, hostresolve.ErrResolve
139 }
140 b := lossBinding{bucket: bucket, lossID: c.ID, host: host, opID: c.Op.ID, kind: c.Op.Kind, box: c.Box, claimETag: c.ClaimETag, incarnation: c.HolderIncarnation, account: account, app: app}
141 scoped := &lossScopedStore{raw: w.raw, pub: w.pub, now: w.now, b: b, homeRegion: w.homeRegion}
142 if _, err := moveLossService(scoped, w.pub, signer, bucket, w.now).Declare(ctx, actor, host, in); err != nil {
143 return hostclaim.Observed{}, err
144 }
145 // The service answers its claim CAS; the owner's release wrote after it.
146 return hostclaim.Read(ctx, w.raw, host)
147}
148
149// readOnlyStore refuses every write.
150type readOnlyStore struct{ raw release.Store }
151
152func (s readOnlyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
153 return s.raw.Get(ctx, key)
154}
155func (s readOnlyStore) List(ctx context.Context, prefix string) ([]string, error) {
156 return s.raw.List(ctx, prefix)
157}
158func (s readOnlyStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
159 return s.raw.ListDirs(ctx, prefix)
160}
161func (readOnlyStore) Put(context.Context, string, []byte, string) (string, error) {
162 return "", errMoveLossScope
163}
164func (readOnlyStore) Delete(context.Context, string) error { return errMoveLossScope }
165
166// lossScopedStore is one declaration's private store (see above).
167type lossScopedStore struct {
168 homeRegion string
169 raw release.Store
170 pub ed25519.PublicKey
171 now func() time.Time
172 b lossBinding
173}
174
175func (s *lossScopedStore) Get(ctx context.Context, key string) ([]byte, string, error) {
176 return s.raw.Get(ctx, key)
177}
178func (s *lossScopedStore) List(ctx context.Context, prefix string) ([]string, error) {
179 return s.raw.List(ctx, prefix)
180}
181func (s *lossScopedStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
182 return s.raw.ListDirs(ctx, prefix)
183}
184func (s *lossScopedStore) Delete(context.Context, string) error { return errMoveLossScope }
185
186func (s *lossScopedStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) {
187 lossKey, err := hostclaim.LossRecordKey(s.b.lossID)
188 if err != nil {
189 return "", errMoveLossScope
190 }
191 claimKey, err := hostclaim.Key(s.b.host)
192 if err != nil {
193 return "", errMoveLossScope
194 }
195 switch {
196 case key == "control/host-loss-acceptances/"+s.b.lossID+".json":
197 err = s.acceptance(ctx, body, etag)
198 case key == lossKey:
199 err = s.lossRecord(ctx, body, etag)
200 case key == hostactors.Key:
201 err = s.quarantine(ctx, body, etag)
202 case key == claimKey:
203 err = s.claim(ctx, body, etag)
204 case provision.IsRecordKey(key):
205 err = s.record(ctx, key, body, etag)
206 default:
207 err = errMoveLossScope
208 }
209 if err != nil {
210 return "", err
211 }
212 if provision.IsRecordKey(key) {
213 return putSequencedRecord(ctx, s.raw, key, body, etag)
214 }
215 return s.raw.Put(ctx, key, body, etag)
216}
217
218// bound holds a loss record to the declaration.
219func (s *lossScopedStore) bound(r hostclaim.LossRecord) bool {
220 b := s.b
221 return r.ID == b.lossID && r.Host == b.host && r.OpID == b.opID && r.OpKind == b.kind && r.Box == b.box &&
222 r.ExpectedClaimETag == b.claimETag && r.HolderIncarnation == b.incarnation && r.Validate() == nil
223}
224
225// registered rereads the claim and the move's admitted registration. The
226// claim must still be this holder's, under this op.
227func (s *lossScopedStore) registered(ctx context.Context) (hostclaim.Observed, error) {
228 o, err := hostclaim.Read(ctx, s.raw, s.b.host)
229 if err != nil {
230 return hostclaim.Observed{}, err
231 }
232 if o.Absent || o.Claim == nil || o.Claim.Holder == nil {
233 return hostclaim.Observed{}, ErrIdentity
234 }
235 h := o.Claim.Holder
236 if !operationHomeMatches(s.homeRegion, o.Claim) {
237 return hostclaim.Observed{}, ErrIdentity
238 }
239 if h.Account != s.b.account || h.App != s.b.app || h.Incarnation != s.b.incarnation || o.Claim.Op.ID != s.b.opID {
240 return hostclaim.Observed{}, ErrIdentity
241 }
242 if err := moveAdmission(ctx, s.raw, s.b.host, o.Claim); err != nil {
243 return hostclaim.Observed{}, err
244 }
245 return o, nil
246}
247
248func (s *lossScopedStore) acceptance(ctx context.Context, body []byte, etag string) error {
249 if etag != "" {
250 return errMoveLossScope
251 }
252 var a struct {
253 V int `json:"v"`
254 Bucket string `json:"bucket"`
255 RosterETag string `json:"roster_etag"`
256 Loss hostclaim.LossRecord `json:"loss"`
257 }
258 if release.VerifyRootEnvelope(s.pub, body, &a) != nil || a.V != 1 || s.b.bucket == "" || a.Bucket != s.b.bucket || a.RosterETag == "" || a.Loss.State != "prepared" || !s.bound(a.Loss) {
259 return errMoveLossScope
260 }
261 _, err := s.registered(ctx)
262 return err
263}
264
265func (s *lossScopedStore) lossRecord(ctx context.Context, body []byte, etag string) error {
266 var r hostclaim.LossRecord
267 if release.VerifyRootEnvelope(s.pub, body, &r) != nil || !s.bound(r) {
268 return errMoveLossScope
269 }
270 if _, err := s.registered(ctx); err != nil {
271 return err
272 }
273 before, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID)
274 if errors.Is(err, hostclaim.ErrLossRecordAbsent) {
275 if etag != "" || r.State != "prepared" {
276 return errMoveLossScope
277 }
278 return nil
279 }
280 if err != nil {
281 return err
282 }
283 if before.ETag != etag {
284 return release.ErrCASConflict
285 }
286 if hostclaim.ValidateLossRecordReplacement(before, r) != nil {
287 return errMoveLossScope
288 }
289 return nil
290}
291
292// quarantine admits exactly Directory.Quarantine of the bound box for the
293// bound declaration: one revision, that box's state, revision and loss ids,
294// nothing else.
295func (s *lossScopedStore) quarantine(ctx context.Context, body []byte, etag string) error {
296 if _, err := s.registered(ctx); err != nil {
297 return err
298 }
299 raw, tag, err := s.raw.Get(ctx, hostactors.Key)
300 if err != nil {
301 return err
302 }
303 if tag != etag {
304 return release.ErrCASConflict
305 }
306 var before, after hostactors.Roster
307 if release.VerifyRootEnvelope(s.pub, raw, &before) != nil || release.VerifyRootEnvelope(s.pub, body, &after) != nil {
308 return errMoveLossScope
309 }
310 old, ok := before.Boxes[s.b.box]
311 next, ok2 := after.Boxes[s.b.box]
312 if !ok || !ok2 || after.Revision != before.Revision+1 || next.State != "quarantined" || next.QuarantineRevision != old.QuarantineRevision+1 ||
313 !reflect.DeepEqual(next.LossIDs, append(append([]string(nil), old.LossIDs...), s.b.lossID)) {
314 return errMoveLossScope
315 }
316 rest := hostactors.Clone(after)
317 rest.Revision = before.Revision
318 box := next
319 box.State, box.QuarantineRevision, box.LossIDs = old.State, old.QuarantineRevision, old.LossIDs
320 rest.Boxes[s.b.box] = box
321 a, err1 := json.Marshal(rest)
322 b, err2 := json.Marshal(before)
323 if err1 != nil || err2 != nil || !bytes.Equal(a, b) {
324 return errMoveLossScope
325 }
326 return nil
327}
328
329// claim admits exactly the declaration's claim CAS (hostclaim.DeclareLost
330// of the bound loss id, while the loss is prepared) or the owner's release
331// of the lost target (once the claim carries the declaration), each
332// recomputed from the bucket's current claim at the offered ETag.
333func (s *lossScopedStore) claim(ctx context.Context, body []byte, etag string) error {
334 o, err := s.registered(ctx)
335 if err != nil {
336 return err
337 }
338 if o.ETag != etag {
339 return release.ErrCASConflict
340 }
341 loss, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID)
342 if err != nil {
343 return err
344 }
345 if !s.bound(loss.Record) {
346 return errMoveLossScope
347 }
348 owner := &moveLossOwner{store: readOnlyStore{s.raw}, pub: s.pub, now: s.now}
349 var want []byte
350 switch {
351 case loss.Record.State == "prepared" && !slicesContain(o.Claim.LossRefs, s.b.lossID):
352 want, err = simulateClaim(ctx, s.raw, o, func(sim release.Store) error {
353 _, err := hostclaim.DeclareLost(ctx, sim, s.pub, o, s.b.lossID, owner)
354 return err
355 })
356 case loss.Record.State == "committed" && slicesContain(o.Claim.LossRefs, s.b.lossID):
357 in, _, ierr := lossIntentOf(loss.Record)
358 if ierr != nil {
359 return ierr
360 }
361 rel := moveLossRelease(in, loss.Record, o.Claim.Holder, s.b.host)
362 want, err = simulateClaim(ctx, s.raw, o, func(sim release.Store) error {
363 _, err := hostclaim.Update(ctx, sim, o, hostclaim.ReleaseLostTarget(rel))
364 return err
365 })
366 default:
367 return errMoveLossScope
368 }
369 if err != nil {
370 return err
371 }
372 if !bytes.Equal(want, body) {
373 return errMoveLossScope
374 }
375 return nil
376}
377
378// record admits exactly the owner's two record transitions for the holder's
379// record: the prepared declaration's decision at the inspected record, or
380// the committed declaration's clean release once the claim has released
381// the target. Only move fields, the record sequence and the move's update
382// time may differ from the recomputation.
383func (s *lossScopedStore) record(ctx context.Context, key string, body []byte, etag string) error {
384 o, err := s.registered(ctx)
385 if err != nil {
386 return err
387 }
388 if key != o.Claim.Holder.Key {
389 return errMoveLossScope
390 }
391 if err := CheckCreateFreeze(ctx, s.raw, s.pub); err != nil {
392 return err
393 }
394 cur, err := ReadRecord(ctx, s.raw, key)
395 if err != nil {
396 return err
397 }
398 if cur.ETag != etag {
399 return release.ErrCASConflict
400 }
401 if !matches(cur, o.Claim.Holder) {
402 return ErrIdentity
403 }
404 loss, err := hostclaim.ReadLossRecord(ctx, s.raw, s.pub, s.b.lossID)
405 if err != nil {
406 return err
407 }
408 if !s.bound(loss.Record) {
409 return errMoveLossScope
410 }
411 in, ref, err := lossIntentOf(loss.Record)
412 if err != nil {
413 return err
414 }
415 _, seq, err := recordSequence(cur.Body)
416 if err != nil {
417 return err
418 }
419 _, next, err := recordSequence(body)
420 if err != nil || next != seq+1 {
421 return errMoveLossScope
422 }
423 want, err := provision.DecodeRecord(cur.Body)
424 if err != nil {
425 return ErrIdentity
426 }
427 switch {
428 case loss.Record.State == "prepared" && o.ETag == loss.Record.ExpectedClaimETag && cur.ETag == in.RecordETag && seq == in.RecordSeq:
429 if in.Supersedes != "" {
430 // A rebind is admitted only while the superseded declaration is
431 // still signed conflicted and the intent recomputes exactly.
432 again, ierr := lossIntentAt(ctx, readOnlyStore{s.raw}, s.pub, o, cur, in.Target)
433 if ierr != nil || !reflect.DeepEqual(again, in) {
434 return errMoveLossScope
435 }
436 }
437 err = prepareMoveLoss(&want, in, ref, loss.Record.FencedAt)
438 case loss.Record.State == "committed" && hostclaim.LostTargetReleased(o.Claim, s.b.lossID):
439 err = provision.MoveRollbackLossCommitted(provision.MoveGuard{ID: in.Move, Incarnation: in.Incarnation})(&want)
440 default:
441 return errMoveLossScope
442 }
443 if err != nil {
444 return err
445 }
446 expected, err := json.Marshal(want)
447 if err != nil {
448 return err
449 }
450 if !sameExceptSequenceAndUpdate(expected, body) {
451 return errMoveLossScope
452 }
453 return nil
454}
455
456// sameExceptSequenceAndUpdate compares two record bodies as JSON values,
457// ignoring record_seq and move.updated_at only.
458func sameExceptSequenceAndUpdate(a, b []byte) bool {
459 strip := func(raw []byte) (map[string]any, bool) {
460 var v map[string]any
461 dec := json.NewDecoder(bytes.NewReader(raw))
462 dec.UseNumber()
463 if dec.Decode(&v) != nil {
464 return nil, false
465 }
466 delete(v, "record_seq")
467 if m, ok := v["move"].(map[string]any); ok {
468 delete(m, "updated_at")
469 }
470 return v, true
471 }
472 x, ok1 := strip(a)
473 y, ok2 := strip(b)
474 return ok1 && ok2 && reflect.DeepEqual(x, y)
475}
476
477// simulateClaim runs a claim write against a private copy of the bucket's
478// current claim and returns the bytes it would Put. Every other key reads
479// through to the bucket; no write reaches it.
480func simulateClaim(ctx context.Context, raw release.Store, o hostclaim.Observed, write func(release.Store) error) ([]byte, error) {
481 key, err := hostclaim.Key(o.Host)
482 if err != nil {
483 return nil, err
484 }
485 sim := &claimSimulation{raw: raw, key: key, etag: o.ETag}
486 if err := write(sim); err != nil {
487 return nil, err
488 }
489 if sim.put == nil {
490 return nil, errMoveLossScope
491 }
492 return sim.put, nil
493}
494
495type claimSimulation struct {
496 raw release.Store
497 key, etag string
498 put []byte
499}
500
501func (s *claimSimulation) Get(ctx context.Context, key string) ([]byte, string, error) {
502 return s.raw.Get(ctx, key)
503}
504func (s *claimSimulation) List(ctx context.Context, prefix string) ([]string, error) {
505 return s.raw.List(ctx, prefix)
506}
507func (s *claimSimulation) ListDirs(ctx context.Context, prefix string) ([]string, error) {
508 return s.raw.ListDirs(ctx, prefix)
509}
510func (s *claimSimulation) Delete(context.Context, string) error { return errMoveLossScope }
511func (s *claimSimulation) Put(_ context.Context, key string, body []byte, etag string) (string, error) {
512 if key != s.key || etag != s.etag || s.put != nil {
513 return "", errMoveLossScope
514 }
515 s.put = append([]byte(nil), body...)
516 return "simulated", nil
517}
518