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>
9 files changed,
+735
−64
internal/host/agent.go+1 −1internal/host/claimenvfile_test.go+109 −0internal/host/claimfiles.go+79 −9internal/host/claimfiles_test.go+24 −12internal/host/movesfiles.go+188 −36internal/host/movesfiles_test.go+1 −3internal/host/movesprobeowners_test.go+1 −1internal/host/relocationinstall.go+60 −2internal/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) |
| + } |
| + }) |
| +} |