All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Post-deploy half of the deploy-verification contract for m12-harden-20260831. Flipped, exactly as each assertion instructed its own successor to do: - 06-logs.spec.ts: asserted `jsonLines === 0`. JSON_LOGS is live, so it now asserts every sampled line parses as JSON. The ANSI check stays pinned at 0. - 09-operator-authority.spec.ts: asserted JSON_LOGS was absent from the StatefulSet. Now asserts JSON_LOGS=1 AND TIDAL_SERVICE_NAME=tidaldb, because the second is load-bearing: enabling structured logs makes the app's own `service` field win in the fleet's Vector normalize transform, silently renaming the log stream tidaldb -> tidal-server and blinding every query keyed on it. The fleet's _stream_fields contract pins field names but no legal values, so nothing there would have caught the flip. Calibrated, NOT loosened — the two backup assertions were unpassable by construction for ~25 minutes every day: - The schedule fires at 03:30 and measured runs take 9.1-24.8 min (n=15), so the newest object is legitimately InProgress during its own window. The "newest backup completed cleanly" test now selects the newest FINISHED backup; a namespace where nothing has ever finished still fails. - "no backup stuck in progress" asserted InProgress -> fail, full stop. It now bounds in-flight age at 60 min: ~2.4x the slowest success and a quarter of the 240.0 min timeout that the observed PartiallyFailed runs (2026-08-17/19/25) all hit. A gate that cries wolf on a schedule gets muted, and then it is not a gate. Both thresholds come from reading every backup in the namespace, not from a guess. Playwright 34/34 and hermetic semantics 5/5 against the deployed image.
257 lines
10 KiB
TypeScript
257 lines
10 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 exported, 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);
|
|
|
|
// Inverted 2026-08-30. This previously asserted `http === 0` and was correct
|
|
// for the image running when it was written; it failed the moment a newer
|
|
// image was rolled, which is exactly how the drift got noticed. Runbook
|
|
// section 9.1 is now LIVE, so a regression to zero HTTP metrics — an image
|
|
// rollback, or the route-metrics layer being dropped — fails here.
|
|
expect(
|
|
counts[pod].http,
|
|
`${pod} exports no tidaldb_http_* metrics. Section 9.1 went live on ` +
|
|
`2026-08-30; losing them means an image rollback or the route-metrics layer ` +
|
|
`being removed, and the five HTTP dashboard panels are now blank.`,
|
|
).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
test('structured logging is enabled on the StatefulSet, with the service name pinned', 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);
|
|
|
|
// FLIPPED 2026-08-31 on the roll of m12-harden-20260831, exactly as the previous
|
|
// assertion instructed. The feature was never missing — `logging.rs:85` has
|
|
// implemented it all along; the StatefulSet simply never asked for it, which is
|
|
// why runbook 9.3 read as a gap for months.
|
|
const jsonLogs = env.find((line) => line.startsWith('JSON_LOGS='));
|
|
expect(
|
|
jsonLogs,
|
|
'JSON_LOGS has been removed from the StatefulSet. Structured logs are what make ' +
|
|
'VictoriaLogs `level:` selectors match at all; without it every log-based alert ' +
|
|
'and dashboard silently returns nothing.',
|
|
).toBe('JSON_LOGS=1');
|
|
|
|
// TIDAL_SERVICE_NAME is load-bearing, not cosmetic: enabling JSON_LOGS makes the
|
|
// app's own `service` field win in the fleet's Vector normalize transform
|
|
// (victoria-logs.yaml:230), which would silently rename the log stream from
|
|
// `tidaldb` to `tidal-server` and blind every query keyed on it. The fleet's
|
|
// `_stream_fields` contract pins field NAMES but no legal VALUES, so nothing in
|
|
// k3s-fleet would have caught the flip — `verify-live` now asserts it too.
|
|
const serviceName = env.find((line) => line.startsWith('TIDAL_SERVICE_NAME='));
|
|
expect(
|
|
serviceName,
|
|
'TIDAL_SERVICE_NAME is unset while JSON_LOGS is on — the log stream will silently ' +
|
|
'rename itself to tidal-server and every `service:tidaldb` query will return zero.',
|
|
).toBe('TIDAL_SERVICE_NAME=tidaldb');
|
|
});
|
|
});
|