host: a relocation target activates its guard at the install stamp from an empty box and builds P under the replica lease, rechecking its authority before every write; the pin needs positive evidence of the instance key, and a pin's empty database, its files digest and a configured key's env are never inferred from absence
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+1432
−16
internal/host/relocationassembly.go+1 −0internal/host/relocationbuild.go+308 −0internal/host/relocationbuild_test.go+630 −0internal/host/relocationinstall.go+154 −0internal/hostrelocate/pin.go+120 −16internal/hostrelocate/pin_envkey_test.go+173 −0internal/hostrelocate/pin_test.go+46 −0
diff --git a/internal/host/relocationassembly.go b/internal/host/relocationassembly.go| index 9d72194ed..e2089018b 100644 |
| --- a/internal/host/relocationassembly.go |
| +++ b/internal/host/relocationassembly.go |
| @@ -108,6 +108,7 @@ func (a ClaimAssembly) RelocationEffects(cfg RelocationAssemblyConfig, ack func( |
| 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), |
| } |
| } |
diff --git a/internal/host/relocationbuild.go b/internal/host/relocationbuild.go| new file mode 100644 |
| index 000000000..7f4afdf6e |
| --- /dev/null |
| +++ b/internal/host/relocationbuild.go |
| @@ -0,0 +1,308 @@ |
| +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) |
| + } |
| + held, err := lease.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, c.Holder.Location+"/files/"+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 |
| + } |
| +} |
| + |
| +// 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: <WorkDir>/<op>, where BuildGeneration leaves <host>.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 (<WorkDir>/<op>/<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 |
| + } |
| + // A files digest without its retained copy, or a copy without its |
| + // digest, is never read as a host that published no files (absence is |
| + // never evidence); CopyPinnedManifest refuses the same pin. |
| + if (pin.Files.SHA256 == "") != (pin.Files.Retained == "") { |
| + return hostclaim.PreparedAuthority{}, false, fmt.Errorf("%w: the pinned files manifest names a digest or a retained copy without the other", hostrelocate.ErrRefused) |
| + } |
| + p := c.Op.Journal["prepared"] |
| + raw, _, err := v.Files.Get(ctx, hostfiles.ManifestKey(c.Holder.Location+"/files/"+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) |
| + } |
| + 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) |
| +} |
diff --git a/internal/host/relocationbuild_test.go b/internal/host/relocationbuild_test.go| new file mode 100644 |
| index 000000000..efb0cd2cf |
| --- /dev/null |
| +++ b/internal/host/relocationbuild_test.go |
| @@ -0,0 +1,630 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log/slog" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/benbjohnson/litestream" |
| + lsfile "github.com/benbjohnson/litestream/file" |
| + "github.com/carlosframework/platform/internal/genselect" |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + execlease "github.com/carlosframework/platform/internal/hostexec" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/superfly/ltx" |
| +) |
| + |
| +const buildHost = "shop.example" |
| + |
| +// buildFixture is a relocation target at installing on an empty box: the |
| +// claim journals a pin of a real litestream replica sealed into a |
| +// versioned bucket (served over a loopback S3 endpoint, so the replica |
| +// lease is lease.Acquire's own), a retained files manifest with one entry, |
| +// an empty guard directory, and main's Local recorded. |
| +type buildFixture struct { |
| + t *testing.T |
| + claims *release.DirStore |
| + files *hookedStore |
| + bucket *leasetest.VersionedBucket |
| + versions *hookedVersions |
| + a ClaimAssembly |
| + cfg RelocationAssemblyConfig |
| + obs hostclaim.Observed |
| + locals []hostbox.Selection |
| + localErr error |
| + verifier PreparedFromInstallManifest |
| +} |
| + |
| +// hookedStore and hookedVersions run after, once a write has landed, so a |
| +// test can move the build's authority between two durable writes. |
| +type hookedStore struct { |
| + release.Store |
| + afterPut func(key string) |
| +} |
| + |
| +func (s *hookedStore) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| + out, err := s.Store.Put(ctx, key, body, etag) |
| + if err == nil && s.afterPut != nil { |
| + s.afterPut(key) |
| + } |
| + return out, err |
| +} |
| + |
| +type hookedVersions struct { |
| + lease.VersionedStore |
| + afterPut func(key string) |
| +} |
| + |
| +func (s *hookedVersions) PutIfAbsent(ctx context.Context, bucket, key string, body []byte) (string, error) { |
| + out, err := s.VersionedStore.PutIfAbsent(ctx, bucket, key, body) |
| + if err == nil && s.afterPut != nil { |
| + s.afterPut(key) |
| + } |
| + return out, err |
| +} |
| + |
| +// buildOptions shape a buildFixture: a pin with or without a database, the |
| +// data root (a fresh one with a hostguard directory when empty), and the |
| +// pinned manifest's entries and platform items, each stored as an object |
| +// under the source generation's prefix. |
| +type buildOptions struct { |
| + DB bool |
| + Data string |
| + Files map[string]string |
| + Platform map[string]string |
| +} |
| + |
| +func newBuildFixture(t *testing.T, withDB bool) *buildFixture { |
| + return newBuildFixtureWith(t, buildOptions{DB: withDB, Files: map[string]string{"public/hello.txt": "hello"}}) |
| +} |
| + |
| +func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture { |
| + t.Helper() |
| + ctx := t.Context() |
| + withDB := o.DB |
| + f := &buildFixture{t: t, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket()} |
| + f.bucket.AddBucket("data") |
| + f.files = &hookedStore{Store: release.NewDirStore(t.TempDir())} |
| + f.versions = &hookedVersions{VersionedStore: f.bucket} |
| + handler, _ := leasetest.NewS3Server(f.bucket, "data") |
| + srv := httptest.NewServer(handler) // 127.0.0.1 only |
| + t.Cleanup(srv.Close) |
| + t.Setenv("CARLOS_S3_ENDPOINT", srv.URL) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_PROFILE", "") |
| + t.Setenv("AWS_REGION", "") |
| + |
| + created := time.Date(2026, 10, 7, 1, 0, 0, 0, time.UTC) |
| + enrollment := strings.Repeat("a", 32) |
| + nh := hostclaim.NewHolder{Family: "instance", Key: "apps/acct/app/instances/" + buildHost + ".json", Account: "acct", App: "app", Host: buildHost, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| + body := []byte(`{"host":"` + buildHost + `","account":"acct","app":"app","region":"ie","created_at":"2026-10-07T01:00:00Z"}`) |
| + obs, err := hostclaim.Read(ctx, f.claims, buildHost) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tr := range []func(*hostclaim.Claim) error{ |
| + hostclaim.BeginCreateOn(nh, body, "c1", "console", created, hostclaim.ExecutorSelection{Box: "src", Enrollment: enrollment}), |
| + hostclaim.CompleteCreate("c1", "src"), |
| + hostclaim.AdmitWriter(hostclaim.Admission{Box: "src", Mode: "cold", NewGeneration: "g1", Region: "ie", KeepStamp: true}), |
| + } { |
| + if obs, err = hostclaim.Update(ctx, f.claims, obs, tr); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + location := obs.Claim.Holder.Location |
| + const op = "rel-1" |
| + |
| + pin := hostrelocate.PinObject{V: 1, Host: buildHost, Op: op, ClaimGen: obs.Claim.Gen, PinnedAt: created, Release: "v1"} |
| + if withDB { |
| + files, n := sealedReplica(t, f.bucket, moves.GenerationPrefix(location, "g1")) |
| + pin.DB = &genselect.Plan{Generation: "g1", Files: files, MaxTXID: n, MaxLevel0TXID: n} |
| + } else { |
| + pin.Empty = "session-0" |
| + } |
| + srcPrefix := location + "/files/g1/" |
| + pinned := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Platform: map[string]hostfiles.Entry{}, Entries: map[string]hostfiles.Entry{}} |
| + object := func(body string, mode uint32) hostfiles.Entry { |
| + sum := sha256.Sum256([]byte(body)) |
| + sha := hex.EncodeToString(sum[:]) |
| + key := srcPrefix + "objects/" + fmt.Sprintf("%016d-%s", 5, sha) |
| + if _, err := f.files.Store.Put(ctx, key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return hostfiles.Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: mode, Seq: 5} |
| + } |
| + for rel, body := range o.Files { |
| + pinned.Entries[rel] = object(body, 0o644) |
| + } |
| + for name, body := range o.Platform { |
| + pinned.Platform[name] = object(body, 0o600) |
| + } |
| + pinnedRaw, err := json.Marshal(pinned) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pinnedSum := sha256.Sum256(pinnedRaw) |
| + retained := hostrelocate.RetainedManifestKey(buildHost, op, hex.EncodeToString(pinnedSum[:])) |
| + if _, err := f.claims.Put(ctx, retained, pinnedRaw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + pin.Files = hostrelocate.FilesPin{Prefix: srcPrefix, Seq: 5, SHA256: hex.EncodeToString(pinnedSum[:]), Origins: []string{srcPrefix}, Retained: retained} |
| + pinRaw, err := json.Marshal(pin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pinKey := hostrelocate.PinKey(buildHost, op, obs.Claim.Gen) |
| + if _, err := f.claims.Put(ctx, pinKey, pinRaw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + pinSum := sha256.Sum256(pinRaw) |
| + ref := hostclaim.RecoveryPin{Key: pinKey, SHA256: hex.EncodeToString(pinSum[:]), Generation: "g1", FilesManifest: retained, FilesSeq: 5, PinnedAt: created, Release: "v1", Empty: pin.DB == nil} |
| + if pin.DB != nil { |
| + ref.MaxTXID = pin.DB.MaxTXID |
| + } |
| + refRaw, err := json.Marshal(ref) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // The installing CAS: this box executes the op, the holder's stamp is |
| + // the install stamp, P is named (hostclaim's install()). |
| + if obs, err = hostclaim.Update(ctx, f.claims, obs, func(c *hostclaim.Claim) error { |
| + stamp := c.Gen + 1 |
| + c.Holder.Stamp = stamp |
| + c.Op = hostclaim.Op{Kind: hostclaim.KindRelocating, ID: op, Phase: hostclaim.PhaseInstalling, Exec: "dst", Participants: []string{"src", "dst"}, |
| + Intent: map[string]string{"region": "ie", "source_box": "src", "target_box": "dst"}, |
| + Journal: map[string]string{"pin": string(refRaw), "prepared": "p1", "install_stamp": strconv.FormatUint(stamp, 10), "install_release": "v1"}} |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.obs = obs |
| + |
| + data := o.Data |
| + if data == "" { |
| + data = t.TempDir() |
| + } |
| + if err := os.MkdirAll(filepath.Join(data, "hostguard"), 0o770); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.a = ClaimAssembly{Claims: f.claims, Guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: f.claims}}} |
| + f.cfg = RelocationAssemblyConfig{Data: f.files, Versions: f.versions, Bucket: "data", Region: "ie", LeaseRegion: "eu-west-1", DataDir: data, |
| + WorkDir: filepath.Join(data, "relocate", "build"), Now: func() time.Time { return created.Add(time.Hour) }, |
| + Source: hostbox.WorkSource{Box: "dst", Region: "ie", Local: func(_ context.Context, c *hostclaim.Claim, s hostbox.Selection) error { |
| + f.locals = append(f.locals, s) |
| + return f.localErr |
| + }}} |
| + f.verifier = PreparedFromInstallManifest{Claims: f.claims, Files: f.files, Data: f.bucket, Bucket: "data"} |
| + return f |
| +} |
| + |
| +// sealedReplica writes a real SQLite database through litestream's file |
| +// replica and seals every LTX file into the bucket under prefix, keyed as |
| +// genselect lists a generation (<prefix><level>/<min>-<max>.ltx, prefix |
| +// moves.GenerationPrefix). |
| +func sealedReplica(t *testing.T, b *leasetest.VersionedBucket, prefix string) ([]genselect.File, uint64) { |
| + t.Helper() |
| + ctx := t.Context() |
| + dir := t.TempDir() |
| + path := filepath.Join(dir, "src.db") |
| + write := func(n int) { |
| + d, err := sql.Open("sqlite", path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + if _, err := d.Exec(`CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + for range n { |
| + if _, err := d.Exec(`INSERT INTO t (body) VALUES (?)`, strings.Repeat("x", 100)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + write(1) |
| + client := lsfile.NewReplicaClient(filepath.Join(dir, "replica")) |
| + db := litestream.NewDB(path) |
| + db.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + db.SetMetaPath(filepath.Join(dir, "meta")) |
| + db.MonitorInterval = time.Hour |
| + db.Replica = litestream.NewReplicaWithClient(db, client) |
| + db.Replica.SyncInterval = time.Hour |
| + if err := db.Open(); err != nil { |
| + t.Fatal(err) |
| + } |
| + for range 2 { |
| + if err := db.SyncAndWait(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + write(2) |
| + } |
| + if err := db.SyncAndWait(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := db.Close(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []genselect.File |
| + var n uint64 |
| + for level := 0; level <= litestream.SnapshotLevel; level++ { |
| + itr, err := client.LTXFiles(ctx, level, 0, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + infos, err := ltx.SliceFileIterator(itr) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, info := range infos { |
| + rc, err := client.OpenLTXFile(ctx, info.Level, info.MinTXID, info.MaxTXID, 0, 0) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := io.ReadAll(rc) |
| + rc.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := prefix + fmt.Sprintf("%04x/", level) + ltx.FormatFilename(info.MinTXID, info.MaxTXID) |
| + v := b.Put("data", key, body) |
| + out = append(out, genselect.File{Key: key, VersionID: v, Level: level, MinTXID: uint64(info.MinTXID), MaxTXID: uint64(info.MaxTXID), Size: int64(len(body))}) |
| + if level == 0 && uint64(info.MaxTXID) > n { |
| + n = uint64(info.MaxTXID) |
| + } |
| + } |
| + } |
| + if n == 0 { |
| + t.Fatal("the source replica has no level-0 files") |
| + } |
| + return out, n |
| +} |
| + |
| +func (f *buildFixture) build() error { |
| + return f.a.relocationBuild(f.cfg)(f.t.Context(), f.obs) |
| +} |
| + |
| +func (f *buildFixture) prefix() string { |
| + return moves.GenerationPrefix(f.obs.Claim.Holder.Location, "p1") |
| +} |
| + |
| +func (f *buildFixture) installManifestExists() bool { |
| + _, ok := f.bucket.Current("data", moves.InstallManifestKey(f.prefix())) |
| + return ok |
| +} |
| + |
| +// moveExec moves the op to another executor, as a replaced target sees it. |
| +func (f *buildFixture) moveExec() { |
| + f.t.Helper() |
| + o, err := hostclaim.Read(context.Background(), f.claims, buildHost) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + if _, err := hostclaim.Update(context.Background(), f.claims, o, func(c *hostclaim.Claim) error { |
| + c.Op.Exec, c.Op.Intent["target_box"] = "other", "other" |
| + c.Op.Participants = []string{"src", "other"} |
| + return nil |
| + }); err != nil { |
| + f.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (f *buildFixture) retireStamp() { |
| + f.t.Helper() |
| + if err := f.a.Guard.RetireInstallation(context.Background(), buildHost, func(*hostclaim.Claim) error { return nil }); err != nil { |
| + f.t.Fatal(err) |
| + } |
| +} |
| + |
| +// Prepared is done only when P's files manifest and, for a non-empty pin, |
| +// its install manifest both verify; an unconfigured verifier reports |
| +// nothing built. Done carries what ToInstalled requires: P's manifest at |
| +// sequence 0 built from the pinned sequence, and the install manifest at |
| +// the pin's N. |
| +func TestPreparedFromInstallManifestNeedsBothManifests(t *testing.T) { |
| + if _, done, err := (PreparedFromInstallManifest{}).Verify(t.Context(), hostclaim.Observed{Claim: &hostclaim.Claim{Holder: &hostclaim.Holder{}}}); done || err == nil { |
| + t.Fatalf("an unconfigured verifier reported P built: %v", err) |
| + } |
| + f := newBuildFixture(t, true) |
| + if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil { |
| + t.Fatalf("before the build: done=%v %v", done, err) |
| + } |
| + if err := f.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + a, done, err := f.verifier.Verify(t.Context(), f.obs) |
| + if err != nil || !done { |
| + t.Fatalf("after the build: done=%v %v", done, err) |
| + } |
| + var ref hostclaim.RecoveryPin |
| + _ = json.Unmarshal([]byte(f.obs.Claim.Op.Journal["pin"]), &ref) |
| + if a.Generation != "p1" || a.FilesSeq != 0 || a.SourceFilesSeq != 5 || len(a.FilesManifestSHA256) != 64 || |
| + a.InstallManifest != moves.InstallManifestKey(f.prefix()) || len(a.SHA256) != 64 || a.SnapshotTXID != ref.MaxTXID { |
| + t.Fatalf("authority: %+v", a) |
| + } |
| + // An empty bootstrap needs only P's files manifest. |
| + empty := newBuildFixture(t, false) |
| + if err := empty.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a, done, err := empty.verifier.Verify(t.Context(), empty.obs); err != nil || !done || a.InstallManifest != "" || a.SnapshotTXID != 0 { |
| + t.Fatalf("empty bootstrap: %+v done=%v %v", a, done, err) |
| + } |
| + if empty.installManifestExists() { |
| + t.Fatal("an empty pin built a snapshot") |
| + } |
| +} |
| + |
| +// A crash after the files manifest and before the install manifest, and a |
| +// store holding the install manifest without P's files manifest, each |
| +// leave P unverified, never an error that would stop the worker. |
| +func TestPreparedRefusesAPartialBuild(t *testing.T) { |
| + t.Run("files manifest only", func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + // The process dies after P's files manifest: the next authority |
| + // check refuses, as a crash would stop it. |
| + f.files.afterPut = func(string) { f.localErr = errors.New("crashed") } |
| + if err := f.build(); err == nil { |
| + t.Fatal("the stopped build succeeded") |
| + } |
| + if _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/")); err != nil { |
| + t.Fatalf("P's files manifest was not written first: %v", err) |
| + } |
| + if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil { |
| + t.Fatalf("done=%v err=%v", done, err) |
| + } |
| + }) |
| + t.Run("install manifest only", func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + if err := f.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + v := f.verifier |
| + v.Files = release.NewDirStore(t.TempDir()) // P's files manifest is not there |
| + if _, done, err := v.Verify(t.Context(), f.obs); done || err != nil { |
| + t.Fatalf("done=%v err=%v", done, err) |
| + } |
| + }) |
| + t.Run("a files manifest that is not the pinned entries", func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + bad := hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}} |
| + raw, _ := json.Marshal(bad) |
| + if _, err := f.files.Store.Put(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/"), raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("done=%v err=%v", done, err) |
| + } |
| + }) |
| +} |
| + |
| +// A build whose authority moved under it stops before the next durable |
| +// write: after P's files manifest the executor moved; after the snapshot |
| +// PUT the executor moved, or the guard retired the stamp. No install |
| +// manifest is written for a box that no longer builds. |
| +func TestRelocationBuildStopsWhenAuthorityChanges(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + hook func(f *buildFixture) |
| + after string |
| + }{ |
| + {"after the files manifest", func(f *buildFixture) { f.moveExec() }, "files"}, |
| + {"after the snapshot PUT", func(f *buildFixture) { f.moveExec() }, "snapshot"}, |
| + {"after the stamp is retired", func(f *buildFixture) { f.retireStamp() }, "snapshot"}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + fired := false |
| + fire := func(string) { |
| + if !fired { |
| + fired = true |
| + tc.hook(f) |
| + } |
| + } |
| + if tc.after == "files" { |
| + f.files.afterPut = fire |
| + } else { |
| + f.versions.afterPut = func(key string) { |
| + if strings.HasPrefix(key, f.prefix()) && strings.HasSuffix(key, ".ltx") { |
| + fire(key) |
| + } |
| + } |
| + } |
| + if err := f.build(); !errors.Is(err, hostrelocate.ErrRefused) || !fired { |
| + t.Fatalf("the build went on after its authority changed: %v (fired %v)", err, fired) |
| + } |
| + if f.installManifestExists() { |
| + t.Fatal("the install manifest was written for a box that no longer builds") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Ruling 59: the replica lease, the claim and the guard all stay valid; |
| +// only main's Local refuses (the execution lease lost), and the next write |
| +// is refused with its error. |
| +func TestRelocationBuildStopsWhenOnlyTheExecutionLeaseIsLost(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + f.versions.afterPut = func(key string) { |
| + if strings.HasSuffix(key, ".ltx") { |
| + f.localErr = execlease.ErrLost |
| + } |
| + } |
| + if err := f.build(); !errors.Is(err, execlease.ErrLost) { |
| + t.Fatalf("the build went on without its execution lease: %v", err) |
| + } |
| + if f.installManifestExists() { |
| + t.Fatal("the install manifest was written after the execution lease was lost") |
| + } |
| + if c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost); err != nil || c.Stamp != f.obs.Claim.Holder.Stamp { |
| + t.Fatalf("the guard moved: %+v %v", c, err) |
| + } |
| +} |
| + |
| +// A replay from installing (a crash after the build, before ToInstalled) |
| +// is a success that changes nothing: the same files manifest bytes, the |
| +// same install manifest version. |
| +func TestRelocationBuildReplaysToTheSameBytes(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + if err := f.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + key := moves.InstallManifestKey(f.prefix()) |
| + first := f.bucket.LatestID("data", key) |
| + filesKey := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/p1/") |
| + before, _, err := f.files.Get(t.Context(), filesKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := f.build(); err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + after, _, err := f.files.Get(t.Context(), filesKey) |
| + if err != nil || string(after) != string(before) || f.bucket.LatestID("data", key) != first { |
| + t.Fatalf("the replay rewrote P: %v", err) |
| + } |
| + if _, done, err := f.verifier.Verify(t.Context(), f.obs); !done || err != nil { |
| + t.Fatalf("after the replay: done=%v %v", done, err) |
| + } |
| +} |
| + |
| +// Finding 2, ruling 43: the build activates the target's guard at |
| +// install_stamp from an empty box, under main's Local, before Capture; |
| +// never for another executor, never past a refusing Local, never at |
| +// another phase, never a retired stamp. The build's host-named scratch is |
| +// gone once P is built. |
| +func TestRelocationBuildActivatesTheGuardFromAnEmptyTarget(t *testing.T) { |
| + t.Run("from an empty box", func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + if err := f.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := hostguard.Capture(f.a.Guard.DataDir, buildHost) |
| + if err != nil || c.Incarnation != f.obs.Claim.Holder.Incarnation || c.Stamp != f.obs.Claim.Holder.Stamp { |
| + t.Fatalf("capture %+v %v", c, err) |
| + } |
| + if len(f.locals) == 0 || f.locals[0].Action != relocationActionInstall || f.locals[0].Captured.Stamp != f.obs.Claim.Holder.Stamp || f.locals[0].Box != "dst" { |
| + t.Fatalf("main's Local was not asked for the install: %+v", f.locals) |
| + } |
| + if _, err := os.Stat(filepath.Join(f.cfg.WorkDir, "rel-1")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the build's scratch survived: %v", err) |
| + } |
| + }) |
| + for _, tc := range []struct { |
| + name string |
| + change func(f *buildFixture) |
| + want error |
| + }{ |
| + {"another executor", func(f *buildFixture) { |
| + f.moveExec() |
| + f.obs, _ = hostclaim.Read(context.Background(), f.claims, buildHost) |
| + }, hostrelocate.ErrRefused}, |
| + {"main's Local refuses", func(f *buildFixture) { f.localErr = execlease.ErrLost }, execlease.ErrLost}, |
| + {"installed, not installing", func(f *buildFixture) { |
| + o, _ := hostclaim.Read(context.Background(), f.claims, buildHost) |
| + f.obs, _ = hostclaim.Update(context.Background(), f.claims, o, func(c *hostclaim.Claim) error { c.Op.Phase = hostclaim.PhaseInstalled; return nil }) |
| + }, hostrelocate.ErrRefused}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + tc.change(f) |
| + before := f.bucket.Snapshot() |
| + if err := f.build(); !errors.Is(err, tc.want) { |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + if entries, _ := os.ReadDir(filepath.Join(f.a.Guard.DataDir, "hostguard")); len(entries) != 0 { |
| + t.Fatalf("the guard was written: %v", entries) |
| + } |
| + if f.bucket.Snapshot() != before { |
| + t.Fatal("the bucket was written") |
| + } |
| + }) |
| + } |
| + t.Run("a retired stamp", func(t *testing.T) { |
| + f := newBuildFixture(t, true) |
| + if _, err := f.a.relocationActivate(f.cfg)(t.Context(), f.obs); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.retireStamp() |
| + before := f.bucket.Snapshot() |
| + if err := f.build(); !errors.Is(err, hostguard.ErrRetiredStamp) { |
| + t.Fatalf("a retired stamp was reactivated: %v", err) |
| + } |
| + if f.bucket.Snapshot() != before { |
| + t.Fatal("the bucket was written") |
| + } |
| + }) |
| +} |
| + |
| +// A pin whose files digest names no retained copy is refused, never read |
| +// as a host that published no files (absence is never evidence). The |
| +// pinned manifest here is itself empty, so only that check tells the two |
| +// apart. |
| +func TestPreparedRefusesAFilesDigestWithoutItsRetainedCopy(t *testing.T) { |
| + ctx := t.Context() |
| + f := newBuildFixtureWith(t, buildOptions{}) |
| + if err := f.build(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, done, err := f.verifier.Verify(ctx, f.obs); !done || err != nil { |
| + t.Fatalf("the built empty pin: done=%v %v", done, err) |
| + } |
| + var ref hostclaim.RecoveryPin |
| + if err := json.Unmarshal([]byte(f.obs.Claim.Op.Journal["pin"]), &ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + pin, err := hostrelocate.ReadPin(ctx, f.claims, ref) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if pin.Files.SHA256 == "" || pin.Files.Retained == "" { |
| + t.Fatalf("the fixture's pin has no retained manifest: %+v", pin.Files) |
| + } |
| + pin.Files.Retained = "" |
| + raw, err := json.Marshal(pin) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ref.Key += ".forged" |
| + if _, err := f.claims.Put(ctx, ref.Key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + sum := sha256.Sum256(raw) |
| + ref.SHA256 = hex.EncodeToString(sum[:]) |
| + refRaw, err := json.Marshal(ref) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := *f.obs.Claim |
| + c.Op.Journal = map[string]string{} |
| + for k, v := range f.obs.Claim.Op.Journal { |
| + c.Op.Journal[k] = v |
| + } |
| + c.Op.Journal["pin"] = string(refRaw) |
| + obs := f.obs |
| + obs.Claim = &c |
| + if _, done, err := f.verifier.Verify(ctx, obs); done || !errors.Is(err, hostrelocate.ErrRefused) { |
| + t.Fatalf("done=%v err=%v", done, err) |
| + } |
| +} |
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go| new file mode 100644 |
| index 000000000..74206d16d |
| --- /dev/null |
| +++ b/internal/host/relocationinstall.go |
| @@ -0,0 +1,154 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "slices" |
| + "strconv" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostrelocate" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// relocationActionInstall is the install authority's action for the |
| +// target's guard activation, its build writes and its local install |
| +// (ruling 46). The claim's Policy admits no action for relocating (R1); |
| +// main's Local is asked for this one until claim ask C9 moves the install |
| +// into the claim. |
| +const relocationActionInstall = "install" |
| + |
| +func relocationInstallStamp(c *hostclaim.Claim) (uint64, error) { |
| + stamp, err := strconv.ParseUint(c.Op.Journal["install_stamp"], 10, 64) |
| + if err != nil || stamp == 0 { |
| + return 0, fmt.Errorf("%w: no install stamp", hostrelocate.ErrRefused) |
| + } |
| + return stamp, nil |
| +} |
| + |
| +// relocationInstallAuthority is the install's claim check (rulings 43, 46, |
| +// 52): this box executes this relocating op at one of phases, P unopened, |
| +// not stuck, not lost, and the holder's stamp is the journaled install |
| +// stamp. |
| +func relocationInstallAuthority(c *hostclaim.Claim, op, box string, phases ...string) error { |
| + if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindRelocating || c.Op.ID != op || c.Op.Exec != box || c.Op.Journal["stuck"] != "" || |
| + slices.Contains(c.Lost, box) || !slices.Contains(phases, c.Op.Phase) || hostclaim.PreparedOpened(c) { |
| + return fmt.Errorf("%w: the install's authority changed", hostrelocate.ErrRefused) |
| + } |
| + stamp, err := relocationInstallStamp(c) |
| + if err != nil { |
| + return err |
| + } |
| + if c.Holder.Stamp != stamp { |
| + return fmt.Errorf("%w: the holder's stamp is not the install stamp", hostrelocate.ErrRefused) |
| + } |
| + return nil |
| +} |
| + |
| +// relocationInstallLocal is main's Local check (identity, quarantine, the |
| +// participation slot's binding, deadline and loss latch) for an install |
| +// action on c at captured: the one Selection both the guard activation |
| +// and every build write ask with. |
| +func relocationInstallLocal(ctx context.Context, src hostbox.WorkSource, c *hostclaim.Claim, captured hostguard.Captured) error { |
| + sel := hostbox.Selection{Host: c.Host, Action: relocationActionInstall, Box: src.Box, Region: src.Region, Captured: captured, |
| + Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session} |
| + return src.Local(ctx, c, sel) |
| +} |
| + |
| +// relocationActivationReader validates every fresh read the guard makes |
| +// while it activates the target's installation (ruling 43), as reconcile's |
| +// activationReader does for its own. |
| +type relocationActivationReader struct { |
| + claims release.Store |
| + check func(context.Context, *hostclaim.Claim) error |
| +} |
| + |
| +func (r relocationActivationReader) Read(ctx context.Context, host string) (hostclaim.Observed, error) { |
| + obs, err := hostclaim.Read(ctx, r.claims, host) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if obs.Absent || obs.Claim == nil { |
| + return hostclaim.Observed{}, hostbox.ErrChanged |
| + } |
| + if err := r.check(ctx, obs.Claim); err != nil { |
| + return hostclaim.Observed{}, err |
| + } |
| + return obs, nil |
| +} |
| + |
| +// relocationActivate is ruling 43: the first act of installing, before the |
| +// remote build reads hostguard.Capture. It activates this box's guard |
| +// record at install_stamp (Activate takes holder.stamp, which the |
| +// installing CAS set to install_stamp), creating the record on an empty |
| +// target, under a reader that refuses any read that is not this box's |
| +// installing op at that stamp or that main's Local check refuses. |
| +// Idempotent on the same stamp; a retired stamp is ErrRetiredStamp, never |
| +// reactivated (ruling 52). |
| +func (a ClaimAssembly) relocationActivate(cfg RelocationAssemblyConfig) func(context.Context, hostclaim.Observed) (hostguard.Captured, error) { |
| + return func(ctx context.Context, o hostclaim.Observed) (hostguard.Captured, error) { |
| + src := cfg.Source |
| + if src.Local == nil || src.Box == "" || o.Claim == nil || o.Claim.Holder == nil { |
| + return hostguard.Captured{}, ErrClaimStopUnavailable |
| + } |
| + op := o.Claim.Op.ID |
| + guard := a.Guard |
| + guard.Claims = relocationActivationReader{claims: a.Claims, check: func(ctx context.Context, c *hostclaim.Claim) error { |
| + if err := relocationInstallAuthority(c, op, src.Box, hostclaim.PhaseInstalling); err != nil { |
| + return err |
| + } |
| + return relocationInstallLocal(ctx, src, c, hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp}) |
| + }} |
| + if err := guard.Activate(ctx, o.Host, src.Box, nil); err != nil { |
| + return hostguard.Captured{}, err |
| + } |
| + captured, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + if err != nil { |
| + return hostguard.Captured{}, err |
| + } |
| + stamp, err := relocationInstallStamp(o.Claim) |
| + if err != nil { |
| + return hostguard.Captured{}, err |
| + } |
| + if captured.Stamp != stamp || captured.Incarnation != o.Claim.Holder.Incarnation { |
| + return hostguard.Captured{}, hostguard.ErrStampMismatch |
| + } |
| + return captured, nil |
| + } |
| +} |
| + |
| +// relocationBuildAuthority is the remote build's check before every |
| +// durable write (ruling 59): the claim still runs this box's installing op |
| +// at the install stamp with the same P and pin, the guard still captures |
| +// that installation, and main's Local still admits it. Local is the |
| +// execution lease's own check, so an execution lease lost while the |
| +// replica lease, claim and guard are unchanged stops the next write. |
| +func (a ClaimAssembly) relocationBuildAuthority(cfg RelocationAssemblyConfig, o hostclaim.Observed, captured hostguard.Captured) func(context.Context) error { |
| + return func(ctx context.Context) error { |
| + src := cfg.Source |
| + if src.Local == nil || src.Box == "" || o.Claim == nil { |
| + return ErrClaimStopUnavailable |
| + } |
| + fresh, err := hostclaim.Read(ctx, a.Claims, o.Host) |
| + if err != nil { |
| + return err |
| + } |
| + c := fresh.Claim |
| + if err := relocationInstallAuthority(c, o.Claim.Op.ID, src.Box, hostclaim.PhaseInstalling); err != nil { |
| + return err |
| + } |
| + if c.Op.Journal["prepared"] != o.Claim.Op.Journal["prepared"] || c.Op.Journal["pin"] != o.Claim.Op.Journal["pin"] { |
| + return fmt.Errorf("%w: the build's P or pin changed", hostrelocate.ErrRefused) |
| + } |
| + now, err := hostguard.Capture(a.Guard.DataDir, o.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if now != captured || captured.Stamp != c.Holder.Stamp { |
| + return fmt.Errorf("%w: this box's installation is not the one the installing CAS issued (%w)", hostrelocate.ErrRefused, hostguard.ErrStampMismatch) |
| + } |
| + return relocationInstallLocal(ctx, src, c, captured) |
| + } |
| +} |
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go| index dc8e635fb..4ca47d4a0 100644 |
| --- a/internal/hostrelocate/pin.go |
| +++ b/internal/hostrelocate/pin.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -37,6 +38,18 @@ type PinObject struct { |
| // Release is the app release version the relocation installs, read |
| // at pinning from StorePinner.Release (R9); a reuse carries it. |
| Release string `json:"release"` |
| + // InstanceKey is the key a nonempty database is sealed with when the |
| + // pinned manifest evidences it by an adoption record rather than by |
| + // instance.key (O2', instance-key plan rev 3): its digest and source. |
| + // The install delivers exactly this key or refuses (O3'). |
| + InstanceKey *KeyPin `json:"instance_key,omitempty"` |
| +} |
| + |
| +// KeyPin is a pinned instance key: the SHA-256 of the key and the file |
| +// that supplies it at launch (instancekey.SourcePlatform or SourceConfig). |
| +type KeyPin struct { |
| + Source string `json:"source"` |
| + SHA256 string `json:"sha256"` |
| } |
| // FilesPin names one generation's files manifest by prefix, sequence and |
| @@ -142,11 +155,15 @@ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.T |
| // that writer's instance.key: the pinned manifest must carry it (rev |
| // 18, round 17 finding 1). |
| needKey := c.Holder.Session != 0 && obj.DB != nil && obj.DB.MaxTXID > 0 |
| - files, retained, err := p.filesPin(ctx, c.Holder, released && rel.ManifestSeq == 0, needKey) |
| + // The writer whose data is pinned: the session admitted as the pinned |
| + // database's generation (O2', instance-key plan ruling 20). Asked only |
| + // when instance.key is missing. |
| + writer := func() (instancekey.Binding, error) { return pinnedWriter(c, obj.DB.Generation) } |
| + files, keyPin, retained, err := p.filesPin(ctx, writer, c.Holder, released && rel.ManifestSeq == 0, needKey) |
| if err != nil { |
| return hostclaim.RecoveryPin{}, err |
| } |
| - obj.Files = files |
| + obj.Files, obj.InstanceKey = files, keyPin |
| if released && obj.Files.Seq < rel.ManifestSeq { |
| return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the files manifest is at %d, the release at %d", ErrNotYet, obj.Files.Seq, rel.ManifestSeq) |
| } |
| @@ -221,7 +238,7 @@ var ErrFilesUnevidenced = errors.New("hostrelocate: the files evidence does not |
| // stays restorable at g10. Every referenced object must lie under this |
| // holder's location (its incarnation) in this region's data bucket, at |
| // files/<generation>/objects/; any other reference refuses the pin. |
| -func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) { |
| +func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Binding, error), h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, *KeyPin, []byte, error) { |
| chain := []string{h.Generation} |
| for i := len(h.Admissions) - 1; i >= 0; i-- { |
| chain = append(chain, h.Admissions[i].Generation, h.Admissions[i].Predecessor) |
| @@ -241,24 +258,33 @@ func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, released |
| continue |
| } |
| if err != nil { |
| - return FilesPin{}, nil, err |
| + return FilesPin{}, nil, nil, err |
| } |
| var m hostfiles.Manifest // decoded from the bytes the digest names |
| if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion { |
| - return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err) |
| - } |
| - if needKey && !liveEntry(m, InstanceKeyPath) { |
| - // The manifest was copied before the app started and the |
| - // key it minted afterwards never published (§5.6's |
| - // continuous-sync window, L21): installing this set would mint |
| - // a new key over data sealed with the old one. |
| - return FilesPin{}, nil, fmt.Errorf("%w: %s's pinned manifest has no %s for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath) |
| + return FilesPin{}, nil, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err) |
| } |
| if origins, err = referencedOrigins(h.Location, origins, m); err != nil { |
| - return FilesPin{}, nil, err |
| + return FilesPin{}, nil, nil, err |
| + } |
| + var keyPin *KeyPin |
| + if needKey && !liveEntry(m, InstanceKeyPath) { |
| + // (a) failed: no instance.key. (b'): an adoption record bound |
| + // to the pinned writer, for the key the target will deliver |
| + // (O2', instance-key plan rev 3). Without it the manifest may |
| + // have been copied before the app minted a key of its own |
| + // (§5.6's continuous-sync window), and installing this set |
| + // would mint a new key over data sealed with the old one. |
| + w, err := writer() |
| + if err != nil { |
| + return FilesPin{}, nil, nil, err |
| + } |
| + if keyPin, err = p.adoptedKey(ctx, w, prefix, m, origins); err != nil { |
| + return FilesPin{}, nil, nil, err |
| + } |
| } |
| sum := sha256.Sum256(raw) |
| - return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil |
| + return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, keyPin, raw, nil |
| } |
| // No manifest anywhere in the chain. An empty files pin needs the |
| // same kind of evidence an empty database pin does (ruling 15): no |
| @@ -272,14 +298,14 @@ func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, released |
| // 17, finding F2), never an install that mints a fresh key over data |
| // sealed with the old one. |
| if h.Session != 0 && !releasedEmpty { |
| - return FilesPin{}, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location) |
| + return FilesPin{}, nil, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location) |
| } |
| gen := h.Generation |
| if gen == "" { |
| gen = hostclaim.RootGeneration |
| } |
| prefix := h.Location + "/files/" + gen + "/" |
| - return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil |
| + return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil, nil |
| } |
| // referencedOrigins adds to origins the files prefix of every generation |
| @@ -340,6 +366,12 @@ func ReadPin(ctx context.Context, claims release.Store, ref hostclaim.RecoveryPi |
| if obj.Release == "" || obj.Release != ref.Release { |
| return PinObject{}, fmt.Errorf("%w: pin object %s names release %q, the journal %q", ErrRefused, ref.Key, obj.Release, ref.Release) |
| } |
| + // A pin without a database is an empty bootstrap only by its named |
| + // evidence, and the journal's copy must agree: a missing plan is never |
| + // read as an empty database (absence is never evidence). |
| + if (obj.DB == nil) == (obj.Empty == "") || ref.Empty != (obj.DB == nil) { |
| + return PinObject{}, fmt.Errorf("%w: pin object %s does not say whether its database is empty", ErrRefused, ref.Key) |
| + } |
| return obj, nil |
| } |
| @@ -395,3 +427,75 @@ func CopyPinnedManifest(ctx context.Context, claims, data release.Store, pin Fil |
| } |
| return pinned, nil |
| } |
| + |
| +// pinnedWriter is the writer whose data a pin of generation gen holds: the |
| +// holder's own session when gen is the holder's generation, else the |
| +// admitted session recorded for gen. A generation no admission names has |
| +// no writer to bind evidence to, and refuses. |
| +func pinnedWriter(c *hostclaim.Claim, gen string) (instancekey.Binding, error) { |
| + h := c.Holder |
| + w := instancekey.Binding{Host: c.Host, Incarnation: h.Incarnation, Generation: gen} |
| + if gen == h.Generation { |
| + w.Session = h.Session |
| + } |
| + for i := len(h.Admissions) - 1; w.Session == 0 && i >= 0; i-- { |
| + if h.Admissions[i].Generation == gen { |
| + w.Session = h.Admissions[i].Session |
| + } |
| + } |
| + if w.Session == 0 || w.Incarnation == "" || gen == "" { |
| + return instancekey.Binding{}, fmt.Errorf("%w: no admitted writer for generation %q", ErrFilesUnevidenced, gen) |
| + } |
| + return w, nil |
| +} |
| + |
| +// adoptedKey is O2' source (b') (instance-key plan rev 3, ruling 20): the |
| +// pinned manifest's adoption record must bind exactly the pinned writer, |
| +// and name the key the target will deliver. A platform-sourced record must |
| +// match the env item's own key, which the install restores byte for byte. |
| +// A config-sourced record needs an env item with no own key (one would |
| +// override the configuration at launch); its digest is pinned, and the |
| +// install verifies the configuration it renders still delivers it (O3'). |
| +// The env item is parsed with instancekey.ParseEnv, the activator's own |
| +// rule. A named item that cannot be read is an error, never "no evidence". |
| +func (p StorePinner) adoptedKey(ctx context.Context, writer instancekey.Binding, prefix string, m hostfiles.Manifest, origins []string) (*KeyPin, error) { |
| + read := func(name string) ([]byte, bool, error) { |
| + if e, ok := m.Platform[name]; !ok || e.Deleted { |
| + return nil, false, nil |
| + } |
| + raw, err := hostfiles.ReadPlatform(ctx, p.Data, prefix, m, name, origins) |
| + return raw, err == nil, err |
| + } |
| + rec, ok, err := read(instancekey.AdoptionItem) |
| + if err != nil { |
| + return nil, err |
| + } |
| + refuse := func(why string) (*KeyPin, error) { |
| + return nil, fmt.Errorf("%w: %s's pinned manifest has no %s and %s, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, why) |
| + } |
| + if !ok { |
| + return refuse("no adoption record") |
| + } |
| + a, err := instancekey.ParseAdoption(rec) |
| + if err != nil { |
| + return refuse("its adoption record is malformed") |
| + } |
| + if !a.Binds(writer) { |
| + return refuse("its adoption record is not the pinned writer's") |
| + } |
| + env, haveEnv, err := read(hostfiles.EnvItem) |
| + if err != nil { |
| + return nil, err |
| + } |
| + own, _ := instancekey.Effective(nil, instancekey.ParseEnv(env)) |
| + switch { |
| + case a.Source == instancekey.SourcePlatform && own != "" && instancekey.Digest(own) == a.SHA256: |
| + // A configured key needs the env item that shows the own file supplies |
| + // none: a manifest without host.env is not evidence that nothing |
| + // overrides the configuration (absence is never evidence). |
| + case a.Source == instancekey.SourceConfig && haveEnv && own == "": |
| + default: |
| + return refuse("its adoption record does not name the key the env delivers") |
| + } |
| + return &KeyPin{Source: a.Source, SHA256: a.SHA256}, nil |
| +} |
diff --git a/internal/hostrelocate/pin_envkey_test.go b/internal/hostrelocate/pin_envkey_test.go| new file mode 100644 |
| index 000000000..bca47d25f |
| --- /dev/null |
| +++ b/internal/hostrelocate/pin_envkey_test.go |
| @@ -0,0 +1,173 @@ |
| +package hostrelocate |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostfiles" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// O2' (instance-key plan rev 3, replacing stage B's O2 and ruling 55's |
| +// KeyEvidence reader): a nonempty database's pinned manifest evidences its |
| +// key by (a) instance.key, or (b') an adoption record bound to the pinned |
| +// writer for the key the target will deliver. Presence of a key, in the env |
| +// or in the configuration, is never evidence (Astra rounds 1 and 2). |
| +func TestStorePinnerKeyEvidence(t *testing.T) { |
| + ctx := context.Background() |
| + at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) |
| + const key = "q+/Zk3vJ0aW1Yx9sR7tUe2oPl4mN8bC6dF5gH0iJ1kA=" |
| + writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 3, Generation: "g3"} |
| + earlier := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 2, Generation: "g2"} |
| + envWithKey := "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=" + key + "\n" |
| + rec := func(b instancekey.Binding, source, k string) string { |
| + return string(instancekey.AdoptionRecord(b, source, instancekey.Digest(k))) |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + appKey bool // (a) instance.key in the manifest |
| + env, adoption string // "" is no item |
| + unreadable string // a named item whose object is missing |
| + holder func(*hostclaim.Holder) |
| + want error |
| + pinned *KeyPin |
| + }{ |
| + {name: "(a) the app's own instance.key", appKey: true, env: envWithKey, want: nil}, |
| + {name: "(b') the platform key, adopted by the pinned writer", env: envWithKey, adoption: rec(writer, "platform", key), pinned: &KeyPin{Source: "platform", SHA256: instancekey.Digest(key)}}, |
| + {name: "(b') a configured key, adopted by the pinned writer", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}}, |
| + {name: "(b') a configured key under an empty override", env: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}}, |
| + {name: "a backed-up platform key, the app's own key missing", env: envWithKey, want: ErrFilesUnevidenced}, |
| + {name: "configured A, the app's own unpublished B: no report, no record", env: "CARLOS_ADMIN_TOKEN=t\n", want: ErrFilesUnevidenced}, |
| + {name: "a stale record from a previous release", env: envWithKey, adoption: rec(earlier, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "a rollback: a new session, the old session's record", env: envWithKey, adoption: rec(writer, "platform", key), |
| + holder: func(h *hostclaim.Holder) { |
| + h.Session, h.Generation = 4, "g4" |
| + h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Generation: "g4", Predecessor: "g3", Session: 4}) |
| + }, want: ErrFilesUnevidenced}, |
| + {name: "a record from another installation", env: envWithKey, adoption: rec(instancekey.Binding{Host: host, Incarnation: "inc-0", Session: 3, Generation: "g3"}, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "a platform record for another key", env: envWithKey, adoption: rec(writer, "platform", "another-key"), want: ErrFilesUnevidenced}, |
| + {name: "a config record while the env's own key overrides it", env: envWithKey, adoption: rec(writer, "config", key), want: ErrFilesUnevidenced}, |
| + {name: "a platform record without the platform key", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "an exported key line is not what the app receives", env: "export CARLOS_INSTANCE_KEY=" + key + "\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced}, |
| + {name: "a malformed record", env: envWithKey, adoption: `{"v":1}`, want: ErrFilesUnevidenced}, |
| + {name: "no admitted writer for the pinned generation", env: envWithKey, adoption: rec(writer, "platform", key), holder: func(h *hostclaim.Holder) { h.Incarnation = "" }, want: ErrFilesUnevidenced}, |
| + {name: "a named adoption item that cannot be read", env: envWithKey, unreadable: instancekey.AdoptionItem, want: errUnreadable}, |
| + {name: "a named env item that cannot be read", adoption: rec(writer, "config", "configured-A"), unreadable: hostfiles.EnvItem, want: errUnreadable}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + obs := pinClaim() |
| + obs.Claim.Holder.Incarnation = "inc-1" |
| + gen := "g3" |
| + if tc.holder != nil { |
| + tc.holder(obs.Claim.Holder) |
| + gen = obs.Claim.Holder.Generation |
| + } |
| + m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}} |
| + if tc.appKey { |
| + m = keyed(m) |
| + } |
| + item := func(name, body string, put bool) { |
| + sum := sha256.Sum256([]byte(body)) |
| + sha := hex.EncodeToString(sum[:]) |
| + obj := pinLocation + "/files/" + gen + "/objects/" + fmt.Sprintf("%016d-%s", 4, sha) |
| + if put { |
| + if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + m.Platform[name] = hostfiles.Entry{Object: obj, SHA256: sha, Size: int64(len(body)), Mode: 0o600, Seq: 4} |
| + } |
| + if tc.env != "" { |
| + item(hostfiles.EnvItem, tc.env, true) |
| + } |
| + if tc.adoption != "" { |
| + item(instancekey.AdoptionItem, tc.adoption, true) |
| + } |
| + if tc.unreadable != "" { |
| + item(tc.unreadable, "never stored\n", false) |
| + } |
| + putManifest(t, data, gen, m) |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + ref, err := p.Pin(ctx, obs, at) |
| + switch { |
| + case tc.want == errUnreadable: |
| + if err == nil || errors.Is(err, ErrFilesUnevidenced) { |
| + t.Fatalf("got %v, want a read error, never a refusal for no evidence", err) |
| + } |
| + return |
| + case !errors.Is(err, tc.want): |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + if err != nil { |
| + return |
| + } |
| + raw, _, err := claims.Get(ctx, ref.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var obj PinObject |
| + if err := json.Unmarshal(raw, &obj); err != nil { |
| + t.Fatal(err) |
| + } |
| + if (obj.InstanceKey == nil) != (tc.pinned == nil) || (tc.pinned != nil && *obj.InstanceKey != *tc.pinned) { |
| + t.Fatalf("pinned key %+v, want %+v", obj.InstanceKey, tc.pinned) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +var errUnreadable = errors.New("sentinel: any read error") |
| + |
| +// A configured key's record vouches only beside the env item that shows |
| +// the instance's own file supplies no key. A manifest without host.env is |
| +// not evidence that nothing overrides the configuration at launch (absence |
| +// is never evidence): the pin refuses, and the same record beside an env |
| +// item with no key of its own pins. |
| +func TestStorePinnerConfigRecordNeedsTheEnvItem(t *testing.T) { |
| + ctx := context.Background() |
| + at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) |
| + writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 3, Generation: "g3"} |
| + record := string(instancekey.AdoptionRecord(writer, instancekey.SourceConfig, instancekey.Digest("configured-A"))) |
| + for _, tc := range []struct { |
| + name string |
| + env bool |
| + want error |
| + }{ |
| + {"no env item", false, ErrFilesUnevidenced}, |
| + {"an env item with no key of its own", true, nil}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + obs := pinClaim() |
| + obs.Claim.Holder.Incarnation = "inc-1" |
| + m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}} |
| + item := func(name, body string) { |
| + sum := sha256.Sum256([]byte(body)) |
| + sha := hex.EncodeToString(sum[:]) |
| + obj := pinLocation + "/files/g3/objects/" + fmt.Sprintf("%016d-%s", 4, sha) |
| + if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + m.Platform[name] = hostfiles.Entry{Object: obj, SHA256: sha, Size: int64(len(body)), Mode: 0o600, Seq: 4} |
| + } |
| + item(instancekey.AdoptionItem, record) |
| + if tc.env { |
| + item(hostfiles.EnvItem, "CARLOS_ADMIN_TOKEN=t\n") |
| + } |
| + putManifest(t, data, "g3", m) |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + if _, err := p.Pin(ctx, obs, at); !errors.Is(err, tc.want) && (tc.want != nil || err != nil) { |
| + t.Fatalf("got %v, want %v", err, tc.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go| index eabb20cdf..c75168102 100644 |
| --- a/internal/hostrelocate/pin_test.go |
| +++ b/internal/hostrelocate/pin_test.go |
| @@ -624,3 +624,49 @@ func TestStorePinnerPinsTheRouteRelease(t *testing.T) { |
| t.Fatalf("a pin object with no release was reused: %v", err) |
| } |
| } |
| + |
| +// Absence is never evidence: a pin with no database is an empty bootstrap |
| +// only when the object names its evidence and the journal agrees. An |
| +// object with neither a database nor evidence, or one the journal |
| +// disagrees with about emptiness, is refused, never read as an empty |
| +// database. |
| +func TestReadPinNeedsTheEmptyEvidence(t *testing.T) { |
| + ctx := context.Background() |
| + plan := &genselect.Plan{Generation: "g3", MaxTXID: 41} |
| + for _, tc := range []struct { |
| + name string |
| + obj PinObject |
| + empty bool // the journal's copy |
| + ok bool |
| + }{ |
| + {"an evidenced empty bootstrap", PinObject{Empty: "session-0"}, true, true}, |
| + {"a database", PinObject{DB: plan}, false, true}, |
| + {"no database and no evidence", PinObject{}, true, false}, |
| + {"a database and empty evidence", PinObject{DB: plan, Empty: "release"}, false, false}, |
| + {"an empty pin the journal calls a database", PinObject{Empty: "session-0"}, false, false}, |
| + {"a database the journal calls empty", PinObject{DB: plan}, true, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + claims := release.NewDirStore(t.TempDir()) |
| + obj := tc.obj |
| + obj.V, obj.Host, obj.Op, obj.ClaimGen, obj.Release = 1, host, "rel-1", 12, "v1" |
| + raw, err := json.Marshal(obj) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := PinKey(host, "rel-1", 12) |
| + if _, err := claims.Put(ctx, key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + ref := pinRef(key, raw, obj) |
| + ref.Empty = tc.empty |
| + _, err = ReadPin(ctx, claims, ref) |
| + if tc.ok && err != nil { |
| + t.Fatalf("an evidenced pin was refused: %v", err) |
| + } |
| + if !tc.ok && !errors.Is(err, ErrRefused) { |
| + t.Fatalf("got %v, want ErrRefused", err) |
| + } |
| + }) |
| + } |
| +} |