test: add baseos image pipeline scenario

This commit is contained in:
2026-05-07 19:49:57 +00:00
parent f2e104b0e3
commit ef1b678790
3 changed files with 257 additions and 3 deletions
+4 -2
View File
@@ -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": {
+252
View File
@@ -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<void>((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<void>((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();
+1 -1
View File
@@ -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