Files
tanshu 1923c80298 Stop the health wait from failing a healthy deploy, and clear a stale address
The first real deploy left the site on 502 and this is why. podman builds an OCI
image by default and drops the Docker HEALTHCHECK instruction, so the app
container has no State.Health at all; the role waited on that field, exhausted
its retries and failed the play, so the caddy role never ran and Caddy kept
proxying menu.hopsngrains.com to the old nginx container that is now gone.

The wait now asks the app itself: docker exec node -e fetch /healthz, which needs
no healthcheck and no DNS. And because the address may still be claimed by the
snippet the retired nginx-menu playbook wrote, the caddy role drops that marker
before writing its own - two blocks for one address make Caddy refuse the config,
and a refused reload quietly leaves the old proxy in place.

check-site also asked for the font the old design used, so it would have failed a
healthy site; it checks one we ship.
2026-09-22 23:44:54 +05:30

129 lines
4.4 KiB
TypeScript

/**
* Checks a running Site from the outside, the way a customer and a phone would
* meet it. Read-only: it never signs in and never writes.
*
* npm run check-site -- https://menu-new.hopsngrains.com
*
* Exits non-zero if anything fails, so it can gate a cutover.
*/
const base = (process.argv[2] ?? 'http://127.0.0.1:3000').replace(/\/$/, '');
interface Result {
name: string;
ok: boolean;
detail: string;
}
const results: Result[] = [];
function record(name: string, ok: boolean, detail: string): void {
results.push({ name, ok, detail });
}
async function request(path: string): Promise<Response> {
return fetch(`${base}${path}`, { redirect: 'manual' });
}
async function checkPage(path: string, expected: string[]): Promise<string | null> {
let response: Response;
try {
response = await request(path);
} catch (error) {
record(`GET ${path}`, false, `unreachable: ${(error as Error).message}`);
return null;
}
const body = await response.text();
const missing = expected.filter((needle) => !body.includes(needle));
record(
`GET ${path}`,
response.status === 200 && missing.length === 0,
`status ${response.status}${missing.length === 0 ? '' : `, missing ${missing.join(', ')}`}`,
);
return body;
}
// --- The site answers at all -------------------------------------------------
const landing = await checkPage('/', ['<html', 'class="landing"']);
if (landing !== null && !/class="row"|The menus are being updated/.test(landing)) {
record('landing has menus or an empty state', false, 'neither Section rows nor the empty state');
}
await checkPage('/healthz', ['ok']);
await checkPage('/robots.txt', ['Disallow: /admin']);
await checkPage('/admin/login', ['type="password"']);
await checkPage('/assets/styles.css', ['--copper']);
await checkPage('/assets/app.js', ['data-zoom']);
await checkPage('/assets/fonts/playfair-display-latin.woff2', []);
const missingPage = await request('/definitely-not-here');
record('GET /definitely-not-here', missingPage.status === 404, `status ${missingPage.status}`);
// --- Each Section a customer can reach, and its images -----------------------
const sections =
landing === null ? [] : [...landing.matchAll(/class="row" href="([^"]+)"/g)].map((m) => m[1]!);
if (sections.length === 0) {
record('at least one Section has a Current Menu Set', false, 'the landing page lists none');
}
for (const href of sections) {
const body = await checkPage(href, ['class="page"']);
if (body === null) continue;
const pages = [...body.matchAll(/data-page="(\d+)"/g)].map((match) => match[1]!);
record(`${href} shows Pages`, pages.length > 0, `${pages.length} page(s)`);
const media = body.match(/\/media\/[0-9a-f-]{36}\.[0-9a-f]+\.webp/);
if (media === null) {
record(`${href} links a Viewing image`, false, 'no media URL in the page');
continue;
}
const image = await request(media[0]);
const cache = image.headers.get('cache-control') ?? '';
record(
`GET ${media[0]}`,
image.status === 200 &&
(image.headers.get('content-type') ?? '').startsWith('image/webp') &&
cache.includes('immutable'),
`status ${image.status}, type ${image.headers.get('content-type')}, cache ${cache}`,
);
const jpeg = await request(media[0].replace(/\.webp$/, '.jpg'));
record(
`GET ${media[0].replace(/\.webp$/, '.jpg')}`,
jpeg.status === 200 && (jpeg.headers.get('content-type') ?? '').startsWith('image/jpeg'),
`status ${jpeg.status}, type ${jpeg.headers.get('content-type')}`,
);
const wrongExtension = await request(media[0].replace(/\.webp$/, '.jpeg'));
record(
'Originals are not reachable',
wrongExtension.status === 404,
`a non-variant extension gives ${wrongExtension.status}`,
);
const traversal = await request(`${media[0].replace('/media/', '/media/..%2Foriginals%2F')}`);
record(
'media paths cannot escape the set directory',
traversal.status === 404,
`status ${traversal.status}`,
);
}
// --- Report -----------------------------------------------------------------
const width = Math.max(...results.map((result) => result.name.length));
for (const result of results) {
console.log(`${result.ok ? 'ok ' : 'FAIL'} ${result.name.padEnd(width)} ${result.detail}`);
}
const failures = results.filter((result) => !result.ok);
console.log(
`\n${results.length - failures.length}/${results.length} checks passed against ${base}`,
);
if (failures.length > 0) process.exitCode = 1;