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();