feat: harden podman deployment orchestration
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
const image = process.env.VERIFY_CONTAINER_IMAGE ?? 'podman-backend';
|
||||
const port = Number(process.env.VERIFY_CONTAINER_PORT ?? 8799);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const runId = `${process.pid}-${Date.now()}`;
|
||||
const apiContainer = `podman-verify-api-${runId}`;
|
||||
const agentContainer = `podman-verify-agent-${runId}`;
|
||||
const envPath = process.env.DOTENV_CONFIG_PATH ?? (existsSync('.env') ? '.env' : 'backend/.env');
|
||||
const doFetch = globalThis.fetch;
|
||||
|
||||
loadEnv({ path: envPath, quiet: true });
|
||||
|
||||
const containerEnv = {
|
||||
PORT: String(port),
|
||||
LIVEKIT_URL: process.env.LIVEKIT_URL ?? 'REPLACE_ME',
|
||||
LIVEKIT_API_KEY: process.env.LIVEKIT_API_KEY ?? 'verify-key',
|
||||
LIVEKIT_API_SECRET: process.env.LIVEKIT_API_SECRET ?? 'verify-secret',
|
||||
GEMINI_API_KEY: process.env.GEMINI_API_KEY ?? 'verify-gemini',
|
||||
GITHUB_TOKEN: process.env.GITHUB_TOKEN ?? 'verify-github',
|
||||
GITHUB_REPO: process.env.GITHUB_REPO ?? 'karti-ai/podman',
|
||||
MONGODB_URI: process.env.MONGODB_URI ?? 'mongodb://127.0.0.1:27017/podman',
|
||||
};
|
||||
|
||||
function runPodman(args, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('podman', args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: code ?? 1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function envArgs(extra = {}) {
|
||||
return Object.entries({ ...containerEnv, ...extra }).flatMap(([key, value]) => [
|
||||
'--env',
|
||||
`${key}=${value}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
async function assertPodmanAvailable() {
|
||||
const result = await runPodman(['--version']);
|
||||
if (result.code !== 0) fail(`podman is not available: ${result.stderr.trim()}`);
|
||||
}
|
||||
|
||||
async function assertImageExists() {
|
||||
const result = await runPodman(['image', 'exists', image]);
|
||||
if (result.code !== 0) {
|
||||
fail(
|
||||
`container image "${image}" does not exist locally; build it before running this verifier`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeContainer(name) {
|
||||
await runPodman(['rm', '-f', name]);
|
||||
}
|
||||
|
||||
async function startContainer(name, extraEnv) {
|
||||
await removeContainer(name);
|
||||
const result = await runPodman([
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
name,
|
||||
'--network',
|
||||
'host',
|
||||
...envArgs(extraEnv),
|
||||
image,
|
||||
]);
|
||||
if (result.code !== 0) {
|
||||
fail(`failed to start ${name}: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopContainer(name) {
|
||||
await runPodman(['stop', '--time', '3', name]);
|
||||
await removeContainer(name);
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
const res = await doFetch(`${baseUrl}${path}`);
|
||||
const text = await res.text();
|
||||
let body;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
fail(`${path} returned non-JSON response: ${text.slice(0, 200)}`);
|
||||
}
|
||||
if (!res.ok) fail(`${path} returned ${res.status}: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function waitForApi() {
|
||||
let lastError = 'not attempted';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const body = await fetchJson('/health');
|
||||
if (body?.ok === true) return;
|
||||
lastError = `unexpected /health body: ${JSON.stringify(body)}`;
|
||||
} catch (error) {
|
||||
lastError = error.message;
|
||||
}
|
||||
await delay(500);
|
||||
}
|
||||
const logs = await runPodman(['logs', apiContainer]);
|
||||
fail(
|
||||
`API container did not become healthy at ${baseUrl}: ${lastError}\n${logs.stdout}${logs.stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyApiContainer() {
|
||||
await startContainer(apiContainer, { PODMAN_PROCESS: 'server' });
|
||||
await waitForApi();
|
||||
const pods = await fetchJson('/api/pods');
|
||||
if (!Array.isArray(pods)) fail(`/api/pods returned unexpected payload: ${JSON.stringify(pods)}`);
|
||||
}
|
||||
|
||||
function requireLiveKitEnv() {
|
||||
const missing = ['LIVEKIT_URL', 'LIVEKIT_API_KEY', 'LIVEKIT_API_SECRET'].filter((key) => {
|
||||
const value = process.env[key];
|
||||
return !value || value === 'REPLACE_ME';
|
||||
});
|
||||
if (missing.length) {
|
||||
fail(`agent container verification requires real LiveKit env vars: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAgentContainer() {
|
||||
requireLiveKitEnv();
|
||||
await startContainer(agentContainer, { PODMAN_PROCESS: 'agent', POD_ROOM: 'demo-pod' });
|
||||
|
||||
let output = '';
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const logs = await runPodman(['logs', agentContainer]);
|
||||
output = `${logs.stdout}${logs.stderr}`;
|
||||
if (output.includes('podman-hermes joined room')) return;
|
||||
|
||||
const inspect = await runPodman([
|
||||
'inspect',
|
||||
'--format',
|
||||
'{{.State.Running}} {{.State.ExitCode}}',
|
||||
agentContainer,
|
||||
]);
|
||||
if (inspect.code === 0 && inspect.stdout.trim().startsWith('false')) break;
|
||||
|
||||
await delay(500);
|
||||
}
|
||||
|
||||
fail(`agent logs did not include "podman-hermes joined room":\n${output}`);
|
||||
}
|
||||
|
||||
let exitCode = 0;
|
||||
try {
|
||||
await assertPodmanAvailable();
|
||||
await assertImageExists();
|
||||
await verifyApiContainer();
|
||||
await verifyAgentContainer();
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
image,
|
||||
baseUrl,
|
||||
containers: [apiContainer, agentContainer],
|
||||
checks: ['image-exists', 'api-health', 'api-pods', 'agent-joined-room'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
exitCode = 1;
|
||||
} finally {
|
||||
await stopContainer(agentContainer);
|
||||
await stopContainer(apiContainer);
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
Reference in New Issue
Block a user