test: add baseos image pipeline scenario
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user