moves-phase1-6 Task 3 fix round 4 (3, binding): log rows bound to the instance incarnation
Rows carry the incarnation from the transition's evidence (moves.Transition gains Incarnation; the console queues only a transition of the grant's move and instance) instead of an app-naming report prefix. WriteMoveLog takes a host, move, incarnation and name: the account and app are the host claim's, only while its holder is that incarnation, and the move must be one the holder's record names (live at that incarnation, or closed as its last). A row for another app's host, a stale row after recreation under the same names, and a retired instance's rows are refused and dropped. The ruling's App-table gating of move_log_outbox is NOT done here; see the report (an effect permit per row needs an exclusive app operation, which BeginExclusive refuses while the move's registration is live).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+197
−81
internal/console/movesrecovery.go+12 −22internal/console/movesrecovery_test.go+67 −4internal/console/store.go+20 −18internal/console/store_gate.go+1 −1internal/console/testdata/claim-authority-sites.txt+1 −1internal/hostops/move_log_writer.go+39 −12internal/hostops/move_log_writer_test.go+48 −17internal/moves/transition.go+8 −5internal/provision/plan9_b8_census_test.go+1 −1
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index 7ef90d6c8..a231b261f 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -10,7 +10,6 @@ import ( |
| "log" |
| "os" |
| "slices" |
| - "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/alerting" |
| @@ -601,27 +600,22 @@ func (s *Server) queueMoveLog(o hostops.ConsoleMoveObservation, t moves.Transiti |
| // queueMoveLogAs queues the entry for t, confirmed or not: the drainer |
| // delivers a confirmed entry, and settles an unconfirmed one from the claim |
| -// (settleUnconfirmed). |
| +// (settleUnconfirmed). The row is bound to the incarnation t's evidence |
| +// carried, which must be the grant's move and instance. |
| func (s *Server) queueMoveLogAs(o hostops.ConsoleMoveObservation, t moves.Transition, now time.Time, confirmed bool) { |
| + if t.Op != o.OpID || t.Incarnation != o.Incarnation { |
| + log.Printf("console: moves: %s: the log of move %s at %s names move %s of incarnation %q, not the grant's; not queued", o.Host, o.OpID, t.Identity, t.Op, t.Incarnation) |
| + return |
| + } |
| body, err := moveLogEntry(o, t, now) |
| if err == nil { |
| - err = s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, moves.ReportPrefix(o.Account, o.App, o.Host, o.OpID), body, confirmed, now) |
| + err = s.store.QueueMoveLog(o.Host, o.OpID, t.Identity, t.Incarnation, body, confirmed, now) |
| } |
| if err != nil { |
| log.Printf("console: moves: %s: the log of move %s at %s is lost: %v", o.Host, o.OpID, t.Identity, err) |
| } |
| } |
| -// moveLogPrefixParts is the account and app of a row's report prefix, which |
| -// must be exactly moves.ReportPrefix of them, the row's host and its move. |
| -func moveLogPrefixParts(prefix, host, op string) (account, app string, ok bool) { |
| - p := strings.Split(prefix, "/") |
| - if len(p) != 6 || p[0] != "moves" || p[5] != "" || moves.ReportPrefix(p[1], p[2], host, op) != prefix { |
| - return "", "", false |
| - } |
| - return p[1], p[2], true |
| -} |
| - |
| // drainMoveLog is the Recovery.MoveLogs hook: every due row of the outbox, |
| // delivered through the pass's writer. |
| func (s *Server) drainMoveLog(ctx context.Context, lw *hostops.MoveLogWriter) error { |
| @@ -695,13 +689,7 @@ func (s *Server) deliverMoveLog(ctx context.Context, lw *hostops.MoveLogWriter, |
| log.Printf("console: moves: %s: the log outbox of move %s: %v", r.Host, r.Op, err) |
| } |
| } |
| - account, app, ok := moveLogPrefixParts(r.Prefix, r.Host, r.Op) |
| - if !ok { |
| - log.Printf("console: moves: %s: the log of move %s at %s names no move's report prefix (%q); dropped", r.Host, r.Op, r.Name, r.Prefix) |
| - settle() |
| - return |
| - } |
| - _, err := lw.WriteMoveLog(ctx, hostops.MoveLogEntry{Account: account, App: app, Host: r.Host, Op: r.Op, Name: r.Name, Body: r.Body}) |
| + _, err := lw.WriteMoveLog(ctx, hostops.MoveLogEntry{Host: r.Host, Op: r.Op, Incarnation: r.Incarnation, Name: r.Name, Body: r.Body}) |
| switch { |
| case err == nil: |
| settle() |
| @@ -729,10 +717,12 @@ func abortTransition(rec provision.Record) (moves.Transition, bool) { |
| } |
| // claimTransition is the entry for a claim write of the console's: the move's |
| -// regions from the record the pass acted on. |
| +// regions and incarnation from the record the pass acted on (a closed |
| +// move's, from the grant's observation of the same instance). |
| func claimTransition(o hostops.ConsoleMoveObservation, rec provision.Record, event, identity string) moves.Transition { |
| - t := moves.Transition{Op: o.OpID, Event: event, Identity: identity} |
| + t := moves.Transition{Op: o.OpID, Event: event, Identity: identity, Incarnation: o.Incarnation} |
| if m := rec.Move; m != nil { |
| + t.Incarnation = m.Incarnation |
| t.Fields = map[string]string{"from": m.From, "to": m.To} |
| } |
| return t |
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go| index bd35ba42c..560c4e03a 100644 |
| --- a/internal/console/movesrecovery_test.go |
| +++ b/internal/console/movesrecovery_test.go |
| @@ -958,7 +958,7 @@ func TestClaimReleasedIsPublishedOnlyAfterTheRelease(t *testing.T) { |
| journal := captureLog(t) |
| w := closed(t, "code-mvpass-release-other") |
| body := []byte(`{"v":1}`) |
| - if err := w.fx.st.QueueMoveLog("acme.go.tito.io", "mv_another", "claim-released", moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", "mv_another"), body, false, w.clock); err != nil { |
| + if err := w.fx.st.QueueMoveLog("acme.go.tito.io", "mv_another", "claim-released", w.claim(t).Holder.Incarnation, body, false, w.clock); err != nil { |
| t.Fatal(err) |
| } |
| w.pass(t) |
| @@ -986,14 +986,14 @@ func TestUnresolvedMoveLogRowsDoNotStarveTheBatch(t *testing.T) { |
| } |
| return b |
| } |
| - prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", w.id) |
| + inc := w.claim(t).Holder.Incarnation |
| for i := 0; i < moveLogDrainBatch; i++ { |
| name := fmt.Sprintf("held-%03d", i) |
| - if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, name, prefix, body(name), false, w.clock); err != nil { |
| + if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, name, inc, body(name), false, w.clock); err != nil { |
| t.Fatal(err) |
| } |
| } |
| - if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, "late", prefix, body("late"), true, w.clock); err != nil { |
| + if err := w.fx.st.QueueMoveLog("acme.go.tito.io", w.id, "late", inc, body("late"), true, w.clock); err != nil { |
| t.Fatal(err) |
| } |
| for i := 0; i < 2 && moveLogOf(t, w)["late"].Identity == ""; i++ { |
| @@ -1067,3 +1067,66 @@ func TestReleaseCASConflictPublishesNothing(t *testing.T) { |
| } |
| noPendingLog(t, w) |
| } |
| + |
| +// Fix round 4, ruling 3: a row is bound to the instance incarnation its |
| +// move's evidence carried, and the writer takes the account and app from the |
| +// host's claim only when its holder is that incarnation, for a move its |
| +// record names. A row for another app's host, a row whose instance was |
| +// recreated under the same names, and the rows of a retired instance are |
| +// never written, and are dropped. |
| +func TestMoveLogRowsAreBoundToTheirIncarnation(t *testing.T) { |
| + w := landingMovePass(t, "code-mvpass-tenancy") |
| + inc := w.claim(t).Holder.Incarnation |
| + entry := func(host, op, name string) []byte { |
| + b, err := json.Marshal(moves.LogEvent{V: 1, Op: op, Host: host, Event: "x", Identity: name, Actor: "console", At: w.clock}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| + } |
| + queue := func(host, op, name, inc string) { |
| + t.Helper() |
| + if err := w.fx.st.QueueMoveLog(host, op, name, inc, entry(host, op, name), true, w.clock); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + // Another app's host (the fixture's kass instance, which has no claim |
| + // here), under this instance's incarnation. |
| + queue("kass.carlosapp.dev", w.id, "elsewhere", inc) |
| + // This host, after a recreation under the same names: another incarnation. |
| + queue("acme.go.tito.io", w.id, "stale", strings.Repeat("ef", 32)) |
| + // This host and incarnation, but a move its record never named. |
| + queue("acme.go.tito.io", "mv_forged", "forged", inc) |
| + // The instance's own row, written. |
| + queue("acme.go.tito.io", w.id, "genuine", inc) |
| + w.pass(t) |
| + log := moveLogOf(t, w) |
| + if log["genuine"].Identity != "genuine" || log["stale"].Identity != "" { |
| + t.Fatalf("log %+v", log) |
| + } |
| + if forged, err := moves.ReadLog(context.Background(), w.fx.rel, moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", "mv_forged")); err != nil || len(forged) != 0 { |
| + t.Fatalf("a move the record never named was logged: %+v %v", forged, err) |
| + } |
| + for _, op := range []string{w.id, "mv_forged"} { |
| + for _, h := range []string{"acme.go.tito.io", "kass.carlosapp.dev"} { |
| + if got, err := w.fx.st.MoveLogPending(h, op); err != nil || len(got) != 0 { |
| + t.Fatalf("%s %s still pending: %+v %v", h, op, got, err) |
| + } |
| + } |
| + } |
| + // Retirement: once the instance's claim is gone, its rows are dropped. |
| + queue("acme.go.tito.io", w.id, "retired", inc) |
| + claimKey, err := hostclaim.Key("acme.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := w.fx.rel.Delete(context.Background(), claimKey); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := w.passErr(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := w.fx.st.MoveLogPending("acme.go.tito.io", w.id); err != nil || len(got) != 0 { |
| + t.Fatalf("a retired instance's rows still pending: %+v %v", got, err) |
| + } |
| +} |
diff --git a/internal/console/store.go b/internal/console/store.go| index 6cfab03cf..efa71a212 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -361,17 +361,19 @@ func (s *Store) migrate() error { |
| );`, |
| // The move pass's outbox (movesrecovery.go, Plan 6 Task 3): move log |
| // entries the pass owes and has not yet written, with their exact |
| - // bytes and the report prefix they go under, until the recovery |
| - // pass's drainer writes them or finds them written. An unconfirmed |
| - // row waits for the write it records to be confirmed; next_at is |
| - // the earliest next attempt, and warned marks the one journal |
| - // warning an hour-old row gets. |
| + // bytes and the instance incarnation their move's evidence carried, |
| + // until the recovery pass's drainer writes them, finds them written, |
| + // or finds the incarnation gone. An unconfirmed row waits for the |
| + // write it records to be confirmed; next_at is the earliest next |
| + // attempt, and warned marks the one journal warning an hour-old row |
| + // gets. This is the table's only definition: it exists only on this |
| + // unmerged branch, so no console database has an older shape. |
| `CREATE TABLE IF NOT EXISTS move_log_outbox ( |
| host TEXT NOT NULL, |
| op TEXT NOT NULL, |
| - name TEXT NOT NULL, |
| - prefix TEXT NOT NULL, |
| - body BLOB NOT NULL, |
| + name TEXT NOT NULL, |
| + incarnation TEXT NOT NULL, |
| + body BLOB NOT NULL, |
| queued_at INTEGER NOT NULL, |
| confirmed INTEGER NOT NULL, |
| attempts INTEGER NOT NULL DEFAULT 0, |
| @@ -2235,19 +2237,19 @@ ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at`, rule, at. |
| // MoveLogPendingEntry is one move log entry the move pass owes. |
| type MoveLogPendingEntry struct { |
| - Host, Op, Name, Prefix string |
| - Body []byte |
| - QueuedAt time.Time |
| - Confirmed, Warned bool |
| - Attempts int |
| + Host, Op, Name, Incarnation string |
| + Body []byte |
| + QueuedAt time.Time |
| + Confirmed, Warned bool |
| + Attempts int |
| } |
| // QueueMoveLog keeps an entry for the drainer. An entry already queued under |
| // the name is kept as it was. An unconfirmed entry is not delivered until |
| // ConfirmMoveLog. |
| -func (s *Store) QueueMoveLog(host, op, name, prefix string, body []byte, confirmed bool, at time.Time) error { |
| - _, err := s.db.Exec(`INSERT INTO move_log_outbox (host, op, name, prefix, body, queued_at, confirmed) VALUES (?, ?, ?, ?, ?, ?, ?) |
| -ON CONFLICT(host, op, name) DO NOTHING`, host, op, name, prefix, body, at.Unix(), confirmed) |
| +func (s *Store) QueueMoveLog(host, op, name, incarnation string, body []byte, confirmed bool, at time.Time) error { |
| + _, err := s.db.Exec(`INSERT INTO move_log_outbox (host, op, name, incarnation, body, queued_at, confirmed) VALUES (?, ?, ?, ?, ?, ?, ?) |
| +ON CONFLICT(host, op, name) DO NOTHING`, host, op, name, incarnation, body, at.Unix(), confirmed) |
| return err |
| } |
| @@ -2263,7 +2265,7 @@ func scanMoveLogRows(rows *sql.Rows) ([]MoveLogPendingEntry, error) { |
| for rows.Next() { |
| var e MoveLogPendingEntry |
| var at int64 |
| - if err := rows.Scan(&e.Host, &e.Op, &e.Name, &e.Prefix, &e.Body, &at, &e.Confirmed, &e.Attempts, &e.Warned); err != nil { |
| + if err := rows.Scan(&e.Host, &e.Op, &e.Name, &e.Incarnation, &e.Body, &at, &e.Confirmed, &e.Attempts, &e.Warned); err != nil { |
| return nil, err |
| } |
| e.QueuedAt = time.Unix(at, 0).UTC() |
| @@ -2272,7 +2274,7 @@ func scanMoveLogRows(rows *sql.Rows) ([]MoveLogPendingEntry, error) { |
| return out, rows.Err() |
| } |
| -const moveLogColumns = `host, op, name, prefix, body, queued_at, confirmed, attempts, warned` |
| +const moveLogColumns = `host, op, name, incarnation, body, queued_at, confirmed, attempts, warned` |
| // MoveLogPending is one move's queued entries, oldest first. |
| func (s *Store) MoveLogPending(host, op string) ([]MoveLogPendingEntry, error) { |
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go| index 804ff3bbc..8f0ea3e3d 100644 |
| --- a/internal/console/store_gate.go |
| +++ b/internal/console/store_gate.go |
| @@ -210,7 +210,7 @@ var claimSQLTables = map[string]claimSQLTable{ |
| "invites": {NonApp: "account invites, keyed by account and address; names no app"}, |
| "alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"}, |
| "move_pages": {NonApp: "the move pass's last page attempt per finding (rule names the host and the move); no app column"}, |
| - "move_log_outbox": {NonApp: "the move pass's undelivered move log entries (platform observability, never app state), keyed by host, move and entry, each with the report prefix it goes under; the writer re-derives the key and checks the host's claim still names that app; no app column"}, |
| + "move_log_outbox": {NonApp: "the move pass's undelivered move log entries, keyed by host, move and entry and bound to the instance incarnation their move's evidence carried; no account or app column: the writer takes both from the host's claim, only while its holder is that incarnation, for a move its record names, and a row of a retired or recreated instance is dropped"}, |
| "edge_proof_seen": {NonApp: "single-row edge proof marker"}, |
| "cli_auth_requests": {NonApp: "device-code logins in flight, keyed by code hash"}, |
| "step_ups": {NonApp: "passkey step-up marks, keyed by session hash"}, |
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt| index 15ad0defa..c43bbd3b4 100644 |
| --- a/internal/console/testdata/claim-authority-sites.txt |
| +++ b/internal/console/testdata/claim-authority-sites.txt |
| @@ -107,7 +107,7 @@ geoippublish.go|Commit|os.Open(st.path) |
| hostclaimbackfill.go|RunClaimRecoveryPass|a.Control.RunRecovery(ctx, s.runClaimRecoveryPassBody) |
| hostwakereset.go|registerWakeRetryReset|a.Control.RegisterStart(ctx, h.Account, h.App, reg.AppID, id, reg, registeredAt) |
| hostwakereset.go|registerWakeRetryReset|start.FinishWakeRetryReset(cleanup) |
| -movesrecovery.go|deliverMoveLog|lw.WriteMoveLog(ctx, hostops.MoveLogEntry{Account: account, App: app, Host: r.Host, Op: r.Op, Name: r.Name, Body: r.Body}) |
| +movesrecovery.go|deliverMoveLog|lw.WriteMoveLog(ctx, hostops.MoveLogEntry{Host: r.Host, Op: r.Op, Incarnation: r.Incarnation, Name: r.Name, Body: r.Body}) |
| movesrecovery.go|movesRecovery|a.Control.ConsoleMove(ctx, co, hostops.ConsoleMoveAbort) |
| movesrecovery.go|movesRecovery|g.Claim(ctx, d.Claim) |
| movesrecovery.go|movesRecovery|g.Convert(ctx, *in.Fence) |
diff --git a/internal/hostops/move_log_writer.go b/internal/hostops/move_log_writer.go| index 11f4753d9..123d0ad23 100644 |
| --- a/internal/hostops/move_log_writer.go |
| +++ b/internal/hostops/move_log_writer.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/carlosframework/platform/internal/controlname" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -26,11 +27,13 @@ const MoveLogEntryMax = 4096 |
| // transition, logged first by another writer. Nothing is written. |
| var ErrMoveLogTaken = errors.New("hostops: the move log entry was written by another writer") |
| -// MoveLogEntry is one move log entry to deliver: the move's account, app, |
| -// host and id, the entry's name (its identity) and its exact bytes. |
| +// MoveLogEntry is one move log entry to deliver: the move's host and id, the |
| +// instance incarnation the move's evidence carried, the entry's name (its |
| +// identity) and its exact bytes. It names no account or app: those are the |
| +// host's claim's, once its holder is that incarnation. |
| type MoveLogEntry struct { |
| - Account, App, Host, Op, Name string |
| - Body []byte |
| + Host, Op, Incarnation, Name string |
| + Body []byte |
| } |
| // MoveLogWriter is one recovery pass's move log writer, valid only while its |
| @@ -58,13 +61,17 @@ func runMoveLogDrain(ctx context.Context, hook func(context.Context, *MoveLogWri |
| // WriteMoveLog writes one entry, with the checks platform-1e set for the |
| // grant's Log, which this writer replaced (fix round 3, ruling 3): |
| // |
| -// - The key is derived, never given: e.Name under the report prefix of |
| -// e's account, app, host and move. Each is a valid name; the move id and |
| -// the entry name are one segment of letters, digits, '_' and '-'. |
| +// - The host's claim holder is e's incarnation (fix round 4, ruling 3): a |
| +// recreated instance (the same names, another incarnation), a retired |
| +// one (no claim) or another app's host never takes the entry. The |
| +// account and app are the holder's. |
| +// - The move is one the holder's record names: live at that incarnation, |
| +// or closed as its last move. The caller's move id is never taken alone. |
| +// - The key is derived, never given: e.Name under the report prefix of the |
| +// holder's account and app, e's host and move. The move id and the entry |
| +// name are one segment of letters, digits, '_' and '-'. |
| // - Body is at most MoveLogEntryMax bytes and decodes, strictly, as a |
| // moves.LogEvent of that move and host, named e.Name. |
| -// - The host's claim still names the app (so a purged or re-created |
| -// instance's log is never written). |
| // - The write is create-only. A replay of the same bytes is a no-op |
| // (created false); other bytes under the name are ErrMoveLogTaken. |
| // |
| @@ -79,7 +86,7 @@ func (lw *MoveLogWriter) WriteMoveLog(ctx context.Context, e MoveLogEntry) (crea |
| if !lw.state.active { |
| return false, ErrPending |
| } |
| - if !controlname.ValidAccount(e.Account) || !controlname.ValidApp(e.App) || !moveLogName(e.Op) || !moveLogName(e.Name) || len(e.Body) > MoveLogEntryMax { |
| + if e.Incarnation == "" || !moveLogName(e.Op) || !moveLogName(e.Name) || len(e.Body) > MoveLogEntryMax { |
| return false, ErrIdentity |
| } |
| if _, err := hostclaim.Key(e.Host); err != nil { |
| @@ -96,10 +103,30 @@ func (lw *MoveLogWriter) WriteMoveLog(ctx context.Context, e MoveLogEntry) (crea |
| if err != nil { |
| return false, err |
| } |
| - if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Holder.Account != e.Account || o.Claim.Holder.App != e.App { |
| + if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Holder.Incarnation != e.Incarnation { |
| return false, ErrIdentity |
| } |
| - key := moves.LogKeyFor(moves.ReportPrefix(e.Account, e.App, e.Host, e.Op), e.Name) |
| + h := o.Claim.Holder |
| + if !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) { |
| + return false, ErrIdentity |
| + } |
| + r, err := ReadRecord(ctx, lw.w.raw, h.Key) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !matches(r, h) { |
| + return false, ErrIdentity |
| + } |
| + rec, err := provision.DecodeRecord(r.Body) |
| + if err != nil { |
| + return false, ErrIdentity |
| + } |
| + live := rec.Move != nil && rec.Move.ID == e.Op && rec.Move.Incarnation == e.Incarnation |
| + closed := rec.LastMove != nil && rec.LastMove.ID == e.Op |
| + if !live && !closed { |
| + return false, ErrIdentity |
| + } |
| + key := moves.LogKeyFor(moves.ReportPrefix(h.Account, h.App, e.Host, e.Op), e.Name) |
| if _, err := lw.w.raw.Put(ctx, key, e.Body, ""); err == nil { |
| return true, nil |
| } else if !errors.Is(err, release.ErrCASConflict) { |
diff --git a/internal/hostops/move_log_writer_test.go b/internal/hostops/move_log_writer_test.go| index a10c21058..001e3d82a 100644 |
| --- a/internal/hostops/move_log_writer_test.go |
| +++ b/internal/hostops/move_log_writer_test.go |
| @@ -12,15 +12,16 @@ import ( |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| -// Plan 6 Task 3 fix round 3, ruling 1: the recovery pass's move log writer |
| +// Plan 6 Task 3 fix rounds 3 and 4: the recovery pass's move log writer |
| // (Recovery.MoveLogs) delivers the console's queued entries whatever their |
| -// moves now are, with the grant Log's key derivation and checks: the key is |
| -// derived from the entry's account, app, host and move under the report |
| -// prefix; the name is one segment and the entry's identity; the entry is at |
| -// most MoveLogEntryMax bytes and this move's and host's; the host's claim |
| -// still names the app; the write is create-only, a replay of the same bytes |
| -// a no-op and other bytes ErrMoveLogTaken. It answers only during its hook, |
| -// inside a recovery pass. |
| +// moves now are. The entry names a host, a move, the instance incarnation the |
| +// move's evidence carried, and an entry name; the account and app come from |
| +// the host's claim, whose holder must be that incarnation, and the move must |
| +// be one its record names (live, or closed as its last). The name is one |
| +// segment and the entry's identity; the entry is at most MoveLogEntryMax |
| +// bytes and this move's and host's; the write is create-only, a replay of the |
| +// same bytes a no-op and other bytes ErrMoveLogTaken. It answers only during |
| +// its hook, inside a recovery pass. |
| func TestMoveLogWriterWritesOnlyVerifiedEntries(t *testing.T) { |
| w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) {}) |
| ctx := context.Background() |
| @@ -31,7 +32,7 @@ func TestMoveLogWriterWritesOnlyVerifiedEntries(t *testing.T) { |
| } |
| return b |
| } |
| - good := MoveLogEntry{Account: "bab", App: "app", Host: w.host, Op: "move", Name: "done", Body: entry("move", w.host, "done", "console")} |
| + good := MoveLogEntry{Host: w.host, Op: "move", Incarnation: w.inc, Name: "done", Body: entry("move", w.host, "done", "console")} |
| with := func(f func(*MoveLogEntry)) MoveLogEntry { e := good; f(&e); return e } |
| big := moves.LogEvent{V: 1, Op: "move", Host: w.host, Event: "x", Identity: "done", Fields: map[string]string{"pad": strings.Repeat("a", MoveLogEntryMax)}} |
| bigBody, _ := json.Marshal(big) |
| @@ -49,14 +50,14 @@ func TestMoveLogWriterWritesOnlyVerifiedEntries(t *testing.T) { |
| return err |
| } |
| for what, e := range map[string]MoveLogEntry{ |
| - "other bytes": with(func(e *MoveLogEntry) { e.Body = entry("move", w.host, "done", "target") }), |
| - "another app": with(func(e *MoveLogEntry) { e.App = "other" }), |
| - "another account": with(func(e *MoveLogEntry) { e.Account = "cab" }), |
| - "another move's entry": with(func(e *MoveLogEntry) { e.Body = entry("other", w.host, "done", "console") }), |
| - "another host's entry": with(func(e *MoveLogEntry) { e.Body = entry("move", "elsewhere.example", "done", "console") }), |
| - "a name that is not the identity": with(func(e *MoveLogEntry) { e.Name = "opened" }), |
| - "a path": with(func(e *MoveLogEntry) { e.Name = "../source" }), |
| - "a move id that is a path": with(func(e *MoveLogEntry) { e.Op = "../move" }), |
| + "other bytes": with(func(e *MoveLogEntry) { e.Body = entry("move", w.host, "done", "target") }), |
| + "a stale incarnation (recreated under the same names)": with(func(e *MoveLogEntry) { e.Incarnation = strings.Repeat("ef", 32) }), |
| + "a move its record does not name": with(func(e *MoveLogEntry) { e.Op = "forged"; e.Body = entry("forged", w.host, "done", "console") }), |
| + "another move's entry": with(func(e *MoveLogEntry) { e.Body = entry("other", w.host, "done", "console") }), |
| + "another host's entry": with(func(e *MoveLogEntry) { e.Body = entry("move", "elsewhere.example", "done", "console") }), |
| + "a name that is not the identity": with(func(e *MoveLogEntry) { e.Name = "opened" }), |
| + "a path": with(func(e *MoveLogEntry) { e.Name = "../source" }), |
| + "a move id that is a path": with(func(e *MoveLogEntry) { e.Op = "../move" }), |
| "a host with no claim": with(func(e *MoveLogEntry) { |
| e.Host = "gone.example" |
| e.Body = entry("move", "gone.example", "done", "console") |
| @@ -98,3 +99,33 @@ func TestMoveLogWriterWritesOnlyVerifiedEntries(t *testing.T) { |
| t.Fatalf("a writer minted outside a recovery pass: %v", err) |
| } |
| } |
| + |
| +// A closed move is named by its record's last move alone, so the claim |
| +// holder's incarnation is what refuses a stale row there: the instance |
| +// recreated under the same names, whose record may still close the same |
| +// move id. |
| +func TestMoveLogWriterRefusesAStaleIncarnationOfAClosedMove(t *testing.T) { |
| + w := newC7World(t, func(c *hostclaim.Claim, r *provision.Record, _ *AppAdmission) { |
| + r.LastMove = &provision.MoveOutcome{ID: r.Move.ID, Outcome: "done", At: r.CreatedAt} |
| + r.Move = nil |
| + }) |
| + ctx := context.Background() |
| + body, err := json.Marshal(moves.LogEvent{V: 1, Op: "move", Host: w.host, Event: "done", Identity: "done", Actor: "console", At: w.now}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var stale, current error |
| + err = w.control.RunRecovery(ctx, func(ctx context.Context) error { |
| + return runMoveLogDrain(ctx, func(ctx context.Context, lw *MoveLogWriter) error { |
| + _, stale = lw.WriteMoveLog(ctx, MoveLogEntry{Host: w.host, Op: "move", Incarnation: strings.Repeat("ef", 32), Name: "done", Body: body}) |
| + _, current = lw.WriteMoveLog(ctx, MoveLogEntry{Host: w.host, Op: "move", Incarnation: w.inc, Name: "done", Body: body}) |
| + return nil |
| + }) |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !errors.Is(stale, ErrIdentity) || current != nil { |
| + t.Fatalf("stale incarnation: %v (want refused); current: %v", stale, current) |
| + } |
| +} |
diff --git a/internal/moves/transition.go b/internal/moves/transition.go| index 934ff6e49..94f9b7785 100644 |
| --- a/internal/moves/transition.go |
| +++ b/internal/moves/transition.go |
| @@ -8,11 +8,14 @@ import ( |
| // Transition is what one record write did to a move, as the move log names |
| // it (log.go): the move, the event, the identity that keys its log object, |
| -// and the fields it carries (the regions, the evidence's digests, timings, |
| -// a stuck's role). The box's writers and the console's move pass name their |
| -// writes with it, so a transition has one identity whoever writes it. |
| +// the fields it carries (the regions, the evidence's digests, timings, a |
| +// stuck's role), and the instance incarnation the move's record carried, |
| +// which binds a queued entry to that instance (the console's outbox). The |
| +// box's writers and the console's move pass name their writes with it, so a |
| +// transition has one identity whoever writes it. |
| type Transition struct { |
| Op, Event, Identity string |
| + Incarnation string |
| Fields map[string]string |
| } |
| @@ -93,12 +96,12 @@ func TransitionOf(before, after provision.Record) (Transition, bool) { |
| } |
| f := transitionFields(bm) |
| f["offline_s"] = strconv.FormatFloat(after.LastMove.OfflineS, 'f', -1, 64) |
| - return Transition{Op: bm.ID, Event: after.LastMove.Outcome, Identity: after.LastMove.Outcome, Fields: f}, true |
| + return Transition{Op: bm.ID, Event: after.LastMove.Outcome, Identity: after.LastMove.Outcome, Incarnation: bm.Incarnation, Fields: f}, true |
| } |
| if am.ID == "" || (bm != nil && bm.ID != am.ID) { |
| return Transition{}, false |
| } |
| - e := Transition{Op: am.ID, Fields: transitionFields(am)} |
| + e := Transition{Op: am.ID, Incarnation: am.Incarnation, Fields: transitionFields(am)} |
| if bm == nil { |
| bm = &provision.MoveState{} |
| } |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index b0b4e1bf0..cf1f72cdf 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -476,7 +476,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // The recovery pass's move log writer: one create-only entry of a move's |
| // log under its report prefix, for a host whose claim still names the |
| // app; never a record (Plan 6 Task 3 fix round 3, ruling 1). |
| - {"internal/hostops/move_log_writer.go", "MoveLogWriter.WriteMoveLog", "nonrecord", "sel:Put", "9dffdb6f7f15f7dfffc104e0f09df4b6c6d7868a31ee1c85cfb811a04ce5c90c"}, |
| + {"internal/hostops/move_log_writer.go", "MoveLogWriter.WriteMoveLog", "nonrecord", "sel:Put", "2241e022d7c13c03ad3011ef8175ec5308b9fdfbcd3452589269ee47003a4e7f"}, |
| {"internal/hostops/move_recovery_grant.go", "MoveRecoveryGrant.Record", "record-writer", "key:WriteRecord", "f7f04a9e2c579eaa7d254ce6310e2726d95855307d61f3f442ee9c1fa2a5d825"}, |
| // Plan 10: reviewed typed console move and loss writers. |
| {"internal/hostops/console_move_grant.go", "ControlWriter.ConsoleMove", "record-writer", "key:WriteRecord", "21e7a6ddc4dbe5b3bf06e65b39a228e2eb588af1edc43beb09d91dd72570dd44"}, |