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
+5
View File
@@ -0,0 +1,5 @@
.nogit/
node_modules/
dist/
dist_*/
*.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lossless GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@serve.zone/corebuild",
"version": "0.1.0",
"private": false,
"description": "Build worker for serve.zone image and ISO artifact generation.",
"type": "module",
"exports": {
".": "./dist_ts/index.js"
},
"scripts": {
"build": "tsbuild tsfolders --allowimplicitany",
"start": "node dist_ts/index.js",
"startTs": "tsrun ts/index.ts",
"test": "pnpm run build"
},
"dependencies": {
"@push.rocks/smartbucket": "^3.3.10",
"@tsclass/tsclass": "^9.2.0"
},
"devDependencies": {
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsrun": "^2.0.2",
"@types/node": "^25.6.0"
},
"files": [
"ts/**/*",
"dist_ts/**/*",
"readme.md",
"license.md"
],
"repository": {
"type": "git",
"url": "ssh://git@code.foss.global:29419/serve.zone/corebuild.git"
},
"author": "Lossless GmbH",
"license": "MIT",
"packageManager": "pnpm@10.28.2"
}
+4085
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
# @serve.zone/corebuild
CoreBuild is the serve.zone worker service for heavy artifact generation jobs such as BaseOS ISO builds.
Cloudly owns orchestration and user-facing downloads. CoreBuild runs on suitable builder nodes, executes `isocreator`, uploads artifacts to S3-compatible storage, and returns artifact metadata to Cloudly.
## Runtime
Required environment:
- `COREBUILD_PORT`: HTTP port, defaults to `3060`.
- `COREBUILD_TOKEN`: shared worker token expected from Cloudly.
- `COREBUILD_WORKDIR`: temp workspace, defaults to `.nogit/workdir`.
- `ISO_CREATOR_COMMAND`: command used to run isocreator, defaults to `isocreator`.
For local development against the workspace checkout:
```bash
ISO_CREATOR_COMMAND="deno run --allow-all ../isocreator/mod.ts" pnpm run startTs
```
## API
- `GET /health`
- `GET /corebuild/v1/capabilities`
- `POST /corebuild/v1/jobs/baseos-image`
The BaseOS image job expects Cloudly to provide the S3 descriptor and a one-time provisioning token. CoreBuild never stores those values beyond the build workspace.
+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;
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"types": ["node"]
},
"exclude": [
"dist_*/**/*.d.ts"
]
}