package host import ( "context" "crypto/ecdh" "crypto/ed25519" "errors" "fmt" "path/filepath" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/controlsock" "github.com/carlosframework/platform/internal/genselect" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" execctl "github.com/carlosframework/platform/internal/hostexec" "github.com/carlosframework/platform/internal/hostrelocate" "github.com/carlosframework/platform/internal/hostwrite" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/park" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // RelocationActivator is the part of activator.Service relocation uses: // the probe attempt (Task 8d) and the local fence that ends a passed // probe's session before the serving CAS (ruling 49). The fence is an // owned one (ruling 75): relocation takes and meets it again only under // the owner the probe stop persisted, and lifts it only under that owner. // The activator's other paths do not ask for the owner: a plain // FenceAndStop or LiftFence at the same generation, or a recovery probe // opening on a RecoveryFence equal to it, replaces or lifts it and clears // the owner, after which the owner can neither meet nor lift it. type RelocationActivator interface { ProbeWake(context.Context, activator.ProbeConfig) (activator.ProbeResult, error) FenceAndStopAs(context.Context, string, uint64, string) (*activator.StopEvidence, error) LiftFenceAs(string, uint64, string) bool } var _ RelocationActivator = (*activator.Service)(nil) // RelocationAssemblyConfig is what the relocation providers need beyond // the bound claim assembly. Task 10 fills it once per agent. type RelocationAssemblyConfig struct { Directory hostactors.Directory // Data is the region's data bucket as a release.Store (files // manifests), built with release.NewS3Store (ruling 50); Versions is // the same bucket's versioned client (LTX, install manifests). Data release.Store Versions lease.VersionedStore Bucket string // Region is the logical placement region (genselect requests, pins, // the worker). LeaseRegion is the AWS region lease.Acquire signs for: // the roster's Cloud.Region, as main's Wake.LeaseRegion (ruling 51). Region, LeaseRegion string Registry *registry.Registry DataDir string // hostguard.Guard.DataDir, the managed data root OptDir string // the box's opt root: the version store and live/ WorkDir string // the build's scratch: /relocate/build ReleaseDir string // /relocate/release SecretsKey *ecdh.PrivateKey Activator RelocationActivator // Source is the bound assembly's WorkSource (ClaimProviders.Selector): // the install authority's guard, identity, Local and Prepare (ruling 46). Source hostbox.WorkSource // LaterProbeAttempts is set only once claim ask C11 is answered on // main: root then authorizes a relocating launch at the admitted // writer's stamp, not only at install_stamp (ruling 58). LaterProbeAttempts bool // Discharge is main's participation discharge for a completed // relocation (productionClaimLocal.DischargeCompleted, N1). The hold // and the activator fence are released only after it (ruling 66). Discharge func(context.Context, hostclaim.Observed) error Now func() time.Time // ReleasePub and Ratchet are §4.1's evidence for the held stage // (release.MaterializeHeldUnder): the agent's release key // (CARLOS_RELEASE_PUBKEY) and the edge's pointer digest ratchet, // pointerRatchet(false, optDir, dataDir, getenv), exactly as moves' T7 // takes them in the agent (movespass.go, heldReleaseReady). Never // root's: heldStagePass runs as root with pointerRatchet(true, ...). ReleasePub ed25519.PublicKey Ratchet *release.DigestRatchet // render, materialize and verifyLive are appconfig.RenderHeldUnder, // release.MaterializeHeldUnder (with ReleasePub and Ratchet bound) and // release.VerifyHeldLiveUnder; nil is those. Tests replace them. render func(context.Context, release.Store, *ecdh.PrivateKey, string, registry.Route, string, string, func(appconfig.HeldRender) (hostwrite.Work, error)) (appconfig.HeldRender, error) materialize func(context.Context, release.Store, string, registry.Route, string, *release.ProofGate, hostwrite.Work, func(func() error) error) (*release.Manifest, error) verifyLive func(context.Context, release.Store, string, registry.Route, string, *release.ProofGate, func(func() error) error) error } // RelocationEffects binds every provider to this bound assembly. ack is // main's participation owner (productionClaimLocal.Acknowledged). func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func(context.Context, hostclaim.Observed) (execctl.State, error)) *RelocationEffects { if cfg.Now == nil { cfg.Now = time.Now } receipts := RelocationReleaseReceipts{Store: a.Claims, Directory: cfg.Directory} paths := relocationHostPaths(cfg.DataDir, cfg.Registry) return &RelocationEffects{ Claims: a.Claims, Participation: ack, Identity: func(ctx context.Context) (RelocationIdentity, error) { return a.localStopIdentity(ctx) }, Producers: func(ctx context.Context, o hostclaim.Observed) (StopDrainer, error) { if a.Wake == nil || a.Wake.Producers == nil { return nil, ErrClaimStopUnavailable } p, err := a.Wake.Producers(ctx, o) if err != nil { return nil, err } return p, nil }, Collect: func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) { if _, ok := d.(RecoveryReleaseDrain); ok { r.Recover = true // 6b's authenticated recovery path: root replays the durable receipt } if a.collectStop != nil { return a.collectStop(ctx, r, d) } return CollectClaimStop(ctx, controlsock.Path, r, d) }, WritersStopped: func(ctx context.Context, o hostclaim.Observed) (ClaimWritersDrained, WritersStoppedEvidence, error) { return a.WritersStopped(ctx, o.Host) }, Releases: ReleaseObligation{Receipts: receipts}, FlushDB: a.relocationFlushDB(cfg), FlushFiles: ReleaseFlushFiles{Data: cfg.Data, Registry: cfg.Registry, FilesDir: func(host string) string { return paths(host).FilesDir }, EnvPath: func(host string) string { return paths(host).EnvPath }}.Flush, Positions: ReleasePositionsFile{Dir: cfg.ReleaseDir}, PreflightKind: a.relocationPreflight(cfg), BuildP: a.relocationBuild(cfg), Install: a.relocationInstall(cfg), VerifyLocal: a.relocationVerifyLocal(cfg), Receipt: a.relocationReceipt(cfg), Admit: a.relocationAdmit(cfg), Passed: relocationPassedProbe(cfg.DataDir), StopProbe: a.relocationStopProbe(cfg), ReleaseHold: a.relocationReleaseHold(cfg), } } // relocationPreflight is ruling 48's gate before an attempt is counted: // root speaks the isolated launch protocol, and its capability for kind // relocating answers only with the attempt reader installed (Task 8d). func (a ClaimAssembly) relocationPreflight(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) error { return func(ctx context.Context, o hostclaim.Observed) error { if a.Wake == nil || a.Wake.Roles == nil || o.Claim == nil { return activator.ErrClaimIsolationUnavailable } // Ruling 58: main's root pins every relocating launch to // install_stamp (claimLaunchAuthority), and a cold attempt after // P's opening runs at a new stamp. Until claim ask C11 relaxes the // pin to P's opening, such an attempt is not made. if hostclaim.PreparedOpened(o.Claim) && !cfg.LaterProbeAttempts { return hostrelocate.ErrLaterAttemptUnlaunchable } // A defect in the claim the attempt would be made over (the // release, the prepared authority, the pin, probing_since) is // deterministic: it is found here, before the attempt is counted, // never after three counted refusals (review M1). if _, err := relocationProbeClaim(o.Claim); err != nil { return err } if err := a.Wake.Roles.Preflight(ctx, ClaimLaunchInputs{}); err != nil { return err } return a.Wake.Roles.PreflightKind(ctx, hostclaim.KindRelocating) } } // relocationPaths are one host's files directory and env file, derived // from its route by the layout the activator would classify it with // (design §3.1: a nested row's files live in its instance directory with // the same names). Every relocation target route is nested (ruling 45). type relocationPaths struct{ FilesDir, EnvPath string } func relocationPathsFor(dataDir string, rt registry.Route) relocationPaths { dir := dataDir if layout, _ := activator.Classify(rt, activator.LayoutInputs{DataDir: dataDir}); layout == activator.LayoutNested { dir = provision.InstanceDir(dataDir, rt.Host) } return relocationPaths{FilesDir: filepath.Join(dir, rt.Host), EnvPath: filepath.Join(dir, rt.Host+".env")} } // relocationHostPaths reads the host's own row: the source's flush // publishes the paths its writer used. No row, or a row that cannot be // read, is no paths, and ReleaseFlushFiles.Flush refuses empty paths // before it stages anything rather than guess. func relocationHostPaths(dataDir string, reg *registry.Registry) func(string) relocationPaths { return func(host string) relocationPaths { if reg == nil { return relocationPaths{} } rt, err := reg.Get(host) if err != nil || rt == nil { return relocationPaths{} } return relocationPathsFor(dataDir, *rt) } } // relocationFlushDB is release step 2 (ruling 8) for the session's own // replicator: releaseFinalTXID over the claim wake's attempt for the host. func (a ClaimAssembly) relocationFlushDB(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (uint64, error) { return func(ctx context.Context, o hostclaim.Observed) (uint64, error) { if a.Wake == nil || o.Claim == nil || o.Claim.Holder == nil { return 0, ErrClaimStopUnavailable } att := a.Wake.get(o.Host) if att == nil || att.rep == nil || cfg.Versions == nil || cfg.Bucket == "" { return 0, ErrClaimStopUnavailable } sel := func(ctx context.Context, r genselect.Request) (*genselect.Plan, error) { return genselect.Select(ctx, cfg.Versions, r) } return releaseFinalTXID(ctx, att.rep, sel, genselect.Request{Bucket: cfg.Bucket, Region: cfg.Region, Host: o.Host, Holder: park.HolderRegionView(*o.Claim.Holder, cfg.Source.HomeRegion, cfg.Region)}) } } // releaseReplica is the part of the session's replicator the release's // database flush uses (lease.Replicator). type releaseReplica interface { SyncAndWait(context.Context) error } // releaseFinalTXID is release step 2 (ruling 8): SyncAndWait with every // writer stopped, then N is the newest level-0 MaxTXID, which must equal // the plan's MaxTXID. // // An empty listing is not evidence of an empty database (fix round 1, I2; // N1): a wrong bucket, or a listing not yet visible, reads the same. N is // 0 only for writer session 0, a holder no writer was ever admitted to // (pin.go's "session-0", ruling 25). An empty listing for any other // session refuses: the release is never journaled, and the worker marks // the op stuck ReleaseDeadline after releasing began, for an operator, as // a refused pin leaves it. func releaseFinalTXID(ctx context.Context, rep releaseReplica, sel func(context.Context, genselect.Request) (*genselect.Plan, error), req genselect.Request) (uint64, error) { if rep == nil || sel == nil { return 0, ErrClaimStopUnavailable } if err := rep.SyncAndWait(ctx); err != nil { return 0, err } plan, err := sel(ctx, req) if errors.Is(err, genselect.ErrEmpty) { if req.Holder.Session == 0 { return 0, nil } return 0, fmt.Errorf("%w: the listing is empty for writer session %d", ErrClaimStopUnavailable, req.Holder.Session) } if err != nil { return 0, err } if plan == nil { return 0, ErrClaimStopUnavailable } if plan.MaxLevel0TXID != plan.MaxTXID { return 0, fmt.Errorf("%w: level 0 at %d, the database at %d", ErrClaimStopUnavailable, plan.MaxLevel0TXID, plan.MaxTXID) } return plan.MaxTXID, nil }