Files
corebuild/ts/classes.baseosimagebuilder.ts
2026-05-07 20:33:14 +00:00

503 lines
16 KiB
TypeScript

import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawn } from 'node:child_process';
import * as plugins from './plugins.js';
import type {
IBaseOsImageArtifactResult,
IBaseOsImageJob,
IS3Descriptor,
TBaseOsImageKind,
TBaseOsImageSourcePreset,
} from './types.js';
interface IBalenaSourcePreset {
preset: TBaseOsImageSourcePreset;
architecture: IBaseOsImageJob['architecture'];
deviceType: string;
}
const balenaSourcePresets: IBalenaSourcePreset[] = [
{
preset: 'balena-generic-amd64',
architecture: 'amd64',
deviceType: 'generic-amd64',
},
{
preset: 'balena-generic-aarch64',
architecture: 'arm64',
deviceType: 'generic-aarch64',
},
{
preset: 'balena-raspberrypi4-64',
architecture: 'rpi',
deviceType: 'raspberrypi4-64',
},
];
export interface IBaseOsImageBuilderOptions {
workdir: string;
isoCreatorCommand: string;
}
export class BaseOsImageBuilder {
constructor(private options: IBaseOsImageBuilderOptions) {}
public async build(jobArg: IBaseOsImageJob): Promise<{
artifact: IBaseOsImageArtifactResult;
logs: string[];
}> {
const logs: string[] = [];
const imageKind = this.getImageKind(jobArg);
const jobDir = path.join(this.options.workdir, jobArg.id);
const outputDir = path.join(jobDir, 'output');
await fsp.rm(jobDir, { recursive: true, force: true });
await fsp.mkdir(outputDir, { recursive: true });
const filename = this.getArtifactFilename(jobArg, imageKind);
const outputPath = path.join(outputDir, filename);
const configPath = path.join(jobDir, 'isocreator.config.json');
const isoCreatorConfig = imageKind === 'balena-raw'
? await this.createRawImageConfig(jobArg, outputDir, filename, logs)
: this.createIsoCreatorConfig(jobArg, outputDir, filename);
await fsp.writeFile(configPath, `${JSON.stringify(isoCreatorConfig, null, 2)}\n`);
logs.push(`Starting isocreator for ${jobArg.architecture} ${imageKind}`);
await this.runIsoCreator(configPath, logs);
const stat = await fsp.stat(outputPath);
const sha256 = await this.sha256File(outputPath);
await this.uploadArtifact(
jobArg,
outputPath,
stat.size,
this.getContentType(filename, imageKind),
logs,
);
await fsp.rm(jobDir, { recursive: true, force: true }).catch(() => undefined);
return {
artifact: {
bucketName: jobArg.s3Descriptor.bucketName,
key: jobArg.artifactKey,
filename,
contentType: this.getContentType(filename, imageKind),
size: stat.size,
sha256,
createdAt: Date.now(),
},
logs,
};
}
private createIsoCreatorConfig(jobArg: IBaseOsImageJob, outputDirArg: string, filenameArg: string) {
const installScript = this.createBaseOsInstallScript();
const serviceFile = this.createBaseOsServiceFile();
const envFile = this.createBaseOsEnvFile(jobArg);
return {
version: '1.0',
imageKind: 'iso',
...(jobArg.sourceImageUrl
? {
source: {
type: 'url',
url: jobArg.sourceImageUrl,
},
}
: {}),
iso: {
ubuntu_version: jobArg.ubuntuVersion || '24.04',
architecture: jobArg.architecture === 'amd64' ? 'amd64' : 'arm64',
flavor: 'server',
},
output: {
filename: filenameArg,
path: outputDirArg,
},
...(jobArg.wifi?.ssid
? {
network: {
wifi: jobArg.wifi,
},
}
: {}),
cloud_init: {
hostname: jobArg.hostname || `baseos-${jobArg.id.slice(0, 8)}`,
users: jobArg.sshPublicKey
? [
{
name: 'baseos',
ssh_authorized_keys: [jobArg.sshPublicKey],
sudo: 'ALL=(ALL) NOPASSWD:ALL',
shell: '/bin/bash',
groups: ['sudo'],
},
]
: undefined,
package_update: true,
packages: ['curl', 'git', 'ca-certificates'],
write_files: [
{
path: '/etc/baseos/baserunner.env',
owner: 'root:root',
permissions: '0600',
content: envFile,
},
{
path: '/etc/systemd/system/baseos-baserunner.service',
owner: 'root:root',
permissions: '0644',
content: serviceFile,
},
{
path: '/usr/local/bin/install-baseos.sh',
owner: 'root:root',
permissions: '0755',
content: installScript,
},
],
runcmd: [
'mkdir -p /var/lib/baseos /opt/baseos',
'/usr/local/bin/install-baseos.sh',
'systemctl daemon-reload',
'systemctl enable baseos-baserunner.service',
'systemctl start baseos-baserunner.service',
],
},
};
}
private async createRawImageConfig(
jobArg: IBaseOsImageJob,
outputDirArg: string,
filenameArg: string,
logsArg: string[],
) {
const source = await this.resolveRawImageSource(jobArg, logsArg);
return {
version: '1.0',
imageKind: 'raw-image',
source,
output: {
filename: filenameArg,
path: outputDirArg,
},
...(jobArg.wifi?.ssid
? {
network: {
wifi: jobArg.wifi,
},
}
: {}),
raw_image: {
sourceFormat: 'auto',
bootPartition: '/dev/sda1',
outputCompression: filenameArg.endsWith('.xz') ? 'xz' : 'none',
},
balena_os: {
hostname: jobArg.hostname || `baseos-${jobArg.id.slice(0, 8)}`,
sshPublicKeys: jobArg.sshPublicKey ? [jobArg.sshPublicKey] : [],
configJson: {
serveZone: {
baseos: {
buildId: jobArg.id,
cloudlyUrl: jobArg.cloudlyUrl,
provisioningToken: jobArg.provisioningToken,
},
},
},
baseOsEnv: {
BASEOS_CLOUDLY_URL: jobArg.cloudlyUrl,
BASEOS_JOIN_TOKEN: jobArg.provisioningToken,
BASEOS_STATE_PATH: '/data/baseos/state.json',
BASEOS_PRELOAD_TARGET_STATE_PATH: '/data/baseos/preload-target-state.json',
BASEOS_HEARTBEAT_INTERVAL_MS: '60000',
SERVEZONE_RUNTIME: 'baseos',
},
},
};
}
private getImageKind(jobArg: IBaseOsImageJob): TBaseOsImageKind {
if (jobArg.imageKind) {
return jobArg.imageKind;
}
if (jobArg.architecture === 'rpi') {
return 'balena-raw';
}
if (jobArg.sourceImagePreset || jobArg.balenaOsVersion) {
return 'balena-raw';
}
if (jobArg.sourceImageUrl && this.isRawImageUrl(jobArg.sourceImageUrl)) {
return 'balena-raw';
}
return 'ubuntu-iso';
}
private async resolveRawImageSource(jobArg: IBaseOsImageJob, logsArg: string[]) {
if (jobArg.sourceImageUrl) {
const filename = this.getSourceFilenameFromUrl(jobArg.sourceImageUrl);
return {
type: 'url' as const,
url: jobArg.sourceImageUrl,
...(filename ? { filename } : {}),
};
}
const preset = this.getBalenaSourcePreset(jobArg);
const version = await this.resolveBalenaOsVersion(preset, jobArg.balenaOsVersion, logsArg);
const downloadUrl = new URL('https://api.balena-cloud.com/download');
downloadUrl.searchParams.set('deviceType', preset.deviceType);
downloadUrl.searchParams.set('version', version);
downloadUrl.searchParams.set('fileType', '.zip');
return {
type: 'url' as const,
url: downloadUrl.toString(),
filename: `balenaos-${preset.deviceType}-${version}.img.zip`,
};
}
private getBalenaSourcePreset(jobArg: IBaseOsImageJob) {
const preset = jobArg.sourceImagePreset
? balenaSourcePresets.find((presetArg) => presetArg.preset === jobArg.sourceImagePreset)
: balenaSourcePresets.find((presetArg) => presetArg.architecture === jobArg.architecture);
if (!preset) {
throw new Error(`No balenaOS source preset is available for ${jobArg.architecture}`);
}
if (preset.architecture !== jobArg.architecture) {
throw new Error(`${preset.preset} is only valid for ${preset.architecture} BaseOS images`);
}
return preset;
}
private async resolveBalenaOsVersion(
presetArg: IBalenaSourcePreset,
versionArg: string | undefined,
logsArg: string[],
) {
const requestedVersion = versionArg?.trim() || 'latest';
if (requestedVersion !== 'latest') {
return requestedVersion;
}
const releaseUrl = new URL('https://api.balena-cloud.com/v7/release');
releaseUrl.searchParams.set('$select', 'raw_version');
releaseUrl.searchParams.set(
'$filter',
`(is_final eq true) and (is_invalidated eq false) and (status eq 'success') and (semver_major gt 0) and (belongs_to__application/any(bta:(bta/is_host eq true) and (bta/is_for__device_type/any(dt:dt/slug eq '${presetArg.deviceType}'))))`,
);
releaseUrl.searchParams.set('$orderby', 'semver_major desc,semver_minor desc,semver_patch desc,revision desc');
releaseUrl.searchParams.set('$top', '1');
const response = await fetch(releaseUrl);
if (!response.ok) {
throw new Error(`Failed to resolve latest balenaOS version for ${presetArg.deviceType}: HTTP ${response.status}`);
}
const responseBody = await response.json() as { d?: Array<{ raw_version?: string }> };
const latestVersion = responseBody.d?.[0]?.raw_version;
if (!latestVersion) {
throw new Error(`No balenaOS version found for ${presetArg.deviceType}`);
}
logsArg.push(`Resolved balenaOS ${presetArg.deviceType} latest version ${latestVersion}`);
return latestVersion;
}
private isRawImageUrl(sourceImageUrlArg: string) {
if (/\.(img|img\.xz|zip)(\?|$)/i.test(sourceImageUrlArg)) {
return true;
}
try {
const sourceUrl = new URL(sourceImageUrlArg);
return sourceUrl.searchParams.get('fileType') === '.zip';
} catch {
return false;
}
}
private getSourceFilenameFromUrl(sourceImageUrlArg: string) {
try {
const sourceUrl = new URL(sourceImageUrlArg);
const lowerPath = sourceUrl.pathname.toLowerCase();
if (lowerPath.endsWith('.img') || lowerPath.endsWith('.img.xz') || lowerPath.endsWith('.zip')) {
return undefined;
}
if (sourceUrl.searchParams.get('fileType') === '.zip') {
return 'source.img.zip';
}
} catch {
return undefined;
}
return undefined;
}
private getArtifactFilename(jobArg: IBaseOsImageJob, imageKindArg: TBaseOsImageKind) {
const architectureSuffix = jobArg.architecture === 'amd64' ? '' : `-${jobArg.architecture}`;
if (imageKindArg === 'balena-raw') {
return `baseos${architectureSuffix}.img.xz`;
}
return `baseos${architectureSuffix}.iso`;
}
private getContentType(filenameArg: string, imageKindArg: TBaseOsImageKind) {
if (imageKindArg === 'ubuntu-iso') {
return 'application/x-iso9660-image';
}
return filenameArg.endsWith('.xz') ? 'application/x-xz' : 'application/octet-stream';
}
private createBaseOsEnvFile(jobArg: IBaseOsImageJob) {
return [
`BASEOS_CLOUDLY_URL=${this.escapeEnvValue(jobArg.cloudlyUrl)}`,
`BASEOS_JOIN_TOKEN=${this.escapeEnvValue(jobArg.provisioningToken)}`,
'BASEOS_STATE_PATH=/var/lib/baseos/state.json',
'BASEOS_HEARTBEAT_INTERVAL_MS=60000',
'',
].join('\n');
}
private createBaseOsServiceFile() {
return `[Unit]
Description=BaseOS Runner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/baseos/baserunner.env
WorkingDirectory=/opt/baseos
ExecStart=/usr/local/bin/deno run --allow-all /opt/baseos/mod.ts start
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
`;
}
private createBaseOsInstallScript() {
return `#!/bin/sh
set -eu
if ! command -v /usr/local/bin/deno >/dev/null 2>&1; then
curl -fsSL https://deno.land/install.sh | DENO_INSTALL=/usr/local sh
fi
if [ ! -d /opt/baseos/.git ]; then
rm -rf /opt/baseos
git clone https://code.foss.global/serve.zone/baseos.git /opt/baseos
else
git -C /opt/baseos pull --ff-only || true
fi
`;
}
private escapeEnvValue(valueArg: string) {
return JSON.stringify(valueArg);
}
private async runIsoCreator(configPathArg: string, logsArg: string[]) {
const command = `${this.options.isoCreatorCommand} build --config ${this.shellQuote(configPathArg)}`;
await this.runShellCommand(command, logsArg);
}
private async runShellCommand(commandArg: string, logsArg: string[]) {
await new Promise<void>((resolve, reject) => {
const child = spawn(commandArg, {
shell: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (chunk) => this.collectLog(logsArg, chunk));
child.stderr.on('data', (chunk) => this.collectLog(logsArg, chunk));
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with exit code ${code}: ${commandArg}`));
}
});
});
}
private collectLog(logsArg: string[], chunkArg: Buffer) {
const text = chunkArg.toString('utf8');
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed) {
logsArg.push(trimmed);
}
}
if (logsArg.length > 500) {
logsArg.splice(0, logsArg.length - 500);
}
}
private async uploadArtifact(
jobArg: IBaseOsImageJob,
outputPathArg: string,
contentLengthArg: number,
contentTypeArg: string,
logsArg: string[],
) {
logsArg.push(`Uploading artifact to ${jobArg.s3Descriptor.bucketName}/${jobArg.artifactKey}`);
const s3Client = this.createS3Client(jobArg.s3Descriptor);
await s3Client.send(new plugins.awsS3.PutObjectCommand({
Bucket: jobArg.s3Descriptor.bucketName,
Key: jobArg.artifactKey,
Body: fs.createReadStream(outputPathArg),
ContentLength: contentLengthArg,
ContentType: contentTypeArg,
}));
}
private createS3Client(s3DescriptorArg: IS3Descriptor) {
return new plugins.awsS3.S3Client({
endpoint: this.getS3Endpoint(s3DescriptorArg),
region: s3DescriptorArg.region || 'us-east-1',
credentials: {
accessKeyId: s3DescriptorArg.accessKey,
secretAccessKey: s3DescriptorArg.accessSecret,
},
forcePathStyle: true,
requestChecksumCalculation: 'WHEN_REQUIRED',
});
}
private getS3Endpoint(s3DescriptorArg: IS3Descriptor) {
const rawEndpoint = s3DescriptorArg.endpoint.trim();
const useSsl = s3DescriptorArg.useSsl !== false;
const endpointUrl = /^https?:\/\//i.test(rawEndpoint)
? new URL(rawEndpoint)
: new URL(`${useSsl ? 'https' : 'http'}://${rawEndpoint}`);
if (s3DescriptorArg.port) {
endpointUrl.port = String(s3DescriptorArg.port);
}
return endpointUrl.origin;
}
private async sha256File(filePathArg: string) {
return await new Promise<string>((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePathArg);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
private shellQuote(valueArg: string) {
return `'${valueArg.replace(/'/g, `'\\''`)}'`;
}
public static getDefaultWorkdir() {
return path.join(process.cwd(), '.nogit', 'workdir');
}
public static getDefaultWorkerId() {
return `${os.hostname()}-${process.pid}`;
}
}