343 lines
10 KiB
TypeScript
343 lines
10 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,
|
|
TBaseOsImageKind,
|
|
} from './types.js';
|
|
|
|
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'
|
|
? this.createRawImageConfig(jobArg, outputDir, filename)
|
|
: 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, 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 createRawImageConfig(jobArg: IBaseOsImageJob, outputDirArg: string, filenameArg: string) {
|
|
if (!jobArg.sourceImageUrl) {
|
|
throw new Error('sourceImageUrl is required for balena-raw BaseOS image builds');
|
|
}
|
|
|
|
return {
|
|
version: '1.0',
|
|
imageKind: 'raw-image',
|
|
source: {
|
|
type: 'url',
|
|
url: jobArg.sourceImageUrl,
|
|
},
|
|
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_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.sourceImageUrl && /\.(img|img\.xz|zip)(\?|$)/i.test(jobArg.sourceImageUrl)) {
|
|
return 'balena-raw';
|
|
}
|
|
return 'ubuntu-iso';
|
|
}
|
|
|
|
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, logsArg: string[]) {
|
|
logsArg.push(`Uploading artifact to ${jobArg.s3Descriptor.bucketName}/${jobArg.artifactKey}`);
|
|
const smartbucket = new plugins.smartbucket.SmartBucket({
|
|
...jobArg.s3Descriptor,
|
|
port: Number(jobArg.s3Descriptor.port || 443),
|
|
} as any);
|
|
const bucket = await smartbucket.getBucketByName(jobArg.s3Descriptor.bucketName);
|
|
await bucket.fastPutStream({
|
|
path: jobArg.artifactKey,
|
|
readableStream: fs.createReadStream(outputPathArg),
|
|
overwrite: true,
|
|
});
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
}
|