host: committed recovery after a restarted probe; the owner's input policy (Plan 3 Task 7 fix round 1, finding 1; claim Plan 10 rev 5)
A fresh generation's fenced publication first proves the files on disk are the landing's (the install receipt's identities), then refreshes .files-gen to the published generation under the admitted writer's fence; a mismatch fails the publication and refreshes nothing. Task 4's start check is unchanged, and a committed wake after a restarted probe, and repeated wakes, now pass it. The owner's InputPolicy, the entry point the claim's dispatcher routes move kinds to, binds the committed wake's spawn to Move.Inputs; the committed wake launches the pinned inputs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+338
−15
internal/host/movesprobeowners.go+123 −8internal/host/movesprobeowners_committed_test.go+186 −2internal/host/movesprobeowners_fix1_test.go+9 −5internal/host/movesprobeowners_test.go+20 −0
diff --git a/internal/host/movesprobeowners.go b/internal/host/movesprobeowners.go| index a51391589..66afde813 100644 |
| --- a/internal/host/movesprobeowners.go |
| +++ b/internal/host/movesprobeowners.go |
| @@ -725,12 +725,7 @@ func (m *movesProbeOwners) Materialize(ctx context.Context, o hostclaim.Observed |
| if envDigest(m.EnvKey, in.Env) != pins.EnvReport { |
| return probeInputsChanged() |
| } |
| - m.mu.Lock() |
| - if m.fences == nil { |
| - m.fences = map[string]hostwrite.Work{} |
| - } |
| - m.fences[claimPublishedKey(o.Host, h.Session)] = work |
| - m.mu.Unlock() |
| + m.recordFence(o.Host, h.Session, work) |
| receipt, err := m.preparedMarker(ctx, o.Host, v, fence) |
| if err != nil { |
| return err |
| @@ -934,6 +929,18 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| if in.Prepared || h.Generation == v.op { |
| return ClaimPublicationReceipt{}, hostbox.ErrChanged |
| } |
| + // The admitted writer's spawn selection, recorded by Materialize: the |
| + // fence the local files marker is refreshed under (fix round 1). |
| + m.mu.Lock() |
| + work := m.fences[claimPublishedKey(o.Host, h.Session)] |
| + m.mu.Unlock() |
| + if work == nil { |
| + return ClaimPublicationReceipt{}, fmt.Errorf("%w: no admitted writer's fence for session %d", hostbox.ErrChanged, h.Session) |
| + } |
| + // The files on disk must be the landing's before anything is published. |
| + if err := work.With(ctx, in.Identity, func() error { return m.verifyFiles(o.Host, v) }); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| if err := pub.Sync(ctx); err != nil { |
| return ClaimPublicationReceipt{}, err |
| } |
| @@ -958,6 +965,9 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| return ClaimPublicationReceipt{}, hostbox.ErrChanged |
| } |
| receipt := ClaimPublicationReceipt{MarkerVersion: current.ID, Root: root} |
| + if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| m.record(o.Host, h.Session, receipt) |
| return receipt, nil |
| } |
| @@ -980,10 +990,74 @@ func (m *movesProbeOwners) PublishFenced(ctx context.Context, o hostclaim.Observ |
| return ClaimPublicationReceipt{}, hostwrite.ErrUnavailable |
| } |
| receipt := ClaimPublicationReceipt{MarkerVersion: version, Root: root} |
| + if err := m.refreshFilesGen(ctx, o.Host, v, work, in.Identity); err != nil { |
| + return ClaimPublicationReceipt{}, err |
| + } |
| m.record(o.Host, h.Session, receipt) |
| return receipt, nil |
| } |
| +// recordFence keeps the admitted spawn selection Materialize ran under, by |
| +// host and session: the writer's fence for that session's marker writes and |
| +// its files marker refresh. |
| +func (m *movesProbeOwners) recordFence(host string, session uint64, work hostwrite.Work) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if m.fences == nil { |
| + m.fences = map[string]hostwrite.Work{} |
| + } |
| + m.fences[claimPublishedKey(host, session)] = work |
| +} |
| + |
| +// verifyFiles requires the files directory and <host>.env on disk to be the |
| +// ones the landing placed: the install receipt's identities |
| +// (landStepFilesPlaced). Anything else fails the publication. |
| +func (m *movesProbeOwners) verifyFiles(host string, v probeLanding) error { |
| + j, err := moves.ReadJournal(m.DataDir, host, v.op) |
| + if err != nil { |
| + return err |
| + } |
| + placed, err := journalledStaged(j, landStepFilesPlaced) |
| + if err != nil { |
| + return fmt.Errorf("%w: %v", hostbox.ErrChanged, err) |
| + } |
| + dir, env := m.FilesDir(host) |
| + if ok, err := sameIdentity(dir, placed.Files); err != nil || !ok { |
| + return fmt.Errorf("%w: %s is not the files directory the landing placed (%v)", hostbox.ErrChanged, dir, err) |
| + } |
| + if placed.Env != "" { |
| + if ok, err := sameIdentity(env, placed.Env); err != nil || !ok { |
| + return fmt.Errorf("%w: %s is not the environment file the landing placed (%v)", hostbox.ErrChanged, env, err) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// refreshFilesGen points .files-gen at the published generation (fix round |
| +// 1, finding 1), inside the admitted writer's fence, after the files on disk |
| +// are proved the landing's again: the files are the same bytes the move |
| +// installed, and the generation that now owns them is the one just |
| +// published. Task 4's start check is unchanged: the next admission's cold |
| +// predecessor is this generation. <dbPath>.gen needs no refresh: every cold |
| +// restore rewrites it to the admitted generation (H11). |
| +func (m *movesProbeOwners) refreshFilesGen(ctx context.Context, host string, v probeLanding, work hostwrite.Work, id hostwrite.Identity) error { |
| + h := v.holder |
| + marker, err := json.Marshal(moves.FilesGen{Op: h.Generation, Incarnation: h.Incarnation}) |
| + if err != nil { |
| + return err |
| + } |
| + dir, _ := m.FilesDir(host) |
| + return work.With(ctx, id, func() error { |
| + if err := m.verifyFiles(host, v); err != nil { |
| + return err |
| + } |
| + if err := syncPath(dir); err != nil { |
| + return err |
| + } |
| + return writeFileAtomic(filepath.Join(dir, archive.MarkerName), marker, 0o644) |
| + }) |
| +} |
| + |
| func (m *movesProbeOwners) record(host string, session uint64, receipt ClaimPublicationReceipt) { |
| m.mu.Lock() |
| defer m.mu.Unlock() |
| @@ -1110,10 +1184,16 @@ func (m *movesProbeOwners) selectCommitted(ctx context.Context, rt registry.Rout |
| if err != nil { |
| return ClaimLaunchInputs{}, err |
| } |
| - if l.Identity.Config == "" || l.Identity.Release == "" || !filepath.IsAbs(l.Executable) || !claimHexDigest(l.ExecutableSHA256) || l.Version == "" { |
| + if !filepath.IsAbs(l.Executable) || !claimHexDigest(l.ExecutableSHA256) || l.Version == "" { |
| return ClaimLaunchInputs{}, hostwrite.ErrUnavailable |
| } |
| - return ClaimLaunchInputs{Roles: []string{"main"}, Identity: l.Identity, Baseline: plan, Admission: m.committedAdmission(o, v, gen, plan), |
| + // The committed wake runs the move's pinned inputs (InputPolicy): the |
| + // live release must be the pinned one. |
| + pins := v.rec.Move.Inputs |
| + if l.Version != pins.Release { |
| + return ClaimLaunchInputs{}, fmt.Errorf("%w: the live release is not the move's pinned one", hostbox.ErrChanged) |
| + } |
| + return ClaimLaunchInputs{Roles: []string{"main"}, Identity: launchIdentity(pins), Baseline: plan, Admission: m.committedAdmission(o, v, gen, plan), |
| Executable: l.Executable, ExecutableSHA256: l.ExecutableSHA256, Args: append([]string(nil), l.Args...), Env: withoutProbeVars(l.Env), |
| DBPath: rt.DBPath, Version: l.Version, Limit: l.Limit}, nil |
| } |
| @@ -1137,9 +1217,13 @@ func (m *movesProbeOwners) committedEvidence(ctx context.Context, o hostclaim.Ob |
| } |
| func (m *movesProbeOwners) materializeCommitted(ctx context.Context, o hostclaim.Observed, v probeLanding, in ClaimLaunchInputs, work hostwrite.Work) error { |
| + if in.Identity != launchIdentity(v.rec.Move.Inputs) { |
| + return hostbox.ErrChanged |
| + } |
| if err := writerFence(work, in.Identity)(ctx); err != nil { |
| return err |
| } |
| + m.recordFence(o.Host, v.holder.Session, work) |
| rt, err := m.Route(o.Host) |
| if err != nil { |
| return err |
| @@ -1152,3 +1236,34 @@ func (m *movesProbeOwners) materializeCommitted(ctx context.Context, o hostclaim |
| } |
| return m.startCheck(ctx, o, in) |
| } |
| + |
| +// InputPolicy is the moves owner's input policy: the entry point claim Plan |
| +// 10 rev 5's ClaimOwnerDispatch routes a move kind's input check to (the |
| +// shape of ClaimOwnerBindings.InputPolicy; a move kind never falls back to |
| +// serving's inputs). It answers the committed wake's spawn (Plan 3 Task 7): |
| +// at landing/committed the launch identity must be the move's pins', |
| +// HeldConfigIdentity(Move.Inputs) and Move.Inputs.Release, over a committed |
| +// record of this move, as MovesClaimPolicy binds the landing's rows. The |
| +// other move-kind inputs are MovesClaimPolicy's own rows; anything else is |
| +// refused here. |
| +func (m *movesProbeOwners) InputPolicy(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection, id hostwrite.Identity) error { |
| + if m == nil || m.Records == nil || ctx == nil { |
| + return hostwrite.ErrUnavailable |
| + } |
| + if c == nil || c.Holder == nil || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseLandingCommitted || |
| + s.Phase != hostclaim.PhaseLandingCommitted || s.Action != movesActionSpawn || s.Op != c.Op.ID || s.Host != c.Host { |
| + return hostbox.ErrChanged |
| + } |
| + rec, err := m.Records(ctx, c.Holder.Account, c.Holder.App, c.Host) |
| + if err != nil { |
| + return err |
| + } |
| + if rec == nil || rec.Host != c.Host || rec.Move == nil || rec.Move.ID != c.Op.ID || rec.Move.Incarnation != c.Holder.Incarnation || |
| + rec.Move.State != provision.MoveCommitted || rec.Move.Inputs == nil || rec.Move.Inputs.Release == "" { |
| + return hostbox.ErrChanged |
| + } |
| + if id != launchIdentity(rec.Move.Inputs) { |
| + return fmt.Errorf("%w: the committed launch is not the move's pinned inputs", hostbox.ErrChanged) |
| + } |
| + return nil |
| +} |
diff --git a/internal/host/movesprobeowners_committed_test.go b/internal/host/movesprobeowners_committed_test.go| index d9d1fbb2d..c605085e6 100644 |
| --- a/internal/host/movesprobeowners_committed_test.go |
| +++ b/internal/host/movesprobeowners_committed_test.go |
| @@ -2,14 +2,22 @@ package host |
| import ( |
| "context" |
| + "encoding/json" |
| "errors" |
| + "os" |
| + "path/filepath" |
| "strings" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/genselect" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/superfly/ltx" |
| ) |
| @@ -55,8 +63,10 @@ func TestMovesProbeOwnersCommittedWake(t *testing.T) { |
| if in.Prepared || in.Baseline == nil || in.Baseline.Generation != poOp || in.Baseline.MaxTXID != 42 || a.RemoteMaxTXID != 42 { |
| t.Fatalf("the committed baseline: %+v", in.Baseline) |
| } |
| - if in.Identity != f.launch.Identity { |
| - t.Fatalf("the committed wake's identity %+v, want the launch's %+v", in.Identity, f.launch.Identity) |
| + // Bound to the move's pins (platform-1e, claim Plan 10 rev 5): the |
| + // committed wake launches the inputs the move pinned. |
| + if in.Identity != launchIdentity(f.rec.Move.Inputs) { |
| + t.Fatalf("the committed wake's identity %+v, want the pins' %+v", in.Identity, launchIdentity(f.rec.Move.Inputs)) |
| } |
| for _, kv := range in.Env { |
| if strings.HasPrefix(kv, "CARLOS_MOVE_PROBE=") { |
| @@ -95,3 +105,177 @@ func TestMovesProbeOwnersCommittedWake(t *testing.T) { |
| t.Fatal("a committed wake over a landing record") |
| } |
| } |
| + |
| +// filesGen reads the local .files-gen. |
| +func (f *poFixture) filesGen() moves.FilesGen { |
| + f.t.Helper() |
| + raw, err := os.ReadFile(filepath.Join(f.files, archive.MarkerName)) |
| + if err != nil { |
| + f.t.Fatal(err) |
| + } |
| + var fg moves.FilesGen |
| + if err := json.Unmarshal(raw, &fg); err != nil { |
| + f.t.Fatal(err) |
| + } |
| + return fg |
| +} |
| + |
| +// restore is ColdRestore's local effect on a fresh admission: <dbPath>.gen |
| +// names the admitted generation. |
| +func (f *poFixture) restore(gen string) { |
| + f.t.Helper() |
| + if err := hostbox.WriteGenerationMarker(f.route.DBPath+".gen", gen); err != nil { |
| + f.t.Fatal(err) |
| + } |
| +} |
| + |
| +// publishFresh puts the fresh generation's first root and publishes it |
| +// fenced, as the claim's sequence does once the session's root is durable. |
| +func (f *poFixture) publishFresh(admitted hostclaim.Observed, in ClaimLaunchInputs) error { |
| + f.t.Helper() |
| + gen := admitted.Claim.Holder.Generation |
| + f.bucket.Put("data", "instances/"+poHost+"/g-"+gen+"/0009/"+ltx.FormatFilename(1, 44), f.ltxHeader(1, 44)) |
| + pub := ClaimPublication{Sync: func(context.Context) error { return nil }, Check: func(context.Context) error { return nil }} |
| + _, err := f.owners.PublishFenced(f.t.Context(), admitted, in, pub) |
| + return err |
| +} |
| + |
| +// restartedProbePublished runs a dead first attempt and a restarted attempt |
| +// that passes and publishes its fresh generation. |
| +func restartedProbePublished(t *testing.T) (*poFixture, hostclaim.Observed, ClaimLaunchInputs) { |
| + t.Helper() |
| + f := newPOFixture(t) |
| + ctx := t.Context() |
| + first, in := f.selectAndAdmit() |
| + if err := f.owners.Materialize(ctx, first, in, newFenceWork()); err != nil { |
| + t.Fatal(err) |
| + } |
| + again, err := f.owners.Select(ctx, f.route, first, poProbe) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + restarted := f.admit(first, again) |
| + f.restore(restarted.Claim.Holder.Generation) |
| + if err := f.owners.Materialize(ctx, restarted, again, newFenceWork()); err != nil { |
| + t.Fatalf("the restarted probe's start: %v", err) |
| + } |
| + return f, restarted, again |
| +} |
| + |
| +// commit moves the fixture to the commit: the claim at landing/committed, |
| +// the record committed, the row open. |
| +func (f *poFixture) commit() hostclaim.Observed { |
| + f.t.Helper() |
| + c := *f.read().Claim |
| + c.Op.Phase = hostclaim.PhaseLandingCommitted |
| + f.putClaim(&c) |
| + f.rec.Move.State, f.rec.Move.LandingPhase = provision.MoveCommitted, "" |
| + f.route.Status, f.route.HoldOp = "ready", "" |
| + return f.read() |
| +} |
| + |
| +// Task 7 fix round 1, finding 1: a restarted probe's successful publication |
| +// refreshes .files-gen to the published generation, under the writer's |
| +// fence, after the files on disk are proved the landing's. After the commit, |
| +// a process death and a wake, and then several more, each start passes |
| +// Task 4's unweakened start check. |
| +func TestMovesProbeOwnersRestartedProbeThenCommittedWakes(t *testing.T) { |
| + f, restarted, again := restartedProbePublished(t) |
| + ctx := t.Context() |
| + if err := f.publishFresh(restarted, again); err != nil { |
| + t.Fatal(err) |
| + } |
| + fresh := restarted.Claim.Holder.Generation |
| + if fg := f.filesGen(); fg.Op != fresh || fg.Incarnation != f.inc { |
| + t.Fatalf(".files-gen after the restarted probe's publication: %+v, want %s", fg, fresh) |
| + } |
| + o := f.commit() |
| + for i := 0; i < 3; i++ { |
| + in, err := f.owners.Select(ctx, f.route, o, poWake) |
| + if err != nil { |
| + t.Fatalf("wake %d: %v", i, err) |
| + } |
| + admitted := f.admit(o, in) |
| + f.restore(admitted.Claim.Holder.Generation) |
| + if err := f.owners.Materialize(ctx, admitted, in, newFenceWork()); err != nil { |
| + t.Fatalf("wake %d's start after the probe's %s: %v", i, fresh, err) |
| + } |
| + if err := f.publishFresh(admitted, in); err != nil { |
| + t.Fatalf("wake %d's publication: %v", i, err) |
| + } |
| + if fg := f.filesGen(); fg.Op != admitted.Claim.Holder.Generation { |
| + t.Fatalf("wake %d's .files-gen: %+v", i, fg) |
| + } |
| + o = f.read() |
| + } |
| +} |
| + |
| +// A files directory that is not the one the landing placed fails the |
| +// publication: no marker is written and .files-gen is not refreshed. |
| +func TestMovesProbeOwnersPublicationRefusesMismatchedFiles(t *testing.T) { |
| + f, restarted, again := restartedProbePublished(t) |
| + before := f.bucket.LatestID("data", f.markerKey()) |
| + // Another directory in its place: made while the placed one still |
| + // exists, so it cannot reuse its inode. |
| + old := f.files + ".old" |
| + if err := os.Rename(f.files, old); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Mkdir(f.files, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(old) }) |
| + f.writeLocal(poOp, restarted.Claim.Holder.Generation, f.inc) |
| + if err := f.publishFresh(restarted, again); err == nil { |
| + t.Fatal("published over files the landing did not place") |
| + } |
| + if f.bucket.LatestID("data", f.markerKey()) != before { |
| + t.Fatal("a marker was written") |
| + } |
| + if fg := f.filesGen(); fg.Op != poOp { |
| + t.Fatalf(".files-gen was refreshed: %+v", fg) |
| + } |
| +} |
| + |
| +// platform-1e (claim Plan 10 rev 5): the moves owner's input policy binds |
| +// the committed wake's spawn to Move.Inputs, as MovesClaimPolicy binds the |
| +// landing's rows: a committed cold admission with the pins' launch identity |
| +// is accepted, and refused once the pins differ. |
| +func TestMovesProbeOwnersInputPolicyBindsTheCommittedWake(t *testing.T) { |
| + f, o := committedAfterProbe(t) |
| + ctx := t.Context() |
| + in, err := f.owners.Select(ctx, f.route, o, poWake) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + admitted := f.admit(o, in) |
| + c := admitted.Claim |
| + sel := hostbox.Selection{Host: poHost, Action: "spawn", Box: "uk-box", Region: "uk", Captured: hostguard.Captured{Incarnation: c.Holder.Incarnation, Stamp: c.Holder.Stamp}, |
| + Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: c.Holder.Generation, Session: c.Holder.Session} |
| + policy := MovesClaimPolicyWithInputs(f.owners.Records, f.owners.InputPolicy) |
| + if err := f.owners.InputPolicy(ctx, c, sel, in.Identity); err != nil { |
| + t.Fatalf("matching pins: %v", err) |
| + } |
| + if err := policy(ctx, c, sel, in.Identity); err != nil { |
| + t.Fatalf("matching pins through the policy: %v", err) |
| + } |
| + for name, mutate := range map[string]func(){ |
| + "another release": func() { f.rec.Move.Inputs.Release = "v9" }, |
| + "another overlay": func() { f.rec.Move.Inputs.OverlayETag = "e-other" }, |
| + } { |
| + pins := *f.rec.Move.Inputs |
| + mutate() |
| + if err := f.owners.InputPolicy(ctx, c, sel, in.Identity); err == nil { |
| + t.Fatalf("%s: accepted", name) |
| + } |
| + if err := policy(ctx, c, sel, in.Identity); err == nil { |
| + t.Fatalf("%s: accepted through the policy", name) |
| + } |
| + f.rec.Move.Inputs = &pins |
| + } |
| + if err := f.owners.InputPolicy(ctx, c, sel, f.launch.Identity); err == nil { |
| + t.Fatal("the ordinary identity was accepted") |
| + } |
| + _ = genselect.Marker{} |
| + _ = hostwrite.Identity{} |
| +} |
diff --git a/internal/host/movesprobeowners_fix1_test.go b/internal/host/movesprobeowners_fix1_test.go| index e481df618..8fa715228 100644 |
| --- a/internal/host/movesprobeowners_fix1_test.go |
| +++ b/internal/host/movesprobeowners_fix1_test.go |
| @@ -274,9 +274,9 @@ func TestMovesProbeOwnersSupersedeTheEarlierStaysMarker(t *testing.T) { |
| // first attempt published the move id; the restarted attempt fresh1 |
| // published its own marker after its root and crashed. The next attempt's |
| // baseline is the certified remote from fresh1, whose chain reaches the move |
| -// id, and fresh1's marker is kept, not superseded by the prepared one. (That |
| -// attempt then stops at Task 4's files rule: .files-gen names the move id, |
| -// two cold admissions back.) |
| +// id, and fresh1's marker is kept, not superseded by the prepared one. Since |
| +// Task 7's fix round 1 fresh1's publication refreshed .files-gen to fresh1, |
| +// so that attempt passes Task 4's files rule too. |
| func TestMovesProbeOwnersRecogniseARestartAfterACrash(t *testing.T) { |
| f := newPOFixture(t) |
| ctx := t.Context() |
| @@ -290,6 +290,10 @@ func TestMovesProbeOwnersRecogniseARestartAfterACrash(t *testing.T) { |
| } |
| restarted := f.admit(first, again) |
| fresh := again.Admission.NewGeneration |
| + f.restore(fresh) |
| + if err := f.owners.Materialize(ctx, restarted, again, newFenceWork()); err != nil { |
| + t.Fatal(err) |
| + } |
| f.bucket.Put("data", "instances/"+poHost+"/g-"+fresh+"/0009/"+ltx.FormatFilename(1, 44), f.ltxHeader(1, 44)) |
| pub := ClaimPublication{Sync: func(context.Context) error { return nil }, Check: func(context.Context) error { return nil }} |
| if _, err := f.owners.PublishFenced(ctx, restarted, again, pub); err != nil { |
| @@ -304,13 +308,13 @@ func TestMovesProbeOwnersRecogniseARestartAfterACrash(t *testing.T) { |
| t.Fatalf("the restart's baseline is not the certified remote of %s: %+v", fresh, next.Baseline) |
| } |
| admitted := f.admit(restarted, next) |
| - f.writeLocal(poOp, admitted.Claim.Holder.Generation, f.inc) |
| + f.restore(admitted.Claim.Holder.Generation) |
| written := f.bucket.LatestID("data", f.markerKey()) |
| err = f.owners.Materialize(ctx, admitted, next, newFenceWork()) |
| if m, _ := f.currentMarker(); m.Op != fresh || f.bucket.LatestID("data", f.markerKey()) != written { |
| t.Fatalf("the restart's marker was superseded: %+v", m) |
| } |
| - if !errors.Is(err, moves.ErrStartFiles) { |
| + if err != nil { |
| t.Fatalf("the second restart: %v", err) |
| } |
| } |
diff --git a/internal/host/movesprobeowners_test.go b/internal/host/movesprobeowners_test.go| index 129c084f6..0cbc8bc8c 100644 |
| --- a/internal/host/movesprobeowners_test.go |
| +++ b/internal/host/movesprobeowners_test.go |
| @@ -112,6 +112,22 @@ func newPOFixture(t *testing.T) *poFixture { |
| } |
| body, _ := json.Marshal(f.manifest) |
| j.Mark(landStepInstall, string(body)) |
| + // The install receipt the landing journals: the files directory and |
| + // <host>.env, placed (moveslandinstall.go). |
| + envPath := filepath.Join(f.dataDir, poHost+".env") |
| + if err := os.WriteFile(envPath, []byte("CARLOS_ADMIN_TOKEN=t\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + filesID, err := pathIdentity(f.files) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + envID, err := pathIdentity(envPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + placed, _ := json.Marshal(stagedFiles{Files: filesID, Env: envID}) |
| + j.Mark(landStepFilesPlaced, string(placed)) |
| if err := j.Write(f.dataDir); err != nil { |
| t.Fatal(err) |
| } |
| @@ -582,6 +598,10 @@ func TestMovesProbeOwnersPublishARestartedAttemptFenced(t *testing.T) { |
| t.Fatal(err) |
| } |
| admitted := f.admit(first, again) |
| + f.restore(admitted.Claim.Holder.Generation) |
| + if err := f.owners.Materialize(ctx, admitted, again, newFenceWork()); err != nil { |
| + t.Fatal(err) |
| + } |
| synced := 0 |
| pub := ClaimPublication{Sync: func(context.Context) error { synced++; return nil }, Check: func(context.Context) error { return nil }} |
| if err := f.owners.Publish(ctx, admitted, again); !errors.Is(err, hostbox.ErrChanged) { |