host: the prepared manifest's exact version is journalled and the probe's establishment takes exactly it (Task 6 fix round 1, finding 2)
hostfiles.Prepared.Commit returns the version the store answered for the manifest's bytes (on a replay, the version the key holds). The binding local journals carries it, and the install refuses a store that answers none. For the probe's admission (in.Prepared) Establish no longer opens whatever the key holds: it decodes the op journal's binding (decodePreparedManifest: held to P's key, the claim's op and the holder's stamp), reads the bytes at exactly that version, checks them against the journalled digest, validates them as a prepared manifest (sequence 0, objects under P), and requires the key to hold either those bytes (the first establishment, replaced by compare-and-swap) or this box's own establishment of them (a retry after a crash past the commit: the same entries, the env bytes on disk, no adoption item, which then writes nothing). Anything else refuses before any write. movesFiles.Data is the versioned store itself, and DataDir names the op journals. Test: the crash/retry across the sequence-0 to sequence-1 transition, the exact version still satisfying stage B's prepared rule (sequence 0, the same entries), a digest mismatch, a foreign current manifest, a foreign prepared manifest, a missing binding and a missing manifest.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
6 files changed,
+325
−64
internal/host/claimfiles.go+1 −1internal/host/movesfiles.go+170 −25internal/host/movesfiles_test.go+110 −21internal/host/movesland_test.go+13 −0internal/hostfiles/prepared.go+17 −13internal/hostfiles/prepared_test.go+14 −4
diff --git a/internal/host/claimfiles.go b/internal/host/claimfiles.go| index 43f2d2851..88075abb6 100644 |
| --- a/internal/host/claimfiles.go |
| +++ b/internal/host/claimfiles.go |
| @@ -109,7 +109,7 @@ func (s *movesFilesSource) Open(ctx context.Context, _ release.Store, data lease |
| if err != nil { |
| return nil, err |
| } |
| - return &movesFiles{Files: files, Data: data, Bucket: actor.Bucket, HomeRegion: actor.HomeRegion, Region: region, Box: actor.Box, 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, FilesDir: movesFilesDirFor(s.DataDir)}, nil |
| } |
| func (s *movesFilesSource) Close(context.Context) error { return nil } |
diff --git a/internal/host/movesfiles.go b/internal/host/movesfiles.go| index 6d1b81b40..60f4de1a6 100644 |
| --- a/internal/host/movesfiles.go |
| +++ b/internal/host/movesfiles.go |
| @@ -1,6 +1,7 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| @@ -10,15 +11,16 @@ import ( |
| "io/fs" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| - "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/hostfiles" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/regionname" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -54,10 +56,16 @@ type movesFiles struct { |
| // (the S3 store's Put with If-None-Match and If-Match). Every write |
| // this provider makes goes through it. |
| Files release.Store |
| - // Data is the same bucket read by exact version (the claim's |
| - // hostbox.RequestStore in production): the chain's manifests are read |
| - // at the version the listing names, never by key. |
| - Data genselect.Reader |
| + // Data is the same bucket as a versioned store (the claim's |
| + // hostbox.RequestStore in production): the chain's manifests and the |
| + // prepared manifest are read at the exact version the listing or the |
| + // op journal names, never by key, and BuildPrepared writes through it |
| + // create-only. |
| + Data lease.VersionedStore |
| + // DataDir is the box's data directory, where the op journals live |
| + // (moves.ReadJournal): the probe's admission reads the prepared |
| + // manifest's binding from the op journal local wrote. |
| + DataDir string |
| // Bucket is the data bucket's name, HomeRegion the fleet's signed home |
| // region, Region this box's engine region (park.PlacementRegion of its |
| // placement) and Box its id. Every region comparison goes through |
| @@ -77,13 +85,35 @@ var _ ClaimFilesManifest = (*movesFiles)(nil) |
| // preparedManifest is the files_manifest journal step's value: the |
| // binding {location, P, op, stamp} of the prepared manifest an install |
| -// wrote, with the digest of its exact bytes. Key is |
| +// wrote, with the digest and the exact version of its bytes. Key is |
| // <location>/files/g-<P>/manifest.json, which names the location and P. |
| +// The probe's admission takes exactly Version and checks it against |
| +// SHA256 (fix round 1, finding 2). |
| type preparedManifest struct { |
| - Key string `json:"key"` |
| - SHA256 string `json:"sha256"` |
| - Op string `json:"op"` |
| - Stamp uint64 `json:"stamp"` |
| + Key string `json:"key"` |
| + SHA256 string `json:"sha256"` |
| + Version string `json:"version"` |
| + Op string `json:"op"` |
| + Stamp uint64 `json:"stamp"` |
| +} |
| + |
| +// decodePreparedManifest decodes a journalled binding and holds it to the |
| +// expected key, op and stamp (fix round 1, finding 3). An empty value, |
| +// malformed JSON, a binding for another key, op or stamp, or one without a |
| +// digest or a version, is an error: the journal is damaged or another |
| +// install's, and the step is neither done nor rebuilt over it. |
| +func decodePreparedManifest(raw, key, op string, stamp uint64) (preparedManifest, error) { |
| + var pm preparedManifest |
| + if raw == "" { |
| + return pm, fmt.Errorf("%w: the journal's %s step is empty", errLandingChanged, landStepFilesManifest) |
| + } |
| + if err := json.Unmarshal([]byte(raw), &pm); err != nil { |
| + return pm, fmt.Errorf("%w: the journal's %s step %q: %v", errLandingChanged, landStepFilesManifest, raw, err) |
| + } |
| + if pm.Key != key || pm.Op != op || pm.Stamp != stamp || !claimHexDigest(pm.SHA256) || pm.Version == "" || pm.Version == "null" { |
| + return pm, fmt.Errorf("%w: the journal's %s step %+v is not the binding of %s by %s at stamp %d", errLandingChanged, landStepFilesManifest, pm, key, op, stamp) |
| + } |
| + return pm, nil |
| } |
| // movesFilesPrefix is a generation's files prefix in its location, |
| @@ -170,10 +200,10 @@ func buildPreparedManifest(ctx context.Context, data lease.VersionedStore, bucke |
| // The caller runs it as op.exec in a phase that permits installation, |
| // after the install's own claim sync and stamp capture (local_). |
| func (f *movesFiles) BuildPrepared(ctx context.Context, location, generation, op string, stamp uint64, dir, envPath string, wake hostfiles.WakeState) (preparedManifest, error) { |
| - if f == nil || f.Files == nil { |
| + if f == nil { |
| return preparedManifest{}, hostwrite.ErrUnavailable |
| } |
| - return buildPrepared(ctx, f.Files, location, generation, op, stamp, dir, envPath, wake) |
| + return buildPreparedManifest(ctx, f.Data, f.Bucket, location, generation, op, stamp, dir, envPath, wake) |
| } |
| func buildPrepared(ctx context.Context, s release.Store, location, generation, op string, stamp uint64, dir, envPath string, wake hostfiles.WakeState) (preparedManifest, error) { |
| @@ -228,12 +258,15 @@ func buildPrepared(ctx context.Context, s release.Store, location, generation, o |
| if err := p.StagePlatform(ctx, hostfiles.WakeItem, body); err != nil { |
| return preparedManifest{}, err |
| } |
| - _, raw, err := p.Commit(ctx) |
| + _, raw, version, err := p.Commit(ctx) |
| if err != nil { |
| return preparedManifest{}, err |
| } |
| + if version == "" || version == "null" { |
| + return preparedManifest{}, fmt.Errorf("%w: the store answered no version for %s", hostwrite.ErrUnavailable, hostfiles.ManifestKey(prefix)) |
| + } |
| sum := sha256.Sum256(raw) |
| - return preparedManifest{Key: hostfiles.ManifestKey(prefix), SHA256: hex.EncodeToString(sum[:]), Op: op, Stamp: stamp}, nil |
| + return preparedManifest{Key: hostfiles.ManifestKey(prefix), SHA256: hex.EncodeToString(sum[:]), Version: version, Op: op, Stamp: stamp}, nil |
| } |
| // movesFilesChain is the admitted predecessor chain whose manifests are in |
| @@ -321,6 +354,128 @@ func (f *movesFiles) newestManifest(ctx context.Context, location string, chain |
| return hostfiles.Manifest{}, nil, false, 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 |
| +// manifest (sequence 0, objects within the location). The key must |
| +// currently hold either those bytes (the first establishment, which the |
| +// returned publisher replaces by compare-and-swap) or this box's own |
| +// establishment of them (a retry after a crash past the commit: the same |
| +// entries, the env bytes on disk, no adoption item, which the publisher |
| +// then leaves as it is). Anything else refuses before any write. |
| +func (f *movesFiles) openPrepared(ctx context.Context, o hostclaim.Observed, gen, prefix string, env []byte) (*hostfiles.Publisher, error) { |
| + if f.DataDir == "" { |
| + 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) |
| + pm, err := decodePreparedManifest(raw, key, o.Claim.Op.ID, h.Stamp) |
| + if err != nil { |
| + return nil, err |
| + } |
| + exact, err := f.Data.GetVersion(ctx, f.Bucket, key, pm.Version) |
| + if err != nil { |
| + return nil, fmt.Errorf("host: the prepared files manifest %s@%s: %w", key, pm.Version, err) |
| + } |
| + if sum := sha256.Sum256(exact); hex.EncodeToString(sum[:]) != pm.SHA256 { |
| + return nil, fmt.Errorf("%w: the prepared files manifest %s@%s is not the journalled bytes", hostbox.ErrChanged, key, pm.Version) |
| + } |
| + prepared, found, err := hostfiles.Load(ctx, exactBytesStore{key: key, body: exact, version: pm.Version}, prefix) |
| + if err != nil || !found { |
| + return nil, fmt.Errorf("host: the prepared files manifest %s@%s: %v", key, pm.Version, err) |
| + } |
| + if err := hostfiles.ValidateManifest(prepared, []string{prefix}); err != nil { |
| + return nil, fmt.Errorf("host: the prepared files manifest %s@%s: %w", key, pm.Version, err) |
| + } |
| + if prepared.Seq != 0 { |
| + return nil, fmt.Errorf("%w: the prepared files manifest %s@%s is at sequence %d, not 0", hostbox.ErrChanged, key, pm.Version, prepared.Seq) |
| + } |
| + cur, _, err := f.Files.Get(ctx, key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if !bytes.Equal(cur, exact) { |
| + current, found, err := hostfiles.Load(ctx, f.Files, prefix) |
| + if err != nil || !found { |
| + return nil, fmt.Errorf("host: the files manifest %s: %v", key, err) |
| + } |
| + if !establishedOver(current, prepared, env) { |
| + return nil, fmt.Errorf("%w: the files manifest %s is neither the prepared manifest %s nor this box's establishment of it", hostbox.ErrChanged, key, pm.Version) |
| + } |
| + } |
| + return hostfiles.Open(ctx, f.Files, prefix) |
| +} |
| + |
| +// establishedOver reports whether cur is an establishment of prepared by |
| +// this box: the same entries and the same platform items apart from the |
| +// env file, which holds exactly env's bytes, and the adoption item, which |
| +// is gone. |
| +func establishedOver(cur, prepared hostfiles.Manifest, env []byte) bool { |
| + if cur.Seq <= prepared.Seq || !reflect.DeepEqual(cur.Entries, prepared.Entries) { |
| + return false |
| + } |
| + e, ok := cur.Platform[hostfiles.EnvItem] |
| + if !ok || e.Deleted || e.SHA256 != hostfilesSum(env) { |
| + return false |
| + } |
| + if _, has := cur.Platform[instancekey.AdoptionItem]; has { |
| + return false |
| + } |
| + rest := func(m hostfiles.Manifest) map[string]hostfiles.Entry { |
| + out := map[string]hostfiles.Entry{} |
| + for k, v := range m.Platform { |
| + if k != hostfiles.EnvItem && k != instancekey.AdoptionItem { |
| + out[k] = v |
| + } |
| + } |
| + return out |
| + } |
| + return reflect.DeepEqual(rest(cur), rest(prepared)) |
| +} |
| + |
| +func hostfilesSum(body []byte) string { |
| + sum := sha256.Sum256(body) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// exactBytesStore is a read-only release.Store holding one object's exact |
| +// bytes at a version already read: hostfiles.Load's reader for a manifest |
| +// the caller verified. |
| +type exactBytesStore struct { |
| + key, version string |
| + body []byte |
| +} |
| + |
| +func (s exactBytesStore) Get(_ context.Context, key string) ([]byte, string, error) { |
| + if key != s.key { |
| + return nil, "", os.ErrNotExist |
| + } |
| + return s.body, s.version, nil |
| +} |
| +func (exactBytesStore) Put(context.Context, string, []byte, string) (string, error) { |
| + return "", errors.New("host: the prepared manifest's exact bytes are read only") |
| +} |
| +func (exactBytesStore) List(context.Context, string) ([]string, error) { |
| + return nil, errors.New("host: the prepared manifest's exact bytes are read only") |
| +} |
| +func (exactBytesStore) ListDirs(context.Context, string) ([]string, error) { |
| + return nil, errors.New("host: the prepared manifest's exact bytes are read only") |
| +} |
| +func (exactBytesStore) Delete(context.Context, string) error { |
| + return errors.New("host: the prepared manifest's exact bytes are read only") |
| +} |
| + |
| // emptyManifest is the base of an empty start. |
| func emptyManifest() hostfiles.Manifest { |
| return hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}} |
| @@ -367,20 +522,10 @@ func (f *movesFiles) Establish(ctx context.Context, o hostclaim.Observed, in Cla |
| var p *hostfiles.Publisher |
| switch { |
| case in.Prepared: |
| - p, err = hostfiles.Open(ctx, f.Files, prefix) |
| + p, err = f.openPrepared(ctx, o, gen, prefix, env) |
| if err != nil { |
| return err |
| } |
| - m, found, err := hostfiles.Load(ctx, f.Files, prefix) |
| - if err != nil { |
| - return err |
| - } |
| - if !found { |
| - return fmt.Errorf("host: the prepared files manifest %s is missing: %w", hostfiles.ManifestKey(prefix), os.ErrNotExist) |
| - } |
| - if err := hostfiles.ValidateManifest(m, []string{prefix}); err != nil { |
| - return fmt.Errorf("host: the prepared files manifest %s: %w", hostfiles.ManifestKey(prefix), err) |
| - } |
| case in.Empty: |
| p, err = hostfiles.NewFromCopy(f.Files, prefix, emptyManifest(), nil) |
| if err != nil { |
diff --git a/internal/host/movesfiles_test.go b/internal/host/movesfiles_test.go| index 712f8f407..4c445553a 100644 |
| --- a/internal/host/movesfiles_test.go |
| +++ b/internal/host/movesfiles_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "errors" |
| "os" |
| "path/filepath" |
| + "reflect" |
| "strings" |
| "sync" |
| "testing" |
| @@ -19,6 +20,7 @@ import ( |
| "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -95,9 +97,11 @@ type filesFixture struct { |
| files casBucketStore |
| dir string |
| f *movesFiles |
| - // work records whether the start's work ran and can refuse. |
| - workRan int |
| - workErr error |
| + // work records whether the start's work ran and can refuse; workAfter |
| + // fails after the work's effect ran (a crash past it). |
| + workRan int |
| + workErr error |
| + workAfter error |
| } |
| const ( |
| @@ -110,7 +114,10 @@ func (x *filesFixture) With(_ context.Context, _ hostwrite.Identity, fn func() e |
| if x.workErr != nil { |
| return x.workErr |
| } |
| - return fn() |
| + if err := fn(); err != nil { |
| + return err |
| + } |
| + return x.workAfter |
| } |
| func newFilesFixture(t *testing.T) *filesFixture { |
| @@ -118,7 +125,7 @@ func newFilesFixture(t *testing.T) *filesFixture { |
| b := leasetest.NewVersionedBucket() |
| 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", |
| + 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") |
| }} |
| @@ -508,13 +515,41 @@ func TestEstablishHostThatNeverPublishedFiles(t *testing.T) { |
| } |
| } |
| +// journal writes the prepared manifest's binding into the op journal |
| +// under the fixture's data directory, as local does. |
| +func (x *filesFixture) journal(generation string, pm preparedManifest) { |
| + x.t.Helper() |
| + x.journalRaw(generation, string(mustJSON(x.t, pm))) |
| +} |
| + |
| +func (x *filesFixture) journalRaw(generation, value string) { |
| + x.t.Helper() |
| + j, err := moves.ReadJournal(x.dir, filesHost, generation) |
| + if err != nil { |
| + x.t.Fatal(err) |
| + } |
| + j.Mark(landStepFilesManifest, value) |
| + if err := j.Commit(x.dir); err != nil { |
| + x.t.Fatal(err) |
| + } |
| +} |
| + |
| // The probe's admission opens the prepared generation itself (in.Prepared, |
| -// NewGeneration = P): its baseline is P's own prepared manifest at |
| -// sequence 0, which must name destination-local objects only; the env file |
| -// on disk (now carrying the minted key) is staged over it at sequence 1, |
| -// a new version of the same key, and the sequence-0 version stays |
| -// readable by exact version. A prepared manifest naming another |
| -// generation's objects refuses. |
| +// NewGeneration = P). Its baseline is the prepared manifest at the exact |
| +// version the op journal's binding names (fix round 1, finding 2): those |
| +// bytes are read by that version, checked against the journalled digest, |
| +// validated (sequence 0, objects within the location), and must be what |
| +// the key currently holds. The env file on disk (now carrying the minted |
| +// key) is staged over it at sequence 1, a new version of the same key; the |
| +// sequence-0 version stays readable by exact version and still satisfies |
| +// stage B's rule for a prepared manifest (sequence 0, the same entries). |
| +// |
| +// A crash after the commit and before the local effect leaves the key at |
| +// sequence 1: the retry recognises its own establishment (the same |
| +// entries, the env bytes on disk, no adoption item) and writes nothing. A |
| +// binding whose digest is not the exact version's, a current manifest that |
| +// is neither the prepared one nor this box's establishment of it, a |
| +// missing binding and a missing manifest all refuse before any write. |
| func TestEstablishOnThePreparedGenerationContinuesFromSequenceZero(t *testing.T) { |
| x := newFilesFixture(t) |
| ctx := context.Background() |
| @@ -530,34 +565,88 @@ func TestEstablishOnThePreparedGenerationContinuesFromSequenceZero(t *testing.T) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - seq0 := x.bucket.LatestID("data", pm.Key) |
| + if pm.Version == "" || pm.Version != x.bucket.LatestID("data", pm.Key) { |
| + t.Fatalf("the binding's version %q is not the manifest's exact version %q", pm.Version, x.bucket.LatestID("data", pm.Key)) |
| + } |
| + x.journal("mv1", pm) |
| x.writeEnv("K=1\nCARLOS_INSTANCE_KEY=abc\n") |
| o := servingObserved("cur", "cur:ie:@root:ie") |
| + o.Claim.Op = hostclaim.Op{Kind: hostclaim.KindLanding, ID: "mv1", Phase: "probing", Exec: "uk-box"} |
| in := launchInputs("mv1") |
| in.Prepared = true |
| - if err := x.f.Establish(ctx, o, in, x); err != nil { |
| - t.Fatal(err) |
| + // The first attempt crashes after the commit, before the local effect. |
| + x.workAfter = errors.New("crash after the commit") |
| + if err := x.f.Establish(ctx, o, in, x); err == nil { |
| + t.Fatal("the crash was not reported") |
| } |
| + x.workAfter = nil |
| m, found := x.manifest("mv1") |
| + if !found || m.Seq != 1 { |
| + t.Fatalf("after the crashed attempt: %+v %v", m, found) |
| + } |
| + seq1 := x.bucket.LatestID("data", pm.Key) |
| + // The retry reads its own establishment back and writes nothing. |
| + if err := x.f.Establish(ctx, o, in, x); err != nil { |
| + t.Fatalf("retry: %v", err) |
| + } |
| + m, found = x.manifest("mv1") |
| if !found || m.Seq != 1 || m.Entries["a.txt"].Seq != 0 || m.Platform[hostfiles.EnvItem].SHA256 != sha([]byte("K=1\nCARLOS_INSTANCE_KEY=abc\n")) { |
| t.Fatalf("after the probe's establishment: %+v %v", m, found) |
| } |
| - raw, err := x.bucket.GetVersion(ctx, "data", pm.Key, seq0) |
| + if x.bucket.LatestID("data", pm.Key) != seq1 { |
| + t.Fatal("the retry wrote a new revision") |
| + } |
| + // The exact sequence-0 version is what the binding says, and stage B's |
| + // rule for a prepared manifest still holds for it: sequence 0 and the |
| + // same entries as the establishment. |
| + raw, err := x.bucket.GetVersion(ctx, "data", pm.Key, pm.Version) |
| if err != nil || sha(raw) != pm.SHA256 { |
| - t.Fatalf("the sequence-0 version is gone: %v", err) |
| + t.Fatalf("the sequence-0 version is gone or changed: %v", err) |
| + } |
| + var seq0 hostfiles.Manifest |
| + if err := json.Unmarshal(raw, &seq0); err != nil || seq0.Seq != 0 || !reflect.DeepEqual(seq0.Entries, m.Entries) { |
| + t.Fatalf("the exact prepared version: %+v %v", seq0, err) |
| } |
| - // A prepared manifest that names a foreign object (P6-D3) refuses. |
| - foreign := hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{"x": {Object: movesFilesPrefix(filesLoc, "cur") + "objects/0000000000000000-" + sha(nil), SHA256: sha(nil), Seq: 0}}, Platform: map[string]hostfiles.Entry{}} |
| - body, _ := json.Marshal(foreign) |
| - x.bucket.Put("data", hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "mv2")), body) |
| + // A binding whose digest is not the exact version's refuses. |
| + bad := pm |
| + bad.SHA256 = sha([]byte("other")) |
| + x.journal("mv1", bad) |
| + if err := x.f.Establish(ctx, o, in, x); err == nil || errors.Is(err, hostfiles.ErrNoEvidence) { |
| + t.Fatalf("digest mismatch: %v", err) |
| + } |
| + x.journal("mv1", pm) |
| + // A current manifest that is neither the prepared one nor this box's |
| + // establishment of it refuses, and is left as it is. |
| + other := m |
| + other.Entries = map[string]hostfiles.Entry{"b.txt": {Object: movesFilesPrefix(filesLoc, "mv1") + "objects/0000000000000002-" + sha([]byte("B")), SHA256: sha([]byte("B")), Size: 1, Seq: 2}} |
| + other.Seq = 2 |
| + x.bucket.Put("data", pm.Key, mustJSON(t, other)) |
| + if err := x.f.Establish(ctx, o, in, x); !errors.Is(err, hostbox.ErrChanged) { |
| + t.Fatalf("foreign current manifest: %v", err) |
| + } |
| + if cur, _ := x.manifest("mv1"); cur.Seq != 2 { |
| + t.Fatal("the foreign manifest was replaced") |
| + } |
| + // A prepared manifest naming an object outside the location (P6-D3) |
| + // refuses. |
| + foreign := hostfiles.Manifest{V: hostfiles.ManifestVersion, Entries: map[string]hostfiles.Entry{"x": {Object: "instances/other/files/g-z/objects/0000000000000000-" + sha(nil), SHA256: sha(nil), Seq: 0}}, Platform: map[string]hostfiles.Entry{}} |
| + fraw := mustJSON(t, foreign) |
| + fkey := hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "mv2")) |
| + fver := x.bucket.Put("data", fkey, fraw) |
| + x.journal("mv2", preparedManifest{Key: fkey, SHA256: sha(fraw), Version: fver, Op: "mv1", Stamp: 7}) |
| in = launchInputs("mv2") |
| in.Prepared = true |
| if err := x.f.Establish(ctx, o, in, x); !errors.Is(err, hostfiles.ErrOrigin) { |
| t.Fatalf("foreign prepared manifest: %v", err) |
| } |
| - // No prepared manifest at P: an error, never a fresh start. |
| + // No binding in the op journal, and no manifest at P: an error, never a |
| + // fresh start. |
| in = launchInputs("mv3") |
| in.Prepared = true |
| + if err := x.f.Establish(ctx, o, in, x); err == nil || errors.Is(err, hostfiles.ErrNoEvidence) { |
| + t.Fatalf("missing binding: %v", err) |
| + } |
| + x.journal("mv3", preparedManifest{Key: hostfiles.ManifestKey(movesFilesPrefix(filesLoc, "mv3")), SHA256: sha(nil), Version: "v-none", Op: "mv1", Stamp: 7}) |
| if err := x.f.Establish(ctx, o, in, x); err == nil || errors.Is(err, hostfiles.ErrNoEvidence) { |
| t.Fatalf("missing prepared manifest: %v", err) |
| } |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index ece16725a..0c6477db3 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -1317,6 +1317,19 @@ func TestLocalBindsThePreparedManifestToTheNewGeneration(t *testing.T) { |
| if pm.Key != hostfiles.ManifestKey(prefix) || pm.Op != p1ID || pm.Stamp != c.Holder.Stamp { |
| t.Fatalf("binding: %+v, want key %s op %s stamp %d", pm, hostfiles.ManifestKey(prefix), p1ID, c.Holder.Stamp) |
| } |
| + vs, err := h.vs.ListVersions(context.Background(), "data-uk", pm.Key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + exact := "" |
| + for _, v := range vs { |
| + if v.Key == pm.Key && v.IsLatest { |
| + exact = v.VersionID |
| + } |
| + } |
| + if pm.Version == "" || pm.Version != exact { |
| + t.Fatalf("the binding's version %q is not the manifest's exact version %q", pm.Version, exact) |
| + } |
| if m.Seq != 0 { |
| t.Fatalf("prepared manifest at seq %d", m.Seq) |
| } |
diff --git a/internal/hostfiles/prepared.go b/internal/hostfiles/prepared.go| index ad4aac0e8..8a2a78163 100644 |
| --- a/internal/hostfiles/prepared.go |
| +++ b/internal/hostfiles/prepared.go |
| @@ -161,32 +161,36 @@ func (p *Prepared) StagePlatform(ctx context.Context, name string, body []byte) |
| } |
| // Commit writes the one prepared manifest, create-only, and returns it |
| -// with its exact bytes (the caller journals their digest). A manifest |
| -// already at the key is read back: the same bytes are a replay and |
| -// success; any other manifest is ErrManifestExists and nothing is written. |
| -// After Commit the builder is spent. |
| -func (p *Prepared) Commit(ctx context.Context) (Manifest, []byte, error) { |
| +// with its exact bytes and the version the store answered for them (the |
| +// caller journals the digest and the version, so a later reader takes |
| +// exactly this revision). A manifest already at the key is read back: the |
| +// same bytes are a replay and success, with the version the key holds; |
| +// any other manifest is ErrManifestExists and nothing is written. After |
| +// Commit the builder is spent. |
| +func (p *Prepared) Commit(ctx context.Context) (Manifest, []byte, string, error) { |
| if p.spent { |
| - return Manifest{}, nil, errors.New("hostfiles: prepared manifest already committed") |
| + return Manifest{}, nil, "", errors.New("hostfiles: prepared manifest already committed") |
| } |
| p.man.Seq = 0 |
| body, err := json.Marshal(p.man) |
| if err != nil { |
| - return Manifest{}, nil, fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| + return Manifest{}, nil, "", fmt.Errorf("hostfiles: marshal manifest: %w", err) |
| } |
| - if _, err := p.s.Put(ctx, ManifestKey(p.prefix), body, ""); errors.Is(err, release.ErrCASConflict) { |
| - cur, _, gerr := p.s.Get(ctx, ManifestKey(p.prefix)) |
| + version, err := p.s.Put(ctx, ManifestKey(p.prefix), body, "") |
| + if errors.Is(err, release.ErrCASConflict) { |
| + cur, curVersion, gerr := p.s.Get(ctx, ManifestKey(p.prefix)) |
| if gerr != nil { |
| - return Manifest{}, nil, gerr |
| + return Manifest{}, nil, "", gerr |
| } |
| if !bytes.Equal(cur, body) { |
| - return Manifest{}, nil, fmt.Errorf("%w: %s", ErrManifestExists, p.prefix) |
| + return Manifest{}, nil, "", fmt.Errorf("%w: %s", ErrManifestExists, p.prefix) |
| } |
| + version = curVersion |
| } else if err != nil { |
| - return Manifest{}, nil, err |
| + return Manifest{}, nil, "", err |
| } |
| p.spent = true |
| - return p.man, body, nil |
| + return p.man, body, version, nil |
| } |
| // NewFromCopy is a publisher for a generation that has no manifest yet, |
diff --git a/internal/hostfiles/prepared_test.go b/internal/hostfiles/prepared_test.go| index 9249b7b36..326a7c8d5 100644 |
| --- a/internal/hostfiles/prepared_test.go |
| +++ b/internal/hostfiles/prepared_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "strings" |
| "testing" |
| @@ -74,10 +75,13 @@ func TestPreparedIsCreateOnlyAtSequenceZero(t *testing.T) { |
| if err := p.StagePlatform(ctx, EnvItem, []byte("K=1\n")); err != nil { |
| t.Fatal(err) |
| } |
| - m, body, err := p.Commit(ctx) |
| + m, body, version, err := p.Commit(ctx) |
| if err != nil { |
| t.Fatal(err) |
| } |
| + if version == "" { |
| + t.Fatal("Commit returned no version") |
| + } |
| if m.Seq != 0 || m.V != ManifestVersion { |
| t.Fatalf("manifest seq %d v %d", m.Seq, m.V) |
| } |
| @@ -101,7 +105,7 @@ func TestPreparedIsCreateOnlyAtSequenceZero(t *testing.T) { |
| if err := q.Stage(ctx, "a.txt", []byte("B"), 0o644); err != nil { |
| t.Fatal(err) |
| } |
| - if _, _, err := q.Commit(ctx); !errors.Is(err, ErrManifestExists) { |
| + if _, _, _, err := q.Commit(ctx); !errors.Is(err, ErrManifestExists) { |
| t.Fatalf("second build: %v", err) |
| } |
| if again, _, err := s.Get(ctx, ManifestKey(pfx)); err != nil || string(again) != string(body) { |
| @@ -129,8 +133,14 @@ func TestPreparedReplayReusesObjects(t *testing.T) { |
| if err := p.StagePlatform(ctx, WakeItem, []byte(`{"v":1}`)); err != nil { |
| return nil, err |
| } |
| - _, body, err := p.Commit(ctx) |
| - return body, err |
| + _, body, version, err := p.Commit(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if _, etag, err := s.Get(ctx, ManifestKey(pfx)); err != nil || version != etag { |
| + return nil, fmt.Errorf("Commit returned version %q, the manifest is at %q (%v)", version, etag, err) |
| + } |
| + return body, nil |
| } |
| first, err := build() |
| if err != nil { |