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.
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
/**
|
|
* Runbook section 6 — logs are readable and replication is currently healthy.
|
|
*
|
|
* CAP-011 container logs are readable and free of unexpected errors
|
|
*
|
|
* Design note. An earlier draft of this spec allowlisted every WARN it found,
|
|
* which would have permanently hidden `batch ship failing … transport channel
|
|
* closed` — a real replication failure that ran for 115 consecutive seconds
|
|
* after an election. A blanket allowlist is a mute button.
|
|
*
|
|
* So this spec splits the question in two:
|
|
* 1. Is replication healthy RIGHT NOW? → asserted against a recent window.
|
|
* 2. What has the pod been saying? → recorded as evidence, and only
|
|
* genuinely benign, explained lines are tolerated.
|
|
*/
|
|
|
|
import { expect, test } from '@playwright/test';
|
|
import { kubectl } from '../support/cluster';
|
|
import { observed, recordJson } from '../support/evidence';
|
|
import { NAMESPACE, POD_NAMES } from '../support/env';
|
|
|
|
/** Window that must be quiet for replication to count as currently healthy. */
|
|
const HEALTH_WINDOW = '5m';
|
|
|
|
/** Longer window recorded as evidence, not asserted clean. */
|
|
const HISTORY_WINDOW = '30m';
|
|
|
|
/** Strip ANSI so patterns match; the deployed image colours its output. */
|
|
function decolour(text: string): string {
|
|
return text.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
/**
|
|
* Warnings that are genuinely benign on this deployment, each with the reason.
|
|
* An allowlist entry without a rationale is not allowed — if the reason cannot
|
|
* be written down, the line is not understood and must not be silenced.
|
|
*/
|
|
const BENIGN_WARNINGS: { match: RegExp; why: string }[] = [
|
|
{
|
|
match: /TIDAL_ADMIN_KEY is not set/i,
|
|
why:
|
|
'Stale boot-time WARN. kubelet materialized the projected secret at 05:51:43, after the ' +
|
|
'pod started at 05:41, and the credential poller hot-loaded it. The gate IS live — ' +
|
|
'proven behaviourally in 09-operator-authority.spec.ts. See BUG-003.',
|
|
},
|
|
{
|
|
match: /reading credential file failed.*admin-key/i,
|
|
why:
|
|
'Same cause: the admin-key volume is mounted optional:true, so each poller pass logged a ' +
|
|
'miss until the key existed.',
|
|
},
|
|
{
|
|
match: /TIDAL_CLUSTER_KEY not set/i,
|
|
why: 'Peer authentication uses the internal CA; the shared cluster key is unused here.',
|
|
},
|
|
{
|
|
match: /Multi-process cluster mode enabled/i,
|
|
why: 'Informational notice on every cluster-mode boot — this IS the production HA shape.',
|
|
},
|
|
{
|
|
match: /metrics server bound to non-loopback address/i,
|
|
why:
|
|
'Intentional: :9091 must be reachable by the vmagent scraper. Exposure is contained by ' +
|
|
'the NetworkPolicy, which 04-network-isolation.spec.ts proves refuses foreign pods.',
|
|
},
|
|
{
|
|
match: /catch-up stream open failed; will retry/i,
|
|
why:
|
|
'Self-healing by design — the stream reopens on the next detected gap or retry timer. ' +
|
|
'Tolerated only in the history window; the health window must be clean.',
|
|
},
|
|
];
|
|
|
|
/** Replication distress that must not be ONGOING in the health window. */
|
|
const REPLICATION_DISTRESS = /batch ship failing|transport channel closed|quarantin/i;
|
|
|
|
/** The leader's own "it is fine again" line, which closes a distress burst. */
|
|
const SHIP_RECOVERED = /peer recovered; shipping resumed/i;
|
|
|
|
/*
|
|
* There is deliberately NO consecutive-failure ceiling here.
|
|
*
|
|
* A first version of this test capped `consecutive_failures` at 500, on the guess
|
|
* that a restart burst was "~100" and the 2026-08-30 episode's 2697 was
|
|
* qualitatively different. Measurement killed that idea: this cluster sets
|
|
* `reseed_self_restart: true`, so a node latching a reseed marker exits(0),
|
|
* reinstalls a snapshot, and returns — a ~2 minute absence. At the shipper's
|
|
* 100ms retry cadence that is ~1200-2000 consecutive failures, entirely normal.
|
|
* Observed the same day: tidaldb-2 self-restarted for a shard-1 reseed and
|
|
* tidaldb-0 logged 1950 failures, then `peer recovered … failed_attempts=1950`,
|
|
* with `peer_acked_seqno` back at the leader's frontier.
|
|
*
|
|
* So the COUNT cannot separate "a peer restarted" from "shipping is broken" —
|
|
* both produce large bursts, and the count only encodes how long the peer was
|
|
* away. What separates them is whether the burst CLOSED. That is what this test
|
|
* asserts, and it is strictly stronger: a genuinely stuck shipper never logs
|
|
* `peer recovered`, so it fails here no matter how high or low its count is.
|
|
*/
|
|
|
|
test.describe('section 6 — logs', () => {
|
|
test('replication distress, if any, is a bounded burst that has already recovered', async ({}, testInfo) => {
|
|
const perPod: Record<
|
|
string,
|
|
{
|
|
sampled: number;
|
|
distressLines: string[];
|
|
peakConsecutiveFailures: number;
|
|
recovered: boolean;
|
|
lastDistressIsAfterLastRecovery: boolean;
|
|
}
|
|
> = {};
|
|
|
|
for (const pod of POD_NAMES) {
|
|
const result = await observed(testInfo, `recent logs ${pod}`, () =>
|
|
kubectl(['-n', NAMESPACE, 'logs', pod, `--since=${HEALTH_WINDOW}`], {
|
|
timeoutMs: 60_000,
|
|
}),
|
|
);
|
|
expect(result.code, `could not read ${pod} logs: ${result.stderr}`).toBe(0);
|
|
|
|
const lines = decolour(result.stdout)
|
|
.split('\n')
|
|
.filter((line) => line.trim() !== '');
|
|
|
|
const distressIndexes = lines
|
|
.map((line, index) => (REPLICATION_DISTRESS.test(line) ? index : -1))
|
|
.filter((index) => index >= 0);
|
|
const recoveryIndexes = lines
|
|
.map((line, index) => (SHIP_RECOVERED.test(line) ? index : -1))
|
|
.filter((index) => index >= 0);
|
|
|
|
let peak = 0;
|
|
for (const index of distressIndexes) {
|
|
const match = /consecutive_failures=(\d+)/.exec(lines[index]!);
|
|
if (match) peak = Math.max(peak, Number.parseInt(match[1]!, 10));
|
|
}
|
|
|
|
const lastDistress = distressIndexes.at(-1) ?? -1;
|
|
const lastRecovery = recoveryIndexes.at(-1) ?? -1;
|
|
|
|
perPod[pod] = {
|
|
sampled: lines.length,
|
|
distressLines: distressIndexes.slice(-5).map((index) => lines[index]!),
|
|
peakConsecutiveFailures: peak,
|
|
recovered: recoveryIndexes.length > 0,
|
|
// The load-bearing question: is the newest distress line NEWER than the
|
|
// newest recovery line? If so the burst never closed and shipping is
|
|
// still broken right now.
|
|
lastDistressIsAfterLastRecovery: lastDistress > lastRecovery,
|
|
};
|
|
}
|
|
|
|
await recordJson(testInfo, 'replication-health-window', {
|
|
window: HEALTH_WINDOW,
|
|
perPod,
|
|
rationale:
|
|
'A burst that has RECOVERED is designed behavior around a reseed_self_restart ' +
|
|
'(a ~2min absence is ~1200-2000 retries at 100ms). An UNRECOVERED burst means ' +
|
|
'shipping is still broken right now. peakConsecutiveFailures is recorded as ' +
|
|
'evidence only - it measures how long the peer was away, not whether anything ' +
|
|
'is wrong.',
|
|
});
|
|
|
|
for (const pod of POD_NAMES) {
|
|
const state = perPod[pod]!;
|
|
if (state.distressLines.length === 0) continue;
|
|
|
|
// A pod shipping batches into a closed transport channel is not
|
|
// replicating, even while /cluster/status/local still reports lag=0
|
|
// because the leader has not yet advanced past the stuck position. What
|
|
// distinguishes "a peer just restarted" from "shipping is broken" is
|
|
// whether the burst CLOSED, not whether it happened.
|
|
expect(
|
|
state.lastDistressIsAfterLastRecovery,
|
|
`${pod} replication distress is ONGOING — the newest distress line is newer than ` +
|
|
`the newest 'peer recovered' line, so shipping has not resumed: ` +
|
|
`${state.distressLines.join(' | ')}`,
|
|
).toBe(false);
|
|
|
|
}
|
|
});
|
|
|
|
test('no pod logged an ERROR, and every WARN is explained', async ({}, testInfo) => {
|
|
const perPod: Record<
|
|
string,
|
|
{ lines: number; errors: string[]; unexplained: string[]; benignCounts: Record<string, number> }
|
|
> = {};
|
|
|
|
for (const pod of POD_NAMES) {
|
|
const result = await observed(testInfo, `history logs ${pod}`, () =>
|
|
kubectl(['-n', NAMESPACE, 'logs', pod, `--since=${HISTORY_WINDOW}`, '--tail=400'], {
|
|
timeoutMs: 60_000,
|
|
}),
|
|
);
|
|
expect(result.code, `could not read ${pod} logs: ${result.stderr}`).toBe(0);
|
|
|
|
const lines = decolour(result.stdout)
|
|
.split('\n')
|
|
.filter((line) => line.trim() !== '');
|
|
const warnings = lines.filter((line) => /\bWARN\b/.test(line));
|
|
|
|
const benignCounts: Record<string, number> = {};
|
|
const unexplained: string[] = [];
|
|
for (const line of warnings) {
|
|
const matched = BENIGN_WARNINGS.find((entry) => entry.match.test(line));
|
|
if (matched) {
|
|
benignCounts[matched.match.source] = (benignCounts[matched.match.source] ?? 0) + 1;
|
|
} else if (REPLICATION_DISTRESS.test(line)) {
|
|
// Recorded, not failed: the previous test owns the health verdict and
|
|
// a historical, recovered episode is legitimate history.
|
|
benignCounts['recovered-replication-episode'] =
|
|
(benignCounts['recovered-replication-episode'] ?? 0) + 1;
|
|
} else {
|
|
unexplained.push(line.slice(0, 200));
|
|
}
|
|
}
|
|
|
|
perPod[pod] = {
|
|
lines: lines.length,
|
|
errors: lines.filter((line) => /\bERROR\b/.test(line)).slice(0, 10),
|
|
unexplained: unexplained.slice(0, 10),
|
|
benignCounts,
|
|
};
|
|
}
|
|
|
|
await recordJson(testInfo, 'log-classification', {
|
|
window: HISTORY_WINDOW,
|
|
allowlist: BENIGN_WARNINGS.map((entry) => ({ pattern: entry.match.source, why: entry.why })),
|
|
perPod,
|
|
});
|
|
|
|
for (const pod of POD_NAMES) {
|
|
expect(perPod[pod].lines, `${pod} should be logging at all`).toBeGreaterThan(0);
|
|
expect(
|
|
perPod[pod].errors,
|
|
`${pod} logged ERROR lines: ${perPod[pod].errors.join(' | ')}`,
|
|
).toEqual([]);
|
|
expect(
|
|
perPod[pod].unexplained,
|
|
`${pod} logged WARNs with no recorded rationale — investigate and either fix or ` +
|
|
`add an explained allowlist entry: ${perPod[pod].unexplained.join(' | ')}`,
|
|
).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test('the deployed image emits uncoloured plain text — level filtering still belongs at the source', async ({}, testInfo) => {
|
|
const result = await observed(testInfo, 'log format sample', () =>
|
|
kubectl(['-n', NAMESPACE, 'logs', 'tidaldb-0', '--tail=5'], { timeoutMs: 45_000 }),
|
|
);
|
|
expect(result.code, result.stderr).toBe(0);
|
|
|
|
const rawLines = result.stdout.split('\n').filter((line) => line.trim() !== '');
|
|
expect(rawLines.length, 'expected log lines to classify').toBeGreaterThan(0);
|
|
|
|
const jsonLines = rawLines.filter((line) => {
|
|
try {
|
|
return typeof JSON.parse(decolour(line)) === 'object';
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
const ansiLines = rawLines.filter((line) => /\x1b\[/.test(line));
|
|
|
|
await recordJson(testInfo, 'log-format', {
|
|
sampled: rawLines.length,
|
|
jsonLines: jsonLines.length,
|
|
ansiLines: ansiLines.length,
|
|
conclusion:
|
|
'Uncoloured plain text. BUG-006 (ANSI escapes in container logs) is RESOLVED on ' +
|
|
'the deployed image. Logs are still unstructured, so VictoriaLogs `level:error` ' +
|
|
'cannot match and filtering stays at the source (runbook 9.3).',
|
|
});
|
|
|
|
// BUG-006 resolved 2026-08-30. The previous version of this test asserted
|
|
// `ansiLines > 0` — correct for the image running when it was written, and it
|
|
// failed the moment a newer image was rolled. That failure is the test doing
|
|
// its job: it is how the drift got noticed. Now pinned in the other direction
|
|
// so a REGRESSION to coloured output fails here.
|
|
expect(
|
|
ansiLines.length,
|
|
'ANSI escapes are back in container logs — BUG-006 has regressed. Coloured output ' +
|
|
'breaks log-store level matching and makes every downstream filter guess.',
|
|
).toBe(0);
|
|
|
|
// Still-open tripwire, unchanged in direction: logs are NOT yet structured.
|
|
expect(
|
|
jsonLines.length,
|
|
'logs became structured JSON — roll runbook section 9.3 from pending to live and ' +
|
|
'invert this assertion',
|
|
).toBe(0);
|
|
});
|
|
});
|