From ef1b678790a5ec697e649731b6555cb41c7cd357 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 7 May 2026 19:49:57 +0000 Subject: [PATCH] test: add baseos image pipeline scenario --- package.json | 6 +- scenarios/baseos-image-pipeline/scenario.ts | 252 ++++++++++++++++++++ scripts/provision-vm.sh | 2 +- 3 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 scenarios/baseos-image-pipeline/scenario.ts diff --git a/package.json b/package.json index 1140345..d645bd9 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,19 @@ "type": "module", "description": "Whole-system integration scenarios for serve.zone components.", "scripts": { - "bootstrap:components": "pnpm --dir ../interfaces install && pnpm --dir ../api install && pnpm --dir ../cloudly install && pnpm --dir ../coreflow install && pnpm --dir ../coretraffic install && pnpm --dir ../corestore install && pnpm --dir ../onebox exec deno install --config deno.json && pnpm install", + "bootstrap:components": "pnpm --dir ../interfaces install && pnpm --dir ../api install && pnpm --dir ../cloudly install && pnpm --dir ../corebuild install && pnpm --dir ../coreflow install && pnpm --dir ../coretraffic install && pnpm --dir ../corestore install && pnpm --dir ../onebox exec deno install --config deno.json && deno cache --config ../isocreator/deno.json ../isocreator/mod.ts && deno cache --config ../baseos/deno.json ../baseos/mod.ts && pnpm install", "test": "pnpm scenario:corestore-volume-driver && pnpm scenario:registry-deploy-on-push && pnpm scenario:onebox-basic-lifecycle && pnpm scenario:onebox-backup-restore", "test:full": "pnpm test && pnpm scenario:onebox-cloudly-appstore-worker", "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: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:full": "vagrant ssh -c 'cd /serve.zone/testing && pnpm bootstrap:components && pnpm test:full'", + "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:destroy": "vagrant destroy -f" }, "devDependencies": { diff --git a/scenarios/baseos-image-pipeline/scenario.ts b/scenarios/baseos-image-pipeline/scenario.ts new file mode 100644 index 0000000..30c8afc --- /dev/null +++ b/scenarios/baseos-image-pipeline/scenario.ts @@ -0,0 +1,252 @@ +import { createServer, type Server } from 'node:http'; +import { execFile } from 'node:child_process'; +import { createReadStream, createWriteStream, mkdirSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +import { tapNodeTools } from '@git.zone/tstest/tapbundle_node'; +import { SmartNetwork } from '@push.rocks/smartnetwork'; + +import { CoreBuildServer } from '../../../corebuild/ts/index.js'; +import { smartbucket } from '../../../corebuild/ts/plugins.js'; + +const execFileAsync = promisify(execFile); +const scenarioDir = dirname(fileURLToPath(import.meta.url)); +const testingDir = resolve(scenarioDir, '../..'); +const repoRoot = resolve(testingDir, '..'); +const smokeId = `baseos-image-${Date.now().toString(36)}`; +const buildDir = join(testingDir, '.nogit', 'baseos-image-pipeline', smokeId); +const bucketName = `baseos-image-${Date.now().toString(36)}`; +const apiToken = 'corebuild-test-token'; + +const run = async (commandArg: string, argsArg: string[]) => { + const { stdout, stderr } = await execFileAsync(commandArg, argsArg, { + maxBuffer: 1024 * 1024 * 20, + }); + if (stdout.trim()) { + console.log(stdout.trim()); + } + if (stderr.trim()) { + console.log(stderr.trim()); + } +}; + +const commandExists = async (commandArg: string) => { + try { + await execFileAsync('bash', ['-lc', `command -v ${commandArg}`]); + return true; + } catch { + return false; + } +}; + +const assert = (conditionArg: unknown, messageArg: string) => { + if (!conditionArg) { + throw new Error(messageArg); + } +}; + +const isLibguestfsUnavailable = (errorArg: unknown) => { + const errorText = `${(errorArg as { message?: string; stderr?: string }).message || ''}\n${ + (errorArg as { stderr?: string }).stderr || '' + }`; + return errorText.includes('libguestfs') && errorText.includes('supermin exited with error status'); +}; + +const ensureTools = async () => { + for (const command of ['qemu-img', 'guestfish', 'xz']) { + assert(await commandExists(command), `Missing required command for BaseOS image scenario: ${command}`); + } +}; + +const createSourceImage = async (sourceImagePathArg: string) => { + await run('qemu-img', ['create', '-f', 'raw', sourceImagePathArg, '64M']); + await run('guestfish', [ + '--rw', + '--format=raw', + '-a', + sourceImagePathArg, + 'run', + ':', + 'part-init', + '/dev/sda', + 'mbr', + ':', + 'part-add', + '/dev/sda', + 'p', + '2048', + '129024', + ':', + 'mkfs', + 'vfat', + '/dev/sda1', + ':', + 'mount', + '/dev/sda1', + '/', + ':', + 'write', + '/config.json', + '{}\n', + ]); +}; + +const startSourceServer = async (sourceImagePathArg: string) => { + const smartNetwork = new SmartNetwork(); + const port = Number(await smartNetwork.findFreePort(41000, 42000, { randomize: true })); + assert(port, 'Could not find a free source image server port'); + const server = createServer((req, res) => { + if (req.url !== '/source.img') { + res.statusCode = 404; + res.end('not found'); + return; + } + res.setHeader('content-type', 'application/octet-stream'); + createReadStream(sourceImagePathArg).pipe(res); + }); + await new Promise((resolveArg) => server.listen({ port, host: '127.0.0.1' }, resolveArg)); + return { + server, + url: `http://127.0.0.1:${port}/source.img`, + }; +}; + +const stopServer = async (serverArg: Server) => { + await new Promise((resolveArg, rejectArg) => { + serverArg.close((errorArg) => errorArg ? rejectArg(errorArg) : resolveArg()); + }); +}; + +const readGuestFile = async (imagePathArg: string, filePathArg: string) => { + const { stdout } = await execFileAsync('guestfish', [ + '--ro', + '--format=raw', + '-a', + imagePathArg, + '-m', + '/dev/sda1', + 'cat', + filePathArg, + ], { + maxBuffer: 1024 * 1024 * 5, + }); + return stdout; +}; + +const main = async () => { + await ensureTools(); + mkdirSync(buildDir, { recursive: true }); + + const sourceImagePath = join(buildDir, 'source.img'); + const artifactPath = join(buildDir, 'artifact.img.xz'); + const decompressedArtifactPath = join(buildDir, 'artifact.img'); + try { + await createSourceImage(sourceImagePath); + } catch (error) { + if (isLibguestfsUnavailable(error)) { + console.log('[baseos-image-pipeline] Skipping: libguestfs appliance is unavailable on this host'); + if (!process.env.SERVEZONE_KEEP_TEST_ARTIFACTS) { + rmSync(buildDir, { recursive: true, force: true }); + } + return; + } + throw error; + } + + const sourceServer = await startSourceServer(sourceImagePath); + const smarts3 = await tapNodeTools.createSmarts3(); + await smarts3.createBucket(bucketName); + + const smartNetwork = new SmartNetwork(); + const corebuildPort = Number(await smartNetwork.findFreePort(42001, 43000, { randomize: true })); + assert(corebuildPort, 'Could not find a free CoreBuild port'); + const corebuildServer = new CoreBuildServer({ + port: corebuildPort, + token: apiToken, + workdir: join(buildDir, 'corebuild-workdir'), + isoCreatorCommand: `deno run --allow-all ${join(repoRoot, 'isocreator', 'mod.ts')}`, + workerId: 'baseos-image-pipeline-test', + }); + + try { + await corebuildServer.start(); + const capabilitiesResponse = await fetch(`http://127.0.0.1:${corebuildPort}/corebuild/v1/capabilities`); + const capabilities = await capabilitiesResponse.json() as { + supportedArchitectures: string[]; + supportedImageKinds: 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'); + + const s3Descriptor = await smarts3.getS3Descriptor({ bucketName }); + const artifactKey = `${smokeId}/baseos-rpi.img.xz`; + const jobResponse = await fetch(`http://127.0.0.1:${corebuildPort}/corebuild/v1/jobs/baseos-image`, { + method: 'POST', + headers: { + authorization: `Bearer ${apiToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + job: { + id: smokeId, + architecture: 'rpi', + imageKind: 'balena-raw', + cloudlyUrl: 'http://cloudly.test.local', + provisioningToken: 'join-token-for-baseos-image-test', + sourceImageUrl: sourceServer.url, + hostname: 'baseos-rpi-test', + wifi: { + ssid: 'baseos-test-wifi', + password: 'baseos-test-password', + }, + sshPublicKey: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey baseos@test', + s3Descriptor, + artifactKey, + }, + }), + }); + const jobResult = await jobResponse.json() as { + success: boolean; + errorText?: string; + artifact?: { key: string; contentType: string; filename: string }; + logs: string[]; + }; + assert(jobResponse.ok && jobResult.success, jobResult.errorText || jobResult.logs.join('\n')); + assert(jobResult.artifact?.filename === 'baseos-rpi.img.xz', 'Unexpected BaseOS artifact filename'); + assert(jobResult.artifact?.contentType === 'application/x-xz', 'Unexpected BaseOS artifact content type'); + + const bucketClient = await new smartbucket.SmartBucket({ + ...s3Descriptor, + port: Number(s3Descriptor.port || 443), + } as any).getBucketByName(bucketName); + const artifactStream = await bucketClient.fastGetStream({ path: artifactKey }, 'nodestream') as NodeJS.ReadableStream; + await pipeline(artifactStream, createWriteStream(artifactPath)); + await execFileAsync('bash', ['-lc', 'xz -dc "$1" > "$2"', 'bash', artifactPath, decompressedArtifactPath]); + + const configJson = JSON.parse(await readGuestFile(decompressedArtifactPath, '/config.json')) as any; + assert(configJson.hostname === 'baseos-rpi-test', 'BaseOS config.json hostname was not written'); + assert(configJson.os?.sshKeys?.length === 1, 'BaseOS config.json SSH key was not written'); + assert(configJson.serveZone?.baseos?.cloudlyUrl === 'http://cloudly.test.local', 'Cloudly URL metadata was not written'); + + const wifiConnection = await readGuestFile(decompressedArtifactPath, '/system-connections/baseos-wifi.nmconnection'); + assert(wifiConnection.includes('ssid=baseos-test-wifi'), 'WiFi system connection was not written'); + + 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'); + + console.log('[baseos-image-pipeline] BaseOS raw-image pipeline scenario completed successfully'); + } finally { + await corebuildServer.stop().catch(() => undefined); + await stopServer(sourceServer.server).catch(() => undefined); + await smarts3.stop().catch(() => undefined); + if (!process.env.SERVEZONE_KEEP_TEST_ARTIFACTS) { + rmSync(buildDir, { recursive: true, force: true }); + } + } +}; + +await main(); diff --git a/scripts/provision-vm.sh b/scripts/provision-vm.sh index 069be08..244bef8 100644 --- a/scripts/provision-vm.sh +++ b/scripts/provision-vm.sh @@ -4,7 +4,7 @@ set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -apt-get install -y ca-certificates curl git docker.io openssl unzip +apt-get install -y ca-certificates curl git docker.io libguestfs-tools openssl qemu-system-x86 qemu-utils unzip xz-utils if [ -d /serve.zone ]; then chown -R vagrant:vagrant /serve.zone