| 1 | package host |
| 2 | |
| 3 | import ( |
| 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). |
| 28 | type 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 | |
| 35 | func (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. |
| 43 | type ClaimMovesOwners struct { |
| 44 | LossRecovery hostclaim.LossRecoveryOwner |
| 45 | LossSources hostbox.LossAdmissionSources |
| 46 | ClaimKindOwners |
| 47 | Operations OperationAttempts |
| 48 | ProbeReady ClaimProbeReadiness |
| 49 | } |
| 50 | |
| 51 | func (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. |
| 66 | type 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 | |
| 76 | func (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. |
| 82 | func 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. |
| 91 | func 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. |
| 105 | type 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. |
| 130 | type 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. |
| 158 | type 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. |
| 163 | func 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. |
| 172 | type 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. |
| 190 | func 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 | |
| 200 | func 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. |
| 216 | type ClaimOwnerDispatch struct { |
| 217 | Serving ClaimKindOwners |
| 218 | Moves *ClaimKindOwners |
| 219 | Relocation *ClaimKindOwners |
| 220 | } |
| 221 | |
| 222 | func (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 | |
| 249 | func (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 | |
| 257 | func (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 | |
| 265 | func (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 | |
| 273 | func (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. |
| 284 | func (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 | |
| 296 | func (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 | |
| 304 | func (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. |
| 321 | type 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 | |
| 333 | func 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 | |
| 340 | func (s *claimServingOwners) owners() ClaimKindOwners { |
| 341 | return ClaimKindOwners{Inputs: s, AdmissionEvidence: s.admissionEvidence, PublicationEvidence: s.publicationEvidence} |
| 342 | } |
| 343 | |
| 344 | func (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 | |
| 357 | func (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. |
| 362 | func 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. |
| 367 | func 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 | |
| 379 | func 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 | |
| 391 | func (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. |
| 404 | func (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 | |
| 415 | func (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. |
| 472 | func (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. |
| 492 | func (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 | |
| 522 | func 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. |
| 529 | func (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. |
| 544 | func (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). |
| 554 | func (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 | |
| 625 | type claimMarkerVersion struct{ ID, ETag string } |
| 626 | |
| 627 | func (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"). |
| 639 | func (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. |
| 686 | func (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. |
| 694 | func (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 | |