carlos / platform Public

region ha: relocation names a generation's files by main's one rule, <location>/files/g-<generation>/ (moves.GenerationFilesPrefix, the twin of GenerationPrefix), in its pinner and allowed-reference rule, build, prepared verifier, install, release flush and probe evidence; main's C8 provider (movesFilesPrefix) and the park view's files live set delegate to it, so a failover finds the provider's manifests, the provider consumes relocation's prepared manifest, and the park view classifies and pins relocation's files (merge round 6, finding 1).

Under the old files/<generation>/ layout the park view's classifier placed no relocation files object in a generation, so every park of the holder's location in the authoritative region refused (ErrUnsafe) and the files live set never read relocation's manifests: a stalled park, not a deletion. Relocation's tests now name the rule; the build fixture keeps files manifests in the versioned data bucket, as production's one bucket does. internal/sim models no files keys and is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 52449165c0e7f18194f9f2a0a4d2b2667ca28c58 parent 4607728
20 files changed, +208 −65
  • internal/host/movesfiles.go +5 −4
  • internal/host/movessources.go +1 −1
  • internal/host/relocationbuild.go +2 −2
  • internal/host/relocationbuild_test.go +9 −7
  • internal/host/relocationfilesprefix_test.go +108 −0
  • internal/host/relocationflush.go +2 −1
  • internal/host/relocationflush_adoption_test.go +3 −3
  • internal/host/relocationflush_saferead_linux_test.go +1 −1
  • internal/host/relocationflush_wake_linux_test.go +1 −1
  • internal/host/relocationinstall.go +3 −2
  • internal/host/relocationinstall_linux_test.go +2 −1
  • internal/host/relocationinstall_test.go +2 −2
  • internal/host/relocationowners_test.go +2 −2
  • internal/host/relocationwake.go +1 −1
  • internal/host/relocationwake_test.go +1 −1
  • internal/hostrelocate/pin.go +10 −6
  • internal/hostrelocate/pin_envkey_test.go +2 −2
  • internal/hostrelocate/pin_test.go +29 −28
  • internal/moves/build.go +17 −0
  • internal/moves/build_test.go +7 −0
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go
index d694da7b3..1b272f1e0 100644
--- a/internal/host/movesfiles.go
+++ b/internal/host/movesfiles.go
@@ -127,9 +127,10 @@ func decodePreparedManifest(raw, key, op string, stamp uint64) (preparedManifest
// movesFilesPrefix is a generation's files prefix in its location,
// <location>/files/g-<generation>/, the rule pinFilesLiveSet and the park
-// view's files live set share (spec §7.6).
+// view's files live set share (spec §7.6), and region HA's relocation
+// with them: moves.GenerationFilesPrefix, the one rule.
func movesFilesPrefix(location, generation string) string {
- return strings.TrimSuffix(location, "/") + "/files/g-" + generation + "/"
+ return moves.GenerationFilesPrefix(location, generation)
}
// movesFilesGeneration accepts a generation name as the claim does
@@ -547,7 +548,7 @@ func (f *movesFiles) movesFilesAllowed(ctx context.Context, o hostclaim.Observed
if err != nil {
return nil, err
}
- base := strings.TrimSuffix(location, "/") + "/files/g-"
+ base := moves.FilesGenerationBase(location)
for _, origin := range named {
if set[origin] {
continue
@@ -585,7 +586,7 @@ func (f *movesFiles) movesFilesAllowed(ctx context.Context, o hostclaim.Observed
// set's (pinFilesLiveSet), so every object a live manifest names stays
// pinned under it.
func movesFilesOrigins(location, prefix string, m hostfiles.Manifest) ([]string, error) {
- base := strings.TrimSuffix(location, "/") + "/files/g-"
+ base := moves.FilesGenerationBase(location)
seen := map[string]bool{}
var out []string
for _, entries := range []map[string]hostfiles.Entry{m.Entries, m.Platform} {
diff --git a/internal/host/movessources.go b/internal/host/movessources.go
index bac3531ab..3b8a8ed1c 100644
--- a/internal/host/movessources.go
+++ b/internal/host/movessources.go
@@ -297,7 +297,7 @@ func pinFilesLiveSet(ctx context.Context, data genselect.Reader, bucket, locatio
continue
}
seen[gen] = true
- prefix := location + "/files/g-" + gen + "/"
+ prefix := movesFilesPrefix(location, gen)
key := hostfiles.ManifestKey(prefix)
var current *lease.ObjectVersion
for i := range listing {
diff --git a/internal/host/relocationbuild.go b/internal/host/relocationbuild.go
index ec7dd5db0..897af6c1b 100644
--- a/internal/host/relocationbuild.go
+++ b/internal/host/relocationbuild.go
@@ -103,7 +103,7 @@ func (a ClaimAssembly) relocationBuildP(cfg RelocationAssemblyConfig) func(conte
if err := check(); err != nil {
return err
}
- if _, err := hostrelocate.CopyPinnedManifest(alive, a.Claims, cfg.Data, pin.Files, c.Holder.Location+"/files/"+p+"/"); err != nil {
+ if _, err := hostrelocate.CopyPinnedManifest(alive, a.Claims, cfg.Data, pin.Files, moves.GenerationFilesPrefix(c.Holder.Location, p)); err != nil {
return err
}
if pin.DB == nil {
@@ -237,7 +237,7 @@ func (v PreparedFromInstallManifest) Verify(ctx context.Context, o hostclaim.Obs
return hostclaim.PreparedAuthority{}, false, err
}
p := c.Op.Journal["prepared"]
- raw, _, err := v.Files.Get(ctx, hostfiles.ManifestKey(c.Holder.Location+"/files/"+p+"/"))
+ raw, _, err := v.Files.Get(ctx, hostfiles.ManifestKey(moves.GenerationFilesPrefix(c.Holder.Location, p)))
if errors.Is(err, os.ErrNotExist) {
return hostclaim.PreparedAuthority{}, false, nil // not built yet
}
diff --git a/internal/host/relocationbuild_test.go b/internal/host/relocationbuild_test.go
index 98914ad44..77d3c2b1e 100644
--- a/internal/host/relocationbuild_test.go
+++ b/internal/host/relocationbuild_test.go
@@ -110,7 +110,9 @@ func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture {
withDB := o.DB
f := &buildFixture{t: t, claims: release.NewDirStore(t.TempDir()), bucket: leasetest.NewVersionedBucket()}
f.bucket.AddBucket("data")
- f.files = &hookedStore{Store: release.NewDirStore(t.TempDir())}
+ // P's files manifests and the pinned objects live in the versioned data
+ // bucket, as production's files store and versioned client are one bucket.
+ f.files = &hookedStore{Store: bucketStore{b: f.bucket, bucket: "data"}}
f.versions = &hookedVersions{VersionedStore: f.bucket}
handler, _ := leasetest.NewS3Server(f.bucket, "data")
srv := httptest.NewServer(handler) // 127.0.0.1 only
@@ -149,7 +151,7 @@ func newBuildFixtureWith(t *testing.T, o buildOptions) *buildFixture {
} else {
pin.Empty = "session-0"
}
- srcPrefix := location + "/files/g1/"
+ srcPrefix := location + "/files/g-g1/"
pinned := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Platform: map[string]hostfiles.Entry{}, Entries: map[string]hostfiles.Entry{}}
object := func(body string, mode uint32) hostfiles.Entry {
sum := sha256.Sum256([]byte(body))
@@ -402,7 +404,7 @@ func TestPreparedRefusesAPartialBuild(t *testing.T) {
if err := f.build(); err == nil {
t.Fatal("the stopped build succeeded")
}
- if _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/")); err != nil {
+ if _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/g-p1/")); err != nil {
t.Fatalf("P's files manifest was not written first: %v", err)
}
if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || err != nil {
@@ -424,7 +426,7 @@ func TestPreparedRefusesAPartialBuild(t *testing.T) {
f := newBuildFixture(t, true)
bad := hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
raw, _ := json.Marshal(bad)
- if _, err := f.files.Store.Put(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/"), raw, ""); err != nil {
+ if _, err := f.files.Store.Put(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/g-p1/"), raw, ""); err != nil {
t.Fatal(err)
}
if _, done, err := f.verifier.Verify(t.Context(), f.obs); done || !errors.Is(err, hostrelocate.ErrRefused) {
@@ -506,7 +508,7 @@ func TestRelocationBuildReplaysToTheSameBytes(t *testing.T) {
}
key := moves.InstallManifestKey(f.prefix())
first := f.bucket.LatestID("data", key)
- filesKey := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/p1/")
+ filesKey := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/g-p1/")
before, _, err := f.files.Get(t.Context(), filesKey)
if err != nil {
t.Fatal(err)
@@ -761,7 +763,7 @@ func TestRelocationBuildPChecksItsAuthorityBeforeEachStep(t *testing.T) {
if tc.at == 1 && f.bucket.Snapshot() != before {
t.Fatal("the replica lease was taken without authority")
}
- _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/p1/"))
+ _, _, err := f.files.Get(t.Context(), hostfiles.ManifestKey(f.obs.Claim.Holder.Location+"/files/g-p1/"))
if wrote := err == nil; wrote != (tc.at > 2) {
t.Fatalf("P's files manifest written=%v (%v)", wrote, err)
}
@@ -856,7 +858,7 @@ func TestPreparedRefusesAnythingButThisPinsBuild(t *testing.T) {
t.Fatalf("the untouched build: done=%v %v", done, err)
}
if tc.files != nil {
- key := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/p1/")
+ key := hostfiles.ManifestKey(f.obs.Claim.Holder.Location + "/files/g-p1/")
raw, etag, err := f.files.Get(ctx, key)
if err != nil {
t.Fatal(err)
diff --git a/internal/host/relocationfilesprefix_test.go b/internal/host/relocationfilesprefix_test.go
new file mode 100644
index 000000000..3ba62e1c0
--- /dev/null
+++ b/internal/host/relocationfilesprefix_test.go
@@ -0,0 +1,108 @@
+package host
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/lease/leasetest"
+ "github.com/carlosframework/platform/internal/park"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// bucketStore is a release.Store over one bucket of a versioned bucket,
+// as production's S3 store is over the data bucket its versioned client
+// lists: current reads with the current version as the token, create-only
+// and compare-and-swap writes, deletes as delete markers.
+type bucketStore struct {
+ b *leasetest.VersionedBucket
+ bucket string
+}
+
+func (s bucketStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ return casBucketStore(s).Get(ctx, key)
+}
+
+func (s bucketStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ return casBucketStore(s).Put(ctx, key, body, ifMatch)
+}
+
+func (s bucketStore) List(ctx context.Context, prefix string) ([]string, error) {
+ return casBucketStore(s).List(ctx, prefix)
+}
+
+func (s bucketStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ var out []string
+ for _, k := range keys {
+ dir, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
+ if ok && !seen[dir] {
+ seen[dir] = true
+ out = append(out, dir)
+ }
+ }
+ return out, nil
+}
+
+func (s bucketStore) Delete(_ context.Context, key string) error {
+ if _, ok := s.b.Current(s.bucket, key); !ok {
+ return os.ErrNotExist
+ }
+ s.b.BareDelete(s.bucket, key)
+ return nil
+}
+
+var (
+ _ release.Store = bucketStore{}
+ _ lease.VersionedStore = (*leasetest.VersionedBucket)(nil)
+)
+
+// Merge round 6, finding 1: relocation names a generation's files by the
+// one files prefix rule (moves.GenerationFilesPrefix), so what its build
+// writes is what main's provider and the park view read. P's prepared
+// manifest is at movesFilesPrefix(location, P); every files object under
+// the location belongs to a generation by the park view's classifier
+// (landingGenerationOf), where the old files/<generation>/ layout
+// belonged to none and refused every park of the location; and the files
+// live set pins every object P's manifest names, which it never read
+// under the old layout.
+func TestRelocationFilesUseTheParkViewsPrefix(t *testing.T) {
+ f := newBuildFixtureWith(t, buildOptions{Files: map[string]string{"public/a.txt": "a", "uploads/b.bin": "b"}})
+ if err := f.build(); err != nil {
+ t.Fatal(err)
+ }
+ ctx := t.Context()
+ location := f.obs.Claim.Holder.Location
+ m, found, err := hostfiles.Load(ctx, f.files, movesFilesPrefix(location, "p1"))
+ if err != nil || !found || m.Seq != 0 || len(m.Entries) != 2 {
+ t.Fatalf("P's prepared manifest at the files prefix rule: found=%v %+v %v", found, m, err)
+ }
+ listing, err := f.bucket.ListVersions(ctx, "data", location+"/files/")
+ if err != nil || len(listing) == 0 {
+ t.Fatalf("listing: %v", err)
+ }
+ for _, x := range listing {
+ if g, ok := landingGenerationOf(location, x.Key); !ok || (g != "g1" && g != "p1") {
+ t.Fatalf("%s belongs to %q (%v), not a generation", x.Key, g, ok)
+ }
+ }
+ if _, ok := landingGenerationOf(location, location+"/files/p1/manifest.json"); ok {
+ t.Fatal("the old files/<generation>/ layout is classified")
+ }
+ v := park.View{GenerationOf: func(r park.Ref) (string, bool) { return landingGenerationOf(location, r.Key) }}
+ if err := pinFilesLiveSet(ctx, f.bucket, "data", location, listing, &v, "p1"); err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range m.Entries {
+ if !v.Pinned[park.Ref{Key: e.Object, VersionID: f.bucket.LatestID("data", e.Object)}] {
+ t.Fatalf("%s, which P's manifest names, is not pinned: %v", e.Object, v.Pinned)
+ }
+ }
+}
diff --git a/internal/host/relocationflush.go b/internal/host/relocationflush.go
index 461cf9c69..4ee812d1a 100644
--- a/internal/host/relocationflush.go
+++ b/internal/host/relocationflush.go
@@ -14,6 +14,7 @@ import (
"github.com/carlosframework/platform/internal/hostfiles"
"github.com/carlosframework/platform/internal/instancefs"
"github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -58,7 +59,7 @@ func (f ReleaseFlushFiles) Flush(ctx context.Context, obs hostclaim.Observed) (i
if envPath == "" || filesDir == "" {
return 0, fmt.Errorf("%w: no files directory or env file for %s", ErrClaimStopUnavailable, obs.Host)
}
- p, err := hostfiles.Open(ctx, f.Data, h.Location+"/files/"+h.Generation+"/")
+ p, err := hostfiles.Open(ctx, f.Data, moves.GenerationFilesPrefix(h.Location, h.Generation))
if err != nil {
return 0, err
}
diff --git a/internal/host/relocationflush_adoption_test.go b/internal/host/relocationflush_adoption_test.go
index 5b57dd8dc..47dabf686 100644
--- a/internal/host/relocationflush_adoption_test.go
+++ b/internal/host/relocationflush_adoption_test.go
@@ -62,7 +62,7 @@ func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) {
if !errors.Is(err, ErrClaimStopUnavailable) {
t.Fatalf("a malformed record was flushed: %v", err)
}
- if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g6/"); ok || err != nil {
+ if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g-g6/"); ok || err != nil {
t.Fatalf("a refused flush committed a manifest: %v %v", ok, err)
}
return
@@ -70,7 +70,7 @@ func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- prefix := "instances/" + host + "/inc/files/g6/"
+ prefix := "instances/" + host + "/inc/files/g-g6/"
m, ok, err := hostfiles.Load(ctx, data, prefix)
if err != nil || !ok || m.Seq != seq {
t.Fatalf("manifest: %v %v", ok, err)
@@ -107,7 +107,7 @@ func TestReleaseFlushRefusesAHostWithNoPaths(t *testing.T) {
if _, err := f.Flush(ctx, obs); !errors.Is(err, ErrClaimStopUnavailable) {
t.Fatalf("flushed with no paths: %v", err)
}
- if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g6/"); ok || err != nil {
+ if _, ok, err := hostfiles.Load(ctx, data, "instances/"+host+"/inc/files/g-g6/"); ok || err != nil {
t.Fatalf("a refused flush committed a manifest: %v %v", ok, err)
}
}
diff --git a/internal/host/relocationflush_saferead_linux_test.go b/internal/host/relocationflush_saferead_linux_test.go
index 78d3beab6..d5bf76cc2 100644
--- a/internal/host/relocationflush_saferead_linux_test.go
+++ b/internal/host/relocationflush_saferead_linux_test.go
@@ -187,7 +187,7 @@ func newFlushFixture(t *testing.T) *flushFixture {
env: []byte("CARLOS_INSTANCE_KEY=k1\n"),
record: instancekey.AdoptionRecord(writer, instancekey.SourcePlatform, instancekey.Digest("k1")),
envPath: filepath.Join(inst, host+".env"), recPath: filepath.Join(inst, host+instancekey.AdoptionSuffix),
- prefix: "instances/" + host + "/inc/files/g6/",
+ prefix: "instances/" + host + "/inc/files/g-g6/",
}
if err := os.WriteFile(x.envPath, x.env, 0o600); err != nil {
t.Fatal(err)
diff --git a/internal/host/relocationflush_wake_linux_test.go b/internal/host/relocationflush_wake_linux_test.go
index b24905683..2d698c31b 100644
--- a/internal/host/relocationflush_wake_linux_test.go
+++ b/internal/host/relocationflush_wake_linux_test.go
@@ -57,7 +57,7 @@ func TestReleaseFlushFilesCarriesTheLastWakeStateAndEnv(t *testing.T) {
if err != nil || seq <= first {
t.Fatalf("second flush: %d %v", seq, err)
}
- prefix := "instances/" + host + "/inc/files/g6/"
+ prefix := "instances/" + host + "/inc/files/g-g6/"
m, ok, err := hostfiles.Load(ctx, data, prefix)
if err != nil || !ok || m.Seq != seq {
t.Fatalf("manifest: %+v %v %v", m, ok, err)
diff --git a/internal/host/relocationinstall.go b/internal/host/relocationinstall.go
index 3a493210a..8e3affdee 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/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -401,7 +402,7 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
return hostclaim.LocalInstall{}, err
}
p := c.Op.Journal["prepared"]
- prefix := c.Holder.Location + "/files/" + p + "/"
+ prefix := moves.GenerationFilesPrefix(c.Holder.Location, p)
origins := append(append([]string(nil), pin.Files.Origins...), prefix)
// What P's manifest says about the env file and the wake state is
// judged before anything is written: a manifest missing either,
@@ -701,7 +702,7 @@ func (a ClaimAssembly) relocationVerifyLocal(cfg RelocationAssemblyConfig) func(
return false, nil
}
p := o.Claim.Op.Journal["prepared"]
- prefix := o.Claim.Holder.Location + "/files/" + p + "/"
+ prefix := moves.GenerationFilesPrefix(o.Claim.Holder.Location, p)
m, found, err := hostfiles.Load(ctx, cfg.Data, prefix)
if err != nil {
return false, err
diff --git a/internal/host/relocationinstall_linux_test.go b/internal/host/relocationinstall_linux_test.go
index 4396bc67a..c9c18fc12 100644
--- a/internal/host/relocationinstall_linux_test.go
+++ b/internal/host/relocationinstall_linux_test.go
@@ -26,6 +26,7 @@ import (
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -821,7 +822,7 @@ func TestRelocationVerifyLocalReadsTheInstallBack(t *testing.T) {
// (hostfiles.Load); one that is gone is a refusal, not an install to
// repair (the install refuses without it too).
o := f.read()
- if err := f.cfg.Data.Delete(ctx, hostfiles.ManifestKey(o.Claim.Holder.Location+"/files/"+o.Claim.Op.Journal["prepared"]+"/")); err != nil {
+ if err := f.cfg.Data.Delete(ctx, hostfiles.ManifestKey(moves.GenerationFilesPrefix(o.Claim.Holder.Location, o.Claim.Op.Journal["prepared"]))); err != nil {
t.Fatal(err)
}
if ok, err := verify(ctx, o, l); ok || !errors.Is(err, hostrelocate.ErrRefused) {
diff --git a/internal/host/relocationinstall_test.go b/internal/host/relocationinstall_test.go
index c74178789..9782e8941 100644
--- a/internal/host/relocationinstall_test.go
+++ b/internal/host/relocationinstall_test.go
@@ -69,8 +69,8 @@ func TestRelocationInstallPolicyIsTyped(t *testing.T) {
// missing one under a pin of published files, and a tombstone (which no
// publisher writes) under either pin, refuse.
func TestRelocationPlatformItemNeedsEvidenceForNone(t *testing.T) {
- published := hostrelocate.PinObject{Files: hostrelocate.FilesPin{Prefix: "loc/files/g1/", SHA256: "ab", Retained: "retained/ab"}}
- never := hostrelocate.PinObject{Files: hostrelocate.FilesPin{Prefix: "loc/files/g1/"}}
+ published := hostrelocate.PinObject{Files: hostrelocate.FilesPin{Prefix: "loc/files/g-g1/", SHA256: "ab", Retained: "retained/ab"}}
+ never := hostrelocate.PinObject{Files: hostrelocate.FilesPin{Prefix: "loc/files/g-g1/"}}
live := hostfiles.Manifest{Platform: map[string]hostfiles.Entry{hostfiles.EnvItem: {Object: "o", SHA256: "cd", Size: 1, Seq: 1}}}
missing := hostfiles.Manifest{Platform: map[string]hostfiles.Entry{}}
tomb := hostfiles.Manifest{Platform: map[string]hostfiles.Entry{hostfiles.EnvItem: {Deleted: true, Seq: 2}}}
diff --git a/internal/host/relocationowners_test.go b/internal/host/relocationowners_test.go
index 941e9abc9..7398186b2 100644
--- a/internal/host/relocationowners_test.go
+++ b/internal/host/relocationowners_test.go
@@ -125,7 +125,7 @@ func newROFixture(t *testing.T, o roOptions) *roFixture {
auth.InstallManifest, auth.SHA256, auth.SnapshotTXID = moves.InstallManifestKey(prefix), hex.EncodeToString(sum[:]), 42
}
pm, _ := json.Marshal(hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})
- if _, err := f.files.Put(ctx, hostfiles.ManifestKey(h.Location+"/files/"+roP+"/"), pm, ""); err != nil {
+ if _, err := f.files.Put(ctx, hostfiles.ManifestKey(moves.GenerationFilesPrefix(h.Location, roP)), pm, ""); err != nil {
t.Fatal(err)
}
pmSum := sha256.Sum256(pm)
@@ -368,7 +368,7 @@ func TestRelocationAdmissionEvidenceBindsTheBaselineAndThePreparedManifest(t *te
}
// P's files manifest replaced after the build: the digest no longer
// matches the authority.
- key := hostfiles.ManifestKey(o.Claim.Holder.Location + "/files/" + roP + "/")
+ key := hostfiles.ManifestKey(moves.GenerationFilesPrefix(o.Claim.Holder.Location, roP))
_, etag, err := f.files.Get(ctx, key)
if err != nil {
t.Fatal(err)
diff --git a/internal/host/relocationwake.go b/internal/host/relocationwake.go
index 540242350..484562fbc 100644
--- a/internal/host/relocationwake.go
+++ b/internal/host/relocationwake.go
@@ -420,7 +420,7 @@ func (r *RelocationWakeInputs) AdmissionEvidence(ctx context.Context, o hostclai
if !in.KeepStamp || hostclaim.PreparedOpened(c) || in.PredecessorCertified != certified {
return hostbox.ErrChanged
}
- raw, _, err := r.FilesStore.Get(ctx, hostfiles.ManifestKey(h.Location+"/files/"+prepared+"/"))
+ raw, _, err := r.FilesStore.Get(ctx, hostfiles.ManifestKey(moves.GenerationFilesPrefix(h.Location, prepared)))
if err != nil {
return err
}
diff --git a/internal/host/relocationwake_test.go b/internal/host/relocationwake_test.go
index b8312514a..ee1248053 100644
--- a/internal/host/relocationwake_test.go
+++ b/internal/host/relocationwake_test.go
@@ -64,7 +64,7 @@ func newRelocationProbeRig(t *testing.T, launch ClaimServingLaunch) *relocationP
imRaw := mustJSON(t, im)
bucket.Put("data", moves.InstallManifestKey(prefix), imRaw)
pm := mustJSON(t, hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})
- if _, err := files.Put(ctx, hostfiles.ManifestKey(h.Location+"/files/"+roP+"/"), pm, ""); err != nil {
+ if _, err := files.Put(ctx, hostfiles.ManifestKey(moves.GenerationFilesPrefix(h.Location, roP)), pm, ""); err != nil {
t.Fatal(err)
}
imSum, pmSum := sha256.Sum256(imRaw), sha256.Sum256(pm)
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go
index 01c7cacb2..105375dd8 100644
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
@@ -16,6 +16,7 @@ import (
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
"github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
)
@@ -243,7 +244,9 @@ var ErrFilesUnevidenced = errors.New("hostrelocate: the files evidence does not
// eight. An unchanged file published at g1 and carried by every later copy
// stays restorable at g10. Every referenced object must lie under this
// holder's location (its incarnation) in this region's data bucket, at
-// files/<generation>/objects/; any other reference refuses the pin.
+// files/g-<generation>/objects/ (moves.GenerationFilesPrefix, the files
+// prefix rule the C8 provider and the park view's files live set share);
+// any other reference refuses the pin.
func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Binding, error), h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, *KeyPin, []byte, error) {
chain := []string{h.Generation}
for i := len(h.Admissions) - 1; i >= 0; i-- {
@@ -256,7 +259,7 @@ func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Bi
continue
}
seen[gen] = true
- origins = append(origins, h.Location+"/files/"+gen+"/")
+ origins = append(origins, moves.GenerationFilesPrefix(h.Location, gen))
}
for _, prefix := range origins {
raw, _, err := p.Data.Get(ctx, hostfiles.ManifestKey(prefix))
@@ -324,15 +327,16 @@ func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Bi
if gen == "" {
gen = hostclaim.RootGeneration
}
- prefix := h.Location + "/files/" + gen + "/"
+ prefix := moves.GenerationFilesPrefix(h.Location, gen)
return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil, nil
}
// referencedOrigins adds to origins the files prefix of every generation
// whose objects m references, each of which must be an object this
-// incarnation's location published: <location>/files/<generation>/objects/.
+// incarnation's location published: <location>/files/g-<generation>/objects/
+// (moves.GenerationFilesPrefix).
func referencedOrigins(location string, origins []string, m hostfiles.Manifest) ([]string, error) {
- base := location + "/files/"
+ base := moves.FilesGenerationBase(location)
have := make(map[string]bool, len(origins))
for _, o := range origins {
have[o] = true
@@ -346,7 +350,7 @@ func referencedOrigins(location string, origins []string, m hostfiles.Manifest)
if !strings.HasPrefix(e.Object, base) || !ok || gen == "" || gen == "." || gen == ".." || !strings.HasPrefix(rest, "objects/") {
return fmt.Errorf("%w: %s references %s, outside %s: %w", ErrRefused, name, e.Object, base, hostfiles.ErrOrigin)
}
- if o := base + gen + "/"; !have[o] {
+ if o := moves.GenerationFilesPrefix(location, gen); !have[o] {
have[o] = true
extra = append(extra, o)
}
diff --git a/internal/hostrelocate/pin_envkey_test.go b/internal/hostrelocate/pin_envkey_test.go
index 3c0932dee..d41cbc87a 100644
--- a/internal/hostrelocate/pin_envkey_test.go
+++ b/internal/hostrelocate/pin_envkey_test.go
@@ -78,7 +78,7 @@ func TestStorePinnerKeyEvidence(t *testing.T) {
item := func(name, body string, put bool) {
sum := sha256.Sum256([]byte(body))
sha := hex.EncodeToString(sum[:])
- obj := pinLocation + "/files/" + gen + "/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
+ obj := pinLocation + "/files/g-" + gen + "/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
if put {
if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil {
t.Fatal(err)
@@ -153,7 +153,7 @@ func TestStorePinnerConfigRecordNeedsTheEnvItem(t *testing.T) {
item := func(name, body string) {
sum := sha256.Sum256([]byte(body))
sha := hex.EncodeToString(sum[:])
- obj := pinLocation + "/files/g3/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
+ obj := pinLocation + "/files/g-g3/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil {
t.Fatal(err)
}
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go
index 2c491498a..95a8ddfa8 100644
--- a/internal/hostrelocate/pin_test.go
+++ b/internal/hostrelocate/pin_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/platform/internal/genselect"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
)
@@ -32,7 +33,7 @@ func pinClaim() hostclaim.Observed {
// every pinned manifest carries (withPlatformItems).
func keyed(m hostfiles.Manifest) hostfiles.Manifest {
sha := strings.Repeat("b", 64)
- m.Entries["instance.key"] = hostfiles.Entry{Object: pinLocation + "/files/g1/objects/0000000000000001-" + sha, SHA256: sha, Size: 32, Mode: 0o600, Seq: 1}
+ m.Entries["instance.key"] = hostfiles.Entry{Object: pinLocation + "/files/g-g1/objects/0000000000000001-" + sha, SHA256: sha, Size: 32, Mode: 0o600, Seq: 1}
return withPlatformItems(m)
}
@@ -47,7 +48,7 @@ func withPlatformItems(m hostfiles.Manifest) hostfiles.Manifest {
func withItem(m hostfiles.Manifest, name, digit string) hostfiles.Manifest {
if _, ok := m.Platform[name]; !ok {
sha := strings.Repeat(digit, 64)
- m.Platform[name] = hostfiles.Entry{Object: pinLocation + "/files/g1/objects/0000000000000001-" + sha, SHA256: sha, Size: 10, Mode: 0o600, Seq: 1}
+ m.Platform[name] = hostfiles.Entry{Object: pinLocation + "/files/g-g1/objects/0000000000000001-" + sha, SHA256: sha, Size: 10, Mode: 0o600, Seq: 1}
}
return m
}
@@ -67,7 +68,7 @@ func stagePlatformItems(t *testing.T, p *hostfiles.Publisher) {
func putManifest(t *testing.T, data release.Store, gen string, m hostfiles.Manifest) (string, []byte) {
t.Helper()
raw, _ := json.Marshal(m)
- key := hostfiles.ManifestKey(pinLocation + "/files/" + gen + "/")
+ key := hostfiles.ManifestKey(moves.GenerationFilesPrefix(pinLocation, gen))
if _, err := data.Put(context.Background(), key, raw, ""); err != nil {
t.Fatal(err)
}
@@ -98,14 +99,14 @@ func TestStorePinnerPinsTheNewestFilesManifestInTheChain(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if pin.FilesManifest != hostfiles.ManifestKey(pinLocation+"/files/g2/") || pin.FilesSeq != 5 || pin.MaxTXID != 41 || pin.Key != PinKey(host, "rel-1", 12) || pin.Empty {
+ if pin.FilesManifest != hostfiles.ManifestKey(pinLocation+"/files/g-g2/") || pin.FilesSeq != 5 || pin.MaxTXID != 41 || pin.Key != PinKey(host, "rel-1", 12) || pin.Empty {
t.Fatalf("pin: %+v", pin)
}
obj, err := ReadPin(ctx, claims, pin)
if err != nil {
t.Fatal(err)
}
- if want := []string{pinLocation + "/files/g3/", pinLocation + "/files/g2/", pinLocation + "/files/g1/"}; !equalStrings(obj.Files.Origins, want) || obj.ClaimGen != 12 {
+ if want := []string{pinLocation + "/files/g-g3/", pinLocation + "/files/g-g2/", pinLocation + "/files/g-g1/"}; !equalStrings(obj.Files.Origins, want) || obj.ClaimGen != 12 {
t.Fatalf("origins %v gen %d", obj.Files.Origins, obj.ClaimGen)
}
// The exact bytes are retained in the control bucket before the pin.
@@ -239,7 +240,7 @@ func TestStorePinnerPinsEmptyOnlyWithEvidence(t *testing.T) {
func TestStorePinnerRefusesAnUnreadableManifest(t *testing.T) {
ctx := context.Background()
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
- if _, err := data.Put(ctx, hostfiles.ManifestKey(pinLocation+"/files/g3/"), []byte(`{"v":1}`), ""); err != nil {
+ if _, err := data.Put(ctx, hostfiles.ManifestKey(pinLocation+"/files/g-g3/"), []byte(`{"v":1}`), ""); err != nil {
t.Fatal(err)
}
p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
@@ -266,7 +267,7 @@ func pinnedManifest(t *testing.T, claims, data release.Store, gen, origin string
if _, err := claims.Put(context.Background(), retained, raw, ""); err != nil {
t.Fatal(err)
}
- prefix := pinLocation + "/files/" + gen + "/"
+ prefix := moves.GenerationFilesPrefix(pinLocation, gen)
return FilesPin{Prefix: prefix, Seq: seq, SHA256: manifestSHA, Origins: []string{prefix, origin}, Retained: retained}, m
}
@@ -274,19 +275,19 @@ func TestCopyPinnedManifestEnforcesThePin(t *testing.T) {
ctx := context.Background()
t.Run("a second relocation keeps objects of an earlier generation", func(t *testing.T) {
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
- pin, want := pinnedManifest(t, claims, data, "P1", pinLocation+"/files/g1/", 3)
- got, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/P2/")
+ pin, want := pinnedManifest(t, claims, data, "P1", pinLocation+"/files/g-g1/", 3)
+ got, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/g-P2/")
if err != nil || got.Seq != 0 || want.Seq != 3 || got.Entries["a.txt"] != want.Entries["a.txt"] {
t.Fatalf("copy: %+v %v", got, err)
}
// A replay is a success; the copy's entries still name g1's object.
- if _, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/P2/"); err != nil {
+ if _, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/g-P2/"); err != nil {
t.Fatalf("replay: %v", err)
}
// Without the earlier generation among the origins the copy refuses.
narrow := pin
narrow.Origins = []string{pin.Prefix}
- if _, err := CopyPinnedManifest(ctx, claims, data, narrow, pinLocation+"/files/P3/"); !errors.Is(err, hostfiles.ErrOrigin) {
+ if _, err := CopyPinnedManifest(ctx, claims, data, narrow, pinLocation+"/files/g-P3/"); !errors.Is(err, hostfiles.ErrOrigin) {
t.Fatalf("an inherited object outside the origins: %v", err)
}
})
@@ -294,7 +295,7 @@ func TestCopyPinnedManifestEnforcesThePin(t *testing.T) {
// A late PUT to the source changes nothing: P is written from the
// retained bytes, so an admitted pin stays usable (B2-4).
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
- pin, m := pinnedManifest(t, claims, data, "P1", pinLocation+"/files/g1/", 3)
+ pin, m := pinnedManifest(t, claims, data, "P1", pinLocation+"/files/g-g1/", 3)
m.Seq = 4
m.Entries["late.txt"] = m.Entries["a.txt"]
raw, _ := json.Marshal(m)
@@ -302,14 +303,14 @@ func TestCopyPinnedManifestEnforcesThePin(t *testing.T) {
if _, err := data.Put(ctx, hostfiles.ManifestKey(pin.Prefix), raw, etag); err != nil {
t.Fatal(err)
}
- got, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/P2/")
+ got, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/g-P2/")
if _, late := got.Entries["late.txt"]; err != nil || got.Seq != 0 || late || len(got.Entries) != 1 {
t.Fatalf("the pinned revision: %+v %v", got, err)
}
})
t.Run("a retained copy that is not the pinned bytes", func(t *testing.T) {
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
- pin, m := pinnedManifest(t, claims, data, "P1", pinLocation+"/files/g1/", 3)
+ pin, m := pinnedManifest(t, claims, data, "P1", pinLocation+"/files/g-g1/", 3)
e := m.Entries["a.txt"]
e.Size = 6
m.Entries["a.txt"] = e
@@ -318,21 +319,21 @@ func TestCopyPinnedManifestEnforcesThePin(t *testing.T) {
if _, err := tampered.Put(ctx, pin.Retained, raw, ""); err != nil {
t.Fatal(err)
}
- if _, err := CopyPinnedManifest(ctx, tampered, data, pin, pinLocation+"/files/P2/"); !errors.Is(err, ErrRefused) {
+ if _, err := CopyPinnedManifest(ctx, tampered, data, pin, pinLocation+"/files/g-P2/"); !errors.Is(err, ErrRefused) {
t.Fatalf("bytes the pin did not vouch for were copied: %v", err)
}
- if _, _, err := data.Get(ctx, hostfiles.ManifestKey(pinLocation+"/files/P2/")); err == nil {
+ if _, _, err := data.Get(ctx, hostfiles.ManifestKey(pinLocation+"/files/g-P2/")); err == nil {
t.Fatal("P's manifest was written from bytes the pin did not admit")
}
})
t.Run("a host that never published files", func(t *testing.T) {
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
- pin := FilesPin{Prefix: pinLocation + "/files/g3/", Origins: []string{pinLocation + "/files/g3/"}}
- m, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/P1/")
+ pin := FilesPin{Prefix: pinLocation + "/files/g-g3/", Origins: []string{pinLocation + "/files/g-g3/"}}
+ m, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/g-P1/")
if err != nil || m.Seq != 0 || len(m.Entries) != 0 {
t.Fatalf("empty manifest: %+v %v", m, err)
}
- if _, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/P1/"); err != nil {
+ if _, err := CopyPinnedManifest(ctx, claims, data, pin, pinLocation+"/files/g-P1/"); err != nil {
t.Fatalf("replay: %v", err)
}
})
@@ -373,7 +374,7 @@ func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) {
gen := fmt.Sprintf("g%d", i)
o = step(o, hostclaim.AdmitWriter(hostclaim.Admission{Region: "ie", PredecessorRegion: "ie", ObservedSession: o.Claim.Holder.Session, ObservedGeneration: o.Claim.Holder.Generation,
Box: "src", Mode: "cold", NewGeneration: gen, PredecessorCertified: true}))
- prefix := loc + "/files/" + gen + "/"
+ prefix := moves.GenerationFilesPrefix(loc, gen)
gens = append(gens, prefix)
if prev != "" {
if _, err := hostfiles.CopyManifest(ctx, data, prev, prefix, prevSeq, gens); err != nil {
@@ -411,8 +412,8 @@ func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) {
t.Fatalf("g1 is still in the admission list: %+v", h.Admissions)
}
}
- m, _, err := hostfiles.Load(ctx, data, loc+"/files/g10/")
- if err != nil || !strings.HasPrefix(m.Entries["kept.txt"].Object, loc+"/files/g1/objects/") {
+ m, _, err := hostfiles.Load(ctx, data, loc+"/files/g-g10/")
+ if err != nil || !strings.HasPrefix(m.Entries["kept.txt"].Object, loc+"/files/g-g1/objects/") {
t.Fatalf("the unchanged file is not g1's object: %+v %v", m.Entries["kept.txt"], err)
}
obs := o
@@ -426,16 +427,16 @@ func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- got, err := CopyPinnedManifest(ctx, claims, data, obj.Files, loc+"/files/P1/")
+ got, err := CopyPinnedManifest(ctx, claims, data, obj.Files, loc+"/files/g-P1/")
if err != nil || got.Entries["kept.txt"] != m.Entries["kept.txt"] {
t.Fatalf("copy with g1 trimmed from the admissions: %+v %v", got.Entries, err)
}
// A reference outside this incarnation's files is never admitted.
foreign := m
- foreign.Entries = map[string]hostfiles.Entry{"instance.key": m.Entries["instance.key"], "x.txt": {Object: "instances/" + host + "/other-inc/files/g1/objects/" + strings.Repeat("a", 64), Seq: 1, SHA256: strings.Repeat("a", 64)}}
+ foreign.Entries = map[string]hostfiles.Entry{"instance.key": m.Entries["instance.key"], "x.txt": {Object: "instances/" + host + "/other-inc/files/g-g1/objects/" + strings.Repeat("a", 64), Seq: 1, SHA256: strings.Repeat("a", 64)}}
raw, _ := json.Marshal(foreign)
- _, etag, _ := data.Get(ctx, hostfiles.ManifestKey(loc+"/files/g10/"))
- if _, err := data.Put(ctx, hostfiles.ManifestKey(loc+"/files/g10/"), raw, etag); err != nil {
+ _, etag, _ := data.Get(ctx, hostfiles.ManifestKey(loc+"/files/g-g10/"))
+ if _, err := data.Put(ctx, hostfiles.ManifestKey(loc+"/files/g-g10/"), raw, etag); err != nil {
t.Fatal(err)
}
obs.Claim.Gen++
@@ -452,7 +453,7 @@ func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) {
func TestPreparedManifestIsSequenceZeroAndTheSessionPublishesOne(t *testing.T) {
ctx := context.Background()
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
- src := pinLocation + "/files/g3/"
+ src := pinLocation + "/files/g-g3/"
p, err := hostfiles.Open(ctx, data, src)
if err != nil {
t.Fatal(err)
@@ -482,7 +483,7 @@ func TestPreparedManifestIsSequenceZeroAndTheSessionPublishesOne(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- prepared := pinLocation + "/files/P1/"
+ prepared := pinLocation + "/files/g-P1/"
m, err := CopyPinnedManifest(ctx, claims, data, obj.Files, prepared)
if err != nil || m.Seq != 0 {
t.Fatalf("prepared manifest: %+v %v", m, err)
diff --git a/internal/moves/build.go b/internal/moves/build.go
index f50022234..465cdb5f5 100644
--- a/internal/moves/build.go
+++ b/internal/moves/build.go
@@ -76,6 +76,23 @@ func GenerationPrefix(location, op string) string {
return prefix
}
+// FilesGenerationBase is `<location>/files/g-`, the start of every
+// generation's files prefix in a location (GenerationFilesPrefix): what
+// follows it up to the next "/" is the generation.
+func FilesGenerationBase(location string) string {
+ return strings.TrimSuffix(location, "/") + "/files/g-"
+}
+
+// GenerationFilesPrefix is `<location>/files/g-<generation>/` (spec §7.6):
+// a generation's files manifest and the content objects it published. It
+// is the one files prefix rule, GenerationPrefix's twin: the C8 files
+// provider (host.movesFilesPrefix), the park view's files live set and
+// classifier, and region HA's relocation (its pinner, build, install,
+// release flush and probe) all name a generation's files through it.
+func GenerationFilesPrefix(location, generation string) string {
+ return FilesGenerationBase(location) + generation + "/"
+}
+
// SnapshotKey is the generation's level-9 snapshot [1, n].
func SnapshotKey(prefix string, n uint64) string {
return prefix + fmt.Sprintf("%04x/", litestream.SnapshotLevel) + ltx.FormatFilename(1, ltx.TXID(n))
diff --git a/internal/moves/build_test.go b/internal/moves/build_test.go
index fa0096f87..d2b93ee5f 100644
--- a/internal/moves/build_test.go
+++ b/internal/moves/build_test.go
@@ -347,6 +347,13 @@ func TestGenerationPrefixAndKeys(t *testing.T) {
if got := GenerationPrefix("instances/h/inc2", "op1"); got != "instances/h/inc2/g-op1/" {
t.Fatalf("GenerationPrefix with an incarnation = %q", got)
}
+ // The files twin (spec §7.6, merge round 6): every reader and writer of
+ // a generation's files names them by this rule.
+ for _, loc := range []string{"instances/h/inc2", "instances/h/inc2/"} {
+ if got := GenerationFilesPrefix(loc, "op1"); got != "instances/h/inc2/files/g-op1/" || !strings.HasPrefix(got, FilesGenerationBase(loc)) {
+ t.Fatalf("GenerationFilesPrefix(%q) = %q", loc, got)
+ }
+ }
p := "instances/h/g-op1/"
if got := SnapshotKey(p, 7); got != p+"0009/0000000000000001-0000000000000007.ltx" {
t.Fatalf("SnapshotKey = %q", got)