253 lines
9.0 KiB
TypeScript
253 lines
9.0 KiB
TypeScript
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();
|