carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "github.com/carlosframework/platform/internal/park"
8 "path/filepath"
9 "reflect"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14
15 "github.com/carlosframework/platform/internal/activator"
16 "github.com/carlosframework/platform/internal/cgroup"
17 "github.com/carlosframework/platform/internal/genselect"
18 "github.com/carlosframework/platform/internal/hostbox"
19 "github.com/carlosframework/platform/internal/hostclaim"
20 "github.com/carlosframework/platform/internal/hostwrite"
21 "github.com/carlosframework/platform/internal/moves"
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26// ClaimKindOwners is one family of op kinds' input, admission and
27// publication owners (claim Plan 9 D5, moves ruling H16).
28type ClaimKindOwners struct {
29 InputPolicy func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error
30 Inputs ClaimWakeInputs
31 AdmissionEvidence func(context.Context, hostclaim.Observed, hostclaim.Admission, *genselect.Plan) error
32 PublicationEvidence func(context.Context, hostclaim.Observed) error
33}
34
35func (o ClaimKindOwners) complete() bool {
36 return o.Inputs != nil && o.AdmissionEvidence != nil && o.PublicationEvidence != nil
37}
38
39// ClaimMovesOwners is the moves integration's owner set. The dispatcher hands
40// it only moving, landing, reverting and restoring observations, and only
41// with CARLOS_MOVES_PHASE1 on. ProbeReady replaces the claim's probe
42// readiness check when set.
43type ClaimMovesOwners struct {
44 LossRecovery hostclaim.LossRecoveryOwner
45 LossSources hostbox.LossAdmissionSources
46 ClaimKindOwners
47 Operations OperationAttempts
48 ProbeReady ClaimProbeReadiness
49}
50
51func (m ClaimMovesOwners) zero() bool {
52 return m.LossRecovery == nil && m.LossSources == nil && m.Inputs == nil && m.AdmissionEvidence == nil && m.PublicationEvidence == nil && m.Operations == nil && m.ProbeReady == nil
53}
54
55// ClaimRelocationOwners is region HA's owner set for the relocating kind
56// (claim Plan 11 R1, R8). Inputs is region HA's host.RelocationWakeInputs;
57// with the admission and publication evidence it is one ClaimKindOwners
58// family, exactly as the moves owners are. Operations is the relocation's
59// attempt journal for relocating/probing (D2's OperationAttempts contract).
60// ProbeReady replaces the claim's probe readiness check when set. Policy is
61// the typed hostbox.Policy.Relocation owner: it runs only after the claim's
62// own relocating gate passed, so it can only refuse. The dispatcher hands
63// these owners only relocating observations at a phase of the relocating
64// session row, and only with CARLOS_HOST_CLAIM, CARLOS_MOVES_PHASE1 and
65// CARLOS_REGION_HA all on.
66type ClaimRelocationOwners struct {
67 // These hooks verify relocation's source release and target installation.
68 BeforeAdmission func(context.Context, hostclaim.Observed) error
69 AfterAdmission func(context.Context, hostclaim.Observed, hostclaim.Observed) error
70 ClaimKindOwners
71 Operations OperationAttempts
72 ProbeReady ClaimProbeReadiness
73 Policy func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error
74}
75
76func (r ClaimRelocationOwners) zero() bool {
77 return r.BeforeAdmission == nil && r.AfterAdmission == nil && r.Inputs == nil && r.AdmissionEvidence == nil && r.PublicationEvidence == nil && r.Operations == nil && r.ProbeReady == nil && r.Policy == nil
78}
79
80// claimRelocationRefusal is the typed refusal for a relocating observation
81// whose owner set is bound but incomplete.
82func claimRelocationRefusal() error {
83 return &ClaimDependencyError{Contracts: []string{"region HA relocation wake inputs, attempt journal and policy"}}
84}
85
86// claimRelocationC1 decides whether region HA's relocation owners are bound.
87// They need the moves C1 integration (Group D: the session table, the fenced
88// publisher and the operation path) and CARLOS_REGION_HA on. Either off, or
89// no relocation owners, leaves relocating exactly as Plan 9 left it: refused.
90// A partial owner set refuses the whole assembly rather than half-wiring it.
91func claimRelocationC1(getenv func(string) string, movesC1 bool, r ClaimRelocationOwners) (bool, error) {
92 if !movesC1 || getenv == nil || !regionHAEnabled(getenv) || r.zero() {
93 return false, nil
94 }
95 if !r.complete() || r.Operations == nil || r.Policy == nil || r.BeforeAdmission == nil || r.AfterAdmission == nil {
96 return false, hostwrite.ErrUnavailable
97 }
98 return true, nil
99}
100
101// ClaimServingLaunch is the exact launch the serving exec path would run for
102// a route: the live executable and its digest, its arguments and composed
103// environment, the cgroup limit, the live version, and the config and
104// release identities the provider's InputPolicy accepts for that route.
105type ClaimServingLaunch struct {
106 Unit *hostbox.UnitLaunchInputs
107 Identity hostwrite.Identity
108 Executable string
109 ExecutableSHA256 string
110 Args, Env []string
111 Version string
112 // Staged is the file ExecutableSHA256 hashes and the version it is
113 // staged under, from one resolution of the live link
114 // (activator.ClaimLaunchSnapshot.Staged).
115 Staged release.StagedIdentity
116 Limit cgroup.Limit
117 // Roles are the processes the real start spawns: "main", plus "seed"
118 // and "sidecar" when the route carries them. Nil means main alone.
119 Roles []string
120}
121
122// ClaimMarkerStore writes generation.json conditionally (moves §5.1, claim
123// §6.4a item 5): PutMarker succeeds only while the key's current object has
124// expectedETag, or, with "", while the key has no current object
125// (If-None-Match). It returns the version the write created. check is the
126// publisher's fence: the store runs it immediately before issuing every
127// request attempt, retries included, and issues nothing once it fails. Every
128// attempt runs under ctx, which bounds the request and is cancelled by the
129// fence; PutMarker returns only after its last attempt has returned.
130type ClaimMarkerStore interface {
131 PutMarker(ctx context.Context, bucket, key string, body []byte, expectedETag string, check func(context.Context) error) (versionID string, err error)
132}
133
134// ClaimFilesManifest is the moves C8 files-manifest contract (claim §6.2a
135// P6-D3, moves C8). It is external: the moves integration implements it and
136// the claim has no stand-in. Establish runs once per start, after the held
137// identities are proved and before the app executes, and does its local
138// work only under work, the start's captured selection. It establishes the
139// admitted generation's files manifest from the newest valid manifest of its
140// admitted predecessor chain, read by exact version, keeping each reference
141// origin-qualified to its immutable object in the incarnation's location and
142// region. Missing or malformed data is an error, never an empty-files
143// bootstrap; only an empty bootstrap (in.Empty, after EmptyAuthority) starts
144// from no manifest.
145//
146// The instance key (L21, plan 2026-10-10-instance-key-provision.md
147// obligation O1'): the launch source has already minted
148// CARLOS_INSTANCE_KEY into the host's own <host>.env, durably, when
149// Establish runs, and the app executes only after it returns. So the
150// manifest Establish leaves must carry the env file (region HA §5.6's
151// always-continuous platform item) with exactly the bytes on disk when it
152// runs, staged and committed before it returns. A new admission starts
153// with no adoption record: the manifest carries no instancekey.AdoptionItem
154// (StagePlatformDelete), and the local <host>.instance-key-adoption.json is
155// removed under work. An error, a bucket that cannot be reached included,
156// fails the wake and the app does not start. Establish never writes the env
157// file on disk.
158type ClaimFilesManifest interface {
159 Establish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error
160}
161
162// claimFilesRefusal is the typed refusal while no C8 provider is bound.
163func claimFilesRefusal() error {
164 return &ClaimDependencyError{Contracts: []string{"moves C8 origin-qualified files manifest provider"}}
165}
166
167// ClaimServingSources are the typed sources the claim's own serving owners
168// read. The provider supplies them: the launch identity and InputPolicy are
169// one contract, the conditional marker write is a data bucket capability,
170// and Files is the required moves C8 files-manifest provider. Without Files
171// every serving wake is refused, typed, before any lease or launch.
172type ClaimServingSources struct {
173 Launch func(context.Context, registry.Route) (ClaimServingLaunch, error)
174 Markers ClaimMarkerStore
175 Files ClaimFilesManifest
176 // Identity verifies this box's own identity (the roster entry and its
177 // evidence, hostbox.IdentityProvider.Verify) before every data-bucket
178 // request region HA's relocation inputs make, as main's serving owners
179 // do through their RequestStore's Check (claimconstruction.go). Only
180 // the relocation source requires it; main binds its own.
181 Identity func(context.Context) error
182}
183
184// claimMovesC1 decides whether the moves C1 integration (Group D) is bound.
185// The production assembly opens only with CARLOS_HOST_CLAIM on; the
186// integration also needs CARLOS_MOVES_PHASE1 on and a moves owner set.
187// Moves off, or no moves owners, leaves every Group D path inert: main's
188// refusals and phase equality stand. A partial owner set refuses the whole
189// assembly rather than half-wiring it.
190func claimMovesC1(getenv func(string) string, m ClaimMovesOwners) (bool, error) {
191 if getenv == nil || !hostwrite.Enabled(getenv) || !moves.Enabled(getenv) || m.zero() {
192 return false, nil
193 }
194 if !m.complete() || m.Operations == nil {
195 return false, hostwrite.ErrUnavailable
196 }
197 return true, nil
198}
199
200func claimMovesKind(kind string) bool {
201 switch kind {
202 case hostclaim.KindMoving, hostclaim.KindLanding, hostclaim.KindReverting, hostclaim.KindRestoring:
203 return true
204 }
205 return false
206}
207
208// ClaimOwnerDispatch routes every owner call by the observed claim's op kind:
209// serving to the claim's own serving owners, the four move kinds to the
210// moves owners, relocating to region HA's typed Relocation entry (claim Plan
211// 11 R1). Moves nil keeps every move kind refused. Relocation nil keeps
212// relocating unsupported, as before Plan 11; bound, it receives relocating
213// observations only at a phase of the relocating session row (probing, and
214// the passed probe's session serving), and every other relocating phase is
215// refused before any owner runs. Any other kind is refused as unsupported.
216type ClaimOwnerDispatch struct {
217 Serving ClaimKindOwners
218 Moves *ClaimKindOwners
219 Relocation *ClaimKindOwners
220}
221
222func (d ClaimOwnerDispatch) owners(o hostclaim.Observed) (ClaimKindOwners, error) {
223 if o.Absent || o.Claim == nil {
224 return ClaimKindOwners{}, hostbox.ErrChanged
225 }
226 switch kind := o.Claim.Op.Kind; {
227 case kind == hostclaim.KindServing:
228 if !d.Serving.complete() {
229 return ClaimKindOwners{}, hostwrite.ErrUnavailable
230 }
231 return d.Serving, nil
232 case claimMovesKind(kind):
233 if d.Moves == nil || !d.Moves.complete() {
234 return ClaimKindOwners{}, claimOperationRefusal()
235 }
236 return *d.Moves, nil
237 case kind == hostclaim.KindRelocating && d.Relocation != nil:
238 if !d.Relocation.complete() {
239 return ClaimKindOwners{}, claimRelocationRefusal()
240 }
241 if !hostclaim.SessionPhase(hostclaim.KindRelocating, o.Claim.Op.Phase) {
242 return ClaimKindOwners{}, hostbox.ErrChanged
243 }
244 return *d.Relocation, nil
245 }
246 return ClaimKindOwners{}, hostbox.ErrUnsupported
247}
248
249func (d ClaimOwnerDispatch) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) {
250 owners, err := d.owners(o)
251 if err != nil {
252 return ClaimLaunchInputs{}, err
253 }
254 return owners.Inputs.Select(ctx, rt, o, p)
255}
256
257func (d ClaimOwnerDispatch) Materialize(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error {
258 owners, err := d.owners(o)
259 if err != nil {
260 return err
261 }
262 return owners.Inputs.Materialize(ctx, o, in, work)
263}
264
265func (d ClaimOwnerDispatch) EmptyAuthority(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error {
266 owners, err := d.owners(o)
267 if err != nil {
268 return err
269 }
270 return owners.Inputs.EmptyAuthority(ctx, o, in)
271}
272
273func (d ClaimOwnerDispatch) Publish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error {
274 owners, err := d.owners(o)
275 if err != nil {
276 return err
277 }
278 return owners.Inputs.Publish(ctx, o, in)
279}
280
281// PublishFenced routes the fenced publisher by op kind. An owner set without
282// its own fenced publisher keeps a typed refusal and never falls back to the
283// unfenced Publish.
284func (d ClaimOwnerDispatch) PublishFenced(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, pub ClaimPublication) (ClaimPublicationReceipt, error) {
285 owners, err := d.owners(o)
286 if err != nil {
287 return ClaimPublicationReceipt{}, err
288 }
289 p, ok := owners.Inputs.(ClaimFencedPublication)
290 if !ok {
291 return ClaimPublicationReceipt{}, &ClaimDependencyError{Contracts: []string{"moves C1 fenced generation publisher"}}
292 }
293 return p.PublishFenced(ctx, o, in, pub)
294}
295
296func (d ClaimOwnerDispatch) AdmissionEvidence(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) error {
297 owners, err := d.owners(o)
298 if err != nil {
299 return err
300 }
301 return owners.AdmissionEvidence(ctx, o, in, baseline)
302}
303
304func (d ClaimOwnerDispatch) PublicationEvidence(ctx context.Context, o hostclaim.Observed) error {
305 owners, err := d.owners(o)
306 if err != nil {
307 return err
308 }
309 return owners.PublicationEvidence(ctx, o)
310}
311
312// claimServingOwners is the claim's own serving input, admission and
313// publication owner set (claim Plan 9 D5). A serving wake is a cold admission
314// into a fresh generation restored from the certified remote baseline of
315// holder.generation in this region (claim §6.4a; moves §7.14 serving row,
316// all cold while P7 stands). The first writer of a created incarnation is the
317// empty bootstrap (claim §6.4a "Empty bootstrap"). The marker names the new
318// generation, with snapshot_txid 0, only after the session's own root is
319// acknowledged (INV-11), and publication evidence binds the exact marker
320// version this process wrote and the exact root version it named.
321type claimServingOwners struct {
322 emptyRecovery claimEmptyRecovery
323 HomeRegion string
324 Data genselect.Reader
325 Bucket, Region, Box string
326 NewID func() string
327 Now func() time.Time
328 Sources ClaimServingSources
329 mu sync.Mutex
330 published map[string]ClaimPublicationReceipt
331}
332
333func checkClaimServingSources(s ClaimServingSources) error {
334 if s.Launch == nil || s.Markers == nil || s.Files == nil {
335 return &ClaimDependencyError{Contracts: []string{"claimed serving launch, marker and C8 files owners"}}
336 }
337 return nil
338}
339
340func (s *claimServingOwners) owners() ClaimKindOwners {
341 return ClaimKindOwners{Inputs: s, AdmissionEvidence: s.admissionEvidence, PublicationEvidence: s.publicationEvidence}
342}
343
344func (s *claimServingOwners) serving(o hostclaim.Observed) error {
345 if s == nil || s.Data == nil || s.Bucket == "" || s.Region == "" || s.Box == "" || s.NewID == nil || s.Now == nil {
346 return hostwrite.ErrUnavailable
347 }
348 if err := checkClaimServingSources(s.Sources); err != nil {
349 return err
350 }
351 if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Op.ID != "" || o.Claim.Op.Phase != "" || o.Claim.Op.Exec != s.Box {
352 return hostbox.ErrChanged
353 }
354 return nil
355}
356
357func (s *claimServingOwners) baseline(ctx context.Context, o hostclaim.Observed) (*genselect.Plan, error) {
358 return genselect.Select(ctx, s.Data, genselect.Request{Bucket: s.Bucket, Region: s.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, s.HomeRegion, s.Region)})
359}
360
361// claimEmptyBootstrap: a created incarnation before its first writer.
362func claimEmptyBootstrap(h *hostclaim.Holder) bool {
363 return h.Session == 0 && h.Generation == "" && h.WriterBox == "" && len(h.Admissions) == 0
364}
365
366// claimFreshGeneration: a usable generation name this holder never used.
367func claimFreshGeneration(h *hostclaim.Holder, g string) bool {
368 if g == "" || g == "." || g == ".." || g == hostclaim.RootGeneration || len(g) > 64 || strings.ContainsAny(g, "/\\\x00\r\n") || g == h.Generation {
369 return false
370 }
371 for _, a := range h.Admissions {
372 if a.Generation == g || a.Predecessor == g {
373 return false
374 }
375 }
376 return true
377}
378
379func claimHexDigest(s string) bool {
380 if len(s) != 64 {
381 return false
382 }
383 for _, r := range s {
384 if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
385 return false
386 }
387 }
388 return true
389}
390
391func (s *claimServingOwners) launch(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) {
392 l, err := s.Sources.Launch(ctx, rt)
393 if err != nil {
394 return ClaimServingLaunch{}, err
395 }
396 if l.Identity.Config == "" || l.Identity.Release == "" || !filepath.IsAbs(l.Executable) || !claimHexDigest(l.ExecutableSHA256) || l.Version == "" {
397 return ClaimServingLaunch{}, hostwrite.ErrUnavailable
398 }
399 return l, nil
400}
401
402// launchDigest is the serving retry's launch-input digest, from the launch
403// source alone: no baseline listing is needed to name the inputs.
404func (s *claimServingOwners) launchDigest(ctx context.Context, rt registry.Route) (string, error) {
405 if s == nil || s.Sources.Launch == nil {
406 return "", hostwrite.ErrUnavailable
407 }
408 l, err := s.launch(ctx, rt)
409 if err != nil {
410 return "", err
411 }
412 return ClaimLaunchDigest(ClaimLaunchInputs{Identity: l.Identity, Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: l.Args, Env: l.Env})
413}
414
415func (s *claimServingOwners) Select(ctx context.Context, rt registry.Route, o hostclaim.Observed, p activator.StartPolicy) (ClaimLaunchInputs, error) {
416 if err := s.serving(o); err != nil {
417 return ClaimLaunchInputs{}, err
418 }
419 // Prerequisites selects before any lease, stop or launch.
420 if s.Sources.Files == nil {
421 return ClaimLaunchInputs{}, claimFilesRefusal()
422 }
423 if p.Probe || p.Prepared != nil || p.Inputs != nil || rt.Host != o.Host || !filepath.IsAbs(rt.DBPath) {
424 return ClaimLaunchInputs{}, hostbox.ErrUnsupported
425 }
426 l, err := s.launch(ctx, rt)
427 if err != nil {
428 return ClaimLaunchInputs{}, err
429 }
430 h := o.Claim.Holder
431 generation := s.NewID()
432 if !claimFreshGeneration(h, generation) {
433 return ClaimLaunchInputs{}, hostwrite.ErrUnavailable
434 }
435 roles := append([]string(nil), l.Roles...)
436 if len(roles) == 0 {
437 roles = []string{"main"}
438 }
439 in := ClaimLaunchInputs{Unit: l.Unit, Roles: roles, Identity: l.Identity, Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: append([]string(nil), l.Env...), DBPath: rt.DBPath, Version: l.Version, Staged: l.Staged, Limit: l.Limit,
440 Admission: hostclaim.Admission{ObservedSession: h.Session, ObservedGeneration: h.Generation, Box: s.Box, Mode: "cold", NewGeneration: generation, Region: s.Region}}
441 empty, err := s.emptyBootstrap(ctx, o)
442 if err != nil {
443 return ClaimLaunchInputs{}, err
444 }
445 if empty {
446 if len(h.Admissions) > 0 {
447 in.Admission.PredecessorRegion = h.Admissions[len(h.Admissions)-1].Region
448 }
449 in.Empty = true
450 return in, nil
451 }
452 plan, err := s.baseline(ctx, o)
453 if err != nil {
454 return ClaimLaunchInputs{}, err
455 }
456 in.Baseline = plan
457 in.Admission.RemoteMaxTXID = plan.MaxTXID
458 // The baseline's objects are in this region: genselect selects nothing
459 // else. With earlier admissions AdmitWriter takes the last record's.
460 in.Admission.PredecessorRegion = s.Region
461 if n := len(h.Admissions); n > 0 {
462 in.Admission.PredecessorRegion = h.Admissions[n-1].Region
463 }
464 return in, nil
465}
466
467// Materialize proves the selected config and release identities are still
468// the held ones under the selected work, then has the C8 provider establish
469// the admitted generation's files manifest under that same captured work.
470// Config sync and release adoption materialize serving inputs under their
471// own selections; a serving wake writes neither.
472func (s *claimServingOwners) Materialize(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error {
473 if err := s.serving(o); err != nil {
474 return err
475 }
476 if s.Sources.Files == nil {
477 return claimFilesRefusal()
478 }
479 if work == nil || in.Identity.Config == "" || in.Identity.Release == "" {
480 return hostwrite.ErrUnavailable
481 }
482 if err := work.With(ctx, in.Identity, func() error { return nil }); err != nil {
483 return err
484 }
485 return s.Sources.Files.Establish(ctx, o, in, work)
486}
487
488// EmptyAuthority is the empty bootstrap's proof, run before any local
489// effect: the admitted session is the incarnation's first writer, its
490// predecessor names no generation and no region, and a complete listing of
491// the holder's location holds nothing. A listing error refuses.
492func (s *claimServingOwners) EmptyAuthority(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error {
493 if err := s.serving(o); err != nil {
494 return err
495 }
496 h := o.Claim.Holder
497 if !in.Empty || in.Baseline != nil || len(h.Admissions) == 0 || h.WriterBox != s.Box {
498 return hostbox.ErrChanged
499 }
500 n := len(h.Admissions)
501 a := h.Admissions[n-1]
502 predecessor, region := "", ""
503 if n > 1 {
504 if s.emptyRecovery == nil {
505 return hostbox.ErrChanged
506 }
507 yes, err := s.emptyRecovery(ctx, o, h.Admissions[:n-1])
508 if err != nil {
509 return err
510 }
511 if !yes {
512 return hostbox.ErrChanged
513 }
514 predecessor, region = h.Admissions[n-2].Generation, h.Admissions[n-2].Region
515 }
516 if a.Session != h.Session || a.Box != s.Box || a.Mode != "cold" || a.Generation != h.Generation || a.Generation != in.Admission.NewGeneration || a.Predecessor != predecessor || a.PredecessorRegion != region || a.Region != s.Region {
517 return hostbox.ErrChanged
518 }
519 return s.emptyLocation(ctx, h.Location, h.Admissions[:n-1]...)
520}
521
522func claimPublishedKey(host string, session uint64) string {
523 return host + "\x00" + strconv.FormatUint(session, 10)
524}
525
526// Publish is main's unfenced publication entry. The serving marker is written
527// only by PublishFenced, as a producer of the session's launch (claim §6.2a):
528// an unfenced listing could take a successor's ETag and overwrite its marker.
529func (s *claimServingOwners) Publish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs) error {
530 if err := s.serving(o); err != nil {
531 return err
532 }
533 return hostwrite.ErrUnavailable
534}
535
536// PublishFenced writes generation.json for the admitted fresh generation
537// (moves §7.14 marker row). It syncs the session's replicator first, then
538// requires the generation's own root: a zero-position empty bootstrap has
539// none until the app's first transaction and gets ErrClaimRootPending, never
540// a zero snapshot or a fabricated root. The marker supersedes the current
541// one by its exact ETag, or is created when none exists; pub.Check runs
542// immediately before the write and, in the store, before every attempt. A
543// replay that finds this generation's marker from this box records it.
544func (s *claimServingOwners) PublishFenced(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, pub ClaimPublication) (ClaimPublicationReceipt, error) {
545 if err := s.serving(o); err != nil {
546 return ClaimPublicationReceipt{}, err
547 }
548 return s.publishFresh(ctx, o, in, pub)
549}
550
551// publishFresh is PublishFenced after its kind check, unchanged: the one
552// fresh-generation marker writer, which region HA's later probe attempts
553// also use (rev 2, ruling 47).
554func (s *claimServingOwners) publishFresh(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, pub ClaimPublication) (ClaimPublicationReceipt, error) {
555 if pub.Sync == nil || pub.Check == nil {
556 return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable
557 }
558 h := o.Claim.Holder
559 if h.Generation == "" || h.Generation != in.Admission.NewGeneration || h.WriterBox != s.Box || in.Prepared {
560 return ClaimPublicationReceipt{}, hostbox.ErrChanged
561 }
562 if err := pub.Sync(ctx); err != nil {
563 return ClaimPublicationReceipt{}, err
564 }
565 bounded, cancel := context.WithTimeout(ctx, hostbox.RequestLimit)
566 defer cancel()
567 plan, err := s.baseline(bounded, o)
568 if in.Empty && errors.Is(err, genselect.ErrEmpty) {
569 return ClaimPublicationReceipt{}, ErrClaimRootPending
570 }
571 if err != nil {
572 return ClaimPublicationReceipt{}, err
573 }
574 // The generation is fresh and only this session's bound replica client
575 // writes under it, so its root is the session's own acknowledged upload.
576 if plan.Generation != h.Generation || plan.Root == nil || plan.Root.MinTXID != 1 || plan.Root.VersionID == "" {
577 return ClaimPublicationReceipt{}, hostbox.ErrChanged
578 }
579 root := *plan.Root
580 key := strings.TrimSuffix(h.Location, "/") + "/generation.json"
581 versions, err := s.Data.ListVersions(bounded, s.Bucket, key)
582 if err != nil {
583 return ClaimPublicationReceipt{}, err
584 }
585 var current *claimMarkerVersion
586 for _, v := range versions {
587 if v.Key == key && v.IsLatest {
588 if current != nil || v.DeleteMarker {
589 return ClaimPublicationReceipt{}, hostbox.ErrChanged
590 }
591 current = &claimMarkerVersion{ID: v.VersionID, ETag: v.ETag}
592 }
593 }
594 if current != nil && plan.Marker != nil && plan.MarkerVersion == current.ID && plan.Marker.Op == h.Generation {
595 if plan.Marker.Box != s.Box || plan.Marker.Incarnation != h.Incarnation {
596 return ClaimPublicationReceipt{}, hostbox.ErrChanged
597 }
598 receipt := ClaimPublicationReceipt{MarkerVersion: current.ID, Root: root}
599 s.record(o.Host, h.Session, receipt)
600 return receipt, nil
601 }
602 body, err := json.Marshal(genselect.Marker{Op: h.Generation, Incarnation: h.Incarnation, Host: o.Host, Box: s.Box, InstalledAt: s.Now().UTC()})
603 if err != nil {
604 return ClaimPublicationReceipt{}, err
605 }
606 expected := ""
607 if current != nil {
608 expected = current.ETag
609 }
610 if err := pub.Check(bounded); err != nil {
611 return ClaimPublicationReceipt{}, err
612 }
613 version, err := s.Sources.Markers.PutMarker(bounded, s.Bucket, key, body, expected, pub.Check)
614 if err != nil {
615 return ClaimPublicationReceipt{}, err
616 }
617 if version == "" {
618 return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable
619 }
620 receipt := ClaimPublicationReceipt{MarkerVersion: version, Root: root}
621 s.record(o.Host, h.Session, receipt)
622 return receipt, nil
623}
624
625type claimMarkerVersion struct{ ID, ETag string }
626
627func (s *claimServingOwners) record(host string, session uint64, receipt ClaimPublicationReceipt) {
628 s.mu.Lock()
629 defer s.mu.Unlock()
630 if s.published == nil {
631 s.published = map[string]ClaimPublicationReceipt{}
632 }
633 s.published[claimPublishedKey(host, session)] = receipt
634}
635
636// admissionEvidence binds a serving cold admission to its observation and to
637// the certified remote baseline, read again here (claim §6.4a "The baseline
638// is always the certified remote").
639func (s *claimServingOwners) admissionEvidence(ctx context.Context, o hostclaim.Observed, in hostclaim.Admission, baseline *genselect.Plan) error {
640 if err := s.serving(o); err != nil {
641 return err
642 }
643 h := o.Claim.Holder
644 if in.ObservedOpID != "" || in.ObservedPhase != "" || in.ObservedSession != h.Session || in.ObservedGeneration != h.Generation || in.Box != s.Box || in.Region != s.Region ||
645 in.Mode != "cold" || in.PredecessorCertified || in.KeepStamp || in.Coverage != "" || len(in.AdditionalWritesLost) != 0 || in.LossID != "" || !claimFreshGeneration(h, in.NewGeneration) {
646 return hostbox.ErrChanged
647 }
648 empty, err := s.emptyBootstrap(ctx, o)
649 if err != nil {
650 return err
651 }
652 if empty {
653 region := ""
654 if len(h.Admissions) > 0 {
655 region = h.Admissions[len(h.Admissions)-1].Region
656 }
657 if baseline != nil || in.RemoteMaxTXID != 0 || in.PredecessorRegion != region {
658 return hostbox.ErrChanged
659 }
660 return nil
661 }
662 if baseline == nil {
663 return hostbox.ErrChanged
664 }
665 fresh, err := s.baseline(ctx, o)
666 if err != nil {
667 return err
668 }
669 if fresh.Generation != baseline.Generation || fresh.MaxTXID != baseline.MaxTXID || in.RemoteMaxTXID != fresh.MaxTXID || !reflect.DeepEqual(fresh.Files, baseline.Files) {
670 return hostbox.ErrChanged
671 }
672 if len(h.Admissions) == 0 && in.PredecessorRegion != s.Region {
673 return hostbox.ErrChanged
674 }
675 return nil
676}
677
678// publicationEvidence requires the receipt of this process's PublishFenced
679// for the admitted session: the marker is exactly the version it wrote and
680// names the holder's generation, incarnation, host and this box, and the
681// exact root version it named still exists under the generation with its key
682// current. Retention is off for a bound generation, so the session never
683// removes it. A late duplicate of the same upload is another version at the
684// key, and a later compaction may give genselect a better root; neither is a
685// change.
686func (s *claimServingOwners) publicationEvidence(ctx context.Context, o hostclaim.Observed) error {
687 if err := s.serving(o); err != nil {
688 return err
689 }
690 return s.freshEvidence(ctx, o)
691}
692
693// freshEvidence is publicationEvidence after its kind check, unchanged.
694func (s *claimServingOwners) freshEvidence(ctx context.Context, o hostclaim.Observed) error {
695 h := o.Claim.Holder
696 s.mu.Lock()
697 receipt, ok := s.published[claimPublishedKey(o.Host, h.Session)]
698 s.mu.Unlock()
699 prefix, err := hostbox.GenerationPrefix(*h)
700 if !ok || err != nil || receipt.MarkerVersion == "" || receipt.Root.VersionID == "" || !strings.HasPrefix(receipt.Root.Key, prefix) {
701 return hostbox.ErrChanged
702 }
703 plan, err := s.baseline(ctx, o)
704 if err != nil {
705 return err
706 }
707 if plan.Generation != h.Generation || plan.Root == nil || plan.Marker == nil || plan.MarkerVersion != receipt.MarkerVersion || plan.Marker.Op != h.Generation || plan.Marker.Box != s.Box || plan.Marker.Incarnation != h.Incarnation || plan.Marker.Host != o.Host {
708 return hostbox.ErrChanged
709 }
710 versions, err := s.Data.ListVersions(ctx, s.Bucket, receipt.Root.Key)
711 if err != nil {
712 return err
713 }
714 exact, current := false, false
715 for _, v := range versions {
716 if v.Key != receipt.Root.Key || v.DeleteMarker {
717 continue
718 }
719 exact = exact || v.VersionID == receipt.Root.VersionID
720 current = current || v.IsLatest
721 }
722 if !exact || !current {
723 return hostbox.ErrChanged
724 }
725 return nil
726}
727