carlos / platform Public

hostrelocate: the pinner refuses a files manifest without the env file or the wake state before any build, by the install's own rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 1ea69696b9a76fef449a8e9768a499c544b7a998 parent 73bab73
3 files changed, +95 −6
  • internal/hostrelocate/pin.go +14 −0
  • internal/hostrelocate/pin_envkey_test.go +2 −2
  • internal/hostrelocate/pin_test.go +79 −4
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go
index 54c72b7fc..24d5ab6f9 100644
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
@@ -267,6 +267,20 @@ func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Bi
if origins, err = referencedOrigins(h.Location, origins, m); err != nil {
return FilesPin{}, nil, nil, err
}
+ // A pinned manifest carries the env file and the wake state
+ // (Task 8c, ruling 68 amended; the install's own rule,
+ // relocationPlatformItem): a host that published files has no
+ // evidence that it has neither. Platform items have no tombstone
+ // (StagePlatformDelete drops the item), so a missing one reads the
+ // same as one never published, and a manifest no release flush
+ // wrote (a failover's) carries no wake state however many timers
+ // the host had. Refused here, before any build, rather than at the
+ // target's install.
+ for _, name := range []string{hostfiles.EnvItem, hostfiles.WakeItem} {
+ if e, ok := m.Platform[name]; !ok || e.Deleted {
+ return FilesPin{}, nil, nil, fmt.Errorf("%w: %s's manifest has no %s", ErrFilesUnevidenced, prefix, name)
+ }
+ }
var keyPin *KeyPin
if needKey && !liveEntry(m, InstanceKeyPath) {
// (a) failed: no instance.key. (b'): an adoption record bound
diff --git a/internal/hostrelocate/pin_envkey_test.go b/internal/hostrelocate/pin_envkey_test.go
index ca2eb7f5b..3c0932dee 100644
--- a/internal/hostrelocate/pin_envkey_test.go
+++ b/internal/hostrelocate/pin_envkey_test.go
@@ -71,7 +71,7 @@ func TestStorePinnerKeyEvidence(t *testing.T) {
tc.holder(obs.Claim.Holder)
gen = obs.Claim.Holder.Generation
}
- m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ m := withItem(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}, hostfiles.WakeItem, "d")
if tc.appKey {
m = keyed(m)
}
@@ -149,7 +149,7 @@ func TestStorePinnerConfigRecordNeedsTheEnvItem(t *testing.T) {
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
obs := pinClaim()
obs.Claim.Holder.Incarnation = "inc-1"
- m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ m := withItem(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}, hostfiles.WakeItem, "d")
item := func(name, body string) {
sum := sha256.Sum256([]byte(body))
sha := hex.EncodeToString(sum[:])
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go
index 4aaea8970..d9d8cc786 100644
--- a/internal/hostrelocate/pin_test.go
+++ b/internal/hostrelocate/pin_test.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "os"
"strings"
"testing"
"time"
@@ -26,13 +27,42 @@ func pinClaim() hostclaim.Observed {
}
// keyed adds the host's instance.key (H6), as every manifest of a host
-// whose database is nonempty must carry (rev 18).
+// whose database is nonempty must carry (rev 18), and the platform items
+// 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}
+ return withPlatformItems(m)
+}
+
+// withPlatformItems adds the env file and the wake state, as a release
+// flush publishes them, where m has none (Task 8c: a pinned manifest
+// without either refuses). The pinner reads neither unless the key
+// evidence asks for the env.
+func withPlatformItems(m hostfiles.Manifest) hostfiles.Manifest {
+ return withItem(withItem(m, hostfiles.EnvItem, "c"), hostfiles.WakeItem, "d")
+}
+
+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}
+ }
return m
}
+// stagePlatformItems stages the env file and the wake state, as a release
+// flush does.
+func stagePlatformItems(t *testing.T, p *hostfiles.Publisher) {
+ t.Helper()
+ if err := p.StagePlatform(context.Background(), hostfiles.EnvItem, []byte("CARLOS_ADMIN_TOKEN=t\n")); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.StagePlatform(context.Background(), hostfiles.WakeItem, []byte(`{"v":1,"host":"`+host+`","timers":[]}`)); err != nil {
+ t.Fatal(err)
+ }
+}
+
func putManifest(t *testing.T, data release.Store, gen string, m hostfiles.Manifest) (string, []byte) {
t.Helper()
raw, _ := json.Marshal(m)
@@ -361,6 +391,7 @@ func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) {
if err := p.Stage(ctx, "instance.key", []byte("the host's key"), 0o600, false); err != nil {
t.Fatal(err)
}
+ stagePlatformItems(t, p)
if err := p.Stage(ctx, "changed.txt", []byte(gen), 0o644, false); err != nil {
t.Fatal(err)
}
@@ -427,10 +458,12 @@ func TestPreparedManifestIsSequenceZeroAndTheSessionPublishesOne(t *testing.T) {
}
for i := 1; i <= 7; i++ {
if i == 7 {
- // The seventh revision also publishes the host's key (H6).
+ // The seventh revision also publishes the host's key (H6), and
+ // its env file and wake state, as the release flush does.
if err := p.Stage(ctx, "instance.key", []byte("the host's key"), 0o600, false); err != nil {
t.Fatal(err)
}
+ stagePlatformItems(t, p)
}
if err := p.Stage(ctx, "a.txt", []byte(fmt.Sprintf("revision %d", i)), 0o644, false); err != nil {
t.Fatal(err)
@@ -441,7 +474,7 @@ func TestPreparedManifestIsSequenceZeroAndTheSessionPublishesOne(t *testing.T) {
}
pinner := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
ref, err := pinner.Pin(ctx, pinClaim(), time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC))
- if err != nil || ref.FilesSeq != 8 { // seven revisions of a.txt and the key: eight objects
+ if err != nil || ref.FilesSeq != 10 { // seven revisions of a.txt, the key, the env and the wake state: ten objects
t.Fatalf("pin: %+v %v", ref, err)
}
obj, err := ReadPin(ctx, claims, ref)
@@ -522,7 +555,7 @@ func TestStorePinnerRefusesAKeylessManifestForANonemptyDatabase(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
claims, data := release_NewDirStore(t), release_NewDirStore(t)
- m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
+ m := withPlatformItems(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})
if tc.key {
m = keyed(m)
}
@@ -675,3 +708,45 @@ func TestReadPinNeedsTheEmptyEvidence(t *testing.T) {
})
}
}
+
+// Task 8c fix round 1, M6: the install's rule at pin time. A pinned
+// manifest without the env file or without the wake state is no evidence
+// that the host has neither (platform items have no tombstone, and a
+// failover's manifest carries no wake state), so the pin refuses before
+// any build: no pin object, no retained copy. A host that never published
+// files still pins empty (TestStorePinnerRefusesAnEmptyFilesPinAfterAWriter).
+func TestStorePinnerRefusesAManifestWithoutTheEnvOrWakeState(t *testing.T) {
+ ctx := context.Background()
+ at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
+ for _, tc := range []struct {
+ name string
+ missing string
+ want error
+ }{
+ {"both", "", nil},
+ {"no env file", hostfiles.EnvItem, ErrFilesUnevidenced},
+ {"no wake state", hostfiles.WakeItem, ErrFilesUnevidenced},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ m := keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})
+ delete(m.Platform, tc.missing)
+ putManifest(t, data, "g3", m)
+ o := pinClaim()
+ p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
+ _, err := p.Pin(ctx, o, at)
+ if !errors.Is(err, tc.want) {
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ if tc.want == nil {
+ return
+ }
+ if _, _, err := claims.Get(ctx, PinKey(host, "rel-1", o.Claim.Gen)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("a pin object was written: %v", err)
+ }
+ if keys, err := claims.List(ctx, ""); err != nil || len(keys) != 0 {
+ t.Fatalf("the refused pin wrote %v %v", keys, err)
+ }
+ })
+ }
+}