host: the landing's probing and probed phases (Plan 3 Task 6, §7.6, §7.7, §7.13, H8, H10)
restored steps to probing when this box has a probe wake (moveCfg.Probe, Task 5's ProbeWake; nil and waiting in production). probing makes the attempts the cut admitted (CutAttempts, AttemptsFor), each counted on the record and journalled before its call, with the landing's deadline: a pass steps to probed (the claim's step keeps the writer session); Task 5's ProbeInputsChanged and exhausted attempts are rollback marks; a crash restarts only the probe, retrying a counted attempt unless it is spent. Every phase now checks the landing deadline at entry. Task 5's Admitted hook reads CutAttempts (MovesRecordAdmittedAttempts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+497
−10
approved_moves_copy_test.go+1 −0internal/host/movesland.go+29 −7internal/host/movesland_probe_test.go+286 −0internal/host/movesland_test.go+5 −0internal/host/moveslandprobe.go+168 −0internal/host/movespass.go+5 −0internal/host/movesprobeowners_test.go+3 −3
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index f4591b472..43d039478 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -582,6 +582,7 @@ var movesScanFiles = []string{ |
| "internal/host/movesland.go", |
| "internal/host/moveslandinstall.go", |
| "internal/host/moveslandpark.go", |
| + "internal/host/moveslandprobe.go", |
| "internal/host/movesmeasure.go", |
| "internal/host/movesmeasure_linux.go", |
| "internal/host/movesmeasure_other.go", |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index 874a252da..3ed35df5c 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -202,7 +202,10 @@ func (l *landingRun) step(ctx context.Context) (bool, error) { |
| // it back; markRollback nudged the serial for it. |
| return false, nil |
| } |
| - if landingBefore(m.LandingPhase, provision.LandRestored) && l.pastDeadline() { |
| + // Every phase checks the landing's deadline at entry: the install's |
| + // phases (P2-deadline), and restored, probing and probed (H8: the |
| + // probe works to the same deadline). |
| + if l.pastDeadline() { |
| return l.markRollback(ctx, provision.RollbackLandingDeadline) |
| } |
| switch m.LandingPhase { |
| @@ -213,14 +216,27 @@ func (l *landingRun) step(ctx context.Context) (bool, error) { |
| advanced, _, err := installGeneration(ctx, l.s, l.w, l.rec, l.installSource()) |
| return advanced, err |
| case provision.LandRestored: |
| - // The install is complete and the probe's (Plan 3). The one thing |
| - // still this pass's is the claim half of the last CAS: a crash after |
| - // the record's restored, or a mirror that failed, leaves the claim |
| - // behind, and nothing later would walk it (fix round 1, item 1). |
| + // The install is complete. The claim half of the last CAS first: a |
| + // crash after the record's restored, or a mirror that failed, leaves |
| + // the claim behind (fix round 1, item 1). Then the probe begins, |
| + // when this box has a probe wake (unarmed in production). |
| + if _, err := l.syncClaimPhase(ctx); err != nil { |
| + return false, err |
| + } |
| + if l.cfg().Probe == nil { |
| + l.w.notice(l.rec.Host+"#probe", "moves: %s: landing %s waits at restored: this box has no probe wake (claim Plan 11's launcher and Plan 5 Task 9's arming are needed)", l.rec.Host, l.g.ID) |
| + return false, nil |
| + } |
| + return l.advance(ctx, provision.LandRestored, provision.LandProbing, nil) |
| + case provision.LandProbing: |
| + return l.probing(ctx) |
| + case provision.LandProbed: |
| + // The probe passed. The commit is Task 7's; this phase ends at the |
| + // claim's probed step. |
| _, err := l.syncClaimPhase(ctx) |
| return false, err |
| } |
| - return false, nil // probing and later: the probe's (Plan 3) |
| + return false, nil |
| } |
| // landingDeadlineOf is the deadline a landing before restored works to: |
| @@ -304,7 +320,13 @@ func (l *landingRun) mirrorPhase(ctx context.Context, from, to string) error { |
| if err != nil { |
| return err |
| } |
| - _, err = hostclaim.Update(ctx, l.s, obs, hostclaim.SetMovePhase(l.g.ID, hostclaim.KindLanding, from, to)) |
| + step := hostclaim.SetMovePhase(l.g.ID, hostclaim.KindLanding, from, to) |
| + if hostclaim.SessionPhase(hostclaim.KindLanding, from) && hostclaim.SessionPhase(hostclaim.KindLanding, to) { |
| + // probing to probed (and Task 7's committed): the admitted probe |
| + // session is the serving session, so the CAS must keep it (H3). |
| + step = hostclaim.KeepWriterSession(l.g.ID, step) |
| + } |
| + _, err = hostclaim.Update(ctx, l.s, obs, step) |
| return err |
| } |
diff --git a/internal/host/movesland_probe_test.go b/internal/host/movesland_probe_test.go| new file mode 100644 |
| index 000000000..fa2a2e6b2 |
| --- /dev/null |
| +++ b/internal/host/movesland_probe_test.go |
| @@ -0,0 +1,286 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// probeLog is a fake probe wake that answers from a script and records |
| +// every configuration it was given. |
| +type probeLog struct { |
| + configs []activator.ProbeConfig |
| + answers []error |
| + before func(n int) |
| +} |
| + |
| +func (p *probeLog) wake(_ context.Context, cfg activator.ProbeConfig) (activator.ProbeResult, error) { |
| + p.configs = append(p.configs, cfg) |
| + n := len(p.configs) |
| + if p.before != nil { |
| + p.before(n) |
| + } |
| + if n <= len(p.answers) && p.answers[n-1] != nil { |
| + return activator.ProbeResult{}, p.answers[n-1] |
| + } |
| + return activator.ProbeResult{Session: 20, Generation: p1ID}, nil |
| +} |
| + |
| +func failedAttempt(why string) error { |
| + return errors.Join(activator.ErrProbeFailed, errors.New(why)) |
| +} |
| + |
| +// installedLanding is a landing at restored, with the cut's admitted probe |
| +// attempts set to attempts (0 clears them) through the record's writer. |
| +func installedLanding(t *testing.T, attempts int) (*landingHarness, *probeLog) { |
| + t.Helper() |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + h.land(t) |
| + h.installed(t) |
| + if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error { |
| + rec.Move.CutAttempts = attempts |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := &probeLog{} |
| + h.probe = p.wake |
| + return h, p |
| +} |
| + |
| +func (h *landingHarness) atPhase(t *testing.T, phase string) *provision.MoveState { |
| + t.Helper() |
| + m := h.read(t, p1Host).Move |
| + if m.State != provision.MoveLanding || m.LandingPhase != phase || m.Reason != "" { |
| + t.Fatalf("the record is not landing/%s: %+v", phase, m) |
| + } |
| + if c := h.claim(t); c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != phase { |
| + t.Fatalf("the claim is not landing/%s: %+v", phase, c.Op) |
| + } |
| + return m |
| +} |
| + |
| +// TestLandingProbesWithinTheBudget (brief): two admitted attempts, the |
| +// first fails and the second passes, so the landing is probed, record and |
| +// claim; a third is never attempted. Each attempt is counted on the record |
| +// and journalled before its call, and the probe is given the landing's |
| +// deadline (H8), the build's manifest and the pins. |
| +func TestLandingProbesWithinTheBudget(t *testing.T) { |
| + h, p := installedLanding(t, 2) |
| + p.answers = []error{failedAttempt("not ready")} |
| + session := h.claim(t).Holder.Session |
| + h.land(t) |
| + m := h.atPhase(t, provision.LandProbed) |
| + if len(p.configs) != 2 || m.Landing == nil || m.Landing.Attempts != 2 || h.landJournal(t).Attempt != 2 { |
| + t.Fatalf("attempts: calls %d record %+v journal %d", len(p.configs), m.Landing, h.landJournal(t).Attempt) |
| + } |
| + deadline, _ := landingDeadlineOf(m) |
| + want := activator.ProbeConfig{Host: p1Host, Op: p1ID, HoldOp: p1ID, Deadline: *deadline, Prepared: h.preparedManifest(t), Inputs: m.Inputs} |
| + for i, got := range p.configs { |
| + if !reflect.DeepEqual(got, want) { |
| + t.Fatalf("probe %d was given %+v, want %+v", i+1, got, want) |
| + } |
| + } |
| + if c := h.claim(t); c.Holder.Session != session { |
| + t.Fatalf("the probed step changed the writer session: %d, was %d", c.Holder.Session, session) |
| + } |
| + // probed is the probe's end: another pass probes nothing. |
| + h.land(t) |
| + if len(p.configs) != 2 { |
| + t.Fatalf("a pass at probed probed again: %d", len(p.configs)) |
| + } |
| + h.atPhase(t, provision.LandProbed) |
| +} |
| + |
| +// preparedManifest is the build's install manifest from the op journal. |
| +func (h *landingHarness) preparedManifest(t *testing.T) *moves.InstallManifest { |
| + t.Helper() |
| + m := h.installedManifest(t) |
| + return &m |
| +} |
| + |
| +func (h *landingHarness) installedManifest(t *testing.T) moves.InstallManifest { |
| + t.Helper() |
| + j := h.landJournal(t) |
| + var m moves.InstallManifest |
| + if err := json.Unmarshal([]byte(j.Steps[landStepInstall]), &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + return m |
| +} |
| + |
| +// TestLandingExhaustedAttemptsMarkRollback (brief): every admitted attempt |
| +// fails: the landing records the attempts-exhausted rollback sentence, |
| +// which the next pass's rollback decision turns into the rollback. |
| +func TestLandingExhaustedAttemptsMarkRollback(t *testing.T) { |
| + h, p := installedLanding(t, 2) |
| + p.answers = []error{failedAttempt("one"), errors.Join(activator.ErrProbeFailed, activator.ErrAttemptOver)} |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackProbeAttempts || len(p.configs) != 2 { |
| + t.Fatalf("exhausted: %d calls, %+v", len(p.configs), m) |
| + } |
| + h.land(t) |
| + rolledBack(t, h, provision.RollbackProbeAttempts, provision.LandProbing) |
| + if len(p.configs) != 2 { |
| + t.Fatalf("a rolled-back landing probed again: %d", len(p.configs)) |
| + } |
| +} |
| + |
| +// A changed configuration found by the probe (Task 5's ProbeInputsChanged, |
| +// before or after the admission) is the configuration rollback, at once. |
| +func TestLandingConfigChangedDuringTheProbeRollsBack(t *testing.T) { |
| + for name, answer := range map[string]error{ |
| + "before the admission": &ProbeInputsChanged{Reason: provision.RollbackConfigChanged}, |
| + "after the admission": errors.Join(activator.ErrProbeFailed, &ProbeInputsChanged{Reason: provision.RollbackConfigChanged}), |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + h, p := installedLanding(t, 3) |
| + p.answers = []error{answer} |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackConfigChanged || len(p.configs) != 1 { |
| + t.Fatalf("%d calls, %+v", len(p.configs), m) |
| + } |
| + h.land(t) |
| + rolledBack(t, h, provision.RollbackConfigChanged, provision.LandProbing) |
| + }) |
| + } |
| +} |
| + |
| +// H10: a cut that recorded no probe attempts refuses the probe with its |
| +// recorded sentence; nothing is probed. |
| +func TestLandingProbeNeedsTheCutsAttempts(t *testing.T) { |
| + h, p := installedLanding(t, 0) |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.Reason != provision.RollbackProbeUnpriced || len(p.configs) != 0 { |
| + t.Fatalf("%d calls, %+v", len(p.configs), m) |
| + } |
| + h.land(t) |
| + rolledBack(t, h, provision.RollbackProbeUnpriced, provision.LandProbing) |
| +} |
| + |
| +// H8: probing and probed check the landing's deadline at entry. A deadline |
| +// that passes during an attempt ends the probing with the deadline's |
| +// sentence, and no further attempt is made; a probed landing past its |
| +// deadline rolls back. |
| +func TestLandingProbingChecksTheDeadline(t *testing.T) { |
| + h, p := installedLanding(t, 3) |
| + p.before = func(int) { h.clock.Advance(cutLandingWindow + time.Minute) } |
| + p.answers = []error{errors.Join(activator.ErrProbeFailed, activator.ErrAttemptOver)} |
| + h.land(t) |
| + if len(p.configs) != 1 { |
| + t.Fatalf("probed past the deadline: %d calls", len(p.configs)) |
| + } |
| + h.land(t) |
| + rolledBack(t, h, provision.RollbackLandingDeadline, provision.LandProbing) |
| + |
| + h2, _ := installedLanding(t, 3) |
| + h2.land(t) |
| + h2.atPhase(t, provision.LandProbed) |
| + h2.clock.Advance(cutLandingWindow + time.Minute) |
| + h2.land(t) |
| + rolledBack(t, h2, provision.RollbackLandingDeadline, provision.LandProbed) |
| +} |
| + |
| +// TestLandingProbeCrashRestartsOnlyTheProbe (brief, §7.13): a crash after |
| +// an attempt is counted and before its probe returns restarts only the |
| +// probe: a fresh worker probes the same attempt again (its window never |
| +// opened), and local and restored are not rerun, their journal steps and |
| +// .files-gen untouched. An attempt whose window had opened and passed |
| +// counts as failed, and the next one is made. |
| +func TestLandingProbeCrashRestartsOnlyTheProbe(t *testing.T) { |
| + h, p := installedLanding(t, 2) |
| + steps := map[string]string{} |
| + for k, v := range h.landJournal(t).Steps { |
| + steps[k] = v |
| + } |
| + marker := filepath.Join(h.tdir, p1Host, archive.MarkerName) |
| + before, err := os.Stat(marker) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + renders, timers := h.renders, len(h.replaced) |
| + h.landAt = "before-probe-1" |
| + h.crashLand(t) |
| + if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandProbing || m.Landing.Attempts != 1 || h.landJournal(t).Attempt != 1 || len(p.configs) != 0 { |
| + t.Fatalf("the crash point: %+v journal %d calls %d", m, h.landJournal(t).Attempt, len(p.configs)) |
| + } |
| + h.freshTarget() |
| + h.land(t) |
| + m := h.atPhase(t, provision.LandProbed) |
| + if len(p.configs) != 1 || m.Landing.Attempts != 1 { |
| + t.Fatalf("after the restart: %d calls, record %+v", len(p.configs), m.Landing) |
| + } |
| + after, err := os.Stat(marker) |
| + if err != nil || !after.ModTime().Equal(before.ModTime()) { |
| + t.Fatalf(".files-gen was written again: %v", err) |
| + } |
| + j := h.landJournal(t) |
| + for k, v := range steps { |
| + if j.Steps[k] != v { |
| + t.Fatalf("the journal step %s changed", k) |
| + } |
| + } |
| + for k := range j.Steps { |
| + if _, ok := steps[k]; !ok && !strings.HasPrefix(k, "probe_") { |
| + t.Fatalf("a non-probe step %s was added", k) |
| + } |
| + } |
| + if h.renders != renders || len(h.replaced) != timers { |
| + t.Fatalf("configured or local ran again: renders %d->%d timers %d->%d", renders, h.renders, timers, len(h.replaced)) |
| + } |
| + |
| + // An attempt whose window opened and passed before the crash counts as |
| + // failed: the restarted worker makes the next one. |
| + h2, p2 := installedLanding(t, 2) |
| + h2.landAt = "before-probe-1" |
| + h2.crashLand(t) |
| + j2 := h2.landJournal(t) |
| + j2.Mark("probe_opened_1", h2.clock.Now().Add(-31*time.Second).UTC().Format(time.RFC3339Nano)) |
| + if err := j2.Write(h2.tdir); err != nil { |
| + t.Fatal(err) |
| + } |
| + h2.freshTarget() |
| + h2.land(t) |
| + if m := h2.atPhase(t, provision.LandProbed); len(p2.configs) != 1 || m.Landing.Attempts != 2 { |
| + t.Fatalf("the spent attempt was retried: %d calls, record %+v", len(p2.configs), m.Landing) |
| + } |
| +} |
| + |
| +// Unarmed (production until claim Plan 11's launcher and Plan 5 Task 9): |
| +// with no probe wake the landing waits at restored and writes nothing. |
| +func TestLandingUnarmedProbeWaitsAtRestored(t *testing.T) { |
| + h, _ := installedLanding(t, 3) |
| + h.probe = nil |
| + before := h.read(t, p1Host).Move.UpdatedAt |
| + h.land(t) |
| + if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandRestored || !m.UpdatedAt.Equal(before) { |
| + t.Fatalf("an unarmed probe moved the landing: %+v", m) |
| + } |
| +} |
| + |
| +// H10: Task 5's Admitted hook reads the admission's recorded count, and |
| +// refuses a record that has none. |
| +func TestMovesRecordAdmittedAttempts(t *testing.T) { |
| + ctx := context.Background() |
| + if n, err := MovesRecordAdmittedAttempts(ctx, &provision.Record{Move: &provision.MoveState{CutAttempts: 2}}); err != nil || n != 2 { |
| + t.Fatalf("recorded: %d %v", n, err) |
| + } |
| + for _, rec := range []*provision.Record{nil, {}, {Move: &provision.MoveState{}}} { |
| + if _, err := MovesRecordAdmittedAttempts(ctx, rec); err == nil { |
| + t.Fatalf("no recorded count was admitted: %+v", rec) |
| + } |
| + } |
| +} |
diff --git a/internal/host/movesland_test.go b/internal/host/movesland_test.go| index 48e6ba338..63b0faa72 100644 |
| --- a/internal/host/movesland_test.go |
| +++ b/internal/host/movesland_test.go |
| @@ -73,6 +73,8 @@ type landingHarness struct { |
| parkView func(context.Context, hostclaim.Observed) (park.View, error) |
| landFault func(point string) error |
| store lease.VersionedStore // when set, the target's versioned store |
| + // probe, when set, is the target's probe wake (Plan 3 Task 6). |
| + probe func(context.Context, activator.ProbeConfig) (activator.ProbeResult, error) |
| } |
| // fakeParkLeases refuses a second acquisition of a held location. |
| @@ -346,6 +348,9 @@ func (h *landingHarness) landingCfg(t *testing.T) moveCfg { |
| c.SetNextDue = func(host string, due int64) error { h.nextDue = append(h.nextDue, due); return nil } |
| c.RemoveVerifyEntry = dbverify.RemoveHost |
| c.WriteGen = hostbox.WriteGenerationMarker |
| + if h.probe != nil { |
| + c.Probe = h.probe |
| + } |
| c.landFault = func(point string) error { |
| h.landHits = append(h.landHits, point) |
| if h.landFault != nil { |
diff --git a/internal/host/moveslandprobe.go b/internal/host/moveslandprobe.go| new file mode 100644 |
| index 000000000..fc673b4b7 |
| --- /dev/null |
| +++ b/internal/host/moveslandprobe.go |
| @@ -0,0 +1,168 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "strconv" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The landing's probing phase (moves spec §7.6, §7.7, §7.13; Plan 3 Task 6). |
| +// |
| +// restored to probing is the landing's step (movesland.go). At probing the |
| +// pass makes the probe attempts T11 admitted (H10: the record's |
| +// CutAttempts, written by MoveAdmit), at most moves.AttemptsFor("landing", |
| +// n). Each attempt is counted on the record (provision.MoveProbeAttempt) |
| +// and journalled (Journal.Attempt) before its call, then made through the |
| +// probe wake (Task 5's activator.Service.ProbeWake behind moveCfg.Probe): |
| +// one cold admission into the prepared generation (or a fresh one for a |
| +// later attempt), restored, rendered, published and started by the claim |
| +// (Group D), up to five starts inside the attempt's 30 s window, bounded by |
| +// the landing's deadline (H8). The attempt's own journal steps |
| +// (probe_opened_<k>, probe_start_<k>) are Task 5's movesProbeAttempts'; |
| +// probe_failed_<k> is this file's, written when the attempt is over. |
| +// |
| +// Outcomes. A pass: probing to probed, record then claim (the claim's step |
| +// keeps the admitted writer session, H3); the commit is Task 7's. Task 5's |
| +// ProbeInputsChanged: its recorded sentence, the configuration rollback. An |
| +// attempt that is over (ErrProbeFailed, ErrAttemptOver): the next one, if |
| +// any; none left: the attempts-exhausted rollback; the deadline passed: the |
| +// deadline's. Rollbacks are marks (MoveMarkRollback); the one conversion to |
| +// MoveRollBack is the pass's rollback decision (movesrollback.go). Any other |
| +// error ends the pass and the same attempt is made again on the next. |
| +// |
| +// A crash restarts only the probe (§7.13): local and restored are never run |
| +// again; a fresh worker finds probing and the counted attempt, and makes it |
| +// again unless it is spent (its failure journalled, or its window opened and |
| +// passed), in which case the next one is counted. |
| +// |
| +// Unarmed in production: moveCfg.Probe is nil until claim Plan 11's launcher |
| +// (R10) and Plan 5 Task 9's arming, so the landing waits at restored. |
| + |
| +// probeStepFailed marks attempt k over in the op journal. |
| +const probeStepFailed = "probe_failed_" |
| + |
| +// probeWindowLen is the attempt's window, counted from its first start. |
| +var probeWindowLen = time.Duration(moves.ProbeSeconds * float64(time.Second)) |
| + |
| +// MovesRecordAdmittedAttempts is Task 5's Admitted hook (H10): the probe |
| +// attempts the record's admission priced, refused when it recorded none. |
| +func MovesRecordAdmittedAttempts(_ context.Context, rec *provision.Record) (int, error) { |
| + if rec == nil || rec.Move == nil || rec.Move.CutAttempts < 1 { |
| + return 0, errors.New("moves: " + provision.RollbackProbeUnpriced) |
| + } |
| + return rec.Move.CutAttempts, nil |
| +} |
| + |
| +// attemptSpent reports whether counted attempt k is over: its failure is |
| +// journalled, or its window opened and has passed. |
| +func (l *landingRun) attemptSpent(j *moves.Journal, k int) bool { |
| + if j.Done(probeStepFailed + strconv.Itoa(k)) { |
| + return true |
| + } |
| + raw, ok := j.Steps[probeStepOpened+strconv.Itoa(k)] |
| + if !ok { |
| + return false |
| + } |
| + opened, err := time.Parse(time.RFC3339Nano, raw) |
| + return err == nil && !l.cfg().Now().Before(opened.Add(probeWindowLen)) |
| +} |
| + |
| +// probing makes the landing's probe attempts (see the file comment). |
| +func (l *landingRun) probing(ctx context.Context) (bool, error) { |
| + cfg := l.cfg() |
| + obs, err := l.syncClaimPhase(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err := l.requirePhase(obs, provision.LandProbing); err != nil { |
| + return false, err |
| + } |
| + if cfg.Probe == nil { |
| + l.w.notice(l.rec.Host+"#probe", "moves: %s: landing %s waits at probing: this box has no probe wake", l.rec.Host, l.g.ID) |
| + return false, nil |
| + } |
| + m := l.rec.Move |
| + if m.CutAttempts < 1 { |
| + return l.markRollback(ctx, provision.RollbackProbeUnpriced) |
| + } |
| + n := moves.AttemptsFor(moves.ProbeLanding, m.CutAttempts) |
| + j, err := l.journal() |
| + if err != nil { |
| + return false, err |
| + } |
| + raw, ok := j.Steps[landStepInstall] |
| + if !ok { |
| + return false, fmt.Errorf("move %s: probing without an install manifest in the journal", l.g.ID) |
| + } |
| + var manifest moves.InstallManifest |
| + if err := json.Unmarshal([]byte(raw), &manifest); err != nil { |
| + return false, fmt.Errorf("move %s: the journalled install manifest: %w", l.g.ID, err) |
| + } |
| + counted := 0 |
| + if m.Landing != nil { |
| + counted = m.Landing.Attempts |
| + } |
| + k := counted |
| + if k == 0 || l.attemptSpent(j, k) { |
| + k++ |
| + } |
| + for ; k <= n; k++ { |
| + if l.pastDeadline() { |
| + return l.markRollback(ctx, provision.RollbackLandingDeadline) |
| + } |
| + if k > counted { |
| + ok, err := l.transition(ctx, "probe attempt "+strconv.Itoa(k), provision.MoveProbeAttempt(l.g, k)) |
| + if !ok { |
| + return false, err |
| + } |
| + counted = k |
| + } |
| + if j, err = l.journal(); err != nil { |
| + return false, err |
| + } |
| + j.Phase, j.Attempt = provision.LandProbing, k |
| + if err := j.Write(cfg.DataDir); err != nil { |
| + return false, err |
| + } |
| + if err := l.fault("before-probe-" + strconv.Itoa(k)); err != nil { |
| + return false, err |
| + } |
| + deadline, _ := landingDeadlineOf(m) |
| + if deadline == nil { |
| + return false, fmt.Errorf("move %s: the landing has no deadline", l.g.ID) |
| + } |
| + _, err := cfg.Probe(ctx, activator.ProbeConfig{Host: l.rec.Host, Op: l.g.ID, HoldOp: l.g.ID, Deadline: *deadline, Prepared: &manifest, Inputs: m.Inputs}) |
| + var changed *ProbeInputsChanged |
| + switch { |
| + case err == nil: |
| + log.Printf("moves: %s: landing %s passed its probe on attempt %d of %d", l.rec.Host, l.g.ID, k, n) |
| + return l.advance(ctx, provision.LandProbing, provision.LandProbed, nil) |
| + case errors.As(err, &changed): |
| + log.Printf("moves: %s: landing %s probe attempt %d: %v", l.rec.Host, l.g.ID, k, err) |
| + return l.markRollback(ctx, changed.Reason) |
| + case errors.Is(err, activator.ErrProbeFailed) || errors.Is(err, activator.ErrAttemptOver): |
| + log.Printf("moves: %s: landing %s probe attempt %d of %d is over: %v", l.rec.Host, l.g.ID, k, n, err) |
| + if j, err = l.journal(); err != nil { |
| + return false, err |
| + } |
| + j.Mark(probeStepFailed+strconv.Itoa(k), cfg.Now().UTC().Format(time.RFC3339Nano)) |
| + if err := j.Write(cfg.DataDir); err != nil { |
| + return false, err |
| + } |
| + default: |
| + return false, fmt.Errorf("probe attempt %d: %w", k, err) |
| + } |
| + } |
| + if l.pastDeadline() { |
| + return l.markRollback(ctx, provision.RollbackLandingDeadline) |
| + } |
| + return l.markRollback(ctx, provision.RollbackProbeAttempts) |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 5285cc85c..f4e19d5f6 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -558,6 +558,11 @@ type moveCfg struct { |
| SetNextDue func(host string, due int64) error |
| RemoveVerifyEntry func(ctx context.Context, s release.Store, host string) error |
| WriteGen func(path, generation string) error |
| + // Probe is the landing's probe wake (Plan 3 Task 6): Task 5's |
| + // activator.Service.ProbeWake. Nil in production, with a standing |
| + // notice, until claim Plan 11's launcher (R10) and Plan 5 Task 9's |
| + // arming exist; the landing then waits at restored. |
| + Probe func(ctx context.Context, cfg activator.ProbeConfig) (activator.ProbeResult, error) |
| // landFault and parkFault are test seams: the landing's crash points |
| // and the park engine's (park.Engine.After). Nil in production. |
| landFault func(point string) error |
diff --git a/internal/host/movesprobeowners_test.go b/internal/host/movesprobeowners_test.go| index 0e187443c..129c084f6 100644 |
| --- a/internal/host/movesprobeowners_test.go |
| +++ b/internal/host/movesprobeowners_test.go |
| @@ -650,7 +650,7 @@ func TestMovesProbeOwnersThroughTheClaimRuntime(t *testing.T) { |
| pins.EnvReport = envDigest(poEnvKey, []string{"A=1"}) |
| deadline := f.now.Add(time.Hour) |
| rec := &provision.Record{Host: recoveryHost, Account: "bab", App: "jam", Region: "ie", |
| - Move: &provision.MoveState{ID: poOp, Incarnation: h.Incarnation, From: "uk", To: "ie", State: provision.MoveLanding, LandingPhase: provision.LandProbing, Inputs: pins, CutDeadline: &deadline, |
| + Move: &provision.MoveState{ID: poOp, Incarnation: h.Incarnation, From: "uk", To: "ie", State: provision.MoveLanding, LandingPhase: provision.LandProbing, Inputs: pins, CutDeadline: &deadline, CutAttempts: 3, |
| Landing: &provision.MoveLandingJournal{Attempts: 1, InstalledDBSHA256: "sha256:" + manifest.DBSHA256}}} |
| records := func(context.Context, string, string, string) (*provision.Record, error) { return rec, nil } |
| var order []string |
| @@ -663,7 +663,7 @@ func TestMovesProbeOwnersThroughTheClaimRuntime(t *testing.T) { |
| return ClaimServingLaunch{Executable: "/usr/bin/true", ExecutableSHA256: strings.Repeat("a", 64), Env: []string{"A=1"}, Version: pins.Release}, nil |
| }, |
| EnvKey: poEnvKey, |
| - Admitted: func(context.Context, *provision.Record) (int, error) { return 3, nil }, |
| + Admitted: MovesRecordAdmittedAttempts, |
| Works: func(_ context.Context, _, action string, _ hostguard.Captured) (hostwrite.Work, error) { |
| order = append(order, "select:"+action) |
| return plan9D5Work{}, nil |
| @@ -681,7 +681,7 @@ func TestMovesProbeOwnersThroughTheClaimRuntime(t *testing.T) { |
| }, |
| NewID: f.newID, Now: func() time.Time { return f.now }} |
| attempts := &movesProbeAttempts{Claims: f.claims, Records: records, DataDir: journalDir, NewID: f.newID, Now: func() time.Time { return f.now }, |
| - Admitted: func(context.Context, *provision.Record) (int, error) { return 3, nil }} |
| + Admitted: MovesRecordAdmittedAttempts} |
| c := f.controller("boot-1", "controller-1", 4101, "start-1") |
| bound := owners.MovesOwners(attempts) |
| c.wake.Inputs = ClaimOwnerDispatch{Moves: &bound.ClaimKindOwners} |