feat: add corebuild worker

This commit is contained in:
2026-05-07 17:44:31 +00:00
commit f2fa041109
11 changed files with 4667 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
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,
} 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[];
}> {
if (jobArg.architecture === 'rpi') {
throw new Error('Raspberry Pi image builds require a raw-image preset and are not supported by the current isocreator ISO pipeline yet');
}
const logs: string[] = [];
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 = jobArg.architecture === 'amd64' ? 'baseos.iso' : 'baseos-arm64.iso';
const outputPath = path.join(outputDir, filename);
const configPath = path.join(jobDir, 'isocreator.config.json');
await fsp.writeFile(configPath, `${JSON.stringify(this.createIsoCreatorConfig(jobArg, outputDir, filename), null, 2)}\n`);
logs.push(`Starting isocreator for ${jobArg.architecture}`);
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: 'application/x-iso9660-image',
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',
...(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 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}`;
}
}
+140
View File
@@ -0,0 +1,140 @@
import * as http from 'node:http';
import * as os from 'node:os';
import { BaseOsImageBuilder } from './classes.baseosimagebuilder.js';
import type {
IBaseOsImageJobRequest,
IBaseOsImageJobResponse,
ICoreBuildCapabilities,
} from './types.js';
export interface ICoreBuildServerOptions {
port: number;
token?: string;
workdir: string;
isoCreatorCommand: string;
workerId: string;
}
export class CoreBuildServer {
private server?: http.Server;
private builder: BaseOsImageBuilder;
public static fromEnv() {
return new CoreBuildServer({
port: Number(process.env.COREBUILD_PORT || '3060'),
token: process.env.COREBUILD_TOKEN,
workdir: process.env.COREBUILD_WORKDIR || BaseOsImageBuilder.getDefaultWorkdir(),
isoCreatorCommand: process.env.ISO_CREATOR_COMMAND || 'isocreator',
workerId: process.env.COREBUILD_WORKER_ID || BaseOsImageBuilder.getDefaultWorkerId(),
});
}
constructor(private options: ICoreBuildServerOptions) {
this.builder = new BaseOsImageBuilder({
workdir: options.workdir,
isoCreatorCommand: options.isoCreatorCommand,
});
}
public async start() {
this.server = http.createServer(async (req, res) => {
await this.handleRequest(req, res).catch((error) => {
this.sendJson(res, 500, {
success: false,
errorText: (error as Error).message,
});
});
});
await new Promise<void>((resolve) => this.server!.listen(this.options.port, resolve));
console.log(`corebuild listening on ${this.options.port}`);
}
public async stop() {
if (!this.server) {
return;
}
await new Promise<void>((resolve, reject) => {
this.server!.close((error) => error ? reject(error) : resolve());
});
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url || '/', 'http://localhost');
if (req.method === 'GET' && url.pathname === '/health') {
this.sendJson(res, 200, { ok: true });
return;
}
if (req.method === 'GET' && url.pathname === '/corebuild/v1/capabilities') {
this.sendJson(res, 200, this.getCapabilities());
return;
}
if (req.method === 'POST' && url.pathname === '/corebuild/v1/jobs/baseos-image') {
const requestBody = await this.readJson<IBaseOsImageJobRequest>(req);
this.validateToken(req, requestBody.apiToken);
const response = await this.handleBaseOsImageJob(requestBody);
this.sendJson(res, response.success ? 200 : 500, response);
return;
}
this.sendJson(res, 404, { success: false, errorText: 'not found' });
}
private async handleBaseOsImageJob(
requestArg: IBaseOsImageJobRequest,
): Promise<IBaseOsImageJobResponse> {
const logs: string[] = [];
try {
const result = await this.builder.build(requestArg.job);
return {
success: true,
artifact: result.artifact,
logs: result.logs,
};
} catch (error) {
logs.push((error as Error).message);
return {
success: false,
logs,
errorText: (error as Error).message,
};
}
}
private getCapabilities(): ICoreBuildCapabilities {
return {
workerId: this.options.workerId,
supportedBuildTypes: ['baseos-image'],
supportedArchitectures: ['amd64', 'arm64'],
cpuCores: os.cpus().length,
memoryGb: Math.round(os.totalmem() / 1024 / 1024 / 1024),
workdir: this.options.workdir,
};
}
private validateToken(reqArg: http.IncomingMessage, bodyTokenArg?: string) {
if (!this.options.token) {
return;
}
const authHeader = reqArg.headers.authorization;
const headerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined;
const token = bodyTokenArg || headerToken || reqArg.headers['x-corebuild-token'];
if (token !== this.options.token) {
throw new Error('corebuild token is invalid');
}
}
private async readJson<T>(reqArg: http.IncomingMessage): Promise<T> {
const chunks: Buffer[] = [];
for await (const chunk of reqArg) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const body = Buffer.concat(chunks).toString('utf8').trim();
return body ? JSON.parse(body) as T : {} as T;
}
private sendJson(resArg: http.ServerResponse, statusCodeArg: number, bodyArg: object) {
resArg.statusCode = statusCodeArg;
resArg.setHeader('Content-Type', 'application/json');
resArg.end(JSON.stringify(bodyArg));
}
}
+10
View File
@@ -0,0 +1,10 @@
import { CoreBuildServer } from './classes.corebuildserver.js';
export * from './classes.corebuildserver.js';
export * from './classes.baseosimagebuilder.js';
export * from './types.js';
if (import.meta.url === `file://${process.argv[1]}`) {
const server = CoreBuildServer.fromEnv();
await server.start();
}
+3
View File
@@ -0,0 +1,3 @@
import * as smartbucket from '@push.rocks/smartbucket';
export { smartbucket };
+63
View File
@@ -0,0 +1,63 @@
import type { Readable } from 'node:stream';
export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi';
export interface IS3Descriptor {
endpoint: string;
accessKey: string;
accessSecret: string;
port?: number | string;
useSsl?: boolean;
bucketName: string;
region?: string;
}
export interface IBaseOsImageJob {
id: string;
architecture: TBaseOsImageArchitecture;
cloudlyUrl: string;
provisioningToken: string;
sourceImageUrl?: string;
ubuntuVersion?: string;
hostname?: string;
wifi?: {
ssid: string;
password?: string;
};
sshPublicKey?: string;
s3Descriptor: IS3Descriptor;
artifactKey: string;
}
export interface IBaseOsImageJobRequest {
apiToken?: string;
job: IBaseOsImageJob;
}
export interface IBaseOsImageArtifactResult {
bucketName: string;
key: string;
filename: string;
contentType: string;
size: number;
sha256: string;
createdAt: number;
}
export interface IBaseOsImageJobResponse {
success: boolean;
artifact?: IBaseOsImageArtifactResult;
logs: string[];
errorText?: string;
}
export interface ICoreBuildCapabilities {
workerId: string;
supportedBuildTypes: string[];
supportedArchitectures: TBaseOsImageArchitecture[];
cpuCores: number;
memoryGb?: number;
workdir: string;
}
export type TReadableSource = Readable | ReadableStream;