console start: paging is armed from the start scan's evidence before the bootstrap page (fix round 2, item 6)
NewServer's start scan no longer pages; it keeps its answer (Server.SelfServeStartEvidence). serve() calls startPaging, which arms the sinks from that answer (one evidence read: the arm and recovery mode can no longer disagree) and then sends the bootstrap orphan page (Server.SelfServeStartupPages), which an armed sink now delivers. armMovesPaging takes the evidence as a function; its decision table tests hand it the store's evidence, and the source-order test now requires startPaging(ctx, cfg, srv, os.Getenv) before ConfigureClaimRuntime. Witness: TestBootstrapOrphanPageReachesTheSink (cmd/console; NewServer then startPaging, the webhook from the environment only, a counting sink). RED with the bootstrap page where it was: "reached the configured sink 0 times, want once".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+156
−19
cmd/console/main.go+21 −7cmd/console/movesswitch_test.go+15 −6cmd/console/startpaging_test.go+85 −0internal/console/selfserve_pass.go+28 −3internal/console/server.go+7 −3
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 42905355d..fba7ef780 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -493,8 +493,10 @@ func serve() { |
| go sweepLegacyMail(cfg.HostClaim, rel, st) |
| } |
| // The move pass's pages (moves Plan 5 Task 7): the console's alert sinks, |
| - // armed before the claim runtime binds the pass, so no pass runs unarmed. |
| - armMovesPaging(context.Background(), cfg, st, os.Getenv) |
| + // armed before the claim runtime binds the pass, so no pass runs unarmed; |
| + // armed from NewServer's start scan, and only then the bootstrap orphan |
| + // page (startPaging). |
| + startPaging(context.Background(), cfg, srv, os.Getenv) |
| if err := srv.ConfigureClaimRuntime(context.Background()); err != nil { |
| log.Fatalf("console: claim recovery: %v", err) |
| } |
| @@ -1481,18 +1483,30 @@ func applyMovesSwitch(cfg *console.Config, getenv func(string) string) { |
| cfg.MovesEnabled = moves.Enabled(getenv) |
| } |
| +// startPaging is the start's paging, in order (self-serve moves plan 2, |
| +// fix round 2): the sinks armed from the start scan's own evidence |
| +// (Server.SelfServeStartEvidence, the read NewServer made for recovery |
| +// mode, so the two never disagree), then the bootstrap orphan page |
| +// (Server.SelfServeStartupPages), which a sink can now deliver. It reports |
| +// whether paging was armed. |
| +func startPaging(ctx context.Context, cfg console.Config, srv *console.Server, getenv func(string) string) bool { |
| + armed := armMovesPaging(ctx, cfg, srv.SelfServeStartEvidence, getenv) |
| + srv.SelfServeStartupPages(ctx) |
| + return armed |
| +} |
| + |
| // armMovesPaging arms alerting's sinks from CARLOS_ALERT_WEBHOOK and |
| // CARLOS_ALERT_SNS_TOPIC_ARN for claim recovery and its optional move pass, |
| // and for the self-serve moves pass. Claim loss pages run even when moves |
| // are disabled. A claims-off console arms when self-serve mode's |
| // conditions hold: the switch on, or anything the pass still owes in the |
| -// journal or the reservations (console.SelfServeEvidence). A read that |
| -// fails arms, logged: a pass that may have moves to page about never runs |
| -// unarmed. |
| -func armMovesPaging(ctx context.Context, cfg console.Config, st *console.Store, getenv func(string) string) bool { |
| +// journal or the reservations, as evidence answers it (the start scan's, |
| +// Server.SelfServeStartEvidence). Evidence that could not be read arms, |
| +// logged: a pass that may have moves to page about never runs unarmed. |
| +func armMovesPaging(ctx context.Context, cfg console.Config, evidence func() (bool, error), getenv func(string) string) bool { |
| arm := cfg.HostClaim || cfg.SelfServeSwitchOn() |
| if !arm { |
| - found, err := console.SelfServeEvidence(ctx, st, cfg.Releases) |
| + found, err := evidence() |
| if err != nil { |
| log.Printf("console: self-serve moves: reading the journal for paging: %v (paging armed)", err) |
| } |
diff --git a/cmd/console/movesswitch_test.go b/cmd/console/movesswitch_test.go| index fa334d5e8..ed5880968 100644 |
| --- a/cmd/console/movesswitch_test.go |
| +++ b/cmd/console/movesswitch_test.go |
| @@ -108,8 +108,8 @@ func isIdent(e ast.Expr, name string) bool { |
| // The console pages from its move pass (moves Plan 5 Task 7, delta M14): |
| // armMovesPaging arms alerting's sinks from the agent's variables on a |
| -// host-claim console, with or without moves; serve() |
| -// calls it with os.Getenv before ConfigureClaimRuntime, so the recovery |
| +// host-claim console, with or without moves; serve() calls it through |
| +// startPaging with os.Getenv before ConfigureClaimRuntime, so the recovery |
| // pass never runs unarmed. |
| func TestArmMovesPagingWithClaims(t *testing.T) { |
| env := func(k string) string { |
| @@ -120,7 +120,7 @@ func TestArmMovesPagingWithClaims(t *testing.T) { |
| } |
| for _, tc := range []struct{ claim, moves, want bool }{{false, false, false}, {true, false, true}, {false, true, false}, {true, true, true}} { |
| cfg := console.Config{HostClaim: tc.claim, MovesEnabled: tc.moves} |
| - if got := armMovesPaging(context.Background(), cfg, pagingStore(t), env); got != tc.want { |
| + if got := armMovesPaging(context.Background(), cfg, storeEvidence(pagingStore(t), nil), env); got != tc.want { |
| t.Errorf("claim %v moves %v: armed %v, want %v", tc.claim, tc.moves, got, tc.want) |
| } |
| } |
| @@ -143,7 +143,7 @@ func TestArmMovesPagingWithClaims(t *testing.T) { |
| } |
| switch fun := call.Fun.(type) { |
| case *ast.Ident: |
| - if fun.Name == "armMovesPaging" && len(call.Args) == 4 && isIdent(call.Args[2], "st") { |
| + if fun.Name == "startPaging" && len(call.Args) == 4 && isIdent(call.Args[2], "srv") { |
| if sel, ok := call.Args[3].(*ast.SelectorExpr); ok && isIdent(sel.X, "os") && sel.Sel.Name == "Getenv" { |
| armed = call.Pos() |
| } |
| @@ -157,10 +157,17 @@ func TestArmMovesPagingWithClaims(t *testing.T) { |
| }) |
| } |
| if armed == 0 || configured == 0 || armed > configured { |
| - t.Fatalf("serve() must call armMovesPaging(ctx, cfg, st, os.Getenv) before ConfigureClaimRuntime (armed %v, configured %v)", armed, configured) |
| + t.Fatalf("serve() must call startPaging(ctx, cfg, srv, os.Getenv) before ConfigureClaimRuntime (armed %v, configured %v)", armed, configured) |
| } |
| } |
| +// storeEvidence answers the self-serve evidence of st and rel as the start |
| +// scan reads it (console.SelfServeEvidence), for the arming decision's |
| +// table; serve() hands the start scan's own answer (startPaging). |
| +func storeEvidence(st *console.Store, rel release.Store) func() (bool, error) { |
| + return func() (bool, error) { return console.SelfServeEvidence(context.Background(), st, rel) } |
| +} |
| + |
| // pagingStore is an empty console store. |
| func pagingStore(t *testing.T) *console.Store { |
| t.Helper() |
| @@ -185,7 +192,9 @@ func TestPagingArmedForSelfServe(t *testing.T) { |
| } |
| t.Cleanup(func() { alerting.Arm(nil) }) |
| ctx := context.Background() |
| - arm := func(cfg console.Config, st *console.Store) bool { return armMovesPaging(ctx, cfg, st, env) } |
| + arm := func(cfg console.Config, st *console.Store) bool { |
| + return armMovesPaging(ctx, cfg, storeEvidence(st, cfg.Releases), env) |
| + } |
| if arm(console.Config{}, pagingStore(t)) { |
| t.Error("a claims-off console with the switch off and nothing owed armed paging") |
diff --git a/cmd/console/startpaging_test.go b/cmd/console/startpaging_test.go| new file mode 100644 |
| index 000000000..785a1d211 |
| --- /dev/null |
| +++ b/cmd/console/startpaging_test.go |
| @@ -0,0 +1,85 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/alerting" |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestBootstrapOrphanPageReachesTheSink (fix round 2, item 6): in serve()'s |
| +// start order, with the alert webhook configured only through the |
| +// environment (no manual arming), a reservation no move owns at start pages |
| +// once, and that page reaches the sink: paging is armed from the start |
| +// scan's own evidence before the bootstrap orphan scan runs. |
| +func TestBootstrapOrphanPageReachesTheSink(t *testing.T) { |
| + var mu sync.Mutex |
| + var subjects []string |
| + hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _ := io.ReadAll(r.Body) |
| + var p struct{ Subject string } |
| + _ = json.Unmarshal(b, &p) |
| + mu.Lock() |
| + subjects = append(subjects, p.Subject) |
| + mu.Unlock() |
| + })) |
| + t.Cleanup(hook.Close) |
| + t.Cleanup(func() { alerting.Arm(nil) }) |
| + env := func(k string) string { |
| + if k == "CARLOS_ALERT_WEBHOOK" { |
| + return hook.URL |
| + } |
| + return "" |
| + } |
| + rel := release.NewDirStore(t.TempDir()) |
| + orphan := "mv_orphanorphanorphanorphanor" |
| + body, err := json.Marshal(moves.OfflineActiveBody(map[string]moves.OfflineReservation{ |
| + "gone-box": {Kind: moves.OfflineKindMove, ID: orphan, Host: "shop.example.com", Account: "bab", App: "shop", |
| + ReservedAt: time.Now().Add(-time.Hour).UTC()}})) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := rel.Put(context.Background(), moves.OfflineActiveKey, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, _, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dir := t.TempDir() |
| + cfg := console.Config{Origin: "http://127.0.0.1", Keymail: "http://keymail.invalid", Registry: filepath.Join(dir, "registry.db"), |
| + Releases: rel, ReleasePub: pub, AppScheme: "http"} |
| + st := pagingStore(t) |
| + // serve()'s order: the server, then paging. |
| + srv, err := console.NewServer(cfg, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !startPaging(context.Background(), cfg, srv, env) { |
| + t.Fatal("the start scan's evidence did not arm paging") |
| + } |
| + mu.Lock() |
| + defer mu.Unlock() |
| + n := 0 |
| + for _, s := range subjects { |
| + if strings.Contains(s, "self-serve move needs an operator") { |
| + n++ |
| + } |
| + } |
| + if n != 1 { |
| + t.Fatalf("the bootstrap orphan page reached the configured sink %d times, want once (subjects %v)", n, subjects) |
| + } |
| +} |
diff --git a/internal/console/selfserve_pass.go b/internal/console/selfserve_pass.go| index f2c5e9032..2f3d1735e 100644 |
| --- a/internal/console/selfserve_pass.go |
| +++ b/internal/console/selfserve_pass.go |
| @@ -70,10 +70,35 @@ func (s *Server) selfServeScanAtStart(ctx context.Context) { |
| log.Printf("console: self-serve moves: reading at start: %v (recovery mode on; the pass retries each tick)", err) |
| } |
| s.selfServeRecovery.Store(found) |
| - if found && err == nil && s.cfg.Releases != nil { |
| - // A reservation no move owns pages from the start, as every tick. |
| - s.selfServeOrphanScan(ctx, time.Now()) |
| + // The scan's answer is the one evidence read of the start: the paging |
| + // arm reuses it (SelfServeStartEvidence), and the bootstrap orphan page |
| + // waits for the arm (SelfServeStartupPages), so it is never sent, or |
| + // charged to the throttle, before a sink can deliver it (fix round 2). |
| + s.selfServeStartFound, s.selfServeStartErr = found, err |
| +} |
| + |
| +// SelfServeStartEvidence is the start scan's answer (selfServeScanAtStart, |
| +// in NewServer): whether the console started with anything the pass still |
| +// owes, and the read's error when it could not tell (then found is true: |
| +// fail closed). cmd/console arms paging from it, so the arm and recovery |
| +// mode never decide from two reads that can disagree. A host-claim console |
| +// scans nothing: false, nil. |
| +func (s *Server) SelfServeStartEvidence() (bool, error) { |
| + return s.selfServeStartFound, s.selfServeStartErr |
| +} |
| + |
| +// SelfServeStartupPages is the bootstrap orphan page: a reservation no |
| +// move owns, found by the start scan, pages from the start, as every tick |
| +// does. cmd/console calls it once paging is armed (startPaging). Bounded |
| +// by the evidence read's deadline; read failures page nothing (the pass |
| +// retries each tick). |
| +func (s *Server) SelfServeStartupPages(ctx context.Context) { |
| + if s.cfg.HostClaim || s.cfg.Releases == nil || !s.selfServeStartFound || s.selfServeStartErr != nil { |
| + return |
| } |
| + ctx, cancel := context.WithTimeout(ctx, selfServeEvidenceTimeout) |
| + defer cancel() |
| + s.selfServeOrphanScan(ctx, time.Now()) |
| } |
| // selfServeReevaluate is the tick's re-evaluation of recovery mode: a read |
diff --git a/internal/console/server.go b/internal/console/server.go| index 103c9b20a..70a150cdc 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -896,9 +896,13 @@ type Server struct { |
| // sweep share it under returnSweepMu. |
| returnSweepMu sync.Mutex |
| returnSweepAfter string |
| - selfServeLeaseMu sync.Mutex |
| - selfServeLease *leaseToken |
| - selfServeClock func() time.Time |
| + // selfServeStartFound and selfServeStartErr are the start scan's |
| + // answer (SelfServeStartEvidence), written once in NewServer. |
| + selfServeStartFound bool |
| + selfServeStartErr error |
| + selfServeLeaseMu sync.Mutex |
| + selfServeLease *leaseToken |
| + selfServeClock func() time.Time |
| // selfServeRecovery is recovery mode (selfServeMode): set by the scan |
| // at start, re-evaluated by every tick of the pass. |
| selfServeRecovery atomic.Bool |