tidaldb/tests/e2e/features/09-operator-authority.spec.ts
jordan 15f6b11187 test(e2e): Playwright evidence harness for the deploy-verification runbook
Turns docs/runbooks/deploy-verification.md from prose into 32 executable checks
against the live orchard9-k3sf cluster, and it found real defects on its first
run — including in the runbook it verifies.

WHY PLAYWRIGHT, HONESTLY
tidalDB serves zero HTML (no text/html, no Html(), 10 JSON routes), so this uses
Playwright in three distinct roles rather than pretending there is a UI:
  * request fixture as a real HTTP client for DNS/TLS/auth/quorum/404;
  * a browser for the only genuine screens in the chain, Grafana;
  * a test harness for cluster-plane checks with no HTTP surface, shelling out
    to kubectl and attaching the real transcript as evidence.

WHAT IT CAUGHT
  * The runbook asserted the operator/data credential split was "not active yet
    - requires an image roll". globalSetup read the live image and the live
    secret; a probe returned data->403, admin->200. It had been enforcing the
    whole time. Section 9 rewritten. (BUG-001)
  * docs/ops/grafana-tidaldb.json shipped datasource uid ${DS_PROMETHEUS} - a
    Grafana export-for-sharing placeholder with no __inputs block to resolve it.
    Under ConfigMap provisioning every panel queried a datasource that did not
    exist, so the whole board was blank. The API said "loaded" and I had only
    ever checked the API. 41 refs fixed here, 58 across the fleet ConfigMap,
    which was also blanking the postgres and redis dashboards. (BUG-007)
  * Stat panels used calcs "lastNonNull". Grafana's reducer is "lastNotNull", so
    no value was ever computed and Cluster health / Reseed pending / Indexed
    vectors rendered as empty boxes. I chased panel width and then panel height
    before comparing against a working stat panel elsewhere in the same Grafana.
    A spelling error wearing a layout bug's clothes. (BUG-009)
  * The namespace variable defaulted to All, so cluster panels silently included
    tidaldb-586b544c8-vpkmw from the superseded standalone deployment. Latency
    legends read "p50 p50 p50" with no way to tell the nodes apart. Both fixed.
  * "5xx ratio" rendered "No data" as large green text - at a glance a healthy
    value. And Fleet state gave three fields one shared green threshold, so
    reseed_required=1 would have shown GREEN during the exact incident the panel
    exists to surface. Split into three panels with per-field mappings.
  * tidalctl cluster-status exits 2 on a FULLY CONVERGED cluster, because the
    aggregated endpoint reports healthy peers as region=null applied=0
    reachable=false. The runbook claimed `cluster-status && deploy` was a safe
    gate; that claim came from an exit code masked by a shell pipeline. The gate
    can never pass here. Documented, test pins it, engine defect recorded.
    (BUG-005)
  * The deployed image writes ANSI colour into container logs, which the
    collector stores verbatim. Already fixed in logging.rs, not yet rolled;
    pinned as a tripwire. (BUG-006)
  * The runbook's own backup command sorted ALL backups by timestamp and
    selected a restore-canary run: 20 items, one volume, a meaningless pass.
    Now filters on the schedule label the freshness alert actually watches.

DEFECTS FOUND BY LOOKING AT THE SCREENS
Six of the first eight captures were slop and were fixed, not promoted:
230-350px of dead space; a verdict that rendered "exit code 2" in green; the
1600x1800 dashboard scaled into 16:9 until illegible (now clipped to the
evidence band using real element bounds); the dream beat whose caption described
a contradiction the image did not show (now a purpose-built capture holding the
committed doc text, the running image, and the live 403/200 side by side); and a
one-frame blink to bare background at every scene boundary, because Remotion
Sequences do not overlap and both scenes sat at opacity 0 on the boundary frame.

TRIPWIRES IN THE HONEST DIRECTION
Three tests assert what is ABSENT - zero tidaldb_http_* families, JSON_LOGS
unset, plain-text logs - and each carries the message "good news, roll the
runbook section from pending to live". The metric-absence test also asserts the
baseline family count, so "absent" cannot pass for "the scrape failed". That is
the drift that made section 9 stale in the first place.

