feat/claim-loss-pages
Squashed from feat/claim-loss-pages into main. Co-Authored-By: claude <claude~paul@keymail.dev>
13 files changed,
+959
−9
approved_moves_copy_test.go+3 −0cmd/console/main.go+3 −4cmd/console/movesswitch_test.go+3 −3internal/console/claim_authority_census_test.go+5 −1internal/console/claim_loss_pages.go+166 −0internal/console/claim_loss_pages_test.go+730 −0internal/console/claim_runtime.go+1 −0internal/console/server.go+2 −0internal/console/store_gate.go+19 −0internal/console/testdata/claim-authority-sites.txt+10 −0internal/hostops/recovery.go+5 −0internal/provision/plan9_b8_census_test.go+2 −0testdata/claim-moves-copy.md+10 −1
CI passed — run details
CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok amadan.net/carlos/platform.git/launchctx 0.011s
? amadan.net/carlos/platform.git/launchctx/controlname [no test files]
? amadan.net/carlos/platform.git/launchctx/wireid [no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok github.com/carlosframework/platform 317.550s
? github.com/carlosframework/platform/cmd/changelog [no test files]
ok github.com/carlosframework/platform/cmd/console 3.243s
ok github.com/carlosframework/platform/cmd/moveprobe 93.285s
ok github.com/carlosframework/platform/internal/activator 209.026s
ok github.com/carlosframework/platform/internal/agentsock 0.033s
ok github.com/carlosframework/platform/internal/alerting 0.006s
ok github.com/carlosframework/platform/internal/analytics 0.022s
ok github.com/carlosframework/platform/internal/appconfig 2.246s
ok github.com/carlosframework/platform/internal/apperr 0.124s
ok github.com/carlosframework/platform/internal/applog 0.206s
ok github.com/carlosframework/platform/internal/awsinv 0.019s
ok github.com/carlosframework/platform/internal/boxchoice 0.010s
ok github.com/carlosframework/platform/internal/boxid 0.692s
? github.com/carlosframework/platform/internal/boxid/boxidtest [no test files]
? github.com/carlosframework/platform/internal/boxid/internal/signtest [no test files]
ok github.com/carlosframework/platform/internal/boxvolumes 0.012s
ok github.com/carlosframework/platform/internal/caps 0.016s
ok github.com/carlosframework/platform/internal/certwarm 0.003s
ok github.com/carlosframework/platform/internal/cgroup 0.203s
ok github.com/carlosframework/platform/internal/changelog 0.265s
ok github.com/carlosframework/platform/internal/clamd 0.061s
? github.com/carlosframework/platform/internal/cliflag [no test files]
ok github.com/carlosframework/platform/internal/console 355.830s
? github.com/carlosframework/platform/internal/controlname [no test files]
? github.com/carlosframework/platform/internal/controlsock [no test files]
ok github.com/carlosframework/platform/internal/dbdoor 6.512s
ok github.com/carlosframework/platform/internal/dbdoor/dbdoortest 0.011s
ok github.com/carlosframework/platform/internal/dbverify 0.006s
ok github.com/carlosframework/platform/internal/dnsrec 0.006s
ok github.com/carlosframework/platform/internal/docsite 0.006s
ok github.com/carlosframework/platform/internal/domains 1.980s
ok github.com/carlosframework/platform/internal/doorkey 0.007s
ok github.com/carlosframework/platform/internal/economics 0.006s
ok github.com/carlosframework/platform/internal/edge 25.698s
? github.com/carlosframework/platform/internal/edgeproof [no test files]
ok github.com/carlosframework/platform/internal/edgesfile 0.007s
ok github.com/carlosframework/platform/internal/fleetapi 0.125s
ok github.com/carlosframework/platform/internal/gate 1.655s
ok github.com/carlosframework/platform/internal/genselect 0.016s
ok github.com/carlosframework/platform/internal/geo 0.012s
ok github.com/carlosframework/platform/internal/geoip 0.037s
ok github.com/carlosframework/platform/internal/host 54.422s
ok github.com/carlosframework/platform/internal/hostactors 0.420s
ok github.com/carlosframework/platform/internal/hostbox 1.400s
ok github.com/carlosframework/platform/internal/hostclaim 0.235s
ok github.com/carlosframework/platform/internal/hostevidence 0.005s
ok github.com/carlosframework/platform/internal/hostexec 0.311s
ok github.com/carlosframework/platform/internal/hostfence 0.044s
ok github.com/carlosframework/platform/internal/hostfiles 4.155s
ok github.com/carlosframework/platform/internal/hostfloor 0.018s
ok github.com/carlosframework/platform/internal/hostguard 0.146s
ok github.com/carlosframework/platform/internal/hostops 1.346s
? github.com/carlosframework/platform/internal/hostops/fencerelease [no test files]
ok github.com/carlosframework/platform/internal/hostops/internal/fencerelease 0.002s
ok github.com/carlosframework/platform/internal/hostquarantine 0.004s
ok github.com/carlosframework/platform/internal/hostresolve 0.141s
ok github.com/carlosframework/platform/internal/hostwrite 0.004s
ok github.com/carlosframework/platform/internal/infraview 0.002s
ok github.com/carlosframework/platform/internal/instancectl 1.704s
ok github.com/carlosframework/platform/internal/instanceexec 2.682s
ok github.com/carlosframework/platform/internal/instancefs 0.003s
ok github.com/carlosframework/platform/internal/instancekey 0.003s
ok github.com/carlosframework/platform/internal/instanceupdates 0.006s
ok github.com/carlosframework/platform/internal/layout 1.328s
ok github.com/carlosframework/platform/internal/lease 9.431s
ok github.com/carlosframework/platform/internal/lease/leasetest 0.008s
ok github.com/carlosframework/platform/internal/ledger 0.015s
ok github.com/carlosframework/platform/internal/mail 0.161s
ok github.com/carlosframework/platform/internal/maintenance 0.076s
ok github.com/carlosframework/platform/internal/maintenanceexec 5.532s
ok github.com/carlosframework/platform/internal/maintenanceguard 0.072s
ok github.com/carlosframework/platform/internal/metrics 0.007s
ok github.com/carlosframework/platform/internal/moves 21.077s
ok github.com/carlosframework/platform/internal/moves/archive 0.729s
ok github.com/carlosframework/platform/internal/objstore 0.156s
ok github.com/carlosframework/platform/internal/ops 0.151s
ok github.com/carlosframework/platform/internal/park 0.182s
ok github.com/carlosframework/platform/internal/privatefile 0.007s
ok github.com/carlosframework/platform/internal/provision 3.140s
ok github.com/carlosframework/platform/internal/publicrelease 0.009s
? github.com/carlosframework/platform/internal/regionname [no test files]
ok github.com/carlosframework/platform/internal/registry 3.680s
ok github.com/carlosframework/platform/internal/relay 7.222s
ok github.com/carlosframework/platform/internal/release 18.439s
ok github.com/carlosframework/platform/internal/rollout 3.232s
ok github.com/carlosframework/platform/internal/rolloutwords 0.003s
ok github.com/carlosframework/platform/internal/rootaws 0.006s
ok github.com/carlosframework/platform/internal/s3compat 0.005s
ok github.com/carlosframework/platform/internal/safefile 0.004s
ok github.com/carlosframework/platform/internal/schedule 2.198s
ok github.com/carlosframework/platform/internal/sim/hostlife 2.350s
ok github.com/carlosframework/platform/internal/sim/litestreamfacts 3.432s
ok github.com/carlosframework/platform/internal/status 0.039s
ok github.com/carlosframework/platform/internal/steering 0.005s
ok github.com/carlosframework/platform/internal/stopevidence 0.088s
ok github.com/carlosframework/platform/internal/svcrole 0.002s
ok github.com/carlosframework/platform/internal/telemetry 0.004s
ok github.com/carlosframework/platform/internal/termfence 0.004s
ok github.com/carlosframework/platform/internal/tunnelclient 0.004s
ok github.com/carlosframework/platform/internal/unixdial 0.003s
ok github.com/carlosframework/platform/internal/updatewords 0.002s
ok github.com/carlosframework/platform/internal/usage 0.004s
ok github.com/carlosframework/platform/internal/version 0.002s
ok github.com/carlosframework/platform/internal/vet 1.542s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
minio: cached, digest verified
mc: cached, digest verified
minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
buckets and scoped user ready
=== RUN TestDeliveredContractMinio
=== RUN TestDeliveredContractMinio/put
=== RUN TestDeliveredContractMinio/get_round-trips_the_body
=== RUN TestDeliveredContractMinio/list_shows_the_key
=== RUN TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN TestDeliveredContractMinio/delete_removes_it
=== RUN TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
--- PASS: TestDeliveredContractMinio/put (0.00s)
--- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
--- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
--- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
--- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
--- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
--- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok github.com/carlosframework/platform/internal/objstore 0.022s
=== RUN TestT4StorageProbeMinio
2026/10/12 16:51:30 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DDD655C21FD559, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok github.com/carlosframework/platform/internal/host 0.020s
minio contract: PASS
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index eef047380..e15cbdf49 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -41,6 +41,7 @@ const movesCopyRecordPath = "docs/superpowers/plans/2026-10-06-moves-phase1-copy |
| var movesCopyIDs = []string{ |
| "MV-N391", |
| "HC5-N1", "HC5-N2", "HC5-N3", |
| + "HC5-N4", "HC5-N5", "HC5-N6", "HC5-N7", |
| "M-C1", "M-C2", "M-C3", "M-C4", "M-C5", "M-C6", "M-C7", "M-C8", "M-C9", |
| "M-C10", "M-C11", "M-C12", "M-C13", "M-C14", "M-C15", "M-C16", "M-C17", |
| "M-C18", "M-C19", "M-C20", "M-C21", "M-C22", "MV-N1", "MV-N2", "MV-N3", |
| @@ -1152,6 +1153,8 @@ func TestMovesTextAppliedPlaceholders(t *testing.T) { |
| // copy" task appends the files it touched; TestMovesScanFilesCoverTheDiff |
| // fails when the list falls behind. |
| var movesScanFiles = []string{ |
| + // Claim-owned pages share the alert transport; keep their drafts in the copy census. |
| + "internal/console/claim_loss_pages.go", |
| "internal/host/claimhandover_crossbox.go", |
| "internal/host/claimhandover_recovery.go", |
| "internal/host/claimretire_local.go", |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index f4b243819..07d52df85 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -1350,11 +1350,10 @@ func applyMovesSwitch(cfg *console.Config, getenv func(string) string) { |
| } |
| // armMovesPaging arms alerting's sinks from CARLOS_ALERT_WEBHOOK and |
| -// CARLOS_ALERT_SNS_TOPIC_ARN, the agent's variables, for the console's move |
| -// pass (moves Plan 5 Task 7, delta M14). Only a host-claim console with the |
| -// moves switch on runs that pass, so only it arms; it reports whether it did. |
| +// CARLOS_ALERT_SNS_TOPIC_ARN for claim recovery and its optional move pass. |
| +// Claim loss pages run even when moves are disabled. |
| func armMovesPaging(ctx context.Context, cfg console.Config, getenv func(string) string) bool { |
| - if !cfg.HostClaim || !cfg.MovesEnabled { |
| + if !cfg.HostClaim { |
| return false |
| } |
| alerting.Arm(alerting.FromEnv(ctx, getenv, nil)) |
diff --git a/cmd/console/movesswitch_test.go b/cmd/console/movesswitch_test.go| index 6173a520a..48730c28d 100644 |
| --- a/cmd/console/movesswitch_test.go |
| +++ b/cmd/console/movesswitch_test.go |
| @@ -103,17 +103,17 @@ 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 the moves switch on, and nowhere else; serve() |
| +// host-claim console, with or without moves; serve() |
| // calls it with os.Getenv before ConfigureClaimRuntime, so the recovery |
| // pass never runs unarmed. |
| -func TestArmMovesPagingOnlyWithBothSwitches(t *testing.T) { |
| +func TestArmMovesPagingWithClaims(t *testing.T) { |
| env := func(k string) string { |
| if k == "CARLOS_ALERT_WEBHOOK" { |
| return "http://example.test/hook" |
| } |
| return "" |
| } |
| - for _, tc := range []struct{ claim, moves, want bool }{{false, false, false}, {true, false, false}, {false, true, false}, {true, true, true}} { |
| + 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, env); got != tc.want { |
| t.Errorf("claim %v moves %v: armed %v, want %v", tc.claim, tc.moves, got, tc.want) |
diff --git a/internal/console/claim_authority_census_test.go b/internal/console/claim_authority_census_test.go| index ee731d684..a0e03fae4 100644 |
| --- a/internal/console/claim_authority_census_test.go |
| +++ b/internal/console/claim_authority_census_test.go |
| @@ -73,7 +73,11 @@ func claimAuthoritySites(t *testing.T) []string { |
| case *ast.Ident: |
| method = c.Name |
| } |
| - if !selected[method] { |
| + // Loss paging reads signed control evidence and writes only its |
| + // NonApp delivery journal. Inventory those sites explicitly. |
| + lossPageSite := e.Name() == "claim_loss_pages.go" && (method == "List" || method == "ReadLossRecord" || method == "Exec" || method == "ExecContext" || method == "QueryRowContext" || method == "ClaimLossPageAttempt") |
| + lossPageSchema := e.Name() == "store_gate.go" && fn.Name.Name == "OpenGated" && method == "Exec" |
| + if !selected[method] && !lossPageSite && !lossPageSchema { |
| return true |
| } |
| var b bytes.Buffer |
diff --git a/internal/console/claim_loss_pages.go b/internal/console/claim_loss_pages.go| new file mode 100644 |
| index 000000000..75ef9b2be |
| --- /dev/null |
| +++ b/internal/console/claim_loss_pages.go |
| @@ -0,0 +1,166 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "sort" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/alerting" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostops" |
| +) |
| + |
| +const ( |
| + claimLossPageInterval = time.Hour |
| + claimLossPageDailyInterval = 24 * time.Hour |
| + claimLossPageBudget = 30 * time.Second |
| +) |
| + |
| +// claimLossReadBudget bounds one record's read inside a scan. A record whose |
| +// read keeps stalling then fails on its own and the scan moves past it, |
| +// retrying it next lap, instead of spending the whole scan budget on it every |
| +// time and starving the records behind it. |
| +var claimLossReadBudget = 5 * time.Second |
| + |
| +// Recovery schedules one bounded scan. A slow sink holds only this worker; |
| +// later recovery passes skip scheduling until it exits. |
| +func (s *Server) scheduleClaimLossPages(ctx context.Context) { |
| + if !s.cfg.HostClaim || ctx.Err() != nil { |
| + return |
| + } |
| + s.claimLossPageMu.Lock() |
| + defer s.claimLossPageMu.Unlock() |
| + if s.claimLossPageDone != nil { |
| + select { |
| + case <-s.claimLossPageDone: |
| + default: |
| + return |
| + } |
| + } |
| + done := make(chan struct{}) |
| + s.claimLossPageDone = done |
| + go func() { |
| + defer close(done) |
| + pass, cancel := context.WithTimeout(ctx, claimLossPageBudget) |
| + defer cancel() |
| + if err := s.pageClaimLosses(pass); err != nil { |
| + log.Printf("console: claim recovery: %v", err) |
| + } |
| + }() |
| +} |
| + |
| +func (s *Server) pageClaimLosses(ctx context.Context) error { |
| + return s.pageClaimLossesAt(ctx, time.Now) |
| +} |
| + |
| +// Scan records independently of claims: loss evidence outlives a holder or |
| +// operation, and may still need attention after its claim has gone. |
| +func (s *Server) pageClaimLossesAt(ctx context.Context, now func() time.Time) error { |
| + if !s.cfg.HostClaim { |
| + return nil |
| + } |
| + if s.cfg.Releases == nil || s.store == nil || len(s.cfg.ReleasePub) == 0 { |
| + return hostops.ErrContract |
| + } |
| + var after string |
| + if err := s.store.db.QueryRowContext(ctx, `SELECT last_key FROM claim_loss_pages WHERE rule = 'scan'`).Scan(&after); err != nil && !errors.Is(err, sql.ErrNoRows) { |
| + return err |
| + } |
| + keys, err := s.cfg.Releases.List(ctx, "control/host-loss/") |
| + if err != nil { |
| + return err |
| + } |
| + // Store listings need not be ordered. Visit each key once, wrapping even |
| + // when the saved key has since been removed or earlier keys were added. |
| + sort.Strings(keys) |
| + start := sort.Search(len(keys), func(i int) bool { return keys[i] > after }) |
| + var errs []error |
| + for i := range keys { |
| + if err := ctx.Err(); err != nil { |
| + return errors.Join(append(errs, err)...) |
| + } |
| + key := keys[(start+i)%len(keys)] |
| + errs = append(errs, s.pageClaimLossRecord(ctx, key, now)) |
| + if err := ctx.Err(); err != nil { |
| + return errors.Join(append(errs, err)...) |
| + } |
| + // Never advance past canceled work. Other record errors are retried on |
| + // the next lap so one bad record cannot block everything after it. |
| + if _, err := s.store.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at, last_key) VALUES ('scan', 0, 0, ?) |
| +ON CONFLICT(rule) DO UPDATE SET last_key = excluded.last_key`, key); err != nil { |
| + return errors.Join(append(errs, err)...) |
| + } |
| + } |
| + return errors.Join(errs...) |
| +} |
| + |
| +func (s *Server) pageClaimLossRecord(ctx context.Context, key string, now func() time.Time) error { |
| + id := strings.TrimSuffix(strings.TrimPrefix(key, "control/host-loss/"), ".json") |
| + exact, err := hostclaim.LossRecordKey(id) |
| + if err != nil || exact != key { |
| + return hostclaim.ErrLossRecord |
| + } |
| + read, cancel := context.WithTimeout(ctx, claimLossReadBudget) |
| + observed, err := hostclaim.ReadLossRecord(read, s.cfg.Releases, s.cfg.ReleasePub, id) |
| + cancel() |
| + if errors.Is(err, hostclaim.ErrLossRecordAbsent) { |
| + return nil |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + r := observed.Record |
| + var errs []error |
| + if r.AlertsPending { |
| + subject, body := claimLossPendingPage(r.Host, r.ID) |
| + errs = append(errs, s.pageClaimLoss(ctx, "pending", r, now, subject, body)) |
| + } |
| + if r.Timers.Knowledge == hostclaim.TimersPartial || r.Timers.Knowledge == hostclaim.TimersUnknown { |
| + subject, body := claimTimerLossPage(r.Host, r.ID) |
| + errs = append(errs, s.pageClaimLoss(ctx, "timers", r, now, subject, body)) |
| + } |
| + return errors.Join(errs...) |
| +} |
| + |
| +func (s *Server) pageClaimLoss(ctx context.Context, condition string, r hostclaim.LossRecord, now func() time.Time, subject, body string) error { |
| + if err := ctx.Err(); err != nil { |
| + return err |
| + } |
| + attempted, err := s.store.ClaimLossPageAttempt(ctx, condition+" "+r.ID, now()) |
| + if err != nil || !attempted { |
| + return err |
| + } |
| + return alerting.Page(ctx, alerting.LevelWarning, subject, body) |
| +} |
| + |
| +// ClaimLossPageAttempt reserves the attempt before delivery, including failed |
| +// sends. The conditional write also serializes overlapping recovery passes. |
| +func (s *Store) ClaimLossPageAttempt(ctx context.Context, rule string, now time.Time) (bool, error) { |
| + result, err := s.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at) VALUES (?, ?, ?) |
| +ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at |
| +WHERE claim_loss_pages.attempted_at <= CASE |
| +WHEN claim_loss_pages.first_attempted_at > ? THEN ? ELSE ? END`, |
| + rule, now.UnixNano(), now.UnixNano(), now.Add(-claimLossPageDailyInterval).UnixNano(), |
| + now.Add(-claimLossPageInterval).UnixNano(), now.Add(-claimLossPageDailyInterval).UnixNano()) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := result.RowsAffected() |
| + return n == 1, err |
| +} |
| + |
| +// Draft operator copy. The controller collects these strings for approval. |
| +func claimLossPendingPage(host, id string) (subject, body string) { |
| + return fmt.Sprintf("Loss recorded for %s", host), |
| + fmt.Sprintf("A loss was recorded for host %s. Review loss record %s and check what needs recovery.", host, id) |
| +} |
| + |
| +func claimTimerLossPage(host, id string) (subject, body string) { |
| + return fmt.Sprintf("Timers may be lost for %s", host), |
| + fmt.Sprintf("Timers may have been lost with the box serving host %s. Check loss record %s. Check which timers are missing and re-arm them.", host, id) |
| +} |
diff --git a/internal/console/claim_loss_pages_test.go b/internal/console/claim_loss_pages_test.go| new file mode 100644 |
| index 000000000..27771f3f7 |
| --- /dev/null |
| +++ b/internal/console/claim_loss_pages_test.go |
| @@ -0,0 +1,730 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "path/filepath" |
| + "slices" |
| + "strings" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/service/sns" |
| + "github.com/carlosframework/platform/internal/alerting" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func lossPageRecord(id string, pending bool, knowledge hostclaim.TimerKnowledge) hostclaim.LossRecord { |
| + at := time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) |
| + r := hostclaim.LossRecord{V: 1, ID: id, State: "prepared", Actor: "operator", AcceptedAt: at, |
| + Host: "host.example", HolderIncarnation: strings.Repeat("a", 64), HolderStamp: 1, |
| + ExpectedClaimGen: 1, ExpectedClaimETag: "claim", OpID: "operation", OpKind: hostclaim.KindMoving, |
| + OpPhase: "precommit", Box: "box-a", Enrollment: "enrollment", QuarantineRevision: 1, FencedAt: at, |
| + CloudObservation: hostclaim.LossCloudObservation{ObservedAt: at, Account: "123", Region: "eu-west-1", InstanceID: "i-1", State: "stopped", RequestID: "request"}, |
| + OperatorAttestation: hostclaim.LossOperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true}, |
| + ConfirmedConsequences: hostclaim.LossConsequences{RecoveryIntent: json.RawMessage(`{"kind":"lost-target"}`)}, |
| + AlertsPending: pending, Timers: hostclaim.TimerLoss{Knowledge: knowledge}} |
| + if knowledge != hostclaim.TimersUnknown { |
| + r.Timers.Source = &hostclaim.TimerSource{Bucket: "bucket", Key: "timers", VersionID: "v1", SHA256: strings.Repeat("b", 64), CapturedAt: at} |
| + } |
| + return r |
| +} |
| + |
| +func lossPageWorld(t *testing.T) (*Server, *release.DirStore, release.Signer, *[]map[string]string) { |
| + t.Helper() |
| + raw := release.NewDirStore(t.TempDir()) |
| + _, key, err := ed25519.GenerateKey(nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + publishRestoreRoster(t, raw, key) |
| + s := newEnabledRestoreConsole(t, raw, key, crConsole) |
| + var pages []map[string]string |
| + alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(_ string, body []byte) error { |
| + var page map[string]string |
| + if err := json.Unmarshal(body, &page); err != nil { |
| + return err |
| + } |
| + pages = append(pages, page) |
| + return nil |
| + }}) |
| + t.Cleanup(func() { alerting.Arm(nil) }) |
| + return s, raw, release.RootSigner(key), &pages |
| +} |
| + |
| +func putLossPageRecord(t *testing.T, raw release.Store, signer release.Signer, r hostclaim.LossRecord) { |
| + t.Helper() |
| + if _, err := hostclaim.CreateLossRecord(t.Context(), raw, signer, r); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesIntervalAndRestart(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + for _, id := range []string{"loss-one", "loss-two"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, true, hostclaim.TimersComplete)) |
| + } |
| + now := time.Date(2026, 10, 12, 13, 0, 0, 123, time.UTC) |
| + pass := func() { |
| + t.Helper() |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + pass() |
| + pass() |
| + if len(*pages) != 2 { |
| + t.Fatalf("first passes: %v", *pages) |
| + } |
| + // Reopen the actual SQLite file; no in-memory limiter survives this. |
| + path := s.store.gate.path |
| + if err := s.store.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, err := OpenGated(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + s = &Server{cfg: s.cfg, store: st} |
| + now = now.Add(time.Hour - time.Nanosecond) |
| + pass() |
| + if len(*pages) != 2 { |
| + t.Fatalf("restart lost rate limit: %v", *pages) |
| + } |
| + now = now.Add(time.Nanosecond) |
| + pass() |
| + if len(*pages) != 4 { |
| + t.Fatalf("interval did not repeat each record: %v", *pages) |
| + } |
| + key, _ := hostclaim.LossRecordKey("loss-one") |
| + if err := raw.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + now = now.Add(time.Hour) |
| + pass() |
| + if len(*pages) != 5 || !strings.Contains((*pages)[4]["body"], "loss-two") { |
| + t.Fatalf("absent pending record paged: %v", *pages) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesClearedAndTimers(t *testing.T) { |
| + for _, knowledge := range []hostclaim.TimerKnowledge{hostclaim.TimersComplete, hostclaim.TimersPartial, hostclaim.TimersUnknown} { |
| + t.Run(string(knowledge), func(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + r := lossPageRecord("loss-timer", false, knowledge) |
| + putLossPageRecord(t, raw, signer, r) |
| + now := r.AcceptedAt |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now }); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := 1 |
| + if knowledge == hostclaim.TimersComplete { |
| + want = 0 |
| + } |
| + if len(*pages) != want { |
| + t.Fatalf("pages: %v", *pages) |
| + } |
| + if want != 0 && (!strings.Contains((*pages)[0]["body"], r.Host) || !strings.Contains((*pages)[0]["body"], "re-arm")) { |
| + t.Fatalf("timer instructions: %v", *pages) |
| + } |
| + key, _ := hostclaim.LossRecordKey(r.ID) |
| + if err := raw.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now.Add(2 * time.Hour) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*pages) != want { |
| + t.Fatal("cleared record paged") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesRecoveryWiringWithoutMovesOrClaims(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + putLossPageRecord(t, raw, signer, lossPageRecord("orphan-loss", true, hostclaim.TimersComplete)) |
| + if s.cfg.MovesEnabled { |
| + t.Fatal("fixture enabled moves") |
| + } |
| + if err := s.RunClaimRecoveryPass(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + waitClaimLossPages(t, s) |
| + if len(*pages) != 1 { |
| + t.Fatalf("record without a claim was not paged: %v", *pages) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesFlagOff(t *testing.T) { |
| + calls := 0 |
| + alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(string, []byte) error { calls++; return nil }}) |
| + t.Cleanup(func() { alerting.Arm(nil) }) |
| + s := &Server{} |
| + if err := s.pageClaimLossesAt(t.Context(), time.Now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.RunClaimRecoveryPass(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.scheduleClaimLossPages(t.Context()) |
| + if s.claimLossPageDone != nil { |
| + t.Fatal("disabled runtime started a worker") |
| + } |
| + if calls != 0 { |
| + t.Fatal("disabled runtime paged") |
| + } |
| + st, err := Open(filepath.Join(t.TempDir(), "off.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer st.Close() |
| + var count int |
| + if err := st.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name = 'claim_loss_pages'`).Scan(&count); err != nil || count != 0 { |
| + t.Fatalf("flag-off schema changed: %d %v", count, err) |
| + } |
| +} |
| + |
| +func TestClaimLossPageFailedDeliveryIsRateLimited(t *testing.T) { |
| + s, raw, signer, _ := lossPageWorld(t) |
| + putLossPageRecord(t, raw, signer, lossPageRecord("failed-page", true, hostclaim.TimersComplete)) |
| + calls := 0 |
| + failure := errors.New("delivery failed") |
| + alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(string, []byte) error { calls++; return failure }}) |
| + now := time.Now() |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now }); !errors.Is(err, failure) { |
| + t.Fatalf("delivery: %v", err) |
| + } |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return now.Add(time.Minute) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls != 1 { |
| + t.Fatalf("attempts: %d", calls) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesAutomaticReportingDoesNotAcknowledge(t *testing.T) { |
| + s, raw, _, pages := lossPageWorld(t) |
| + ref := hostclaim.FenceRef{Enrollment: strings.Repeat("a", 32), SHA256: strings.Repeat("b", 64), Key: "control/box-fences/" + strings.Repeat("a", 32) + ".json"} |
| + sum := sha256.Sum256([]byte("operation\x00box-a\x00" + ref.Enrollment + "\x00" + ref.SHA256)) |
| + id := "auto-" + hex.EncodeToString(sum[:16]) |
| + loss := hostclaim.AutomaticLoss{ID: id, Box: "box-a", Enrollment: ref.Enrollment, Fence: ref, |
| + ObservedAt: time.Now().UTC().Format(time.RFC3339Nano), Op: "operation", Kind: hostclaim.KindLanding, |
| + Phase: hostclaim.PhaseProbing, Role: "target", Incarnation: strings.Repeat("c", 64), Stamp: 1, Timers: hostclaim.TimersUnknown} |
| + _, err := hostclaim.Update(t.Context(), raw, hostclaim.Observed{Host: "host.example", Absent: true}, func(c *hostclaim.Claim) error { |
| + c.LossRefs = []string{id} |
| + c.EvidenceRefs = []hostclaim.AutomaticLoss{loss} |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.RunClaimRecoveryPass(t.Context()); err != nil { |
| + t.Fatal(err) |
| + } |
| + waitClaimLossPages(t, s) |
| + if len(*pages) != 2 { |
| + t.Fatalf("materialized record must page both conditions in the same pass: %v", *pages) |
| + } |
| + c, err := hostclaim.Read(t.Context(), raw, "host.example") |
| + if err != nil || !c.Claim.EvidenceRefs[0].Reported { |
| + t.Fatalf("reporting did not finish: %+v %v", c, err) |
| + } |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return time.Now().Add(2 * time.Hour) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*pages) != 4 { |
| + t.Fatal("claim reporting silenced pending operator alerts") |
| + } |
| +} |
| + |
| +func TestClaimLossPageReservationSerializesPasses(t *testing.T) { |
| + st := openGatedStore(t) |
| + now := time.Now() |
| + var wg sync.WaitGroup |
| + var attempts atomic.Int64 |
| + for i := 0; i < 12; i++ { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + ok, err := st.ClaimLossPageAttempt(t.Context(), "pending loss", now) |
| + if err != nil { |
| + t.Error(err) |
| + } |
| + if ok { |
| + attempts.Add(1) |
| + } |
| + }() |
| + } |
| + wg.Wait() |
| + if attempts.Load() != 1 { |
| + t.Fatalf("reserved %d attempts", attempts.Load()) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesRejectBadEvidenceAndContinue(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + if _, err := raw.Put(t.Context(), "control/host-loss/bad.json", []byte(`{}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + putLossPageRecord(t, raw, signer, lossPageRecord("valid", true, hostclaim.TimersComplete)) |
| + if err := s.pageClaimLossesAt(t.Context(), time.Now); !errors.Is(err, hostclaim.ErrLossRecord) { |
| + t.Fatalf("invalid signature accepted: %v", err) |
| + } |
| + if len(*pages) != 1 { |
| + t.Fatalf("bad record blocked good record: %v", *pages) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesJournalFailureDoesNotSend(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + putLossPageRecord(t, raw, signer, lossPageRecord("loss", true, hostclaim.TimersComplete)) |
| + if err := s.store.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.pageClaimLossesAt(t.Context(), time.Now); err == nil { |
| + t.Fatal("journal error hidden") |
| + } |
| + if len(*pages) != 0 { |
| + t.Fatal("sent without durable reservation") |
| + } |
| +} |
| + |
| +func waitClaimLossPages(t *testing.T, s *Server) { |
| + t.Helper() |
| + s.claimLossPageMu.Lock() |
| + done := s.claimLossPageDone |
| + s.claimLossPageMu.Unlock() |
| + if done == nil { |
| + t.Fatal("no loss paging worker scheduled") |
| + } |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("loss paging worker did not finish") |
| + } |
| +} |
| + |
| +func TestClaimLossPagesHourlyToDailyAcrossReopen(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + r := lossPageRecord("old-loss", true, hostclaim.TimersUnknown) |
| + putLossPageRecord(t, raw, signer, r) |
| + first := r.AcceptedAt.Add(30 * 24 * time.Hour) |
| + pass := func(at time.Time, want int) { |
| + t.Helper() |
| + if err := s.pageClaimLossesAt(t.Context(), func() time.Time { return at }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(*pages) != want { |
| + t.Fatalf("at %s: got %d pages, want %d", at.Sub(first), len(*pages), want) |
| + } |
| + } |
| + for hour := 0; hour < 24; hour++ { |
| + pass(first.Add(time.Duration(hour)*time.Hour), 2*(hour+1)) |
| + } |
| + path := s.store.gate.path |
| + if err := s.store.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, err := OpenGated(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + s = &Server{cfg: s.cfg, store: st} |
| + pass(first.Add(24*time.Hour-time.Nanosecond), 48) |
| + // Once the first 24 hours end, the last attempt must be a day old. |
| + pass(first.Add(24*time.Hour), 48) |
| + pass(first.Add(47*time.Hour-time.Nanosecond), 48) |
| + pass(first.Add(47*time.Hour), 50) |
| + pass(first.Add(71*time.Hour-time.Nanosecond), 50) |
| + pass(first.Add(71*time.Hour), 52) |
| + for _, condition := range []string{"pending", "timers"} { |
| + var saved int64 |
| + if err := st.db.QueryRow(`SELECT first_attempted_at FROM claim_loss_pages WHERE rule = ?`, condition+" "+r.ID).Scan(&saved); err != nil || saved != first.UnixNano() { |
| + t.Fatalf("first %s attempt changed: %d %v", condition, saved, err) |
| + } |
| + } |
| +} |
| + |
| +func TestClaimLossPagesClockAtEachReservation(t *testing.T) { |
| + s, raw, signer, _ := lossPageWorld(t) |
| + for _, id := range []string{"a-early", "b-late"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, true, hostclaim.TimersComplete)) |
| + } |
| + first := time.Now() |
| + now := first |
| + late := 0 |
| + calls := 0 |
| + alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(_ string, b []byte) error { |
| + calls++ |
| + if strings.Contains(string(b), "b-late") { |
| + late++ |
| + } |
| + if calls == 1 { |
| + now = now.Add(time.Hour) |
| + } |
| + return nil |
| + }}) |
| + clock := func() time.Time { return now } |
| + if err := s.pageClaimLossesAt(t.Context(), clock); err != nil { |
| + t.Fatal(err) |
| + } |
| + now = now.Add(10 * time.Second) |
| + if err := s.pageClaimLossesAt(t.Context(), clock); err != nil { |
| + t.Fatal(err) |
| + } |
| + if late != 1 || calls != 3 { |
| + t.Fatalf("late record retried ten seconds later: late=%d, total=%d", late, calls) |
| + } |
| + var reserved int64 |
| + if err := s.store.db.QueryRow(`SELECT attempted_at FROM claim_loss_pages WHERE rule = 'pending b-late'`).Scan(&reserved); err != nil || reserved != first.Add(time.Hour).UnixNano() { |
| + t.Fatalf("late reservation: %d %v", reserved, err) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesBlockedSinkDoesNotBlockRecovery(t *testing.T) { |
| + s, raw, signer, _ := lossPageWorld(t) |
| + putLossPageRecord(t, raw, signer, lossPageRecord("blocking", true, hostclaim.TimersComplete)) |
| + entered, releaseSink := make(chan struct{}), make(chan struct{}) |
| + var unblock sync.Once |
| + var calls atomic.Int64 |
| + alerting.Arm(&alerting.Sinks{WebhookURL: "http://unused", Post: func(string, []byte) error { |
| + if calls.Add(1) == 1 { |
| + close(entered) |
| + <-releaseSink |
| + } |
| + return nil |
| + }}) |
| + t.Cleanup(func() { |
| + unblock.Do(func() { close(releaseSink) }) |
| + waitClaimLossPages(t, s) |
| + }) |
| + var records, purges, admissions atomic.Int64 |
| + wrap := func(fn func(context.Context) error, count *atomic.Int64) func(context.Context) error { |
| + return func(ctx context.Context) error { count.Add(1); return fn(ctx) } |
| + } |
| + r := s.cfg.HostClaimRecovery |
| + r.SweepRecords = wrap(r.SweepRecords, &records) |
| + r.SweepPurges = wrap(r.SweepPurges, &purges) |
| + r.SweepAdmissions = wrap(r.SweepAdmissions, &admissions) |
| + pass := func() { |
| + t.Helper() |
| + done := make(chan error, 1) |
| + go func() { done <- s.RunClaimRecoveryPass(t.Context()) }() |
| + select { |
| + case err := <-done: |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + case <-time.After(2 * time.Second): |
| + unblock.Do(func() { close(releaseSink) }) |
| + <-done |
| + t.Fatal("paging blocked recovery") |
| + } |
| + } |
| + pass() |
| + select { |
| + case <-entered: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("sink was not called") |
| + } |
| + s.claimLossPageMu.Lock() |
| + worker := s.claimLossPageDone |
| + s.claimLossPageMu.Unlock() |
| + for range 3 { |
| + pass() |
| + } |
| + if records.Load() != 4 || purges.Load() != 4 || admissions.Load() != 4 { |
| + t.Fatalf("sweeps delayed: records=%d purges=%d admissions=%d", records.Load(), purges.Load(), admissions.Load()) |
| + } |
| + s.claimLossPageMu.Lock() |
| + overlapped := s.claimLossPageDone != worker |
| + s.claimLossPageMu.Unlock() |
| + if overlapped || calls.Load() != 1 { |
| + t.Fatal("paging workers overlapped") |
| + } |
| + unblock.Do(func() { close(releaseSink) }) |
| + waitClaimLossPages(t, s) |
| + putLossPageRecord(t, raw, signer, lossPageRecord("next-worker", true, hostclaim.TimersComplete)) |
| + pass() |
| + waitClaimLossPages(t, s) |
| + if calls.Load() != 2 { |
| + t.Fatal("next recovery did not schedule a new paging worker") |
| + } |
| +} |
| + |
| +func TestClaimLossPageJournalUpgrade(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "old.db") |
| + st, err := Open(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + at := time.Now().Add(-23 * time.Hour) |
| + _, err = st.db.Exec(`CREATE TABLE claim_loss_pages (rule TEXT PRIMARY KEY, attempted_at INTEGER NOT NULL); |
| +INSERT INTO claim_loss_pages VALUES ('pending old', ?)`, at.UnixNano()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + for range 2 { |
| + st, err := OpenGated(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var first, last int64 |
| + err = st.db.QueryRow(`SELECT first_attempted_at, attempted_at FROM claim_loss_pages WHERE rule = 'pending old'`).Scan(&first, &last) |
| + var cursor string |
| + cursorErr := st.db.QueryRow(`SELECT last_key FROM claim_loss_pages WHERE rule = 'pending old'`).Scan(&cursor) |
| + st.Close() |
| + if err != nil || first != at.UnixNano() || last != at.UnixNano() { |
| + t.Fatalf("upgrade lost durable attempt: %d %d %v", first, last, err) |
| + } |
| + if cursorErr != nil || cursor != "" { |
| + t.Fatalf("upgrade scan progress: %q %v", cursor, cursorErr) |
| + } |
| + } |
| +} |
| + |
| +type lossPageDeadlineStore struct { |
| + release.Store |
| + deadline chan time.Time |
| +} |
| + |
| +func (s lossPageDeadlineStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + deadline, _ := ctx.Deadline() |
| + s.deadline <- deadline |
| + <-ctx.Done() |
| + return nil, ctx.Err() |
| +} |
| + |
| +func TestClaimLossPagesWorkerBudget(t *testing.T) { |
| + s, _, _, pages := lossPageWorld(t) |
| + deadline := make(chan time.Time, 1) |
| + s.cfg.Releases = lossPageDeadlineStore{Store: s.cfg.Releases, deadline: deadline} |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + before := time.Now() |
| + s.scheduleClaimLossPages(ctx) |
| + select { |
| + case at := <-deadline: |
| + if at.Before(before.Add(claimLossPageBudget)) || at.After(time.Now().Add(claimLossPageBudget)) { |
| + t.Errorf("scan has no per-pass budget: deadline=%v", at) |
| + } |
| + case <-time.After(time.Second): |
| + t.Error("worker never started discovery") |
| + } |
| + cancel() |
| + waitClaimLossPages(t, s) |
| + if len(*pages) != 0 { |
| + t.Fatal("canceled scan delivered a page") |
| + } |
| + s.claimLossPageMu.Lock() |
| + done := s.claimLossPageDone |
| + s.claimLossPageMu.Unlock() |
| + s.scheduleClaimLossPages(ctx) |
| + if s.claimLossPageDone != done { |
| + t.Fatal("canceled recovery scheduled another worker") |
| + } |
| +} |
| + |
| +type lossPageBlockingSNS struct { |
| + entered chan time.Time |
| +} |
| + |
| +func (p lossPageBlockingSNS) Publish(ctx context.Context, _ *sns.PublishInput, _ ...func(*sns.Options)) (*sns.PublishOutput, error) { |
| + deadline, _ := ctx.Deadline() |
| + p.entered <- deadline |
| + <-ctx.Done() |
| + return nil, ctx.Err() |
| +} |
| + |
| +func TestClaimLossPagesSinkTimeoutAndCancellation(t *testing.T) { |
| + s, raw, signer, _ := lossPageWorld(t) |
| + putLossPageRecord(t, raw, signer, lossPageRecord("blocked-sns", true, hostclaim.TimersUnknown)) |
| + entered := make(chan time.Time, 1) |
| + alerting.Arm(&alerting.Sinks{TopicARN: "test", Pub: lossPageBlockingSNS{entered: entered}}) |
| + ctx, cancel := context.WithCancel(t.Context()) |
| + defer cancel() |
| + s.scheduleClaimLossPages(ctx) |
| + select { |
| + case deadline := <-entered: |
| + if deadline.IsZero() || time.Until(deadline) > alerting.PublishTimeout { |
| + t.Errorf("SNS sink has no timeout: %v", deadline) |
| + } |
| + case <-time.After(time.Second): |
| + t.Error("worker never called SNS") |
| + } |
| + cancel() |
| + waitClaimLossPages(t, s) |
| + var attempts int |
| + if err := s.store.db.QueryRow(`SELECT count(*) FROM claim_loss_pages`).Scan(&attempts); err != nil || attempts != 1 { |
| + t.Fatalf("canceled pass reserved another condition: %d %v", attempts, err) |
| + } |
| +} |
| + |
| +type lossPageSlowStore struct { |
| + release.Store |
| + reads []string |
| + reverse bool |
| +} |
| + |
| +func (s *lossPageSlowStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + keys, err := s.Store.List(ctx, prefix) |
| + if s.reverse { |
| + slices.Reverse(keys) |
| + } |
| + return keys, err |
| +} |
| + |
| +func (s *lossPageSlowStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.reads = append(s.reads, key) |
| + if len(s.reads) > 2 { |
| + <-ctx.Done() |
| + return nil, "", ctx.Err() |
| + } |
| + timer := time.NewTimer(10 * time.Millisecond) |
| + defer timer.Stop() |
| + select { |
| + case <-timer.C: |
| + return s.Store.Get(ctx, key) |
| + case <-ctx.Done(): |
| + return nil, "", ctx.Err() |
| + } |
| +} |
| + |
| +func TestClaimLossPagesScanProgressSurvivesRestartAndWraps(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + for _, id := range []string{"b-retained", "c-retained", "d-retained", "e-retained", "f-retained", "z-pending"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, id == "z-pending", hostclaim.TimersComplete)) |
| + } |
| + slow := &lossPageSlowStore{Store: raw} |
| + s.cfg.Releases = slow |
| + pass := func(first string) { |
| + t.Helper() |
| + slow.reads = nil |
| + ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) |
| + defer cancel() |
| + if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("slow scan: %v", err) |
| + } |
| + key, _ := hostclaim.LossRecordKey(first) |
| + if len(slow.reads) != 3 || slow.reads[0] != key { |
| + t.Fatalf("scan reads: %v; want first %s and one unfinished read", slow.reads, key) |
| + } |
| + } |
| + pass("b-retained") |
| + if len(*pages) != 0 { |
| + t.Fatalf("first scan reached beyond its budget: %v", *pages) |
| + } |
| + path := s.store.gate.path |
| + if err := s.store.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, err := OpenGated(path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { st.Close() }) |
| + s = &Server{cfg: s.cfg, store: st} |
| + pass("d-retained") |
| + pass("f-retained") |
| + if len(*pages) != 1 || !strings.Contains((*pages)[0]["body"], "z-pending") { |
| + t.Fatalf("pending record not delivered within three scans: %v", *pages) |
| + } |
| + putLossPageRecord(t, raw, signer, lossPageRecord("a-new", true, hostclaim.TimersComplete)) |
| + key, _ := hostclaim.LossRecordKey("z-pending") |
| + if err := raw.Delete(t.Context(), key); err != nil { |
| + t.Fatal(err) |
| + } |
| + slow.reverse = true |
| + pass("a-new") |
| + if len(*pages) != 2 || !strings.Contains((*pages)[1]["body"], "a-new") { |
| + t.Fatalf("scan did not wrap past the removed cursor to the new early record: %v", *pages) |
| + } |
| +} |
| + |
| +func TestClaimLossPagesScanRetriesUnfinishedPendingRead(t *testing.T) { |
| + s, raw, signer, pages := lossPageWorld(t) |
| + for _, id := range []string{"a-retained", "b-retained", "c-pending"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, id == "c-pending", hostclaim.TimersComplete)) |
| + } |
| + s.cfg.Releases = &lossPageSlowStore{Store: raw} |
| + ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond) |
| + defer cancel() |
| + if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("slow scan: %v", err) |
| + } |
| + if len(*pages) != 0 { |
| + t.Fatalf("unfinished read paged: %v", *pages) |
| + } |
| + slow := &lossPageSlowStore{Store: raw} |
| + s.cfg.Releases = slow |
| + ctx, cancel = context.WithTimeout(t.Context(), 250*time.Millisecond) |
| + defer cancel() |
| + if err := s.pageClaimLossesAt(ctx, time.Now); !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("resumed scan: %v", err) |
| + } |
| + key, _ := hostclaim.LossRecordKey("c-pending") |
| + if !slices.Equal(slow.reads, []string{key, "control/host-loss/a-retained.json", "control/host-loss/b-retained.json"}) || len(*pages) != 1 { |
| + t.Fatalf("unfinished pending read skipped: reads=%v pages=%v", slow.reads, *pages) |
| + } |
| +} |
| + |
| +// lossPageStalledStore never answers a read of one key. |
| +type lossPageStalledStore struct { |
| + release.Store |
| + stalled string |
| + reads []string |
| +} |
| + |
| +func (s *lossPageStalledStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + s.reads = append(s.reads, key) |
| + if key == s.stalled { |
| + <-ctx.Done() |
| + return nil, "", ctx.Err() |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +func TestClaimLossPagesPersistentlyStalledReadDoesNotStarve(t *testing.T) { |
| + prior := claimLossReadBudget |
| + claimLossReadBudget = 50 * time.Millisecond |
| + t.Cleanup(func() { claimLossReadBudget = prior }) |
| + s, raw, signer, pages := lossPageWorld(t) |
| + for _, id := range []string{"a-stalled", "b-pending"} { |
| + putLossPageRecord(t, raw, signer, lossPageRecord(id, true, hostclaim.TimersComplete)) |
| + } |
| + stalledKey, _ := hostclaim.LossRecordKey("a-stalled") |
| + pendingKey, _ := hostclaim.LossRecordKey("b-pending") |
| + store := &lossPageStalledStore{Store: raw, stalled: stalledKey} |
| + s.cfg.Releases = store |
| + for lap := 0; lap < 2; lap++ { |
| + store.reads = nil |
| + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second) |
| + err := s.pageClaimLossesAt(ctx, time.Now) |
| + cancel() |
| + if err == nil || !errors.Is(err, context.DeadlineExceeded) { |
| + t.Fatalf("lap %d: the stalled read should surface as a record error, got %v", lap, err) |
| + } |
| + if !slices.Contains(store.reads, pendingKey) { |
| + t.Fatalf("lap %d: the record behind the stalled one was never read: %v", lap, store.reads) |
| + } |
| + if !slices.Contains(store.reads, stalledKey) { |
| + t.Fatalf("lap %d: the stalled record was not retried: %v", lap, store.reads) |
| + } |
| + } |
| + if len(*pages) != 1 || !strings.Contains((*pages)[0]["body"], "b-pending") { |
| + t.Fatalf("pending record behind a stalled read was not paged exactly once: %v", *pages) |
| + } |
| +} |
diff --git a/internal/console/claim_runtime.go b/internal/console/claim_runtime.go| index 186476dfd..75f3f0bc0 100644 |
| --- a/internal/console/claim_runtime.go |
| +++ b/internal/console/claim_runtime.go |
| @@ -127,6 +127,7 @@ func (s *Server) ConfigureClaimRuntime(ctx context.Context) error { |
| moveLogs = s.drainMoveLog |
| } |
| s.cfg.HostClaimRecovery = &hostops.Recovery{ |
| + ScheduleLossPages: s.scheduleClaimLossPages, |
| ReportAutomaticLosses: func(ctx context.Context, host string) error { |
| return a.Control.ReportAutomaticLosses(ctx, a.Signer, host) |
| }, |
diff --git a/internal/console/server.go b/internal/console/server.go| index 7a60ea992..da8ae45bd 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -821,6 +821,8 @@ type Config struct { |
| // Server is the console's http.Handler. |
| type Server struct { |
| + claimLossPageMu sync.Mutex |
| + claimLossPageDone chan struct{} |
| // Enabled claim runtime (ConfigureClaimRuntime). Unset flag-off. |
| claimRuntimeConfigured bool |
| claimOwner hostops.RegistrationOwner |
diff --git a/internal/console/store_gate.go b/internal/console/store_gate.go| index 6a90ecb46..bda729a56 100644 |
| --- a/internal/console/store_gate.go |
| +++ b/internal/console/store_gate.go |
| @@ -209,6 +209,7 @@ var claimSQLTables = map[string]claimSQLTable{ |
| "memberships": {NonApp: "account membership, keyed by account and address; names no app"}, |
| "invites": {NonApp: "account invites, keyed by account and address; names no app"}, |
| "alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"}, |
| + "claim_loss_pages": {NonApp: "claim recovery page attempts per loss record and condition, and scan progress; no app state or lifecycle authority"}, |
| "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: "a moves audit outbox that stores no app name: rows are keyed by host, move and entry and bound to an instance incarnation, verified against the host's claim and record at every write, written only by the moves recovery and log passes, and purged with the incarnation"}, |
| "edge_proof_seen": {NonApp: "single-row edge proof marker"}, |
| @@ -1789,6 +1790,24 @@ func OpenGated(path string) (*Store, error) { |
| if err := s.migrate(); err != nil { |
| return err |
| } |
| + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS claim_loss_pages ( |
| + rule TEXT PRIMARY KEY, |
| + first_attempted_at INTEGER NOT NULL, |
| + attempted_at INTEGER NOT NULL |
| +);`); err != nil { |
| + return err |
| + } |
| + // The earlier journal retained only the last attempt. Preserve that |
| + // durable time as the start for existing rows; record age is irrelevant. |
| + if _, err := s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN first_attempted_at INTEGER`); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| + return err |
| + } |
| + if _, err := s.db.Exec(`UPDATE claim_loss_pages SET first_attempted_at = attempted_at WHERE first_attempted_at IS NULL`); err != nil { |
| + return err |
| + } |
| + if _, err := s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN last_key TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| + return err |
| + } |
| return s.migrateClaimAppIdentity() |
| }) |
| } |
diff --git a/internal/console/testdata/claim-authority-sites.txt b/internal/console/testdata/claim-authority-sites.txt| index 9e1fbf855..8544c25fd 100644 |
| --- a/internal/console/testdata/claim-authority-sites.txt |
| +++ b/internal/console/testdata/claim-authority-sites.txt |
| @@ -125,3 +125,13 @@ store_gate.go|openGated|sql.OpenDB(claimConnector{g: g, trusted: true}) |
| store_gate.go|openGated|sql.OpenDB(claimConnector{g: g}) |
| store_gate.go|withClaimSQL|s.withPreparedClaimSQL(ctx, scopes, check, func(ctx context.Context, _ claimSQLScope) error { return ctx.Err() }, nil, false, fn) |
| view.go|openRegistry|registry.Open(path) |
| +claim_loss_pages.go|ClaimLossPageAttempt|s.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at) VALUES (?, ?, ?) ON CONFLICT(rule) DO UPDATE SET attempted_at = excluded.attempted_at WHERE claim_loss_pages.attempted_at <= CASE WHEN claim_loss_pages.first_attempted_at > ? THEN ? ELSE ? END`, rule, now.UnixNano(), now.UnixNano(), now.Add(-claimLossPageDailyInterval).UnixNano(), now.Add(-claimLossPageInterval).UnixNano(), now.Add(-claimLossPageDailyInterval).UnixNano()) |
| +claim_loss_pages.go|pageClaimLossRecord|hostclaim.ReadLossRecord(read, s.cfg.Releases, s.cfg.ReleasePub, id) |
| +claim_loss_pages.go|pageClaimLossesAt|s.cfg.Releases.List(ctx, "control/host-loss/") |
| +claim_loss_pages.go|pageClaimLoss|s.store.ClaimLossPageAttempt(ctx, condition+" "+r.ID, now()) |
| +store_gate.go|OpenGated|s.db.Exec(`CREATE TABLE IF NOT EXISTS claim_loss_pages ( rule TEXT PRIMARY KEY, first_attempted_at INTEGER NOT NULL, attempted_at INTEGER NOT NULL );`) |
| +store_gate.go|OpenGated|s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN first_attempted_at INTEGER`) |
| +store_gate.go|OpenGated|s.db.Exec(`UPDATE claim_loss_pages SET first_attempted_at = attempted_at WHERE first_attempted_at IS NULL`) |
| +claim_loss_pages.go|pageClaimLossesAt|s.store.db.ExecContext(ctx, `INSERT INTO claim_loss_pages (rule, first_attempted_at, attempted_at, last_key) VALUES ('scan', 0, 0, ?) ON CONFLICT(rule) DO UPDATE SET last_key = excluded.last_key`, key) |
| +claim_loss_pages.go|pageClaimLossesAt|s.store.db.QueryRowContext(ctx, `SELECT last_key FROM claim_loss_pages WHERE rule = 'scan'`) |
| +store_gate.go|OpenGated|s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN last_key TEXT NOT NULL DEFAULT ''`) |
diff --git a/internal/hostops/recovery.go b/internal/hostops/recovery.go| index 32b6735ac..64d02ef1d 100644 |
| --- a/internal/hostops/recovery.go |
| +++ b/internal/hostops/recovery.go |
| @@ -38,6 +38,8 @@ func ListClaims(ctx context.Context, s release.Store) ([]string, error) { |
| } |
| type Recovery struct { |
| + // ScheduleLossPages triggers a separate worker after materialization. |
| + ScheduleLossPages func(context.Context) |
| ReportAutomaticLosses func(context.Context, string) error |
| MoveFenceAuthority hostclaim.FenceAuthority |
| Moves func(context.Context, *MoveRecoveryGrant) error |
| @@ -121,6 +123,9 @@ func (r Recovery) Pass(ctx context.Context) error { |
| all = append(all, err) |
| } |
| } |
| + if r.ScheduleLossPages != nil { |
| + r.ScheduleLossPages(ctx) |
| + } |
| if r.MoveLogs != nil { |
| if err := runMoveLogDrain(ctx, r.MoveLogs); err != nil && !errors.Is(err, ErrPending) { |
| all = append(all, err) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 519bb1e68..fed065e54 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -472,6 +472,8 @@ func Reviewed(ctx context.Context, s Store, k string) error { |
| // BEGIN REVIEWED INVENTORY |
| var plan9B8CensusInventory = []censusEntry{ |
| + // Read-only discovery of signed loss records; no record write or lifecycle authority. |
| + {"internal/console/claim_loss_pages.go", "Server.pageClaimLossRecord", "key-site", "key:LossRecordKey", "d21e2450f540edf6645c457eb08c72f470969f5ea4fcbc597157bb7dbeb27287"}, |
| {"internal/hostbox/parkleases.go", "ReplicaParkLeaseSet", "key-site", "lit:instances", "0ca8d9b743402fda9d15cc215464423b35e21c1c2b47f91294d76188b7d43fb6"}, |
| {"internal/host/claimretirementcensus.go", "ClaimRetirementCensus", "key-site", "lit:instances", "abc0bd8c5f5d328abfac8821f42f8f23954723ed6506747f33b0197824e2611f"}, |
| {"internal/host/claimproduction.go", "productionClaimRegional.View", "key-site", "lit:instances", "533203b2aa729801438da4f3a85799a58ea0e67d20eb5625f0de32ea623f1607"}, |
diff --git a/testdata/claim-moves-copy.md b/testdata/claim-moves-copy.md| index 6038aa4cb..179c4560b 100644 |
| --- a/testdata/claim-moves-copy.md |
| +++ b/testdata/claim-moves-copy.md |
| @@ -1,7 +1,16 @@ |
| -# Claim moves copy record (HC5-N1 approved by Paul 2026-10-08; HC5-N2 and HC5-N3, from claim Plan 10, approved by Paul 2026-10-10) |
| +# Claim moves copy record (HC5-N1 approved by Paul 2026-10-08; HC5-N2 and HC5-N3, from claim Plan 10, approved by Paul 2026-10-10; HC5-N4 to HC5-N7, claim loss pages, approved by Paul 2026-10-12) |
| | id | file | text | status | |
| |---|---|---|---| |
| | HC5-N1 | internal/console/claim_retire_helpers.go | instance moves are not available on this console yet: they need the host-claim Moves protocol | approved | |
| | HC5-N2 | internal/console/apiinstancemove.go | invalid resolution request | approved | |
| | HC5-N3 | internal/console/apiinstancemove.go | could not read the instance | approved | |
| + |
| +## Claim loss pages (pending Paul's review) |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| HC5-N4 | internal/console/claim_loss_pages.go | Loss recorded for <host> | approved | |
| +| HC5-N5 | internal/console/claim_loss_pages.go | A loss was recorded for host <host>. Review loss record <id> and check what needs recovery. | approved | |
| +| HC5-N6 | internal/console/claim_loss_pages.go | Timers may be lost for <host> | approved | |
| +| HC5-N7 | internal/console/claim_loss_pages.go | Timers may have been lost with the box serving host <host>. Check loss record <id>. Check which timers are missing and re-arm them. | approved | |