chore: add public healthcheck watchdog

This commit is contained in:
Yahya Alhinai
2026-06-28 01:53:03 +00:00
parent 9ac7bc5e8f
commit 205939616b
5 changed files with 215 additions and 3 deletions
@@ -0,0 +1,11 @@
[Unit]
Description=PodMan public URL healthcheck
After=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
Wants=network-online.target mongod.service podman-platform-api.service podman-platform-agent.service caddy.service
[Service]
Type=oneshot
WorkingDirectory=/root/podman
Environment=PODMAN_PUBLIC_URL=https://165-22-129-249.sslip.io/
Environment=PODMAN_HEALTH_TIMEOUT_MS=8000
ExecStart=/usr/bin/node scripts/healthcheck-public.mjs
@@ -0,0 +1,11 @@
[Unit]
Description=Run PodMan public URL healthcheck every minute
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
AccuracySec=10s
Unit=podman-public-healthcheck.service
[Install]
WantedBy=timers.target
+1
View File
@@ -20,6 +20,7 @@
"doctor": "node scripts/deploy-doctor.mjs", "doctor": "node scripts/deploy-doctor.mjs",
"doctor:strict": "node scripts/deploy-doctor.mjs --strict", "doctor:strict": "node scripts/deploy-doctor.mjs --strict",
"deploy:static:local": "node scripts/deploy-static-local.mjs", "deploy:static:local": "node scripts/deploy-static-local.mjs",
"healthcheck:public": "node scripts/healthcheck-public.mjs",
"verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend", "verify": "pnpm lint && pnpm typecheck && pnpm build && pnpm verify:backend && pnpm verify:frontend",
"verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers", "verify:full": "pnpm verify && pnpm build:container && pnpm verify:containers",
"verify:backend": "node scripts/verify-backend.mjs", "verify:backend": "node scripts/verify-backend.mjs",
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
const rootUrl = process.env.PODMAN_PUBLIC_URL ?? 'https://165-22-129-249.sslip.io/';
const apiUrl = process.env.PODMAN_PUBLIC_API_URL ?? new URL('/api/pods', rootUrl).toString();
const timeoutMs = Number(process.env.PODMAN_HEALTH_TIMEOUT_MS ?? 8000);
const doFetch = globalThis.fetch;
const { AbortController, clearTimeout, setTimeout } = globalThis;
const requiredServices = [
'mongod.service',
'podman-platform-api.service',
'podman-platform-agent.service',
'caddy.service',
];
async function fetchOk(url) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await doFetch(url, { signal: controller.signal });
return { ok: res.ok, status: res.status };
} finally {
clearTimeout(timeout);
}
}
function run(command, args) {
return new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('close', (code) => resolve({ code, stdout, stderr }));
child.on('error', (error) => resolve({ code: 127, stdout, stderr: error.message }));
});
}
async function runChecked(command, args) {
const result = await run(command, args);
if (result.code !== 0) {
const output = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n');
throw new Error(
`${command} ${args.join(' ')} failed with ${result.code}${output ? `:\n${output}` : ''}`,
);
}
return result;
}
async function serviceOk(service) {
const result = await run('systemctl', ['is-active', '--quiet', service]);
return { ok: result.code === 0, code: result.code };
}
async function restartServices(reason) {
console.error(`[healthcheck] ${reason}; restarting public app services`);
for (const service of requiredServices) {
await runChecked('systemctl', ['restart', service]);
}
}
const urlChecks = [
['root', rootUrl, await fetchOk(rootUrl).catch((error) => ({ ok: false, error: error.message }))],
['api', apiUrl, await fetchOk(apiUrl).catch((error) => ({ ok: false, error: error.message }))],
];
const serviceChecks = await Promise.all(
requiredServices.map(async (service) => [
`service:${service}`,
service,
await serviceOk(service),
]),
);
const checks = [...urlChecks, ...serviceChecks];
const failed = checks.filter(([, , result]) => !result.ok);
if (failed.length) {
await restartServices(
failed
.map(([name, url, result]) => `${name} ${url} ${result.status ?? result.error}`)
.join('; '),
);
const retryUrlChecks = [
[
'root',
rootUrl,
await fetchOk(rootUrl).catch((error) => ({ ok: false, error: error.message })),
],
['api', apiUrl, await fetchOk(apiUrl).catch((error) => ({ ok: false, error: error.message }))],
];
const retryServiceChecks = await Promise.all(
requiredServices.map(async (service) => [
`service:${service}`,
service,
await serviceOk(service),
]),
);
const retry = [...retryUrlChecks, ...retryServiceChecks];
const stillFailed = retry.filter(([, , result]) => !result.ok);
console.log(JSON.stringify({ ok: stillFailed.length === 0, checks, retry }, null, 2));
process.exit(stillFailed.length === 0 ? 0 : 1);
}
console.log(JSON.stringify({ ok: true, checks }, null, 2));
+85 -3
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { TextEncoder } from 'node:util';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
import { setTimeout as delay } from 'node:timers/promises'; import { setTimeout as delay } from 'node:timers/promises';
@@ -7,6 +9,16 @@ const frontendUrl = process.env.FRONTEND_URL ?? 'http://127.0.0.1:4173/';
const shouldStartPreview = !process.env.FRONTEND_URL; const shouldStartPreview = !process.env.FRONTEND_URL;
const doFetch = globalThis.fetch; const doFetch = globalThis.fetch;
const verifyMember = `Verify ${process.pid}`; const verifyMember = `Verify ${process.pid}`;
const apiBase = process.env.BACKEND_URL
? process.env.BACKEND_URL.replace(/\/$/, '')
: process.env.FRONTEND_URL
? new URL(process.env.FRONTEND_URL).origin
: 'http://localhost:8787';
const { DATA_TOPIC } = await import('../shared/dist/messages.js').catch(() => ({
DATA_TOPIC: 'podman.intervention',
}));
const backendRequire = createRequire(new URL('../backend/package.json', import.meta.url));
const { Room } = backendRequire('@livekit/rtc-node');
async function stopChild(child) { async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return; if (!child || child.exitCode !== null || child.signalCode !== null) return;
@@ -42,6 +54,61 @@ async function waitForPreview() {
throw new Error(`frontend preview did not become ready at ${frontendUrl}`); throw new Error(`frontend preview did not become ready at ${frontendUrl}`);
} }
async function fetchJson(path, init) {
const res = await doFetch(`${apiBase}${path}`, init);
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(`${path} returned ${res.status}: ${text}`);
return body;
}
async function connectPublisher(roomName) {
const { token, url } = await fetchJson('/api/token', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
room: roomName,
identity: `verify-agent-${process.pid}`,
name: 'PodMan',
}),
});
const room = new Room();
await room.connect(url, token);
return room;
}
async function publishIntervention(room, podId) {
const now = new Date().toISOString();
const id = `verify-${process.pid}-${Date.now()}`;
const intervention = {
id: `int-${id}`,
collisionId: `col-${id}`,
podId,
kind: 'card',
message: 'Verification collision: two engineers are editing frontend/src/App.tsx.',
suggestedAction: { kind: 'sync_before_push' },
status: 'pending',
createdAt: now,
};
const message = {
type: 'COLLISION',
collision: {
id: intervention.collisionId,
podId,
file: 'src/App.tsx',
engineers: ['Verify', 'PodMan'],
severity: 'warn',
githubState: { branch: 'verify', unpushed: true, prs: [] },
detectedAt: now,
},
intervention,
};
await room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), {
reliable: true,
topic: DATA_TOPIC,
});
}
let preview = null; let preview = null;
if (shouldStartPreview) { if (shouldStartPreview) {
preview = spawn( preview = spawn(
@@ -99,6 +166,21 @@ try {
if (!hasPodView) { if (!hasPodView) {
throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`); throw new Error(`pod detail controls did not render after join: ${joinedText.slice(0, 500)}`);
} }
const publisher = await connectPublisher('frontend-pod');
try {
await publishIntervention(publisher, 'frontend-pod');
await page
.getByText('Verification collision: two engineers are editing frontend/src/App.tsx.')
.waitFor({ timeout: 15_000 });
await page.getByRole('button', { name: 'Dismiss' }).click();
await page.getByText('No collision detected').waitFor({ timeout: 15_000 });
} finally {
await publisher.disconnect();
}
await page.getByRole('button', { name: 'Leave pod' }).click();
if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`); if (consoleErrors.length) throw new Error(`console errors: ${consoleErrors.join(' | ')}`);
if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`); if (pageErrors.length) throw new Error(`page errors: ${pageErrors.join(' | ')}`);
if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`); if (failedRequests.length) throw new Error(`failed requests: ${failedRequests.join(' | ')}`);
@@ -110,6 +192,7 @@ try {
frontendUrl, frontendUrl,
bodyLength: bodyText.length, bodyLength: bodyText.length,
joined: true, joined: true,
intervention: true,
member: verifyMember, member: verifyMember,
}, },
null, null,
@@ -117,12 +200,11 @@ try {
), ),
); );
} finally { } finally {
const apiBase = process.env.FRONTEND_URL
? new URL(process.env.FRONTEND_URL).origin
: 'http://localhost:8787';
await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, { await doFetch(`${apiBase}/api/pods/frontend-pod/members/${encodeURIComponent(verifyMember)}`, {
method: 'DELETE', method: 'DELETE',
}).catch(() => {}); }).catch(() => {});
await browser.close(); await browser.close();
await stopChild(preview); await stopChild(preview);
} }
process.exit(0);