region HA 1b-next R9: install_release from the pin's release
RecoveryPin gains Release (json "release"), the app release version the relocation installs. JournalPin refuses a pin without it. The installing CAS (hostclaim.install) copies the journaled pin's Release to op.journal.install_release beside install_stamp, so a replayed or re-targeted installation copies the same pin's value and never reads anything else; the pair is written together and only there, and only the serving CAS drops both. StorePinner gains Release(ctx, host), the narrowest injected read of the region controller's authoritative registry route (registry.Route.Version), which stage B wires. Nil is ErrUnsupported; an empty version is ErrRefused before anything is written; a reused pin object carries its stored release, and an object without one is refused. Moves' held-stage relay requires req.Version == op.journal.install_release (amadan #236).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+240
−16
internal/hostclaim/relocation.go+22 −1internal/hostclaim/relocation_test.go+106 −2internal/hostrelocate/pin.go+24 −4internal/hostrelocate/pin_test.go+87 −8internal/hostrelocate/worker_test.go+1 −1
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go| index 9b1add509..07de31589 100644 |
| --- a/internal/hostclaim/relocation.go |
| +++ b/internal/hostclaim/relocation.go |
| @@ -107,6 +107,14 @@ type RecoveryPin struct { |
| // never admitted a writer (session 0). A listing that merely finds |
| // nothing is never evidence (JournalPin refuses it). |
| Empty bool `json:"empty,omitempty"` |
| + // Release is the app release version the relocation installs (R9 on |
| + // amadan #236). The pinner sets it at pinning from the region |
| + // controller's authoritative registry route for the host, never from |
| + // the box, the request or the box's install journal; the installing |
| + // CAS copies it to op.journal.install_release, which moves' held-stage |
| + // relay compares with the request's version. Required: a pin without |
| + // it is refused. |
| + Release string `json:"release"` |
| } |
| // RecoveryPinKey is where the pin selected against claim generation gen |
| @@ -665,7 +673,7 @@ func JournalPin(opID string, p RecoveryPin) func(*Claim) error { |
| if p.Key != RecoveryPinKey(c.Host, opID, c.Gen) { |
| return fmt.Errorf("%w: pin %s was not selected against this observation (gen %d)", ErrRelocation, p.Key, c.Gen) |
| } |
| - if !isHex64(p.SHA256) || p.FilesManifest == "" || p.PinnedAt.IsZero() || (p.Empty != (p.MaxTXID == 0)) { |
| + if !isHex64(p.SHA256) || p.FilesManifest == "" || p.PinnedAt.IsZero() || (p.Empty != (p.MaxTXID == 0)) || p.Release == "" { |
| return fmt.Errorf("%w: incomplete pin", ErrRelocation) |
| } |
| var r Release |
| @@ -767,7 +775,19 @@ func abandonPrepared(c *Claim) error { |
| return journalJSON(c, "abandoned_prepared", append(abandoned, old)) |
| } |
| +// install hands op.exec to box for an installation at a new stamp. The |
| +// same CAS journals install_stamp and install_release, the journaled pin's |
| +// release (R9): a replayed or re-targeted installation copies the same |
| +// pin's value and never reads anything else. The two keys are written |
| +// together and only here, and no transition clears one without the other |
| +// (only the serving CAS, which ends the operation, drops both), so |
| +// install_release always names the release of the installation |
| +// install_stamp names. |
| func install(c *Claim, box, prepared, phase string) error { |
| + var pin RecoveryPin |
| + if !readJournal(c.Op, "pin", &pin) || pin.Release == "" { |
| + return fmt.Errorf("%w: an installation needs the journaled pin's release", ErrRelocation) |
| + } |
| if c.Op.Journal["prepared"] != prepared { |
| if err := abandonPrepared(c); err != nil { |
| return err |
| @@ -777,6 +797,7 @@ func install(c *Claim, box, prepared, phase string) error { |
| c.Op.Exec, c.Op.Phase = box, phase |
| c.Holder.Stamp = stamp |
| c.Op.Journal["install_stamp"] = strconv.FormatUint(stamp, 10) |
| + c.Op.Journal["install_release"] = pin.Release |
| c.Op.Journal["prepared"] = prepared |
| delete(c.Op.Journal, "local_install") |
| if !slices.Contains(c.Touched.Boxes, box) { |
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go| index 36e52e8e7..022b1ddbd 100644 |
| --- a/internal/hostclaim/relocation_test.go |
| +++ b/internal/hostclaim/relocation_test.go |
| @@ -8,6 +8,7 @@ import ( |
| "errors" |
| "reflect" |
| "slices" |
| + "strconv" |
| "strings" |
| "testing" |
| "time" |
| @@ -94,9 +95,12 @@ func ack(box string) func(*Claim) error { |
| return AcknowledgeExecution(box, ExecutionAcknowledgement{Op: "rel-1", Enrollment: relEnrollment(box), BootID: "boot", ControllerID: "ctl", LeaseETag: "lease", Expires: relObserved.Add(time.Hour)}) |
| } |
| +// relRelease is the app release the test pins name (R9). |
| +const relRelease = "v1.4.2" |
| + |
| // relPin is a pin selected against o: its key names o's claim generation. |
| func relPin(o Observed, maxTXID uint64, seq int64, at time.Time) RecoveryPin { |
| - return RecoveryPin{Key: RecoveryPinKey(o.Claim.Host, "rel-1", o.Claim.Gen), SHA256: strings.Repeat("a", 64), Generation: "g1", MaxTXID: maxTXID, FilesManifest: "instances/x/files/g1/manifest.json", FilesSeq: seq, PinnedAt: at, Empty: maxTXID == 0} |
| + return RecoveryPin{Key: RecoveryPinKey(o.Claim.Host, "rel-1", o.Claim.Gen), SHA256: strings.Repeat("a", 64), Generation: "g1", MaxTXID: maxTXID, FilesManifest: "instances/x/files/g1/manifest.json", FilesSeq: seq, PinnedAt: at, Empty: maxTXID == 0, Release: relRelease} |
| } |
| func relPrepared(gen string, maxTXID uint64, filesSeq int64) PreparedAuthority { |
| @@ -1164,7 +1168,7 @@ func TestRelocationConvertedMovePinsOnlyAfterFenceSourceAndTheHold(t *testing.T) |
| } |
| pin := func(o Observed, at time.Time) RecoveryPin { |
| return RecoveryPin{Key: RecoveryPinKey(o.Claim.Host, "move-1", o.Claim.Gen), SHA256: strings.Repeat("a", 64), Generation: "gen-current", MaxTXID: 40, |
| - FilesManifest: "instances/x/files/gen-current/manifest.json", FilesSeq: 7, PinnedAt: at} |
| + FilesManifest: "instances/x/files/gen-current/manifest.json", FilesSeq: 7, PinnedAt: at, Release: relRelease} |
| } |
| for _, late := range []time.Duration{time.Second, time.Hour} { |
| if _, err := Update(ctx, s, o, JournalPin("move-1", pin(o, observed.Add(late)))); !errors.Is(err, ErrRelocation) { |
| @@ -1210,3 +1214,103 @@ func TestRelocationNameTargetDropsTheConvertedMovesTarget(t *testing.T) { |
| t.Fatalf("the move's history or the replacement count changed: %+v", o.Claim.Op) |
| } |
| } |
| + |
| +// R9 (amadan #236): install_release is the journaled pin's release, written |
| +// in the installing CAS beside install_stamp, copied again by a re-targeted |
| +// installation, and never present without install_stamp. |
| +func TestRelocationInstallReleaseFollowsThePin(t *testing.T) { |
| + ctx := context.Background() |
| + pair := func(t *testing.T, c *Claim) { |
| + t.Helper() |
| + _, stamped := c.Op.Journal["install_stamp"] |
| + _, released := c.Op.Journal["install_release"] |
| + if stamped != released { |
| + t.Fatalf("install_stamp present %v, install_release present %v", stamped, released) |
| + } |
| + } |
| + t.Run("the pin needs a release", func(t *testing.T) { |
| + s, o := relAtPhase(t, PhaseReleasing) |
| + h := o.Claim.Holder |
| + o = step(t, s, o, JournalRelease("rel-1", Release{ReleasedBy: relSource, Enrollment: relEnrollment(relSource), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)})) |
| + o, err := PinAfterRelease(ctx, s, o, "rel-1", relReleases{}, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bare := relPin(o, 40, 7, relObserved) |
| + bare.Release = "" |
| + if _, err := Update(ctx, s, o, JournalPin("rel-1", bare)); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("a pin without a release was journaled: %v", err) |
| + } |
| + o = step(t, s, o, JournalPin("rel-1", relPin(o, 40, 7, relObserved))) |
| + pair(t, o.Claim) |
| + // A journaled pin that lost its release (malformed) installs nothing. |
| + broken := o |
| + broken.Claim = cloneForTest(t, o.Claim) |
| + broken.Claim.Op.Journal["pin"] = strings.Replace(broken.Claim.Op.Journal["pin"], relRelease, "", 1) |
| + if _, err := Update(ctx, s, broken, ToInstalling("rel-1", Target{relTarget, relEnrollment(relTarget)}, "P1")); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("installed without the pin's release: %v", err) |
| + } |
| + }) |
| + t.Run("installing copies it", func(t *testing.T) { |
| + _, o := relAtPhase(t, PhaseInstalling) |
| + c := o.Claim |
| + if c.Op.Journal["install_release"] != relRelease || c.Op.Journal["install_stamp"] != strconv.FormatUint(c.Gen, 10) { |
| + t.Fatalf("install_release %q install_stamp %q gen %d", c.Op.Journal["install_release"], c.Op.Journal["install_stamp"], c.Gen) |
| + } |
| + }) |
| + for _, at := range []string{PhaseInstalling, PhaseInstalled, PhaseRelocatingProbing} { |
| + t.Run("a re-targeted installation from "+at+" keeps it", func(t *testing.T) { |
| + s, o := relAtPhase(t, at) |
| + stamp := o.Claim.Op.Journal["install_stamp"] |
| + o, err := ReplaceTarget(ctx, s, o, "rel-1", putFence(t, s, relTarget), nil, Target{relSpare, relEnrollment(relSpare)}, "P2", relAuthority{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := o.Claim |
| + if c.Op.Exec != relSpare || c.Op.Journal["install_stamp"] == stamp || c.Op.Journal["install_release"] != relRelease { |
| + t.Fatalf("exec %s install_stamp %q (was %q) install_release %q", c.Op.Exec, c.Op.Journal["install_stamp"], stamp, c.Op.Journal["install_release"]) |
| + } |
| + pair(t, c) |
| + }) |
| + } |
| + t.Run("a nested pin's release replaces both together", func(t *testing.T) { |
| + s, o := relAtPhase(t, "probing+P") |
| + o, err := ReplaceTarget(ctx, s, o, "rel-1", putFence(t, s, relTarget), nil, Target{relSpare, relEnrollment(relSpare)}, "", relAuthority{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Nested pinning: the pin is gone, the last installation's pair is |
| + // kept together (op.exec is none, so nothing can stage under it). |
| + if o.Claim.Op.Phase != PhasePinning || o.Claim.Op.Exec != "none" || o.Claim.Op.Journal["pin"] != "" { |
| + t.Fatalf("not a nested pinning: %+v", o.Claim.Op) |
| + } |
| + pair(t, o.Claim) |
| + next := relPin(o, 40, 7, relObserved.Add(FailoverPinDelay)) |
| + next.Release = "v1.5.0" |
| + o = step(t, s, o, JournalPin("rel-1", next)) |
| + o = step(t, s, o, ack(relSpare)) |
| + o = toProbing(t, s, o, "P3", 40) |
| + if o.Claim.Op.Journal["install_release"] != "v1.5.0" || o.Claim.Op.Journal["install_stamp"] != strconv.FormatUint(o.Claim.Holder.Stamp, 10) { |
| + t.Fatalf("the new installation's pair: %q %q", o.Claim.Op.Journal["install_release"], o.Claim.Op.Journal["install_stamp"]) |
| + } |
| + pair(t, o.Claim) |
| + }) |
| + t.Run("the serving CAS drops both", func(t *testing.T) { |
| + s, o := relAtPhase(t, "probing+P") |
| + o = step(t, s, o, RecordProbe("rel-1", Probe{Box: relTarget, Session: o.Claim.Holder.Session})) |
| + o = step(t, s, o, CompleteRelocation("rel-1", counter())) |
| + pair(t, o.Claim) |
| + if o.Claim.Op.Journal["install_stamp"] != "" { |
| + t.Fatalf("serving kept the installation: %+v", o.Claim.Op) |
| + } |
| + }) |
| +} |
| + |
| +func cloneForTest(t *testing.T, c *Claim) *Claim { |
| + t.Helper() |
| + out, err := cloneClaim(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
diff --git a/internal/hostrelocate/pin.go b/internal/hostrelocate/pin.go| index 0bbd7492d..21d1ab0fd 100644 |
| --- a/internal/hostrelocate/pin.go |
| +++ b/internal/hostrelocate/pin.go |
| @@ -34,6 +34,9 @@ type PinObject struct { |
| Empty string `json:"empty,omitempty"` // "release" | "session-0": the evidence |
| Files FilesPin `json:"files"` |
| PinnedAt time.Time `json:"pinned_at"` |
| + // Release is the app release version the relocation installs, read |
| + // at pinning from StorePinner.Release (R9); a reuse carries it. |
| + Release string `json:"release"` |
| } |
| // FilesPin names one generation's files manifest by prefix, sequence and |
| @@ -75,10 +78,20 @@ type StorePinner struct { |
| SelectDB func(context.Context, genselect.Request) (*genselect.Plan, error) |
| Bucket string // the region's data bucket name, for genselect |
| Region string |
| + // Release is the app release version the host's relocation installs, |
| + // read from the region controller's authoritative registry route for |
| + // the host (registry.Route.Version, the route's deployed version) at |
| + // pinning (R9 on amadan #236). It is never read from the box, the |
| + // request or the box's install journal, and nothing else in the pin |
| + // supplies it: the installing CAS copies it to install_release, which |
| + // moves' held-stage relay requires the request's version to equal. |
| + // Stage B wires it to the controller's registry. Nil is ErrUnsupported; |
| + // an empty version refuses the pin, never a guess. |
| + Release func(ctx context.Context, host string) (string, error) |
| } |
| func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.Time) (hostclaim.RecoveryPin, error) { |
| - if p.Claims == nil || p.Data == nil || p.SelectDB == nil || p.Bucket == "" || p.Region == "" || obs.Claim == nil || obs.Claim.Holder == nil { |
| + if p.Claims == nil || p.Data == nil || p.SelectDB == nil || p.Release == nil || p.Bucket == "" || p.Region == "" || obs.Claim == nil || obs.Claim.Holder == nil { |
| return hostclaim.RecoveryPin{}, ErrUnsupported |
| } |
| c := obs.Claim |
| @@ -101,7 +114,14 @@ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.T |
| } |
| } |
| } |
| - obj := PinObject{V: 1, Host: c.Host, Op: c.Op.ID, ClaimGen: c.Gen, PinnedAt: now.UTC()} |
| + version, err := p.Release(ctx, c.Host) |
| + if err != nil { |
| + return hostclaim.RecoveryPin{}, err |
| + } |
| + if version == "" { |
| + return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the registry route for %s names no release", ErrRefused, c.Host) |
| + } |
| + obj := PinObject{V: 1, Host: c.Host, Op: c.Op.ID, ClaimGen: c.Gen, PinnedAt: now.UTC(), Release: version} |
| plan, err := p.SelectDB(ctx, genselect.Request{Bucket: p.Bucket, Region: p.Region, Host: c.Host, Holder: *c.Holder}) |
| switch { |
| case err == nil: |
| @@ -157,7 +177,7 @@ func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.T |
| func (p StorePinner) reuse(c *hostclaim.Claim, key string, raw []byte) (hostclaim.RecoveryPin, error) { |
| var obj PinObject |
| - if err := json.Unmarshal(raw, &obj); err != nil || obj.V != 1 || obj.Host != c.Host || obj.Op != c.Op.ID || obj.ClaimGen != c.Gen || (obj.DB == nil) == (obj.Empty == "") { |
| + if err := json.Unmarshal(raw, &obj); err != nil || obj.V != 1 || obj.Host != c.Host || obj.Op != c.Op.ID || obj.ClaimGen != c.Gen || (obj.DB == nil) == (obj.Empty == "") || obj.Release == "" { |
| return hostclaim.RecoveryPin{}, fmt.Errorf("%w: pin object %s is not this operation's at this generation", ErrRefused, key) |
| } |
| return pinRef(key, raw, obj), nil |
| @@ -165,7 +185,7 @@ func (p StorePinner) reuse(c *hostclaim.Claim, key string, raw []byte) (hostclai |
| func pinRef(key string, raw []byte, obj PinObject) hostclaim.RecoveryPin { |
| sum := sha256.Sum256(raw) |
| - ref := hostclaim.RecoveryPin{Key: key, SHA256: hex.EncodeToString(sum[:]), FilesManifest: hostfiles.ManifestKey(obj.Files.Prefix), FilesSeq: obj.Files.Seq, PinnedAt: obj.PinnedAt, Empty: obj.DB == nil} |
| + ref := hostclaim.RecoveryPin{Key: key, SHA256: hex.EncodeToString(sum[:]), FilesManifest: hostfiles.ManifestKey(obj.Files.Prefix), FilesSeq: obj.Files.Seq, PinnedAt: obj.PinnedAt, Empty: obj.DB == nil, Release: obj.Release} |
| if obj.DB != nil { |
| ref.Generation, ref.MaxTXID = obj.DB.Generation, obj.DB.MaxTXID |
| } |
diff --git a/internal/hostrelocate/pin_test.go b/internal/hostrelocate/pin_test.go| index ad3afdcf2..8dd446462 100644 |
| --- a/internal/hostrelocate/pin_test.go |
| +++ b/internal/hostrelocate/pin_test.go |
| @@ -57,7 +57,7 @@ func TestStorePinnerPinsTheNewestFilesManifestInTheChain(t *testing.T) { |
| putManifest(t, data, gen, keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: seq, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})) |
| } |
| selects := 0 |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", |
| SelectDB: func(ctx context.Context, req genselect.Request) (*genselect.Plan, error) { |
| selects++ |
| return selectAt(41)(ctx, req) |
| @@ -150,7 +150,7 @@ func TestStorePinnerChecksTheBoundsBeforeItWrites(t *testing.T) { |
| putManifest(t, data, "g3", keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: tc.seq, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})) |
| o := pinClaim() |
| o.Claim.Op.Journal = tc.journal |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(tc.txid)} |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(tc.txid)} |
| if _, err := p.Pin(ctx, o, tc.now); !errors.Is(err, ErrNotYet) { |
| t.Fatalf("got %v, want ErrNotYet", err) |
| } |
| @@ -186,7 +186,7 @@ func TestStorePinnerPinsEmptyOnlyWithEvidence(t *testing.T) { |
| claims, data := release_NewDirStore(t), release_NewDirStore(t) |
| o := pinClaim() |
| o.Claim.Holder.Session, o.Claim.Op.Journal = tc.session, tc.journal |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: empty} |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: empty} |
| pin, err := p.Pin(ctx, o, at) |
| if tc.want == "" { |
| if !errors.Is(err, genselect.ErrEmpty) { |
| @@ -211,7 +211,7 @@ func TestStorePinnerRefusesAnUnreadableManifest(t *testing.T) { |
| if _, err := data.Put(ctx, hostfiles.ManifestKey(pinLocation+"/files/g3/"), []byte(`{"v":1}`), ""); err != nil { |
| t.Fatal(err) |
| } |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| if _, err := p.Pin(ctx, pinClaim(), time.Now()); !errors.Is(err, ErrRefused) { |
| t.Fatalf("a version 1 manifest was pinned: %v", err) |
| } |
| @@ -385,7 +385,7 @@ func TestPinKeepsAFileOriginTheAdmissionListTrimmed(t *testing.T) { |
| } |
| obs := o |
| obs.Claim.Op.ID = "rel-1" |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| pin, err := p.Pin(ctx, obs, time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)) |
| if err != nil { |
| t.Fatal(err) |
| @@ -439,7 +439,7 @@ func TestPreparedManifestIsSequenceZeroAndTheSessionPublishesOne(t *testing.T) { |
| t.Fatal(err) |
| } |
| } |
| - pinner := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + 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 |
| t.Fatalf("pin: %+v %v", ref, err) |
| @@ -488,7 +488,7 @@ func TestStorePinnerRefusesAnEmptyFilesPinAfterAWriter(t *testing.T) { |
| claims, data := release_NewDirStore(t), release_NewDirStore(t) |
| o := pinClaim() |
| o.Claim.Holder.Session = tc.session |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)} |
| pin, err := p.Pin(ctx, o, at) |
| if !errors.Is(err, tc.want) { |
| t.Fatalf("session %d: %+v %v", tc.session, pin, err) |
| @@ -529,10 +529,89 @@ func TestStorePinnerRefusesAKeylessManifestForANonemptyDatabase(t *testing.T) { |
| putManifest(t, data, "g3", m) |
| o := pinClaim() |
| o.Claim.Op.Journal = tc.journal |
| - p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: tc.db} |
| + p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: tc.db} |
| if _, err := p.Pin(ctx, o, at); !errors.Is(err, tc.want) { |
| t.Fatalf("got %v, want %v", err, tc.want) |
| } |
| }) |
| } |
| } |
| + |
| +// routeRelease stands in for the region controller's registry route |
| +// (registry.Route.Version), the pinner's only source for a pin's release. |
| +func routeRelease(_ context.Context, h string) (string, error) { |
| + if h != host { |
| + return "", fmt.Errorf("asked for %s's route", h) |
| + } |
| + return "v1.4.2", nil |
| +} |
| + |
| +// R9: the pin's release is the registry route's, read at pinning and |
| +// nowhere else; a reuse carries the stored value; no release refuses. |
| +func TestStorePinnerPinsTheRouteRelease(t *testing.T) { |
| + ctx := context.Background() |
| + at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) |
| + claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + putManifest(t, data, "g3", keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})) |
| + var asked []string |
| + route := "v1.4.2" |
| + p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41), |
| + Release: func(_ context.Context, h string) (string, error) { asked = append(asked, h); return route, nil }} |
| + // Everything a box or a request could have written says otherwise; the |
| + // pinner never reads any of it. |
| + o := pinClaim() |
| + o.Claim.Op.Journal["install_release"] = "v-box" |
| + o.Claim.Op.Journal["local_install"] = `{"box":"tgt","release":"v-box"}` |
| + o.Claim.Op.Intent = map[string]string{"release": "v-request", "version": "v-request"} |
| + pin, err := p.Pin(ctx, o, at) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if pin.Release != "v1.4.2" || len(asked) != 1 || asked[0] != host { |
| + t.Fatalf("release %q, route asked for %v", pin.Release, asked) |
| + } |
| + if obj, err := ReadPin(ctx, claims, pin); err != nil || obj.Release != "v1.4.2" { |
| + t.Fatalf("object: %+v %v", obj, err) |
| + } |
| + // The route moves on; a retry at the same generation reuses the stored |
| + // object, its release included, and does not ask the route again. |
| + route = "v1.5.0" |
| + again, err := p.Pin(ctx, o, at.Add(time.Minute)) |
| + if err != nil || again != pin || again.Release != "v1.4.2" || len(asked) != 1 { |
| + t.Fatalf("reuse: %+v %v asked %v", again, err, asked) |
| + } |
| + // A route with no version, or an unreadable route, refuses before |
| + // anything is written. |
| + for name, rel := range map[string]func(context.Context, string) (string, error){ |
| + "empty": func(context.Context, string) (string, error) { return "", nil }, |
| + "unreadable": func(context.Context, string) (string, error) { return "", errors.New("registry unavailable") }, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir()) |
| + putManifest(t, data, "g3", keyed(hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 5, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}})) |
| + p := StorePinner{Claims: claims, Data: data, Bucket: "data", Region: "ie", SelectDB: selectAt(41), Release: rel} |
| + _, err := p.Pin(ctx, pinClaim(), at) |
| + if err == nil || (name == "empty" && !errors.Is(err, ErrRefused)) { |
| + t.Fatalf("pinned without a release: %v", err) |
| + } |
| + if keys, lerr := claims.List(ctx, "control/"); lerr != nil || len(keys) != 0 { |
| + t.Fatalf("a refused pin wrote %v (%v)", keys, lerr) |
| + } |
| + }) |
| + } |
| + // No route at all is unsupported, as every other missing provider is. |
| + p.Release = nil |
| + if _, err := p.Pin(ctx, o, at); !errors.Is(err, ErrUnsupported) { |
| + t.Fatalf("no release provider: %v", err) |
| + } |
| + // A stored object without a release is not this pinner's: refused. |
| + bare := pinClaim() |
| + bare.Claim.Gen = 20 |
| + if _, err := claims.Put(ctx, PinKey(host, "rel-1", 20), []byte(`{"v":1,"host":"`+host+`","op":"rel-1","claim_gen":20,"empty":"session-0","files":{}}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + p.Release = routeRelease |
| + if _, err := p.Pin(ctx, bare, at); !errors.Is(err, ErrRefused) { |
| + t.Fatalf("a pin object with no release was reused: %v", err) |
| + } |
| +} |
diff --git a/internal/hostrelocate/worker_test.go b/internal/hostrelocate/worker_test.go| index e5357f053..846ac24ac 100644 |
| --- a/internal/hostrelocate/worker_test.go |
| +++ b/internal/hostrelocate/worker_test.go |
| @@ -95,7 +95,7 @@ func (p pins) Pin(_ context.Context, o hostclaim.Observed, now time.Time) (hostc |
| var r hostclaim.Release |
| _ = json.Unmarshal([]byte(o.Claim.Op.Journal["release"]), &r) |
| return hostclaim.RecoveryPin{Key: PinKey(o.Claim.Host, o.Claim.Op.ID, o.Claim.Gen), SHA256: strings.Repeat("a", 64), Generation: o.Claim.Holder.Generation, |
| - MaxTXID: max(r.FinalTXID, 40), FilesManifest: "m", FilesSeq: max(r.ManifestSeq, 7), PinnedAt: now}, nil |
| + MaxTXID: max(r.FinalTXID, 40), FilesManifest: "m", FilesSeq: max(r.ManifestSeq, 7), PinnedAt: now, Release: "v1.4.2"}, nil |
| } |
| func newFixture(t *testing.T) *fixture { |