Regression config uses workers:1 and retries:0 deliberately: a live-cluster
check that only passes on the second attempt has told you something true.

Verified: 32 passed (46.8s); 9 demo captures each asserting before photographing;
tsc clean; render 82.05s 1920x1080 h264, 0 empty frames across 10 boundaries;
every promoted image inspected individually and judged perfect; walk-the-render
ledger complete with no fails.
2026-08-23 14:03:29 -06:00

234 lines
8.5 KiB
TypeScript

/**
* Runbook section 9 — operator authority, and what is still inert.
*
* CAP-014 operator authority is separated from data-plane access
* CAP-015 inert observability features are inert for a known reason
*
* This spec exists because the runbook was WRONG about its own subject. It
* claimed the credential split was "not active yet — requires an image roll".
* The harness read the live image and the live secret and found the gate
* already enforcing. Documentation drift, caught by the thing it documents.
* See BUG-001.
*
* The inert-feature tests are tripwires in the honest direction: they assert
* ABSENCE today, so the day someone rolls the observability image they fail and
* say "update the runbook" — instead of the runbook rotting again.
*/
import { expect, test } from '@playwright/test';
import { kubectl, withPortForward } from '../support/cluster';
import { observed, recordJson } from '../support/evidence';
import {
NAMESPACE,
OBS_NAMESPACE,
PORT_CLIENT,
PORT_METRICS,
POD_NAMES,
adminKey,
apiKey,
} from '../support/env';
/** Generous: a short scrape timeout returns zero lines for everything (BUG-002). */
const SCRAPE_TIMEOUT_SECONDS = 15;
test.describe('section 9 — operator authority', () => {
test('the data-plane credential is refused on a destructive operator verb', async ({
playwright,
}, testInfo) => {
const admin = adminKey();
expect(
admin,
'TIDAL_ADMIN_KEY is absent from the secret. Without it the gate degrades to previous ' +
'behaviour by design and any client key can remove a member — add the key before ' +
'treating this deployment as verified.',
).toBeTruthy();
await withPortForward(NAMESPACE, 'svc/tidaldb', PORT_CLIENT, async (forward) => {
const base = `https://127.0.0.1:${forward.localPort}`;
const body = { region: 'tidaldb-1' };
const dataContext = await playwright.request.newContext({
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
authorization: `Bearer ${apiKey()}`,
'content-type': 'application/json',
},
});
const adminContext = await playwright.request.newContext({
ignoreHTTPSErrors: true,
extraHTTPHeaders: {
authorization: `Bearer ${admin}`,
'content-type': 'application/json',
},
});
try {
const dataAttempt = await dataContext.post(`${base}/cluster/promote`, { data: body });
const adminAttempt = await adminContext.post(`${base}/cluster/promote`, { data: body });
await recordJson(testInfo, 'authority-split', {
verb: 'POST /cluster/promote',
dataCredential: dataAttempt.status(),
adminCredential: adminAttempt.status(),
});
// 403, not 401: the data bearer IS a valid credential, it simply lacks
// operator authority. A 401 here would mean the admin gate rejected it
// before authenticating, which would also break peer-callable verbs.
expect(
dataAttempt.status(),
'the data bearer must be authenticated but NOT authorized (403) on an operator verb',
).toBe(403);
// The admin key must clear both gates. It is deliberately a superset
// credential — one Authorization header per request means it has to
// authenticate as well as authorize, or operators get 401 before the
// admin gate ever runs.
expect(
adminAttempt.status(),
'the admin key must clear both authentication and the admin gate',
).not.toBe(401);
expect(adminAttempt.status(), 'the admin key must not be forbidden').not.toBe(403);
} finally {
await dataContext.dispose();
await adminContext.dispose();
}
});
});
test('cluster status requires a credential', async ({ playwright }, testInfo) => {
await withPortForward(NAMESPACE, 'svc/tidaldb', PORT_CLIENT, async (forward) => {
const anonymous = await playwright.request.newContext({ ignoreHTTPSErrors: true });
try {
const response = await anonymous.get(
`https://127.0.0.1:${forward.localPort}/cluster/status`,
);
await recordJson(testInfo, 'anonymous-cluster-status', { status: response.status() });
// Moved behind auth by 388e445. Unauthenticated access would leak
// leader identity, membership, and sequence positions.
expect(
response.status(),
'/cluster/status must require a credential even inside the cluster',
).toBe(401);
} finally {
await anonymous.dispose();
}
});
});
test('the admin key is mounted as a projected secret file', async ({}, testInfo) => {
const result = await observed(testInfo, 'list admin-key mount', () =>
kubectl(
['-n', NAMESPACE, 'exec', 'tidaldb-0', '-c', 'tidaldb', '--', 'ls', '/etc/tidaldb/admin-key/'],
{ timeoutMs: 45_000 },
),
);
await recordJson(testInfo, 'admin-key-mount', {
exitCode: result.code,
entries: result.stdout.trim().split('\n').filter(Boolean),
});
// The mount is optional:true on purpose — a required mount would prevent
// the pod from starting at all when the key is absent. Its presence here
// is what let the credential poller hot-load the key without a restart,
// which is why the boot log's "not set" WARN is stale (BUG-003).
expect(result.code, `admin-key mount unreadable: ${result.stderr}`).toBe(0);
expect(
result.stdout,
'the projected admin-key file must be present for the poller to load',
).toContain('admin-key');
});
test('HTTP request metrics are absent, and the scrape that proves it actually worked', async ({}, testInfo) => {
const counts: Record<string, { baseline: number; http: number }> = {};
for (const pod of POD_NAMES) {
const ip = await kubectl([
'-n',
NAMESPACE,
'get',
'pod',
pod,
'-o',
'jsonpath={.status.podIP}',
]);
expect(ip.code, ip.stderr).toBe(0);
const result = await observed(testInfo, `scrape ${pod}`, () =>
kubectl(
[
'-n',
OBS_NAMESPACE,
'exec',
'deploy/vmagent',
'--',
'sh',
'-c',
`wget -qO- --timeout=${SCRAPE_TIMEOUT_SECONDS} http://${ip.stdout.trim()}:${PORT_METRICS}/metrics ` +
`| awk '/^tidaldb_http_/{h++} /^tidaldb_/{t++} END{print (t+0)" "(h+0)}'`,
],
{ timeoutMs: 60_000 },
),
);
expect(result.code, `scrape of ${pod} failed: ${result.stderr}`).toBe(0);
const [baseline, http] = result.stdout.trim().split(/\s+/).map(Number);
counts[pod] = { baseline, http };
}
await recordJson(testInfo, 'metric-family-counts', counts);
for (const pod of POD_NAMES) {
// Prove the scrape WORKED before concluding a metric is missing. A short
// timeout returns zero for everything, which would make the assertion
// below pass for entirely the wrong reason — the exact trap that made one
// healthy pod look like it had stopped exporting (BUG-002).
expect(
counts[pod].baseline,
`${pod} returned no metrics at all — the scrape failed, so its http-metric count ` +
`proves nothing`,
).toBeGreaterThan(100);
expect(
counts[pod].http,
`${pod} now exports tidaldb_http_* metrics — good news: the observability image has ` +
`been rolled. Move runbook section 9.1 from inert to live, confirm the five HTTP ` +
`dashboard panels populate, and invert this assertion.`,
).toBe(0);
}
});
test('structured logging is not yet enabled on the StatefulSet', async ({}, testInfo) => {
const result = await observed(testInfo, 'statefulset env', () =>
kubectl(
[
'-n',
NAMESPACE,
'get',
'statefulset',
'tidaldb',
'-o',
'jsonpath={range .spec.template.spec.containers[0].env[*]}{.name}={.value}{"\\n"}{end}',
],
{ timeoutMs: 45_000 },
),
);
expect(result.code, result.stderr).toBe(0);
const env = result.stdout
.trim()
.split('\n')
.filter((line) => line.trim() !== '');
await recordJson(testInfo, 'statefulset-env', env);
const jsonLogs = env.find((line) => line.startsWith('JSON_LOGS='));
expect(
jsonLogs,
'JSON_LOGS is now set — roll runbook section 9.3 from pending to live, verify one JSON ' +
'object per line, and invert this assertion',
).toBeUndefined();
});
});