diff --git a/package.json b/package.json index d645bd9..03025f9 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,14 @@ "scenario:corestore-volume-driver": "tsx scenarios/corestore-volume-driver/scenario.ts", "scenario:registry-deploy-on-push": "tsx --tsconfig ../cloudly/tsconfig.json scenarios/registry-deploy-on-push/scenario.ts", "scenario:baseos-image-pipeline": "tsx --tsconfig ../corebuild/tsconfig.json scenarios/baseos-image-pipeline/scenario.ts", + "scenario:baseos-qemu-enrollment": "tsx scenarios/baseos-qemu-enrollment/scenario.ts", "scenario:onebox-basic-lifecycle": "deno run --allow-all --config ../onebox/deno.json scenarios/onebox-basic-lifecycle/scenario.ts", "scenario:onebox-backup-restore": "deno run --allow-all --config ../onebox/deno.json scenarios/onebox-backup-restore/scenario.ts", "scenario:onebox-cloudly-appstore-worker": "deno run --allow-all --config ../onebox/deno.json scenarios/onebox-cloudly-appstore-worker/scenario.ts", "vagrant:up": "vagrant up", "vagrant:test": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm test'", - "vagrant:test:baseos": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm scenario:baseos-image-pipeline'", - "vagrant:test:full": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm test:full && pnpm scenario:baseos-image-pipeline'", + "vagrant:test:baseos": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm scenario:baseos-image-pipeline && pnpm scenario:baseos-qemu-enrollment'", + "vagrant:test:full": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm test:full && pnpm scenario:baseos-image-pipeline && pnpm scenario:baseos-qemu-enrollment'", "vagrant:destroy": "vagrant destroy -f" }, "devDependencies": { diff --git a/scenarios/baseos-image-pipeline/scenario.ts b/scenarios/baseos-image-pipeline/scenario.ts index 30c8afc..dddb0b6 100644 --- a/scenarios/baseos-image-pipeline/scenario.ts +++ b/scenarios/baseos-image-pipeline/scenario.ts @@ -177,9 +177,11 @@ const main = async () => { const capabilities = await capabilitiesResponse.json() as { supportedArchitectures: string[]; supportedImageKinds: string[]; + supportedSourcePresets: string[]; }; assert(capabilities.supportedArchitectures.includes('rpi'), 'CoreBuild did not advertise rpi support'); assert(capabilities.supportedImageKinds.includes('balena-raw'), 'CoreBuild did not advertise balena-raw support'); + assert(capabilities.supportedSourcePresets.includes('balena-raspberrypi4-64'), 'CoreBuild did not advertise Raspberry Pi balenaOS source preset support'); const s3Descriptor = await smarts3.getS3Descriptor({ bucketName }); const artifactKey = `${smokeId}/baseos-rpi.img.xz`; @@ -237,6 +239,7 @@ const main = async () => { const baseosEnv = await readGuestFile(decompressedArtifactPath, '/baseos/baserunner.env'); assert(baseosEnv.includes('BASEOS_CLOUDLY_URL="http://cloudly.test.local"'), 'BaseOS env Cloudly URL was not written'); assert(baseosEnv.includes('BASEOS_JOIN_TOKEN="join-token-for-baseos-image-test"'), 'BaseOS env join token was not written'); + assert(baseosEnv.includes('BASEOS_PRELOAD_TARGET_STATE_PATH="/data/baseos/preload-target-state.json"'), 'BaseOS preload target-state path was not written'); console.log('[baseos-image-pipeline] BaseOS raw-image pipeline scenario completed successfully'); } finally { diff --git a/scenarios/baseos-qemu-enrollment/scenario.ts b/scenarios/baseos-qemu-enrollment/scenario.ts new file mode 100644 index 0000000..2e8aa3f --- /dev/null +++ b/scenarios/baseos-qemu-enrollment/scenario.ts @@ -0,0 +1,147 @@ +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((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 : {}; +}; + +const sendJson = (resArg: ServerResponse, statusCodeArg: number, bodyArg: Record) => { + resArg.statusCode = statusCodeArg; + resArg.setHeader('content-type', 'application/json'); + resArg.end(JSON.stringify(bodyArg)); +}; + +const startMockCloudly = async () => { + let registrationBody: Record | 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((resolveArg) => server.listen({ port: cloudlyPort, host: '0.0.0.0' }, resolveArg)); + return { + server, + getRegistrationBody: () => registrationBody, + }; +}; + +const stopServer = async (serverArg: Server) => { + await new Promise((resolveArg, rejectArg) => { + serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg()); + }); +}; + +const waitForRegistration = async (getRegistrationBodyArg: () => Record | 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((_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(); diff --git a/scripts/provision-vm.sh b/scripts/provision-vm.sh index 244bef8..d1bbbc9 100644 --- a/scripts/provision-vm.sh +++ b/scripts/provision-vm.sh @@ -6,6 +6,9 @@ export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y ca-certificates curl git docker.io libguestfs-tools openssl qemu-system-x86 qemu-utils unzip xz-utils +# libguestfs builds a per-user appliance; the vagrant test user must be able to read the host kernel. +chmod a+r /boot/vmlinuz-* || true + if [ -d /serve.zone ]; then chown -R vagrant:vagrant /serve.zone fi