hostclaim: a malformed journal counter or stamp, replacements included, is an error and never 0, and the relocation worker refuses on it before it looks for a spare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+141
−23
internal/hostclaim/relocation.go+56 −10internal/hostclaim/relocation_test.go+39 −6internal/hostrelocate/worker.go+16 −7internal/hostrelocate/worker_test.go+30 −0
diff --git a/internal/hostclaim/relocation.go b/internal/hostclaim/relocation.go| index 5238422c9..0c27547b1 100644 |
| --- a/internal/hostclaim/relocation.go |
| +++ b/internal/hostclaim/relocation.go |
| @@ -238,9 +238,29 @@ func readJournal(op Op, key string, v any) bool { |
| return ok && json.Unmarshal([]byte(raw), v) == nil |
| } |
| -func journalUint(op Op, key string) uint64 { |
| - n, _ := strconv.ParseUint(op.Journal[key], 10, 64) |
| - return n |
| +// journalUint is a journaled counter or stamp: absent is 0, its starting |
| +// value; a value that does not parse is an error, never 0 (absence or |
| +// malformed data is never evidence). |
| +func journalUint(op Op, key string) (uint64, error) { |
| + raw, ok := op.Journal[key] |
| + if !ok || raw == "" { |
| + return 0, nil |
| + } |
| + n, err := strconv.ParseUint(raw, 10, 64) |
| + if err != nil { |
| + return 0, fmt.Errorf("%w: journal %s is malformed: %q", ErrRelocation, key, raw) |
| + } |
| + return n, nil |
| +} |
| + |
| +// Replacements is the number of target replacements the operation has |
| +// used (§5.8's bound, MaxTargetReplacements). A malformed count is an |
| +// error, never 0. |
| +func Replacements(c *Claim) (uint64, error) { |
| + if c == nil { |
| + return 0, ErrMalformed |
| + } |
| + return journalUint(c.Op, "replacements") |
| } |
| func requireRelocation(c *Claim, opID string, phases ...string) error { |
| @@ -844,8 +864,12 @@ func ToInstalled(opID string, a PreparedAuthority) func(*Claim) error { |
| if err := requireAcks(c); err != nil { |
| return err |
| } |
| + stamp, err := journalUint(c.Op, "install_stamp") |
| + if err != nil { |
| + return err |
| + } |
| c.Op.Phase = PhaseInstalled |
| - a.InstallStamp = journalUint(c.Op, "install_stamp") |
| + a.InstallStamp = stamp |
| return journalJSON(c, "prepared_authority", a) |
| } |
| } |
| @@ -881,8 +905,12 @@ func RecordLocalInstall(opID string, l LocalInstall) func(*Claim) error { |
| if err := requireRelocation(c, opID, PhaseInstalled); err != nil { |
| return err |
| } |
| + stamp, err := journalUint(c.Op, "install_stamp") |
| + if err != nil { |
| + return err |
| + } |
| if l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] || |
| - l.InstallStamp == 0 || l.InstallStamp != journalUint(c.Op, "install_stamp") || !isHex64(l.JournalSHA256) { |
| + l.InstallStamp == 0 || l.InstallStamp != stamp || !isHex64(l.JournalSHA256) { |
| return fmt.Errorf("%w: receipt is not this installation's", ErrRelocation) |
| } |
| if preparedOpened(c) { |
| @@ -907,9 +935,13 @@ func ToProbing(opID string, now time.Time) func(*Claim) error { |
| if err := requireRelocation(c, opID, PhaseInstalled); err != nil { |
| return err |
| } |
| + stamp, err := journalUint(c.Op, "install_stamp") |
| + if err != nil { |
| + return err |
| + } |
| var l LocalInstall |
| if !readJournal(c.Op, "local_install", &l) || l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] || |
| - l.InstallStamp != journalUint(c.Op, "install_stamp") { |
| + l.InstallStamp != stamp { |
| return fmt.Errorf("%w: no local install receipt for the executor", ErrRelocation) |
| } |
| if err := requireAcks(c); err != nil { |
| @@ -938,7 +970,10 @@ func BeginProbeAttempt(opID, box string) func(*Claim) error { |
| return fmt.Errorf("%w: only the executor probes", ErrRelocation) |
| } |
| key := "probe_attempts." + box |
| - n := journalUint(c.Op, key) |
| + n, err := journalUint(c.Op, key) |
| + if err != nil { |
| + return err |
| + } |
| if n >= MaxProbeAttempts { |
| return ErrProbeAttemptsExhausted |
| } |
| @@ -953,8 +988,12 @@ func RecordProbe(opID string, p Probe) func(*Claim) error { |
| if err := requireRelocation(c, opID, PhaseProbing); err != nil { |
| return err |
| } |
| + stamp, err := journalUint(c.Op, "install_stamp") |
| + if err != nil { |
| + return err |
| + } |
| if p.Box != c.Op.Exec || p.Box != c.Holder.WriterBox || p.Session == 0 || p.Session != c.Holder.Session || |
| - p.Session < journalUint(c.Op, "install_stamp") { |
| + p.Session < stamp { |
| return fmt.Errorf("%w: probe is not of the executor's admitted session", ErrRelocation) |
| } |
| return journalJSON(c, "probe", p) |
| @@ -1060,7 +1099,10 @@ func replaceTargetSlot(c *Claim, t Target) error { |
| } |
| old := c.Op.Intent["target_box"] |
| if old != "" && old != t.Box { |
| - n := journalUint(c.Op, "replacements") |
| + n, err := journalUint(c.Op, "replacements") |
| + if err != nil { |
| + return err |
| + } |
| if n >= MaxTargetReplacements { |
| return ErrReplacementsExceeded |
| } |
| @@ -1197,7 +1239,11 @@ func fenceTargetStuck(ctx context.Context, s release.Store, observed Observed, o |
| } |
| reason := noSpare |
| if reason == "" { |
| - if journalUint(c.Op, "replacements") < MaxTargetReplacements { |
| + n, err := journalUint(c.Op, "replacements") |
| + if err != nil { |
| + return err |
| + } |
| + if n < MaxTargetReplacements { |
| return fmt.Errorf("%w: replacements remain; replace the target instead", ErrRelocation) |
| } |
| reason = fmt.Sprintf("%s terminated after %d target replacements", old, MaxTargetReplacements) |
diff --git a/internal/hostclaim/relocation_test.go b/internal/hostclaim/relocation_test.go| index 98bf47e02..c7579dcb0 100644 |
| --- a/internal/hostclaim/relocation_test.go |
| +++ b/internal/hostclaim/relocation_test.go |
| @@ -107,8 +107,17 @@ func relPrepared(gen string, maxTXID uint64, filesSeq int64) PreparedAuthority { |
| return a |
| } |
| +// journalN is journalUint for a journal the test's own transitions wrote. |
| +func journalN(op Op, key string) uint64 { |
| + n, err := journalUint(op, key) |
| + if err != nil { |
| + panic(err) |
| + } |
| + return n |
| +} |
| + |
| func localInstall(c *Claim) LocalInstall { |
| - return LocalInstall{Box: c.Op.Exec, Enrollment: relEnrollment(c.Op.Exec), InstallStamp: journalUint(c.Op, "install_stamp"), FilesManifestVersion: 3, JournalSHA256: strings.Repeat("b", 64)} |
| + return LocalInstall{Box: c.Op.Exec, Enrollment: relEnrollment(c.Op.Exec), InstallStamp: journalN(c.Op, "install_stamp"), FilesManifestVersion: 3, JournalSHA256: strings.Repeat("b", 64)} |
| } |
| // toProbing drives an operation at pinning (with its pin) through |
| @@ -182,7 +191,7 @@ func TestRelocationDrainEndToEnd(t *testing.T) { |
| o = step(t, s, o, ToInstalled("rel-1", relPrepared("P1", 40, 7))) |
| o = step(t, s, o, RecordLocalInstall("rel-1", localInstall(o.Claim))) |
| o = step(t, s, o, ToProbing("rel-1", relObserved)) |
| - install := journalUint(o.Claim.Op, "install_stamp") |
| + install := journalN(o.Claim.Op, "install_stamp") |
| if o.Claim.Op.Exec != relTarget || o.Claim.Holder.Stamp != install || !slices.Contains(o.Claim.Touched.Boxes, relTarget) { |
| t.Fatalf("installation: exec %s stamp %d install %d", o.Claim.Op.Exec, o.Claim.Holder.Stamp, install) |
| } |
| @@ -417,7 +426,7 @@ func TestRelocationTwoReplacementsThenStuck(t *testing.T) { |
| t.Fatalf("third replacement: %v", err) |
| } |
| o = step(t, s, o, MarkStuck("rel-1", "two target replacements used")) |
| - if journalUint(o.Claim.Op, "replacements") != 2 { |
| + if journalN(o.Claim.Op, "replacements") != 2 { |
| t.Fatal("stuck reset the count") |
| } |
| if _, err := Update(ctx, s, o, ToInstalling("rel-1", Target{relSpare2, relEnrollment(relSpare2)}, "P9")); !errors.Is(err, ErrRelocation) { |
| @@ -425,6 +434,30 @@ func TestRelocationTwoReplacementsThenStuck(t *testing.T) { |
| } |
| } |
| +// Final review: a malformed replacements count is an error, never 0 |
| +// (absence or malformed data is never evidence). Absent is 0. |
| +func TestRelocationMalformedReplacementsRefuses(t *testing.T) { |
| + ctx := context.Background() |
| + s, o := relAtPhase(t, PhasePinning) |
| + if n, err := Replacements(o.Claim); err != nil || n != 0 { |
| + t.Fatalf("absent: %d %v", n, err) |
| + } |
| + o = step(t, s, o, func(c *Claim) error { c.Op.Journal["replacements"] = "two"; return nil }) |
| + if _, err := Replacements(o.Claim); !errors.Is(err, ErrRelocation) { |
| + t.Fatalf("malformed count: %v", err) |
| + } |
| + if _, err := ReplaceTarget(ctx, s, o, "rel-1", putFence(t, s, relTarget), nil, Target{relSpare, relEnrollment(relSpare)}, "", relAuthority{}); !errors.Is(err, ErrRelocation) || errors.Is(err, ErrReplacementsExceeded) { |
| + t.Fatalf("a replacement over a malformed count: %v", err) |
| + } |
| + after, err := Read(ctx, s, o.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.ETag != o.ETag || after.Claim.Op.Intent["target_box"] != relTarget { |
| + t.Fatalf("the claim moved: %+v", after.Claim.Op) |
| + } |
| +} |
| + |
| func TestRelocationReplacementInReleasingAcknowledgesBeforePinning(t *testing.T) { |
| ctx := context.Background() |
| s, o := relAtPhase(t, PhaseReleasing) |
| @@ -727,7 +760,7 @@ func TestRelocationProbeAttemptsAreBounded(t *testing.T) { |
| for i := 0; i < MaxProbeAttempts; i++ { |
| o = step(t, s, o, BeginProbeAttempt("rel-1", relTarget)) |
| } |
| - if journalUint(o.Claim.Op, "probe_attempts."+relTarget) != MaxProbeAttempts { |
| + if journalN(o.Claim.Op, "probe_attempts."+relTarget) != MaxProbeAttempts { |
| t.Fatalf("count: %v", o.Claim.Op.Journal) |
| } |
| if _, err := Update(ctx, s, o, BeginProbeAttempt("rel-1", relTarget)); !errors.Is(err, ErrProbeAttemptsExhausted) { |
| @@ -741,7 +774,7 @@ func TestRelocationProbeAttemptsAreBounded(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if journalUint(o.Claim.Op, "probe_attempts."+relSpare) != 0 { |
| + if journalN(o.Claim.Op, "probe_attempts."+relSpare) != 0 { |
| t.Fatal("the replacement inherited attempts") |
| } |
| } |
| @@ -1016,7 +1049,7 @@ func TestRelocationPreparedManifestIsSequenceZero(t *testing.T) { |
| o = step(t, s, o, ToInstalled("rel-1", relPrepared("P1", 40, 7))) |
| var a PreparedAuthority |
| readJournal(o.Claim.Op, "prepared_authority", &a) |
| - if a.FilesSeq != 0 || a.SourceFilesSeq != 7 || a.InstallStamp == 0 || a.InstallStamp != journalUint(o.Claim.Op, "install_stamp") { |
| + if a.FilesSeq != 0 || a.SourceFilesSeq != 7 || a.InstallStamp == 0 || a.InstallStamp != journalN(o.Claim.Op, "install_stamp") { |
| t.Fatalf("journaled authority: %+v", a) |
| } |
| } |
diff --git a/internal/hostrelocate/worker.go b/internal/hostrelocate/worker.go| index 0768f578b..1614e27c8 100644 |
| --- a/internal/hostrelocate/worker.go |
| +++ b/internal/hostrelocate/worker.go |
| @@ -20,7 +20,6 @@ import ( |
| "fmt" |
| "os" |
| "slices" |
| - "strconv" |
| "time" |
| "github.com/carlosframework/platform/internal/hostactors" |
| @@ -353,7 +352,9 @@ func (w Worker) Step(ctx context.Context, host string) (hostclaim.Observed, erro |
| if c.Op.Journal["release"] == "" { |
| return w.releaseOverdue(ctx, obs, "probe_failed_since") |
| } |
| - if exhausted(c) { |
| + if done, err := exhausted(c); err != nil { |
| + return obs, err |
| + } else if done { |
| return w.stuckOn(ctx, obs, hostclaim.ErrReplacementsExceeded) |
| } |
| next, err := w.next(ctx, obs) |
| @@ -503,7 +504,11 @@ func (w Worker) replace(ctx context.Context, obs hostclaim.Observed, ref hostcla |
| // with the replacements used, no spare is looked for; the dead |
| // target's fence is consumed into a stuck pinning (the loss retained, |
| // its installation's authority revoked, no probe to wait for). |
| - if exhausted(obs.Claim) { |
| + done, err := exhausted(obs.Claim) |
| + if err != nil { |
| + return obs, err |
| + } |
| + if done { |
| out, err := hostclaim.FenceExhaustedTarget(ctx, w.Claims, obs, obs.Claim.Op.ID, ref, d, w.Fences) |
| if err == nil { |
| w.Notify(ctx) |
| @@ -550,10 +555,14 @@ func (w Worker) update(ctx context.Context, obs hostclaim.Observed, tr func(*hos |
| return hostclaim.Update(ctx, w.Claims, obs, tr) |
| } |
| -// exhausted reports whether the operation has used its target replacements. |
| -func exhausted(c *hostclaim.Claim) bool { |
| - n, _ := strconv.ParseUint(c.Op.Journal["replacements"], 10, 64) |
| - return n >= hostclaim.MaxTargetReplacements |
| +// exhausted reports whether the operation has used its target |
| +// replacements. A malformed count is an error, never 0. |
| +func exhausted(c *hostclaim.Claim) (bool, error) { |
| + n, err := hostclaim.Replacements(c) |
| + if err != nil { |
| + return false, err |
| + } |
| + return n >= hostclaim.MaxTargetReplacements, nil |
| } |
| // acked is the D6 barrier as the worker reads it before a CAS that needs it. |
diff --git a/internal/hostrelocate/worker_test.go b/internal/hostrelocate/worker_test.go| index 514dab20d..408aba755 100644 |
| --- a/internal/hostrelocate/worker_test.go |
| +++ b/internal/hostrelocate/worker_test.go |
| @@ -549,6 +549,36 @@ func TestWorkerMarksExhaustionStuckBeforeLookingForASpare(t *testing.T) { |
| } |
| } |
| +// Final review: a malformed replacements count is an error, never 0. The |
| +// worker refuses before it looks for a spare, and the claim is unchanged. |
| +func TestWorkerRefusesAMalformedReplacementsCount(t *testing.T) { |
| + f := newFixture(t) |
| + if _, err := f.w.Start(context.Background(), host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + f.participant(func(c *hostclaim.Claim) error { |
| + if c.Op.Journal == nil { |
| + c.Op.Journal = map[string]string{} |
| + } |
| + c.Op.Journal["replacements"] = "two" |
| + return nil |
| + }) |
| + chose := 0 |
| + f.w.Choose = func(context.Context, hostclaim.Observed, []string) (hostclaim.Target, error) { |
| + chose++ |
| + return hostclaim.Target{}, errors.New("no eligible box") |
| + } |
| + f.now = f.now.Add(ReleaseDeadline + time.Minute) |
| + f.terminate("dst") |
| + before := f.claim() |
| + if _, err := f.w.Step(context.Background(), host); !errors.Is(err, hostclaim.ErrRelocation) { |
| + t.Fatalf("step over a malformed count: %v", err) |
| + } |
| + if after := f.claim(); after.ETag != before.ETag || chose != 0 { |
| + t.Fatalf("the claim moved (%v) or a spare was looked for (%d)", after.ETag != before.ETag, chose) |
| + } |
| +} |
| + |
| func TestWorkerFencesAnExhaustedTargetThatDiedAfterItsLocalInstall(t *testing.T) { |
| f := newFixture(t) |
| ctx := context.Background() |