tidaldb/tests/e2e/features/01-cluster-convergence.spec.ts
jordan 71e80ef655 e2e: get the Playwright harness green end to end, and close the stale-evidence gap
The suite had not been run since 2026-08-23 and deps were not installed. Running it
against the freshly rolled m12-vsc-20260830 found four failures. Every one was the
harness doing its job; three were stale pins it explicitly told me to invert.

REAL FINDING, caught by the suite and nothing else: tidaldb-2 was NotReady mid-run.
It had exited(0) with {"reason":"reseed_self_restart","shard":1}, reinstalled a
snapshot and converged. Designed behavior - but the suite sampled readiness ONCE and
reported a self-healing cluster as broken. Readiness is now polled via
waitForPodsReady with a bounded budget and the whole timeline attached as evidence.
Deliberately not Playwright retries: retries:0 is correct here, because a live check
that only passes on attempt two has told you something true.

STALE PINS INVERTED (each verified live first, not taken on the message's word):
  - 06-logs: ANSI escapes are gone (0 in a 5-line sample), BUG-006 resolved on this
    image. Now pinned so a regression to coloured output fails.
  - 09-operator-authority + CAP-015 capture: tidaldb_http_* exists (185 series
    against a 552 baseline). Runbook 9.1 moved from inert to LIVE. CAP-015 keeps its
    purpose - state the gaps - and now names the one that is still real: no JSON_LOGS.
  - The transient /search 500 and public 502 were tidaldb-2's restart window, not
    defects; both surfaces returned 200 on eight retries afterwards.

THRESHOLD CALIBRATED AGAINST A MEASUREMENT, TWICE. My first fix capped
consecutive ship failures at 500, guessing a restart burst was ~100. Measurement
killed it: a reseed restart is a ~2 minute absence, which at the shipper's 100ms
cadence is ~1200-2000 failures - observed exactly 1950, then "peer recovered", with
peer_acked_seqno back at the frontier. A COUNT cannot separate "a peer restarted"
from "shipping is stuck"; it only encodes how long the peer was away. The test now
compares the newest distress line against the newest recovery line and fails only
when distress is newer. Same correction applied to the alert in k3s-fleet.

STALE EVIDENCE WAS THE WORST GAP. demo/public/captures and capture-manifest.json
still described m12-admin-gate-20260823 - two image rolls stale - while
demo:preflight reported "audited perfect" about week-old frames, and the rendered
title card read "image m12-admin-gate-20260823 - 32 checks green". The capture suite
writes to test-results/demo-captures/ and the copy-and-merge step into the published
set simply did not exist; it was done by hand once. Added demo/promote.ts: copies
frames, verifies each PNG against its fragment hash, and stamps buildRevision and
verifiedImage from the live StatefulSet. Verdicts land `pending`, so preflight fails
until the frames are audited - that failure is the gate. scenes.ts now derives the
image tag and check count from the manifest, and preflight fails if a literal is
pasted back in (proven by pasting one back in).

All 10 captures were opened individually at full resolution; the audit note is stored
in the manifest beside each verdict rather than only in prose.

Green: 34 e2e + 5 hermetic semantics + 10 captures + preflight + 2107 lib.
Video: demo/out/deploy-verification.mp4, 90.05s 1920x1080 h264, title card now
reading "image m12-vsc-20260830 - 34 checks green".

CLAUDE.md gains a Deploy Verification section and AGENTS.md a short mandatory
pointer: every deploy is verified through this harness, and maintaining it is part
of the change, not follow-up. The suite pins current reality including defects, so a
correct improvement WILL turn it red - and that is the harness working.
2026-08-30 15:27:56 -06:00

203 lines
7.2 KiB
TypeScript

/**
* Runbook section 1 — the cluster is up and converged.
*
* CAP-001 membership is complete and every voter is Ready
* CAP-002 every node has converged: zero lag, no reseed, one agreed leader
*
* Why each node is asked individually rather than asking the aggregate:
* `GET /cluster/status` can report a peer it holds no frontier report for as
* `applied_events: 0` and then derive lag against that zero, so a fully
* converged peer shows up as the leader's entire history behind. The per-node
* `/cluster/status/local` is the authoritative view. See CAP-012 and
* docs/ops/observability.md section 4.
*/
import { expect, test } from '@playwright/test';
import { kubectl, waitForPodsReady, withPortForward } from '../support/cluster';
import { observed, recordJson } from '../support/evidence';
import { NAMESPACE, POD_NAMES, PORT_CLIENT, apiKey } from '../support/env';
/** One shard group's replication position on one node. */
type ShardStatus = {
shard: number;
is_leader: boolean;
leader: string;
term: number;
role: string;
applied_events: number;
leader_seqno: number;
lag_events: number;
reseed_required: boolean;
reseeding: boolean;
};
type LocalStatus = {
region: string;
leader: string;
reseed_required: boolean;
reseeding: boolean;
quarantined: boolean;
partitioned: string[];
applied_events: number;
lag_events: number;
shards: ShardStatus[];
};
test.describe('section 1 — cluster convergence', () => {
test('membership is exactly the known voter set and every pod is Ready', async ({}, testInfo) => {
const result = await observed(testInfo, 'get pods wide', () =>
kubectl([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
'app.kubernetes.io/name=tidaldb',
'-o',
'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.status.containerStatuses[0].ready}{"\\t"}{.status.containerStatuses[0].restartCount}{"\\t"}{.status.phase}{"\\n"}{end}',
]),
);
expect(result.code, result.stderr).toBe(0);
const pods = result.stdout
.trim()
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => {
const [name, ready, restarts, phase] = line.split('\t');
return {
name,
ready: ready === 'true',
restarts: Number.parseInt(restarts, 10),
phase,
};
});
await recordJson(testInfo, 'pod-inventory', pods);
// An unexpected extra pod means a scale operation is mid-flight or an
// orphan survived — either way the voter set is not what the runbook
// assumes, so assert the exact set rather than a minimum count.
expect(
pods.map((p) => p.name).sort(),
'voter set must be exactly the documented pods',
).toEqual([...POD_NAMES].sort());
for (const pod of pods) {
expect(pod.phase, `${pod.name} phase`).toBe('Running');
}
// Readiness is polled, not sampled: `reseed_self_restart: true` makes a
// bounded exit(0)/reinstall cycle DESIGNED behavior, so one unlucky sample
// would report a converging cluster as a broken one.
const readiness = await waitForPodsReady(NAMESPACE, POD_NAMES);
await recordJson(testInfo, 'pod-readiness-timeline', readiness);
expect(
readiness.converged,
`pods did not all reach Ready within the budget; final=${JSON.stringify(readiness.final)}`,
).toBe(true);
});
test('every node reports zero lag, no reseed, and agrees on one leader', async ({
playwright,
}, testInfo) => {
const key = apiKey();
const statuses: LocalStatus[] = [];
for (const pod of POD_NAMES) {
const status = await test.step(`read ${pod} /cluster/status/local`, async () =>
withPortForward(NAMESPACE, pod, PORT_CLIENT, async (forward) => {
// The pod serves TLS with the internal cluster CA, whose leaf is
// issued for in-cluster DNS names — a 127.0.0.1 tunnel cannot
// validate it. ignoreHTTPSErrors is scoped to this one context: a
// local port-forward to a named pod, never the public endpoint.
const context = await playwright.request.newContext({
ignoreHTTPSErrors: true,
extraHTTPHeaders: { authorization: `Bearer ${key}` },
});
try {
const response = await context.get(
`https://127.0.0.1:${forward.localPort}/cluster/status/local`,
);
expect(response.status(), `${pod} status endpoint`).toBe(200);
return (await response.json()) as LocalStatus;
} finally {
await context.dispose();
}
}));
statuses.push(status);
}
await recordJson(
testInfo,
'per-node-convergence',
statuses.map((status) => ({
region: status.region,
leader: status.leader,
reseed_required: status.reseed_required,
reseeding: status.reseeding,
quarantined: status.quarantined,
partitioned: status.partitioned,
shards: status.shards.map((shard) => ({
shard: shard.shard,
leader: shard.leader,
term: shard.term,
role: shard.role,
applied_events: shard.applied_events,
lag_events: shard.lag_events,
reseed_required: shard.reseed_required,
})),
})),
);
expect(statuses.length, 'one status per pod').toBe(POD_NAMES.length);
for (const status of statuses) {
expect(status.region, 'each pod reports its own region').toBeTruthy();
// reseed_required surviving a restart is the m11p5 livelock signature.
expect(status.reseed_required, `${status.region} must not require reseed`).toBe(false);
expect(status.reseeding, `${status.region} must not be reseeding`).toBe(false);
expect(status.quarantined, `${status.region} must not be quarantined`).toBe(false);
expect(status.partitioned, `${status.region} must see no partitions`).toEqual([]);
expect(status.shards.length, `${status.region} shard groups`).toBe(3);
for (const shard of status.shards) {
expect(
shard.lag_events,
`${status.region} shard ${shard.shard} must have zero lag`,
).toBe(0);
expect(
shard.reseed_required,
`${status.region} shard ${shard.shard} must not require reseed`,
).toBe(false);
expect(
shard.applied_events,
`${status.region} shard ${shard.shard} should have applied real events`,
).toBeGreaterThan(0);
}
}
// Agreement, not identity: leadership legitimately moves between runs (it
// moved from tidaldb-2 to tidaldb-1 during this suite's development), so
// pinning a node name would produce a test that fails on a healthy
// election. What must hold is that every node names the SAME leader for
// each shard group — disagreement is split brain.
for (let shardIndex = 0; shardIndex < 3; shardIndex += 1) {
const leaders = [
...new Set(
statuses.map(
(status) => status.shards.find((s) => s.shard === shardIndex)?.leader,
),
),
];
expect(
leaders,
`all nodes must agree on the leader of shard ${shardIndex}`,
).toHaveLength(1);
expect(leaders[0], `shard ${shardIndex} must have a leader`).toBeTruthy();
}
});
});