148 lines
5.0 KiB
TypeScript
148 lines
5.0 KiB
TypeScript
|
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||
|
|
import { spawn } from 'node:child_process';
|
||
|
|
import { access } from 'node:fs/promises';
|
||
|
|
|
||
|
|
const imagePath = process.env.BASEOS_QEMU_IMAGE;
|
||
|
|
const cloudlyPort = Number(process.env.BASEOS_QEMU_CLOUDLY_PORT || '18080');
|
||
|
|
const bootTimeoutMs = Number(process.env.BASEOS_QEMU_BOOT_TIMEOUT_MS || String(1000 * 60 * 5));
|
||
|
|
|
||
|
|
const commandExists = async (commandArg: string) => {
|
||
|
|
const child = spawn('bash', ['-lc', `command -v ${commandArg}`], { stdio: 'ignore' });
|
||
|
|
return await new Promise<boolean>((resolveArg) => {
|
||
|
|
child.on('close', (codeArg) => resolveArg(codeArg === 0));
|
||
|
|
child.on('error', () => resolveArg(false));
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const readJsonBody = async (reqArg: IncomingMessage) => {
|
||
|
|
const chunks: Buffer[] = [];
|
||
|
|
for await (const chunk of reqArg) {
|
||
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||
|
|
}
|
||
|
|
const bodyText = Buffer.concat(chunks).toString('utf8').trim();
|
||
|
|
return bodyText ? JSON.parse(bodyText) as Record<string, unknown> : {};
|
||
|
|
};
|
||
|
|
|
||
|
|
const sendJson = (resArg: ServerResponse, statusCodeArg: number, bodyArg: Record<string, unknown>) => {
|
||
|
|
resArg.statusCode = statusCodeArg;
|
||
|
|
resArg.setHeader('content-type', 'application/json');
|
||
|
|
resArg.end(JSON.stringify(bodyArg));
|
||
|
|
};
|
||
|
|
|
||
|
|
const startMockCloudly = async () => {
|
||
|
|
let registrationBody: Record<string, unknown> | undefined;
|
||
|
|
const server = createServer(async (req, res) => {
|
||
|
|
try {
|
||
|
|
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${cloudlyPort}`);
|
||
|
|
if (req.method === 'POST' && requestUrl.pathname === '/baseos/v1/nodes/register') {
|
||
|
|
registrationBody = await readJsonBody(req);
|
||
|
|
sendJson(res, 200, {
|
||
|
|
accepted: true,
|
||
|
|
nodeId: 'qemu-baseos-node',
|
||
|
|
nodeToken: 'qemu-node-token',
|
||
|
|
desiredState: {},
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (req.method === 'POST' && requestUrl.pathname === '/baseos/v1/nodes/heartbeat') {
|
||
|
|
sendJson(res, 200, {
|
||
|
|
accepted: true,
|
||
|
|
desiredState: {},
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
sendJson(res, 404, { errorText: 'not found' });
|
||
|
|
} catch (error) {
|
||
|
|
sendJson(res, 500, { errorText: (error as Error).message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
await new Promise<void>((resolveArg) => server.listen({ port: cloudlyPort, host: '0.0.0.0' }, resolveArg));
|
||
|
|
return {
|
||
|
|
server,
|
||
|
|
getRegistrationBody: () => registrationBody,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const stopServer = async (serverArg: Server) => {
|
||
|
|
await new Promise<void>((resolveArg, rejectArg) => {
|
||
|
|
serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg());
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const waitForRegistration = async (getRegistrationBodyArg: () => Record<string, unknown> | undefined) => {
|
||
|
|
const startTime = Date.now();
|
||
|
|
while (Date.now() - startTime < bootTimeoutMs) {
|
||
|
|
const registrationBody = getRegistrationBodyArg();
|
||
|
|
if (registrationBody) {
|
||
|
|
return registrationBody;
|
||
|
|
}
|
||
|
|
await new Promise((resolveArg) => setTimeout(resolveArg, 1000));
|
||
|
|
}
|
||
|
|
throw new Error(`BaseOS QEMU image did not register within ${bootTimeoutMs}ms`);
|
||
|
|
};
|
||
|
|
|
||
|
|
const main = async () => {
|
||
|
|
if (!imagePath) {
|
||
|
|
console.log('[baseos-qemu-enrollment] Skipping: BASEOS_QEMU_IMAGE is not set');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await access(imagePath);
|
||
|
|
if (!(await commandExists('qemu-system-x86_64'))) {
|
||
|
|
throw new Error('Missing required command for BaseOS QEMU scenario: qemu-system-x86_64');
|
||
|
|
}
|
||
|
|
|
||
|
|
const mockCloudly = await startMockCloudly();
|
||
|
|
const qemu = spawn('qemu-system-x86_64', [
|
||
|
|
'-machine',
|
||
|
|
'accel=kvm:tcg',
|
||
|
|
'-m',
|
||
|
|
process.env.BASEOS_QEMU_MEMORY || '2048',
|
||
|
|
'-smp',
|
||
|
|
process.env.BASEOS_QEMU_CPUS || '2',
|
||
|
|
'-nographic',
|
||
|
|
'-no-reboot',
|
||
|
|
'-drive',
|
||
|
|
`file=${imagePath},format=raw,if=virtio`,
|
||
|
|
'-netdev',
|
||
|
|
'user,id=net0',
|
||
|
|
'-device',
|
||
|
|
'virtio-net-pci,netdev=net0',
|
||
|
|
'-serial',
|
||
|
|
'mon:stdio',
|
||
|
|
], {
|
||
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||
|
|
});
|
||
|
|
|
||
|
|
const logs: string[] = [];
|
||
|
|
const collectLog = (chunkArg: Buffer) => {
|
||
|
|
logs.push(chunkArg.toString('utf8'));
|
||
|
|
if (logs.length > 100) {
|
||
|
|
logs.splice(0, logs.length - 100);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
qemu.stdout.on('data', collectLog);
|
||
|
|
qemu.stderr.on('data', collectLog);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const registrationBody = await Promise.race([
|
||
|
|
waitForRegistration(mockCloudly.getRegistrationBody),
|
||
|
|
new Promise<never>((_resolveArg, rejectArg) => {
|
||
|
|
qemu.on('close', (codeArg) => {
|
||
|
|
rejectArg(new Error(`QEMU exited before BaseOS registration with code ${codeArg}\n${logs.join('')}`));
|
||
|
|
});
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
const status = registrationBody.status as { runtime?: string; nodeId?: string } | undefined;
|
||
|
|
if (status?.runtime !== 'baseos' || !status.nodeId) {
|
||
|
|
throw new Error(`Invalid BaseOS registration payload: ${JSON.stringify(registrationBody)}`);
|
||
|
|
}
|
||
|
|
console.log('[baseos-qemu-enrollment] BaseOS QEMU enrollment scenario completed successfully');
|
||
|
|
} finally {
|
||
|
|
qemu.kill('SIGTERM');
|
||
|
|
setTimeout(() => qemu.kill('SIGKILL'), 5000).unref();
|
||
|
|
await stopServer(mockCloudly.server).catch(() => undefined);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
await main();
|