carlos / platform Public

region ha: relocation's first probe establishes its files manifest through main's C8 provider: the provider reads the env file the launch source minted, found through the host's row (a nested row's in its instance directory, read beneath it; fenced, conflicting, missing or moved rows refuse, never a guess at the flat path), reads P's prepared manifest by the binding relocation's own install journals (prepared_authority's digest and stamp, the local_install receipt's journal digest, the install journal's files_manifest step with key, op, stamp, sha256 and exact version), chosen by the claim's op kind, never by which journal exists, and treats a relocating P's opening as prepared whether or not its database is empty, so an empty database still starts from P's files by compare-and-swap over P's manifest (merge round 6, findings 2, 3 and 4).

registerProductionClaimFiles now takes the box's registry; movesFiles.FilesDir becomes EnvFile (claimEnvFileFor in production). Moves' own paths stay flat (moves refuses nested rows). A serving claim that asks for a prepared opening is refused. Tests drive relocation's real build, install and the claim's transitions into the probe, then Select, the admission CAS and Materialize against the real movesFilesSource and Establish; each fix's line was reverted and the tests failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev e77d591262a5c33f6a8195ce15ab5910b643c128 parent 5244916
9 files changed, +735 −64
  • internal/host/agent.go +1 −1
  • internal/host/claimenvfile_test.go +109 −0
  • internal/host/claimfiles.go +79 −9
  • internal/host/claimfiles_test.go +24 −12
  • internal/host/movesfiles.go +188 −36
  • internal/host/movesfiles_test.go +1 −3
  • internal/host/movesprobeowners_test.go +1 −1
  • internal/host/relocationinstall.go +60 −2
  • internal/host/relocationprobefiles_linux_test.go +272 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index a3f06c80a..d99c01c1b 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -293,7 +293,7 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// The C8 files manifest provider (moves Task 6) registers under the
// claim switch alone: every claim-on serving wake establishes its files
// manifest through it (claimfiles.go).
- if err := registerProductionClaimFiles(claimEntryEnv(claimCtx), dd); err != nil {
+ if err := registerProductionClaimFiles(claimEntryEnv(claimCtx), dd, reg); err != nil {
log.Printf("agent: host claims: the files manifest provider is not registered: %v", err)
}
if hostwrite.Enabled(claimEntryEnv(claimCtx)) {
diff --git a/internal/host/claimenvfile_test.go b/internal/host/claimenvfile_test.go
new file mode 100644
index 000000000..53dbaabf2
--- /dev/null
+++ b/internal/host/claimenvfile_test.go
@@ -0,0 +1,109 @@
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// flatClaimEnvFile is a test's env file resolver: every host's env file
+// flat under dir, as the fixtures that predate the row's layout place it.
+func flatClaimEnvFile(dir string) func(host, dbPath string) (claimEnvFile, error) {
+ return func(host, _ string) (claimEnvFile, error) { return claimEnvFile{Dir: dir, Name: host + ".env"}, nil }
+}
+
+// envFileOf adapts a moves FilesDir to the provider's resolver: the env
+// path it names, flat.
+func envFileOf(filesDir func(string) (string, string)) func(host, dbPath string) (claimEnvFile, error) {
+ return func(host, _ string) (claimEnvFile, error) {
+ _, env := filesDir(host)
+ return claimEnvFile{Dir: filepath.Dir(env), Name: filepath.Base(env)}, nil
+ }
+}
+
+// testClaimRoutes is a registry's rows by host; err answers every read.
+type testClaimRoutes struct {
+ rows map[string]registry.Route
+ err error
+}
+
+func (r testClaimRoutes) Get(host string) (*registry.Route, error) {
+ if r.err != nil {
+ return nil, r.err
+ }
+ rt, ok := r.rows[host]
+ if !ok {
+ return nil, nil
+ }
+ return &rt, nil
+}
+
+func (r testClaimRoutes) List() ([]registry.Route, error) {
+ if r.err != nil {
+ return nil, r.err
+ }
+ var out []registry.Route
+ for _, rt := range r.rows {
+ out = append(out, rt)
+ }
+ return out, nil
+}
+
+// Merge round 6, finding 2: the provider finds a host's env file through
+// its row, as the launch source does. A nested row's is in its instance
+// directory, a flat or foreign row's is flat; no row, a row that cannot be
+// read, a row that is not the wake's, and a layout migration in flight
+// each refuse, never a guess at the flat path.
+func TestClaimEnvFileFollowsTheRowsLayout(t *testing.T) {
+ dataDir := t.TempDir()
+ const host = "shop.example.com"
+ nested := registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: provision.NestedDBPath(dataDir, host), Socket: provision.NestedSocket(host)}
+ flat := registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: provision.DefaultDBPath(dataDir, host), Socket: filepath.Join(provision.SocketDir, host+".sock")}
+ foreign := registry.Route{Host: host, Kind: "instance", Hibernate: true, DBPath: filepath.Join(dataDir, "elsewhere", host+".db"), Socket: "/run/other.sock"}
+ resolve := func(rt registry.Route) func(string, string) (claimEnvFile, error) {
+ return claimEnvFileFor(dataDir, testClaimRoutes{rows: map[string]registry.Route{host: rt}})
+ }
+ got, err := resolve(nested)(host, nested.DBPath)
+ if err != nil || got != (claimEnvFile{Dir: provision.InstanceDir(dataDir, host), Name: host + ".env", Nested: true}) {
+ t.Fatalf("nested: %+v %v", got, err)
+ }
+ for name, rt := range map[string]registry.Route{"flat": flat, "foreign": foreign} {
+ got, err := resolve(rt)(host, rt.DBPath)
+ if err != nil || got != (claimEnvFile{Dir: dataDir, Name: host + ".env"}) {
+ t.Fatalf("%s: %+v %v", name, got, err)
+ }
+ }
+ // The wake's row must be the row: another database path is a row that
+ // moved since the wake selected it.
+ if _, err := resolve(nested)(host, flat.DBPath); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a row that moved: %v", err)
+ }
+ if _, err := resolve(nested)(host, ""); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("no database path: %v", err)
+ }
+ if _, err := claimEnvFileFor(dataDir, testClaimRoutes{})(host, nested.DBPath); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("no row: %v", err)
+ }
+ unreadable := errors.New("registry: disk I/O error")
+ if _, err := claimEnvFileFor(dataDir, testClaimRoutes{err: unreadable})(host, nested.DBPath); !errors.Is(err, unreadable) {
+ t.Fatalf("an unreadable registry: %v", err)
+ }
+ // A layout migration in flight: the env file may be at either path.
+ journal := provision.LayoutJournalPath(dataDir, host)
+ if err := os.MkdirAll(filepath.Dir(journal), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(journal, []byte("{}"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for name, rt := range map[string]registry.Route{"nested": nested, "flat": flat} {
+ if _, err := resolve(rt)(host, rt.DBPath); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("%s under a layout journal: %v", name, err)
+ }
+ }
+}
diff --git a/internal/host/claimfiles.go b/internal/host/claimfiles.go
index 153ddc682..be8641853 100644
--- a/internal/host/claimfiles.go
+++ b/internal/host/claimfiles.go
@@ -3,16 +3,21 @@ package host
import (
"context"
"errors"
+ "fmt"
"path/filepath"
"sync"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/layout"
"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"
"github.com/carlosframework/platform/internal/rootaws"
"github.com/carlosframework/platform/internal/s3compat"
@@ -101,31 +106,96 @@ func openClaimFilesManifest(ctx context.Context, getenv func(string) string, sou
// bound to the bucket's cloud region. No pin reader is wired on this
// branch (region HA's ReadPin is not on main), so Evidence is nil and a
// host with no manifest anywhere in its chain refuses every serving wake
-// until it is.
+// until it is. Routes is this box's registry: the env file Establish
+// carries is found through the host's row, as the launch source found it
+// (claimEnvFileFor).
type movesFilesSource struct {
DataDir string
+ Routes claimRoutes
newFiles func(ctx context.Context, bucket, region string) (release.Store, error)
}
+// claimRoutes is the part of the box's registry the files provider reads:
+// a host's row, and every row for the layout's identifier check
+// (*registry.Registry).
+type claimRoutes interface {
+ Get(host string) (*registry.Route, error)
+ List() ([]registry.Route, error)
+}
+
// movesFilesDirFor is the host's files directory and env file under
// dataDir: <dataDir>/<host> and <dataDir>/<host>.env, the layout
-// moveCfg.FilesDir names.
+// moveCfg.FilesDir names. Moves moves only flat rows (routeRefusal's
+// s1Nested; the landing refuses a nested row at checking), so its own
+// paths stay flat; the C8 provider, which every claim-on wake runs, finds
+// the env file through the row instead (claimEnvFileFor).
func movesFilesDirFor(dataDir string) func(host string) (string, string) {
return func(host string) (string, string) {
return filepath.Join(dataDir, host), filepath.Join(dataDir, host+".env")
}
}
+// claimEnvFile is where Establish reads a host's env file: the directory
+// and the name in it. Nested says the directory is the host's instance
+// directory, which the tenant can write, so the file is read only beneath
+// it (instancefs: no link at any component, a regular file, bounded) and
+// the adoption record removed the same way.
+type claimEnvFile struct {
+ Dir, Name string
+ Nested bool
+}
+
+// Path is the env file's path.
+func (e claimEnvFile) Path() string { return filepath.Join(e.Dir, e.Name) }
+
+// claimEnvFileFor resolves a host's env file as the launch source does
+// (activator's pathsFor, through activator.Classify with the exec
+// backend's own box facts: the layout journal and the peer rows): the
+// nested instance directory's <host>.env for a nested row, the flat
+// <dataDir>/<host>.env for a flat or foreign row, which the backend runs
+// as today's exec child with the flat names. The row must be the one the
+// wake selected (its database path, dbPath). Anything else refuses, never
+// a guess at the flat path: no row, a row that cannot be read, a row
+// whose database path moved, a layout migration in flight (fenced), an
+// identifier conflict. A stale flat file is therefore never read for a
+// nested instance.
+func claimEnvFileFor(dataDir string, routes claimRoutes) func(host, dbPath string) (claimEnvFile, error) {
+ return func(host, dbPath string) (claimEnvFile, error) {
+ if dataDir == "" || routes == nil || host == "" {
+ return claimEnvFile{}, hostwrite.ErrUnavailable
+ }
+ rt, err := routes.Get(host)
+ if err != nil {
+ return claimEnvFile{}, fmt.Errorf("host: the row of %s, for its env file: %w", host, err)
+ }
+ if rt == nil || rt.Host != host {
+ return claimEnvFile{}, fmt.Errorf("%w: %s has no row to find its env file by", hostbox.ErrChanged, host)
+ }
+ if dbPath == "" || rt.DBPath != dbPath {
+ return claimEnvFile{}, fmt.Errorf("%w: the row of %s names the database %q, not the wake's %q", hostbox.ErrChanged, host, rt.DBPath, dbPath)
+ }
+ layoutFacts := activator.LayoutInputs{DataDir: dataDir, Journal: func(h string) bool { return layout.JournalPresent(dataDir, h) }, Peers: routes.List}
+ switch l, reason := activator.Classify(*rt, layoutFacts); l {
+ case activator.LayoutNested:
+ return claimEnvFile{Dir: provision.InstanceDir(dataDir, host), Name: host + ".env", Nested: true}, nil
+ case activator.LayoutFlat, activator.LayoutForeign:
+ return claimEnvFile{Dir: dataDir, Name: host + ".env"}, nil
+ default:
+ return claimEnvFile{}, fmt.Errorf("%w: %s's layout is %s (%s); its env file is not read", hostbox.ErrChanged, host, l, reason)
+ }
+ }
+}
+
func (s *movesFilesSource) Open(ctx context.Context, _ release.Store, data lease.VersionedStore, actor hostbox.Actor) (ClaimFilesManifest, error) {
region := park.PlacementRegion(actor.Region)
- if s == nil || s.DataDir == "" || s.newFiles == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" {
+ if s == nil || s.DataDir == "" || s.Routes == nil || s.newFiles == nil || data == nil || actor.Bucket == "" || actor.Box == "" || region == "" || actor.Cloud.Region == "" {
return nil, hostwrite.ErrUnavailable
}
files, err := s.newFiles(ctx, actor.Bucket, actor.Cloud.Region)
if err != nil {
return nil, err
}
- return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, FilesDir: movesFilesDirFor(s.DataDir)}, nil
+ return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, DataDir: s.DataDir, EnvFile: claimEnvFileFor(s.DataDir, s.Routes)}, nil
}
func (s *movesFilesSource) Close(context.Context) error { return nil }
@@ -148,14 +218,14 @@ func newProductionFilesStore(ctx context.Context, bucket, region string) (releas
// registerProductionClaimFiles is the agent's registration, before
// newClaimWiring: with CARLOS_HOST_CLAIM on, the production files source
-// over this box's data directory. Off, it registers nothing and the claim
-// keeps its named refusal (claimFilesRefusal).
-func registerProductionClaimFiles(getenv func(string) string, dataDir string) error {
+// over this box's data directory and registry. Off, it registers nothing
+// and the claim keeps its named refusal (claimFilesRefusal).
+func registerProductionClaimFiles(getenv func(string) string, dataDir string, reg *registry.Registry) error {
if getenv == nil || !hostwrite.Enabled(getenv) {
return nil
}
- if dataDir == "" {
+ if dataDir == "" || reg == nil {
return hostwrite.ErrUnavailable
}
- return RegisterProductionClaimFiles(&movesFilesSource{DataDir: dataDir, newFiles: newProductionFilesStore})
+ return RegisterProductionClaimFiles(&movesFilesSource{DataDir: dataDir, Routes: reg, newFiles: newProductionFilesStore})
}
diff --git a/internal/host/claimfiles_test.go b/internal/host/claimfiles_test.go
index 922ce3f3e..28bbc4246 100644
--- a/internal/host/claimfiles_test.go
+++ b/internal/host/claimfiles_test.go
@@ -11,6 +11,7 @@ import (
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -145,7 +146,7 @@ func TestClaimFilesReadTheBucketThroughTheRequestStore(t *testing.T) {
var refuse error
checks := 0
check := func(context.Context) error { checks++; return refuse }
- src := &movesFilesSource{DataDir: t.TempDir(), newFiles: func(context.Context, string, string) (release.Store, error) {
+ src := &movesFilesSource{DataDir: t.TempDir(), Routes: testClaimRoutes{}, newFiles: func(context.Context, string, string) (release.Store, error) {
return casBucketStore{b: inner.VersionedBucket, bucket: "data"}, nil
}}
actor := hostbox.Actor{Box: "uk-box", Region: "uk", Bucket: "data", HomeRegion: "ie", Cloud: hostbox.CloudIdentity{Region: "eu-west-2"}}
@@ -180,9 +181,7 @@ func TestClaimFilesReadTheBucketThroughTheRequestStore(t *testing.T) {
// refuses, nothing is written.
x := &filesFixture{t: t, bucket: inner.VersionedBucket, files: casBucketStore{b: inner.VersionedBucket, bucket: "data"}, dir: src.DataDir}
x.f = f
- f.FilesDir = func(host string) (string, string) {
- return filepath.Join(x.dir, host), filepath.Join(x.dir, host+".env")
- }
+ f.EnvFile = flatClaimEnvFile(x.dir)
x.writeEnv("K=1\n")
o := servingObserved("g2", "g1:uk:@root:uk", "g2:uk:g1:uk")
if err := f.Establish(t.Context(), o, launchInputs("g2"), x); !errors.Is(err, hostbox.ErrChanged) {
@@ -204,7 +203,7 @@ func TestClaimFilesReadTheBucketThroughTheRequestStore(t *testing.T) {
func TestProductionFilesSourceOpensOverTheActor(t *testing.T) {
data := leasetest.NewVersionedBucket()
var gotBucket, gotRegion string
- src := &movesFilesSource{DataDir: "/data", newFiles: func(_ context.Context, bucket, region string) (release.Store, error) {
+ src := &movesFilesSource{DataDir: "/data", Routes: testClaimRoutes{}, newFiles: func(_ context.Context, bucket, region string) (release.Store, error) {
gotBucket, gotRegion = bucket, region
return release.NewDirStore(t.TempDir()), nil
}}
@@ -220,20 +219,33 @@ func TestProductionFilesSourceOpensOverTheActor(t *testing.T) {
if gotBucket != "data-ie" || gotRegion != "eu-west-1" || f.Bucket != "data-ie" || f.Box != "box-1" || f.HomeRegion != "ie" || f.Region != "@home" || f.Data == nil || f.Files == nil || f.Evidence != nil {
t.Fatalf("provider: %+v (%s %s)", f, gotBucket, gotRegion)
}
- dir, env := f.FilesDir("shop.example.com")
- if dir != filepath.Join("/data", "shop.example.com") || env != filepath.Join("/data", "shop.example.com.env") {
- t.Fatalf("files dir %s env %s", dir, env)
+ if f.EnvFile == nil || f.DataDir != "/data" {
+ t.Fatalf("env file resolver set %v, data %s", f.EnvFile != nil, f.DataDir)
}
- // An actor without a bucket, box or region cannot be opened.
+ // An actor without a bucket, box or region cannot be opened, nor a
+ // source without the box's registry to find env files by.
if _, err := src.Open(context.Background(), release.NewDirStore(t.TempDir()), data, hostbox.Actor{Box: "box-1"}); !errors.Is(err, hostwrite.ErrUnavailable) {
t.Fatalf("bare actor: %v", err)
}
- // Registration happens only with the claim on.
+ noRoutes := *src
+ noRoutes.Routes = nil
+ if _, err := noRoutes.Open(context.Background(), release.NewDirStore(t.TempDir()), data, actor); !errors.Is(err, hostwrite.ErrUnavailable) {
+ t.Fatalf("no registry: %v", err)
+ }
+ // Registration happens only with the claim on, and needs the registry.
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
withClaimFilesSource(t, nil)
- if err := registerProductionClaimFiles(func(string) string { return "" }, "/data"); err != nil || productionClaimFilesSource() != nil {
+ if err := registerProductionClaimFiles(func(string) string { return "" }, "/data", reg); err != nil || productionClaimFilesSource() != nil {
t.Fatalf("claim off registered: %v %v", err, productionClaimFilesSource())
}
- if err := registerProductionClaimFiles(func(k string) string { return "1" }, "/data"); err != nil || productionClaimFilesSource() == nil {
+ if err := registerProductionClaimFiles(func(k string) string { return "1" }, "/data", nil); !errors.Is(err, hostwrite.ErrUnavailable) || productionClaimFilesSource() != nil {
+ t.Fatalf("claim on without a registry: %v", err)
+ }
+ if err := registerProductionClaimFiles(func(k string) string { return "1" }, "/data", reg); err != nil || productionClaimFilesSource() == nil {
t.Fatalf("claim on: %v", err)
}
}
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go
index 1b272f1e0..8a2058aed 100644
--- a/internal/host/movesfiles.go
+++ b/internal/host/movesfiles.go
@@ -22,6 +22,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
"github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancefs"
"github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
@@ -75,9 +76,12 @@ type movesFiles struct {
// placement) and Box its id. Every region comparison goes through
// regionname.SameRegion with HomeRegion as the alias.
Bucket, HomeRegion, Region, Box string
- // FilesDir is the host's files directory and env file on this box
- // (moveCfg.FilesDir).
- FilesDir func(host string) (dir, envPath string)
+ // EnvFile resolves the host's env file on this box for the wake's
+ // database path, as the launch source that minted the instance key into
+ // it resolved it (claimEnvFileFor): the nested instance directory's for
+ // a nested row, the flat one for a flat row, and an error for anything
+ // it cannot place.
+ EnvFile func(host, dbPath string) (claimEnvFile, error)
// Proof proves an origin against admissions the claim has recorded
// (platform-1e's ruling, fix round 3): nil means chainOriginProof, the
// retained admission chain, until claim Plan 15's durable
@@ -612,12 +616,13 @@ func movesFilesOrigins(location, prefix string, m hostfiles.Manifest) ([]string,
return out, nil
}
-// openPrepared is the probe's baseline (in.Prepared): P's prepared
-// manifest at exactly the version the op journal's binding names (fix
-// round 1, finding 2). The binding is decoded and held to P's key, the
-// claim's op and the holder's stamp (the install captured the stamp the
-// probe's first admission keeps); the bytes are read at that version,
-// checked against the journalled digest, and validated as a prepared
+// openPrepared is the probe's baseline (in.Prepared, or a relocating
+// op's opening of P, preparedOpening): P's prepared manifest at exactly
+// the version its installation's binding names (fix round 1, finding 2).
+// The binding is read by preparedBinding, which chooses its reader by the
+// claim's op kind, and is held to P's key, the claim's op and the
+// installation's stamp; the bytes are read at that version,
+// checked against the bound digest, and validated as a prepared
// manifest (sequence 0, objects within the location by the claim's rule,
// movesFilesOrigins: a cross-region landing's names destination-local
// objects by its build, a same-region relocation's may retain source
@@ -632,20 +637,8 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho
return nil, hostwrite.ErrUnavailable
}
h := o.Claim.Holder
- j, err := moves.ReadJournal(f.DataDir, o.Host, gen)
- if err != nil {
- return nil, err
- }
- raw, ok := j.Steps[landStepFilesManifest]
- if !ok {
- return nil, fmt.Errorf("%w: the op journal of %s/%s has no prepared files manifest", errLandingChanged, o.Host, gen)
- }
key := hostfiles.ManifestKey(prefix)
- stamp, err := installationStamp(o.Claim)
- if err != nil {
- return nil, err
- }
- pm, err := decodePreparedManifest(raw, key, o.Claim.Op.ID, stamp)
+ pm, err := f.preparedBinding(o, gen, key)
if err != nil {
return nil, err
}
@@ -698,6 +691,126 @@ func (f *movesFiles) openPrepared(ctx context.Context, files release.Store, o ho
return p, nil
}
+// preparedBinding is the binding of P's prepared manifest, read by the
+// reader the claim's op kind names, never by which file happens to exist:
+// a move's install journals it in the moves op journal
+// (.moves/<host>/<P>/journal.json, the files_manifest step); a
+// relocation's install records it in its own install journal
+// (relocate/ops/<op>/install.json), which the claim's local_install
+// receipt binds by digest, and the claim's prepared_authority binds its
+// bytes. Either way the binding names key, op, stamp, digest and version.
+// The absence of one reader's file is never permission to use the other's,
+// and any other kind opens no prepared generation.
+func (f *movesFiles) preparedBinding(o hostclaim.Observed, gen, key string) (preparedManifest, error) {
+ switch kind := o.Claim.Op.Kind; {
+ case kind == hostclaim.KindRelocating:
+ return relocationPreparedManifest(f.DataDir, f.Box, o, gen, key)
+ case claimMovesKind(kind):
+ return movesPreparedManifest(f.DataDir, o, gen, key)
+ default:
+ return preparedManifest{}, fmt.Errorf("%w: a %s claim opens no prepared generation", hostbox.ErrChanged, kind)
+ }
+}
+
+// movesPreparedManifest is a move's binding: the op journal's
+// files_manifest step, held to P's key, the claim's op and the
+// installation's stamp (installationStamp).
+func movesPreparedManifest(dataDir string, o hostclaim.Observed, gen, key string) (preparedManifest, error) {
+ j, err := moves.ReadJournal(dataDir, o.Host, gen)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ raw, ok := j.Steps[landStepFilesManifest]
+ if !ok {
+ return preparedManifest{}, fmt.Errorf("%w: the op journal of %s/%s has no prepared files manifest", errLandingChanged, o.Host, gen)
+ }
+ stamp, err := installationStamp(o.Claim)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ return decodePreparedManifest(raw, key, o.Claim.Op.ID, stamp)
+}
+
+// relocationPreparedManifest is a relocation's binding (region HA §5.8):
+// the claim's prepared_authority names P, its install stamp and the
+// digest of P's prepared files manifest (sequence 0); the claim's
+// local_install receipt names this box and the digest of its install
+// journal; that journal, read whole and held to the receipt's digest,
+// names the host, the op, this box, the stamp and P, and its
+// files_manifest step binds P's key, the op, the stamp, the digest and the
+// exact version the install read. The step's digest must be the
+// authority's. The probe's admission keeps the install stamp, so the
+// holder's stamp is that stamp too. Anything missing or different refuses.
+func relocationPreparedManifest(dataDir, box string, o hostclaim.Observed, gen, key string) (preparedManifest, error) {
+ c := o.Claim
+ auth, err := preparedAuthority(c)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ stamp, err := relocationInstallStamp(c)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ if auth.Generation != gen || auth.FilesSeq != 0 || !claimHexDigest(auth.FilesManifestSHA256) || auth.InstallStamp != stamp || c.Holder.Stamp != stamp {
+ return preparedManifest{}, fmt.Errorf("%w: the prepared authority %+v is not P %s's at the install stamp %d", hostbox.ErrChanged, auth, gen, stamp)
+ }
+ var receipt hostclaim.LocalInstall
+ if json.Unmarshal([]byte(c.Op.Journal["local_install"]), &receipt) != nil || receipt.Box != box || receipt.InstallStamp != stamp || !claimHexDigest(receipt.JournalSHA256) {
+ return preparedManifest{}, fmt.Errorf("%w: the claim journals no local install receipt of this box at the install stamp", hostbox.ErrChanged)
+ }
+ path, err := relocationInstallPath(dataDir, c.Op.ID)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return preparedManifest{}, fmt.Errorf("host: the relocation's install journal: %w", err)
+ }
+ if hostfilesSum(raw) != receipt.JournalSHA256 {
+ return preparedManifest{}, fmt.Errorf("%w: %s is not the install journal the claim's receipt names", hostbox.ErrChanged, path)
+ }
+ var j relocationInstallJournal
+ if err := json.Unmarshal(raw, &j); err != nil {
+ return preparedManifest{}, fmt.Errorf("%w: %s: %v", hostbox.ErrChanged, path, err)
+ }
+ if j.V != 1 || j.Host != o.Host || j.Op != c.Op.ID || j.Box != box || j.InstallStamp != stamp || j.Prepared != gen {
+ return preparedManifest{}, fmt.Errorf("%w: %s is not this box's install of %s at the install stamp", hostbox.ErrChanged, path, gen)
+ }
+ step, ok := j.Steps[relocationStepFilesManifest]
+ if !ok {
+ return preparedManifest{}, fmt.Errorf("%w: the install journal of %s has no prepared files manifest", errLandingChanged, c.Op.ID)
+ }
+ pm, err := decodePreparedManifest(step, key, c.Op.ID, stamp)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ if pm.SHA256 != auth.FilesManifestSHA256 {
+ return preparedManifest{}, fmt.Errorf("%w: the install bound %s at %s, not the prepared authority's digest", hostbox.ErrChanged, key, pm.SHA256)
+ }
+ return pm, nil
+}
+
+// preparedOpening reports whether this admission opens a prepared
+// generation whose files manifest an installation built. For a move it is
+// in.Prepared. For a relocation it is the probe's admission that opens the
+// journalled P, whether or not the database is empty: relocation always
+// builds P's files manifest (§5.8), so database emptiness (in.Empty) and
+// files preparation are independent, and an empty database still starts
+// from P's files. A relocating admission that says prepared or empty for
+// any other generation is refused.
+func preparedOpening(o hostclaim.Observed, in ClaimLaunchInputs) (bool, error) {
+ c := o.Claim
+ if c.Op.Kind != hostclaim.KindRelocating {
+ return in.Prepared, nil
+ }
+ p := c.Op.Journal["prepared"]
+ opens := p != "" && c.Op.Phase == hostclaim.PhaseRelocatingProbing && in.Admission.NewGeneration == p
+ if (in.Prepared || in.Empty) && !opens {
+ return false, fmt.Errorf("%w: a relocating admission is prepared or empty only when it opens P", hostbox.ErrChanged)
+ }
+ return opens, nil
+}
+
// installationStamp is the stamp the installation that wrote P's prepared
// manifest captured (ir.capture at local), validated apart from the
// admitted session's authority (whole-branch fixes, item 2). A return's
@@ -851,13 +964,18 @@ func emptyManifest() hostfiles.Manifest {
// Establish is ClaimFilesManifest.Establish. It runs once per start under
// the start's captured selection, after the launch source has minted the
-// instance key into <host>.env and before the app executes.
+// instance key into <host>.env and before the app executes. The env file
+// is the one the launch source minted into, found through the host's row
+// (EnvFile; a nested row's is in its instance directory).
//
// The baseline:
-// - in.Prepared (the probe's admission opening P): P's own prepared
-// manifest, read current with its etag so the env revision replaces
-// exactly it (a new version; the sequence-0 version stays), validated
-// against P's prefix alone (destination-local objects, P6-D3);
+// - a prepared opening (preparedOpening: in.Prepared for a move, the
+// probe's admission opening P for a relocation, whatever its
+// database): P's own prepared manifest at the version its
+// installation bound (preparedBinding, by the op's kind), read current
+// with its etag so the env revision replaces exactly it (a new
+// version; the sequence-0 version stays), validated against the
+// origins its entries name;
// - in.Empty (the claim's empty bootstrap, EmptyAuthority already run):
// no manifest;
// - otherwise the newest valid manifest of the chain, read by exact
@@ -873,7 +991,7 @@ func emptyManifest() hostfiles.Manifest {
// the one local effect. Establish never writes the env file. An error, a
// bucket that cannot be reached included, fails the wake.
func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in ClaimLaunchInputs, work hostwrite.Work) error {
- if f == nil || f.Files == nil || f.Data == nil || f.Bucket == "" || f.Region == "" || f.Box == "" || f.FilesDir == nil || ctx == nil || work == nil {
+ if f == nil || f.Files == nil || f.Data == nil || f.Bucket == "" || f.Region == "" || f.Box == "" || f.EnvFile == nil || ctx == nil || work == nil {
return hostwrite.ErrUnavailable
}
if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Host == "" || in.Identity.Config == "" || in.Identity.Release == "" {
@@ -884,16 +1002,23 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla
if in.Admission.Box != f.Box || !regionname.SameRegion(f.HomeRegion, in.Admission.Region, f.Region) || !movesFilesGeneration(gen) || h.Location == "" {
return hostbox.ErrChanged
}
- _, envPath := f.FilesDir(o.Host)
- env, err := os.ReadFile(envPath)
+ envFile, err := f.EnvFile(o.Host, in.DBPath)
+ if err != nil {
+ return err
+ }
+ env, err := readClaimEnvFile(envFile)
if err != nil {
return fmt.Errorf("host: the env file the files manifest must carry: %w", err)
}
+ prepared, err := preparedOpening(o, in)
+ if err != nil {
+ return err
+ }
prefix := movesFilesPrefix(h.Location, gen)
files := selectionStore{Store: f.Files, fence: writerFence(work, in.Identity)}
var p *hostfiles.Publisher
switch {
- case in.Prepared:
+ case prepared:
p, err = f.openPrepared(ctx, files, o, in.Admission, gen, prefix, env)
if err != nil {
return err
@@ -963,10 +1088,37 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla
return err
}
return work.With(ctx, in.Identity, func() error {
- adoption := filepath.Join(filepath.Dir(envPath), o.Host+instancekey.AdoptionSuffix)
- if err := os.Remove(adoption); err != nil && !errors.Is(err, fs.ErrNotExist) {
- return err
- }
- return nil
+ return removeClaimAdoption(envFile, o.Host+instancekey.AdoptionSuffix)
})
}
+
+// readClaimEnvFile reads the env file's exact bytes: in a nested instance
+// directory, which the tenant can write, only beneath it (readInstanceFile:
+// no link at any component, a regular file, at most the env read limit),
+// and a missing file is an error either way.
+func readClaimEnvFile(e claimEnvFile) ([]byte, error) {
+ if !e.Nested {
+ return os.ReadFile(e.Path())
+ }
+ env, absent, err := readInstanceFile(e.Dir, e.Name, instancekey.EnvReadLimit)
+ if err == nil && absent {
+ err = fmt.Errorf("%s: %w", e.Path(), fs.ErrNotExist)
+ }
+ return env, err
+}
+
+// removeClaimAdoption removes the adoption record beside the env file, a
+// nested one beneath the instance directory (instancefs.RemoveBeneath). A
+// record that is not there is already removed.
+func removeClaimAdoption(e claimEnvFile, name string) error {
+ var err error
+ if e.Nested {
+ err = instancefs.RemoveBeneath(e.Dir, name)
+ } else {
+ err = os.Remove(filepath.Join(e.Dir, name))
+ }
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+ return nil
+}
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go
index 1086feb07..901133e34 100644
--- a/internal/host/movesfiles_test.go
+++ b/internal/host/movesfiles_test.go
@@ -132,9 +132,7 @@ func newFilesFixture(t *testing.T) *filesFixture {
b.AddBucket("data")
x := &filesFixture{t: t, bucket: b, data: &recordingReader{VersionedStore: b}, files: casBucketStore{b: b, bucket: "data"}, dir: t.TempDir()}
x.f = &movesFiles{Files: x.files, Data: x.data, Bucket: "data", HomeRegion: "ie", Region: "uk", Box: "uk-box", DataDir: x.dir,
- FilesDir: func(host string) (string, string) {
- return filepath.Join(x.dir, host), filepath.Join(x.dir, host+".env")
- }}
+ EnvFile: flatClaimEnvFile(x.dir)}
return x
}
diff --git a/internal/host/movesprobeowners_test.go b/internal/host/movesprobeowners_test.go
index 97eb4d60f..e1fa506b3 100644
--- a/internal/host/movesprobeowners_test.go
+++ b/internal/host/movesprobeowners_test.go
@@ -349,7 +349,7 @@ func TestProbeIsAColdAdmissionIntoThePreparedGeneration(t *testing.T) {
// directory and files paths, as the claim assembly binds it: the probe
// owners' Files is this, not a fake (whole-branch fixes, item 1).
func (f *poFixture) realC8() *movesFiles {
- return &movesFiles{Files: casBucketStore{b: f.bucket, bucket: "data"}, Data: f.bucket, Bucket: "data", HomeRegion: "ie", Region: f.owners.Region, Box: f.owners.Box, DataDir: f.dataDir, FilesDir: f.owners.FilesDir}
+ return &movesFiles{Files: casBucketStore{b: f.bucket, bucket: "data"}, Data: f.bucket, Bucket: "data", HomeRegion: "ie", Region: f.owners.Region, Box: f.owners.Box, DataDir: f.dataDir, EnvFile: envFileOf(f.owners.FilesDir)}
}
// preparedManifestJournalled builds the prepared manifest local writes for
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index 8e3affdee..d6835c4d2 100644
--- a/internal/host/relocationinstall.go
+++ b/internal/host/relocationinstall.go
@@ -22,6 +22,7 @@ import (
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancefs"
"github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -182,7 +183,54 @@ type relocationInstallJournal struct {
Enrollment string `json:"enrollment"`
InstallStamp uint64 `json:"install_stamp"`
Prepared string `json:"prepared"`
- Steps map[string]string `json:"steps"` // row, files, env, wake, render, key, materialize
+ Steps map[string]string `json:"steps"` // row, files_manifest, files, env, wake, render, key, materialize
+}
+
+// relocationStepFilesManifest is the install journal's binding of P's
+// prepared files manifest (JSON preparedManifest: key, sha256, version,
+// op, stamp): what the probe's C8 establishment consumes as its baseline,
+// read by exactly that version (relocationPreparedManifest), as a move's
+// op journal binds its own (landStepFilesManifest).
+const relocationStepFilesManifest = "files_manifest"
+
+// relocationPreparedBinding binds P's prepared files manifest for the
+// probe's establishment: the current version of P's manifest key as the
+// versioned listing names it, read by that exact version, which must be a
+// prepared manifest (sequence 0) with exactly the entries the install
+// judged (loaded), bound to the op and the install stamp with the digest
+// of its bytes. A missing, deleted or unversioned manifest, or one that is
+// not what the install judged, refuses.
+func relocationPreparedBinding(ctx context.Context, data lease.VersionedStore, bucket, prefix, op string, stamp uint64, loaded hostfiles.Manifest) (preparedManifest, error) {
+ if data == nil || bucket == "" {
+ return preparedManifest{}, ErrClaimStopUnavailable
+ }
+ key := hostfiles.ManifestKey(prefix)
+ vs, err := data.ListVersions(ctx, bucket, key)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ version := ""
+ for _, v := range vs {
+ if v.Key != key || !v.IsLatest {
+ continue
+ }
+ if v.DeleteMarker || v.VersionID == "" || v.VersionID == "null" {
+ return preparedManifest{}, fmt.Errorf("%w: P's files manifest %s is not a readable version", hostrelocate.ErrRefused, key)
+ }
+ version = v.VersionID
+ }
+ if version == "" {
+ return preparedManifest{}, fmt.Errorf("%w: P's files manifest %s has no current version", hostrelocate.ErrRefused, key)
+ }
+ raw, err := data.GetVersion(ctx, bucket, key, version)
+ if err != nil {
+ return preparedManifest{}, err
+ }
+ var m hostfiles.Manifest
+ if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion || m.Seq != 0 || loaded.Seq != 0 || !sameEntries(m, loaded) {
+ return preparedManifest{}, errors.Join(fmt.Errorf("%w: P's files manifest %s@%s is not the prepared manifest the install judged", hostrelocate.ErrRefused, key, version), err)
+ }
+ return preparedManifest{Key: key, SHA256: hostfilesSum(raw), Version: version, Op: op, Stamp: stamp}, nil
}
// relocationOpDir is the operation's local journal directory (ruling 44):
@@ -420,6 +468,16 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
return hostclaim.LocalInstall{}, err
}
}
+ // The binding the probe's C8 establishment reads P's prepared
+ // manifest by: its exact version, journalled below.
+ binding, err := relocationPreparedBinding(ctx, cfg.Versions, cfg.Bucket, prefix, c.Op.ID, stamp, pm)
+ if err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
+ bindingRaw, err := json.Marshal(binding)
+ if err != nil {
+ return hostclaim.LocalInstall{}, err
+ }
rec, err := relocationRecord(ctx, cfg.DataDir, a.Claims, o)
if err != nil {
return hostclaim.LocalInstall{}, err
@@ -452,7 +510,7 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
if err := removeRelocationScratch(cfg, c.Op.ID); err != nil {
return hostclaim.LocalInstall{}, err
}
- journal := relocationInstallJournal{V: 1, Host: o.Host, Op: c.Op.ID, Box: box, Enrollment: local.Actor.Enrollment, InstallStamp: stamp, Prepared: p, Steps: map[string]string{}}
+ journal := relocationInstallJournal{V: 1, Host: o.Host, Op: c.Op.ID, Box: box, Enrollment: local.Actor.Enrollment, InstallStamp: stamp, Prepared: p, Steps: map[string]string{relocationStepFilesManifest: string(bindingRaw)}}
if err := work.With(ctx, hostwrite.Identity{}, func() error {
return a.Reconcile.MintHeld(ctx, rec, captured, "relocating", c.Op.ID)
}); err != nil {
diff --git a/internal/host/relocationprobefiles_linux_test.go b/internal/host/relocationprobefiles_linux_test.go
new file mode 100644
index 000000000..464c3ea6e
--- /dev/null
+++ b/internal/host/relocationprobefiles_linux_test.go
@@ -0,0 +1,272 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// probeFilesFixture is a relocation target at relocating/probing reached
+// through a real build, a real install and the claim's own transitions
+// (the prepared authority as ToInstalled journals it, RecordLocalInstall,
+// ToProbing), with the real C8 provider the production source opens over
+// the box's data directory, registry and data bucket (merge round 6), and
+// the relocation's wake inputs bound to it.
+type probeFilesFixture struct {
+ *installFixture
+ receipt hostclaim.LocalInstall
+ inputs *RelocationWakeInputs
+}
+
+func newProbeFilesFixture(t *testing.T, opts buildOptions) *probeFilesFixture {
+ t.Helper()
+ ctx := t.Context()
+ f := &probeFilesFixture{installFixture: newInstallFixtureWith(t, opts)}
+ auth, done, err := f.verifier.Verify(ctx, f.read())
+ if err != nil || !done {
+ t.Fatalf("P is not verified: done=%v %v", done, err)
+ }
+ f.update(func(c *hostclaim.Claim) error {
+ auth.InstallStamp = c.Holder.Stamp
+ c.Op.Journal["prepared_authority"] = string(mustJSON(t, auth))
+ return nil
+ })
+ if f.receipt, err = f.install(); err != nil {
+ t.Fatal(err)
+ }
+ f.update(hostclaim.RecordLocalInstall("rel-1", f.receipt))
+ f.update(hostclaim.ToProbing("rel-1", time.Now()))
+ src := &movesFilesSource{DataDir: f.r.DataDir, Routes: f.reg, newFiles: func(context.Context, string, string) (release.Store, error) {
+ return bucketStore{b: f.bucket, bucket: "data"}, nil
+ }}
+ c8, err := src.Open(ctx, f.claims, f.bucket, hostbox.Actor{Box: "dst", Region: "ie", Bucket: "data", Cloud: hostbox.CloudIdentity{Region: "eu-west-1"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ seq := 0
+ f.inputs = &RelocationWakeInputs{
+ Launch: func(context.Context, registry.Route) (ClaimServingLaunch, error) {
+ return ClaimServingLaunch{Identity: hostwrite.Identity{Config: "sha256:config", Release: "sha256:release"}, Executable: "/opt/carlos/live/" + buildHost, ExecutableSHA256: strings.Repeat("b", 64), Version: "v1"}, nil
+ },
+ FilesManifest: c8, Markers: plan9D5Markers{f.bucket}, Claims: f.claims, FilesStore: f.files, Data: f.bucket, Bucket: "data", Region: "ie", Box: "dst",
+ NewID: func() string { seq++; return fmt.Sprintf("fresh%d", seq) }, Now: time.Now, Releases: &roReleases{}}
+ return f
+}
+
+func (f *probeFilesFixture) update(tr func(*hostclaim.Claim) error) {
+ f.t.Helper()
+ o, err := hostclaim.Update(f.t.Context(), f.claims, f.read(), tr)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ f.obs = o
+}
+
+// probe runs the relocation's first probe up to the app's launch: Select
+// on the host's row, the attempt counted and the admission applied with
+// the claim's own CAS, then Materialize under the start's work, which
+// establishes the files manifest through the real provider. Before
+// Materialize the launch source's key mint is stood for by env, written
+// over the nested env file the install placed.
+func (f *probeFilesFixture) probe(env string) (ClaimLaunchInputs, error) {
+ f.t.Helper()
+ ctx := f.t.Context()
+ o := f.read()
+ row, err := f.reg.Get(buildHost)
+ if err != nil || row == nil {
+ f.t.Fatalf("row: %v", err)
+ }
+ in, err := f.inputs.Select(ctx, *row, o, roProbe)
+ if err != nil {
+ return in, err
+ }
+ counted, err := hostclaim.Update(ctx, f.claims, o, hostclaim.BeginProbeAttempt("rel-1", "dst"))
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ in.Admission.ObservedSession, in.Admission.ObservedGeneration = counted.Claim.Holder.Session, counted.Claim.Holder.Generation
+ admitted, err := hostclaim.Update(ctx, f.claims, counted, hostclaim.AdmitWriter(in.Admission))
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ if err := os.WriteFile(f.paths().EnvPath, []byte(env), 0o600); err != nil {
+ f.t.Fatal(err)
+ }
+ return in, f.inputs.Materialize(ctx, admitted, in, passWork{})
+}
+
+// established is P's current files manifest and whether its prepared
+// sequence-0 version is still readable by the exact version the install
+// bound.
+func (f *probeFilesFixture) established() (hostfiles.Manifest, preparedManifest) {
+ f.t.Helper()
+ location := f.read().Claim.Holder.Location
+ m, found, err := hostfiles.Load(f.t.Context(), f.files, movesFilesPrefix(location, "p1"))
+ if err != nil || !found {
+ f.t.Fatalf("P's manifest: found=%v %v", found, err)
+ }
+ path, _ := relocationInstallPath(f.r.DataDir, "rel-1")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ var j relocationInstallJournal
+ if err := json.Unmarshal(raw, &j); err != nil {
+ f.t.Fatal(err)
+ }
+ var pm preparedManifest
+ if err := json.Unmarshal([]byte(j.Steps[relocationStepFilesManifest]), &pm); err != nil {
+ f.t.Fatal(err)
+ }
+ return m, pm
+}
+
+const firstProbeEnv = installEnv + "CARLOS_INSTANCE_KEY=minted-by-the-launch-source\n"
+
+// Merge round 6, findings 2 and 3: relocation's first probe of a host with
+// a database establishes its files manifest through main's real C8
+// provider. The provider reads the env file the launch source minted into
+// the nested instance directory (never a stale flat <host>.env), and
+// reads P's prepared manifest by the binding relocation's install
+// journalled (no moves op journal exists), replacing it by compare-and-
+// swap: P's entries carried, the env file's bytes on disk, the sequence-0
+// version still readable at the exact version the install bound.
+func TestRelocationFirstProbeEstablishesThroughTheC8Provider(t *testing.T) {
+ f := newProbeFilesFixture(t, buildOptions{DB: true, Files: map[string]string{"public/index.html": "<h1>shop</h1>"}, Platform: map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: installWake()}})
+ stale := filepath.Join(f.r.DataDir, buildHost+".env")
+ if err := os.WriteFile(stale, []byte("STALE=flat\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(moves.JournalFile(f.r.DataDir, buildHost, "p1")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a moves op journal exists for P: %v", err)
+ }
+ in, err := f.probe(firstProbeEnv)
+ if err != nil {
+ t.Fatalf("the first probe: %v", err)
+ }
+ if !in.Prepared || in.Empty {
+ t.Fatalf("prepared=%v empty=%v", in.Prepared, in.Empty)
+ }
+ m, pm := f.established()
+ if m.Platform[hostfiles.EnvItem].SHA256 != hostfilesSum([]byte(firstProbeEnv)) {
+ t.Fatalf("the manifest carries env %s, not the nested env file's bytes (stale flat %s)", m.Platform[hostfiles.EnvItem].SHA256, hostfilesSum([]byte("STALE=flat\n")))
+ }
+ if _, ok := m.Entries["public/index.html"]; !ok || m.Seq == 0 {
+ t.Fatalf("P's entries were not carried into the establishment: %+v", m)
+ }
+ exact, err := f.bucket.GetVersion(t.Context(), "data", pm.Key, pm.Version)
+ if err != nil || hostfilesSum(exact) != pm.SHA256 {
+ t.Fatalf("the prepared version the install bound: %v", err)
+ }
+ // A retry of the same establishment finds its own revision.
+ if err := f.inputs.FilesManifest.Establish(t.Context(), f.read(), in, passWork{}); err != nil {
+ t.Fatalf("a retry of the establishment: %v", err)
+ }
+}
+
+// Merge round 6, finding 4: an empty database still consumes P's files.
+// The relocation's first probe of a host whose pin evidences an empty
+// database (in.Empty) establishes from P's prepared manifest, which
+// relocation always builds, by compare-and-swap over it: no create-only
+// commit collides with it, and P's files and platform items are carried.
+func TestRelocationEmptyDatabaseProbeConsumesPsFiles(t *testing.T) {
+ f := newProbeFilesFixture(t, buildOptions{DB: false, Files: map[string]string{"uploads/a.txt": "kept"}, Platform: map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: installWake()}})
+ in, err := f.probe(firstProbeEnv)
+ if err != nil {
+ t.Fatalf("the empty database's first probe: %v", err)
+ }
+ if !in.Empty || in.Prepared {
+ t.Fatalf("prepared=%v empty=%v", in.Prepared, in.Empty)
+ }
+ m, _ := f.established()
+ if _, ok := m.Entries["uploads/a.txt"]; !ok || m.Platform[hostfiles.WakeItem].SHA256 == "" || m.Platform[hostfiles.EnvItem].SHA256 != hostfilesSum([]byte(firstProbeEnv)) {
+ t.Fatalf("P's files were not consumed: %+v", m)
+ }
+}
+
+// Merge round 6, finding 3: the reader is chosen by the claim's op kind,
+// and relocation's binding is held as strongly as a move's. A moves op
+// journal for P is never read for a relocating op; the install journal
+// must be the one the claim's receipt names by digest; its binding must
+// carry the prepared authority's digest; and with no install journal the
+// probe refuses rather than reading the absence as an empty start.
+func TestRelocationPreparedBindingIsRelocationsOwn(t *testing.T) {
+ opts := buildOptions{DB: true, Files: map[string]string{"public/index.html": "<h1>shop</h1>"}, Platform: map[string]string{hostfiles.EnvItem: installEnv, hostfiles.WakeItem: installWake()}}
+ installJournal := func(f *probeFilesFixture) string {
+ path, _ := relocationInstallPath(f.r.DataDir, "rel-1")
+ return path
+ }
+ t.Run("no install journal", func(t *testing.T) {
+ f := newProbeFilesFixture(t, opts)
+ if err := os.Remove(installJournal(f)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.probe(firstProbeEnv); err == nil {
+ t.Fatal("a probe with no install journal established")
+ }
+ if m, _, _ := hostfiles.Load(t.Context(), f.files, movesFilesPrefix(f.read().Claim.Holder.Location, "p1")); m.Seq != 0 {
+ t.Fatalf("P's manifest was replaced: %+v", m)
+ }
+ })
+ t.Run("an install journal the receipt does not name", func(t *testing.T) {
+ f := newProbeFilesFixture(t, opts)
+ path := installJournal(f)
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, append(raw, ' '), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.probe(firstProbeEnv); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a changed install journal: %v", err)
+ }
+ })
+ t.Run("a binding that is not the prepared authority's", func(t *testing.T) {
+ f := newProbeFilesFixture(t, opts)
+ f.update(func(c *hostclaim.Claim) error {
+ auth, err := preparedAuthority(c)
+ if err != nil {
+ return err
+ }
+ auth.FilesManifestSHA256 = strings.Repeat("0", 64)
+ c.Op.Journal["prepared_authority"] = string(mustJSON(t, auth))
+ return nil
+ })
+ if _, err := f.probe(firstProbeEnv); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("a binding with another digest: %v", err)
+ }
+ })
+ t.Run("a moves op journal is never read for a relocating op", func(t *testing.T) {
+ f := newProbeFilesFixture(t, opts)
+ location := f.read().Claim.Holder.Location
+ key := hostfiles.ManifestKey(movesFilesPrefix(location, "p1"))
+ // A moves op journal binding P at a version that does not exist:
+ // read, it would refuse; the relocation's own binding is read.
+ j, err := moves.ReadJournal(f.r.DataDir, buildHost, "p1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ j.Mark(landStepFilesManifest, string(mustJSON(t, preparedManifest{Key: key, SHA256: strings.Repeat("1", 64), Version: "v-none", Op: "rel-1", Stamp: f.read().Claim.Holder.Stamp})))
+ if err := j.Commit(f.r.DataDir); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := f.probe(firstProbeEnv); err != nil {
+ t.Fatalf("the relocation's own binding: %v", err)
+ }
+ })
+}