package host import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "github.com/carlosframework/platform/internal/genselect" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfiles" "github.com/carlosframework/platform/internal/hostrelocate" "github.com/carlosframework/platform/internal/lease" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/release" ) // relocationBuild is the target's build at installing: its guard // activated at install_stamp from an empty box (ruling 43), then P built // under the replica lease with every durable write behind the build // authority (ruling 59), then the build's host-named scratch removed // (ruling 44). func (a ClaimAssembly) relocationBuild(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) error { activate := a.relocationActivate(cfg) build := a.relocationBuildP(cfg) return func(ctx context.Context, o hostclaim.Observed) error { captured, err := activate(ctx, o) if err != nil { return err } if err := build(ctx, o, a.relocationBuildAuthority(cfg, o, captured)); err != nil { return err } return removeRelocationScratch(cfg, o.Claim.Op.ID) } } // relocationBuildP builds P from the journaled pin (§5.8 "Building the // prepared generation", L16, L17) under the replica lease on // holder.location, taken for the roster's cloud region (ruling 51); the // lease's loss cancels what follows. auth is the build authority, run // before the manifest copy, before the database build, and before every // write the builder makes (authorityStore). P's files manifest is written // first, so a crash between the two steps leaves an install manifest never // ahead of its files manifest. A replay from installing finds its own // manifest copy (CopyPinnedManifest is create-exact) and its own intent, // snapshot and anchor (moves §7.6). func (a ClaimAssembly) relocationBuildP(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed, func(context.Context) error) error { return func(ctx context.Context, o hostclaim.Observed, auth func(context.Context) error) error { if cfg.Versions == nil || cfg.Data == nil || cfg.Bucket == "" || cfg.LeaseRegion == "" || auth == nil || o.Claim == nil || o.Claim.Holder == nil { return ErrClaimStopUnavailable } c := o.Claim var ref hostclaim.RecoveryPin if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil { return err } pin, err := hostrelocate.ReadPin(ctx, a.Claims, ref) if err != nil { return err } p := c.Op.Journal["prepared"] work, err := relocationBuildDir(cfg, c.Op.ID) if err != nil || p == "" { return errors.Join(fmt.Errorf("%w: no prepared generation to build", hostrelocate.ErrRefused), err) } // The authority first: a stale build never writes the lease object // on holder.location, where it would refuse the source's own wake. if err := auth(ctx); err != nil { return err } acquire := a.acquireReplicaLease if acquire == nil { acquire = func(ctx context.Context, bucket, prefix, region string) (replicaLease, error) { return lease.Acquire(ctx, bucket, prefix, region) } } held, err := acquire(ctx, cfg.Bucket, c.Holder.Location, cfg.LeaseRegion) if err != nil { return err } defer func() { _ = held.Release(context.WithoutCancel(ctx)) }() alive, stop := context.WithCancel(ctx) defer stop() go func() { select { case <-held.Lost(): stop() case <-alive.Done(): } }() check := func() error { if err := auth(alive); err != nil { return err } return alive.Err() } if err := check(); err != nil { return err } if _, err := hostrelocate.CopyPinnedManifest(alive, a.Claims, cfg.Data, pin.Files, moves.GenerationFilesPrefix(c.Holder.Location, p)); err != nil { return err } if pin.DB == nil { return nil // an evidenced empty bootstrap: no snapshot, no anchor (claim P6-D3 forbids [1, 0]) } if err := check(); err != nil { return err } src := make([]moves.SealedObject, 0, len(pin.DB.Files)) for _, f := range pin.DB.Files { src = append(src, moves.SealedObject{Key: f.Key, VersionID: f.VersionID, Size: f.Size}) } if err := os.MkdirAll(work, 0o700); err != nil { return err } _, err = moves.BuildGeneration(alive, moves.BuildInputs{Store: authorityStore{VersionedStore: cfg.Versions, check: check}, Bucket: cfg.Bucket, Source: src, N: pin.DB.MaxTXID, GenerationPrefix: moves.GenerationPrefix(c.Holder.Location, p), Op: c.Op.ID, Host: o.Host, WorkDir: work, Now: cfg.Now, Verify: lease.CheckDB}) return err } } // replicaLease is the part of *lease.Held the build uses: its loss and // its release. type replicaLease interface { Lost() <-chan struct{} Release(context.Context) error } // authorityStore is the versioned store the builder writes through: every // write re-checks the build's authority first, so a delayed build whose // phase, executor, stamp, execution lease or replica lease moved writes // nothing more. Reads are the store's own; the build never deletes. type authorityStore struct { lease.VersionedStore check func() error } func (s authorityStore) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) { if err := s.check(); err != nil { return "", err } return s.VersionedStore.PutIfAbsent(ctx, bucket, key, body) } func (s authorityStore) CopyVersion(ctx context.Context, sb, sk, sv, db, dk string, createOnly bool) (string, string, error) { if err := s.check(); err != nil { return "", "", err } return s.VersionedStore.CopyVersion(ctx, sb, sk, sv, db, dk, createOnly) } func (s authorityStore) DeleteVersion(context.Context, string, string, string) error { return fmt.Errorf("%w: the build never deletes", hostrelocate.ErrRefused) } // relocationBuildDir is the build's scratch for op, moves.BuildInputs' // WorkDir: /, where BuildGeneration leaves .db. func relocationBuildDir(cfg RelocationAssemblyConfig, op string) (string, error) { if cfg.WorkDir == "" || !validOpID(op) { return "", ErrClaimStopUnavailable } return filepath.Join(cfg.WorkDir, op), nil } // removeRelocationScratch removes the build's scratch for op, which names // the host (//.db). The build removes it once P is // built; the local install removes it again first, so a crash in between // never leaves a host-named file for the census. func removeRelocationScratch(cfg RelocationAssemblyConfig, op string) error { dir, err := relocationBuildDir(cfg, op) if err != nil { return err } return os.RemoveAll(dir) } // readInstallManifest is the current install.json of a generation prefix, // its bytes, and its version: the one reader for the build verifier, the // prepared marker and the probe driver. No current version is an error // that is both hostbox.ErrChanged and os.ErrNotExist. func readInstallManifest(ctx context.Context, data genselect.Reader, bucket, prefix string) (moves.InstallManifest, []byte, error) { key := moves.InstallManifestKey(prefix) versions, err := data.ListVersions(ctx, bucket, key) if err != nil { return moves.InstallManifest{}, nil, err } id := "" for _, v := range versions { if v.Key == key && v.IsLatest && !v.DeleteMarker { id = v.VersionID } } if id == "" { return moves.InstallManifest{}, nil, fmt.Errorf("%w: %s has no install.json (%w)", hostbox.ErrChanged, prefix, os.ErrNotExist) } raw, err := data.GetVersion(ctx, bucket, key, id) if err != nil { return moves.InstallManifest{}, nil, err } var m moves.InstallManifest if err := json.Unmarshal(raw, &m); err != nil { return moves.InstallManifest{}, nil, err } return m, raw, nil } // PreparedFromInstallManifest is hostrelocate.Prepared: P is built when a // fresh read verifies its files manifest (sequence 0, exactly the pinned // entries) and, unless the pin is empty, its completed install manifest at // the pin's N whose snapshot and anchor versions exist. type PreparedFromInstallManifest struct { Claims release.Store // pin objects and the retained manifest Files release.Store // files manifests Data lease.VersionedStore // LTX files and install manifests Bucket string } var _ hostrelocate.Prepared = PreparedFromInstallManifest{} func (v PreparedFromInstallManifest) Verify(ctx context.Context, o hostclaim.Observed) (hostclaim.PreparedAuthority, bool, error) { if v.Claims == nil || v.Files == nil || v.Data == nil || v.Bucket == "" || o.Claim == nil || o.Claim.Holder == nil { return hostclaim.PreparedAuthority{}, false, ErrClaimStopUnavailable } c := o.Claim var ref hostclaim.RecoveryPin if err := json.Unmarshal([]byte(c.Op.Journal["pin"]), &ref); err != nil { return hostclaim.PreparedAuthority{}, false, err } pin, err := hostrelocate.ReadPin(ctx, v.Claims, ref) if err != nil { return hostclaim.PreparedAuthority{}, false, err } p := c.Op.Journal["prepared"] raw, _, err := v.Files.Get(ctx, hostfiles.ManifestKey(moves.GenerationFilesPrefix(c.Holder.Location, p))) if errors.Is(err, os.ErrNotExist) { return hostclaim.PreparedAuthority{}, false, nil // not built yet } if err != nil { return hostclaim.PreparedAuthority{}, false, err } var m hostfiles.Manifest if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion || m.Seq != 0 { return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: P's files manifest is not a prepared manifest", hostrelocate.ErrRefused) } // ReadPin refuses a files digest without its retained copy, so no // retained copy here is a host that published no files. pinned := hostfiles.Manifest{Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}} if pin.Files.Retained != "" { // The pinner's retained copy of the pinned bytes, never the source // prefix (ruling 21): P's entries must be exactly theirs. pinnedRaw, _, err := v.Claims.Get(ctx, pin.Files.Retained) if err != nil { return hostclaim.PreparedAuthority{}, false, err } if sum := sha256.Sum256(pinnedRaw); hex.EncodeToString(sum[:]) != pin.Files.SHA256 { return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: the pinned files manifest changed", hostrelocate.ErrRefused) } if err := json.Unmarshal(pinnedRaw, &pinned); err != nil { return hostclaim.PreparedAuthority{}, false, err } } if !sameEntries(m, pinned) { return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: P's files manifest is not the pinned manifest", hostrelocate.ErrRefused) } filesSum := sha256.Sum256(raw) auth := hostclaim.PreparedAuthority{Generation: p, FilesSeq: 0, SourceFilesSeq: pin.Files.Seq, FilesManifestSHA256: hex.EncodeToString(filesSum[:])} if pin.DB == nil { return auth, true, nil } prefix := moves.GenerationPrefix(c.Holder.Location, p) im, body, err := readInstallManifest(ctx, v.Data, v.Bucket, prefix) if errors.Is(err, os.ErrNotExist) { return hostclaim.PreparedAuthority{}, false, nil // the build has not completed } if err != nil { return hostclaim.PreparedAuthority{}, false, err } key := moves.InstallManifestKey(prefix) if im.Op != c.Op.ID || im.Host != o.Host || im.N != pin.DB.MaxTXID || im.SnapshotKey != moves.SnapshotKey(prefix, im.N) || im.AnchorKey != moves.AnchorKey(prefix, im.N) || im.SnapshotVersion == "" || im.AnchorVersion == "" || im.IntegrityCheck != "ok" { return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: install manifest %s is not this pin's build of P", hostrelocate.ErrRefused, key) } for _, want := range []struct{ key, version string }{{im.SnapshotKey, im.SnapshotVersion}, {im.AnchorKey, im.AnchorVersion}} { vs, err := v.Data.ListVersions(ctx, v.Bucket, want.key) if err != nil { return hostclaim.PreparedAuthority{}, false, err } found := false for _, ov := range vs { if ov.Key == want.key && ov.VersionID == want.version && !ov.DeleteMarker { found = true } } if !found { return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: %s@%s named by the install manifest is not there", hostrelocate.ErrRefused, want.key, want.version) } } sum := sha256.Sum256(body) auth.InstallManifest, auth.SHA256, auth.SnapshotTXID = key, hex.EncodeToString(sum[:]), im.N return auth, true, nil } // sameEntries compares two manifests' entries and platform items, nothing // else: P's prepared copy differs from the pinned bytes only in Seq. func sameEntries(a, b hostfiles.Manifest) bool { type entries struct{ E, P map[string]hostfiles.Entry } norm := func(m map[string]hostfiles.Entry) map[string]hostfiles.Entry { if m == nil { return map[string]hostfiles.Entry{} } return m } ra, _ := json.Marshal(entries{norm(a.Entries), norm(a.Platform)}) rb, _ := json.Marshal(entries{norm(b.Entries), norm(b.Platform)}) return string(ra) == string(rb) }