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((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((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}`; } }