feat: add corebuild worker selection

This commit is contained in:
2026-05-07 19:49:56 +00:00
parent c55eb5b832
commit d9dcc5b048
5 changed files with 171 additions and 36 deletions
@@ -1,6 +1,7 @@
import * as plugins from '../plugins.js';
export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi';
export type TBaseOsImageKind = 'ubuntu-iso' | 'balena-raw';
export type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled';
export interface IBaseOsImageArtifact {
@@ -18,6 +19,7 @@ export interface IBaseOsImageBuildPublic {
data: {
status: TBaseOsImageBuildStatus;
architecture: TBaseOsImageArchitecture;
imageKind?: TBaseOsImageKind;
cloudlyUrl: string;
sourceImageUrl?: string;
ubuntuVersion?: string;
+147 -31
View File
@@ -14,6 +14,7 @@ import {
type IBaseOsImageArtifact,
type IBaseOsImageBuildPublic,
type TBaseOsImageArchitecture,
type TBaseOsImageKind,
} from './classes.baseosimagebuild.js';
interface IBaseOsRegisterRequest {
@@ -52,6 +53,7 @@ interface IRequestGetBaseOsNodes {
interface IBaseOsImageBuildRequest {
architecture: TBaseOsImageArchitecture;
imageKind?: TBaseOsImageKind;
cloudlyUrl?: string;
sourceImageUrl?: string;
ubuntuVersion?: string;
@@ -119,6 +121,17 @@ interface ICoreBuildCapabilitiesResponse {
workerId: string;
supportedBuildTypes: string[];
supportedArchitectures: TBaseOsImageArchitecture[];
supportedImageKinds?: TBaseOsImageKind[];
}
interface ICoreBuildWorkerSetting {
id?: string;
url: string;
token?: string;
}
interface ISelectedCoreBuildWorker extends ICoreBuildWorkerSetting {
capabilities: ICoreBuildCapabilitiesResponse;
}
export class CloudlyBaseOsManager {
@@ -403,12 +416,11 @@ export class CloudlyBaseOsManager {
throw new plugins.typedrequest.TypedResponseError('Cloudly S3 storage is required for BaseOS image builds');
}
const workerUrl = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkerUrl');
const workerToken = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkerToken');
if (!workerUrl) {
throw new plugins.typedrequest.TypedResponseError('corebuildWorkerUrl is not configured in Cloudly settings');
const imageKind = this.getImageKind(buildRequestArg);
if (imageKind === 'balena-raw' && !buildRequestArg.sourceImageUrl) {
throw new plugins.typedrequest.TypedResponseError('sourceImageUrl is required for balena-raw BaseOS image builds');
}
await this.assertWorkerSupportsImageBuild(workerUrl, workerToken, buildRequestArg.architecture);
const worker = await this.selectCoreBuildWorker(buildRequestArg.architecture, imageKind);
const now = Date.now();
const buildId = await this.CBaseOsImageBuild.getNewId();
@@ -420,6 +432,7 @@ export class CloudlyBaseOsManager {
data: {
status: 'queued',
architecture: buildRequestArg.architecture,
imageKind,
cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(),
sourceImageUrl: buildRequestArg.sourceImageUrl,
ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04',
@@ -434,7 +447,7 @@ export class CloudlyBaseOsManager {
});
await build.save();
this.executeImageBuild(build, provisioningToken, buildRequestArg, workerUrl, workerToken).catch(async (error) => {
this.executeImageBuild(build, provisioningToken, buildRequestArg, worker).catch(async (error) => {
build.data.status = 'failed';
build.data.errorText = (error as Error).message;
build.data.updatedAt = Date.now();
@@ -554,33 +567,32 @@ export class CloudlyBaseOsManager {
buildArg: BaseOsImageBuild,
provisioningTokenArg: string,
buildRequestArg: IBaseOsImageBuildRequest,
workerUrlArg: string,
workerTokenArg?: string,
workerArg: ISelectedCoreBuildWorker,
) {
buildArg.data.status = 'building';
buildArg.data.startedAt = Date.now();
buildArg.data.updatedAt = Date.now();
await buildArg.save();
const artifactFilename = buildArg.data.architecture === 'amd64'
? 'baseos.iso'
: buildArg.data.architecture === 'arm64'
? 'baseos-arm64.iso'
: 'baseos-rpi.img';
const artifactFilename = this.getArtifactFilename(
buildArg.data.architecture,
buildArg.data.imageKind || 'ubuntu-iso',
);
const artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`;
const response = await fetch(
this.getCoreBuildUrl(workerUrlArg, '/corebuild/v1/jobs/baseos-image'),
this.getCoreBuildUrl(workerArg.url, '/corebuild/v1/jobs/baseos-image'),
{
method: 'POST',
headers: {
'content-type': 'application/json',
...(workerTokenArg ? { authorization: `Bearer ${workerTokenArg}` } : {}),
...(workerArg.token ? { authorization: `Bearer ${workerArg.token}` } : {}),
},
body: JSON.stringify({
apiToken: workerTokenArg,
apiToken: workerArg.token,
job: {
id: buildArg.id,
architecture: buildArg.data.architecture,
imageKind: buildArg.data.imageKind,
cloudlyUrl: buildArg.data.cloudlyUrl,
provisioningToken: provisioningTokenArg,
sourceImageUrl: buildArg.data.sourceImageUrl,
@@ -617,16 +629,98 @@ export class CloudlyBaseOsManager {
await buildArg.save();
}
private async assertWorkerSupportsImageBuild(
workerUrlArg: string,
workerTokenArg: string | undefined,
private async selectCoreBuildWorker(
architectureArg: TBaseOsImageArchitecture,
) {
imageKindArg: TBaseOsImageKind,
): Promise<ISelectedCoreBuildWorker> {
const workers = await this.getConfiguredCoreBuildWorkers();
if (workers.length === 0) {
throw new plugins.typedrequest.TypedResponseError('No CoreBuild workers are configured in Cloudly settings');
}
const rejectionReasons: string[] = [];
for (const worker of workers) {
try {
const capabilities = await this.fetchWorkerCapabilities(worker);
const workerLabel = capabilities.workerId || worker.id || worker.url;
const supportedImageKinds = capabilities.supportedImageKinds || ['ubuntu-iso'];
if (!capabilities.supportedBuildTypes?.includes('baseos-image')) {
rejectionReasons.push(`${workerLabel}: missing baseos-image support`);
continue;
}
if (!capabilities.supportedArchitectures?.includes(architectureArg)) {
rejectionReasons.push(`${workerLabel}: missing ${architectureArg} support`);
continue;
}
if (!supportedImageKinds.includes(imageKindArg)) {
rejectionReasons.push(`${workerLabel}: missing ${imageKindArg} support`);
continue;
}
return {
...worker,
capabilities,
};
} catch (error) {
rejectionReasons.push(`${worker.id || worker.url}: ${(error as Error).message}`);
}
}
throw new plugins.typedrequest.TypedResponseError(
`No CoreBuild worker supports BaseOS ${architectureArg} ${imageKindArg} builds. ${rejectionReasons.join('; ')}`,
);
}
private async getConfiguredCoreBuildWorkers(): Promise<ICoreBuildWorkerSetting[]> {
const workers: ICoreBuildWorkerSetting[] = [];
const workersJson = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkersJson');
if (workersJson) {
try {
const parsedWorkers = JSON.parse(workersJson) as unknown;
if (!Array.isArray(parsedWorkers)) {
throw new Error('corebuildWorkersJson must be a JSON array');
}
for (const worker of parsedWorkers) {
if (typeof worker === 'string' && worker) {
workers.push({ url: worker });
} else if (this.isCoreBuildWorkerSetting(worker)) {
workers.push(worker);
} else {
throw new Error('Each CoreBuild worker must be a URL string or object with a url field');
}
}
} catch (error) {
throw new plugins.typedrequest.TypedResponseError(
`corebuildWorkersJson is invalid: ${(error as Error).message}`,
);
}
}
const legacyWorkerUrl = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkerUrl');
const legacyWorkerToken = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkerToken');
if (legacyWorkerUrl) {
workers.push({
id: 'default',
url: legacyWorkerUrl,
token: legacyWorkerToken,
});
}
const seenUrls = new Set<string>();
return workers.filter((workerArg) => {
if (seenUrls.has(workerArg.url)) {
return false;
}
seenUrls.add(workerArg.url);
return true;
});
}
private async fetchWorkerCapabilities(workerArg: ICoreBuildWorkerSetting) {
const response = await fetch(
this.getCoreBuildUrl(workerUrlArg, '/corebuild/v1/capabilities'),
this.getCoreBuildUrl(workerArg.url, '/corebuild/v1/capabilities'),
{
method: 'GET',
headers: workerTokenArg ? { authorization: `Bearer ${workerTokenArg}` } : {},
headers: workerArg.token ? { authorization: `Bearer ${workerArg.token}` } : {},
},
);
if (!response.ok) {
@@ -634,17 +728,39 @@ export class CloudlyBaseOsManager {
`CoreBuild capabilities request failed with HTTP ${response.status}`,
);
}
const capabilities = await response.json() as ICoreBuildCapabilitiesResponse;
if (!capabilities.supportedBuildTypes?.includes('baseos-image')) {
throw new plugins.typedrequest.TypedResponseError(
`CoreBuild worker ${capabilities.workerId} does not support BaseOS image builds`,
);
return await response.json() as ICoreBuildCapabilitiesResponse;
}
private getImageKind(buildRequestArg: IBaseOsImageBuildRequest): TBaseOsImageKind {
if (buildRequestArg.imageKind) {
return buildRequestArg.imageKind;
}
if (!capabilities.supportedArchitectures?.includes(architectureArg)) {
throw new plugins.typedrequest.TypedResponseError(
`CoreBuild worker ${capabilities.workerId} does not support BaseOS ${architectureArg} builds`,
);
if (buildRequestArg.architecture === 'rpi') {
return 'balena-raw';
}
if (buildRequestArg.sourceImageUrl && /\.(img|img\.xz|zip)(\?|$)/i.test(buildRequestArg.sourceImageUrl)) {
return 'balena-raw';
}
return 'ubuntu-iso';
}
private getArtifactFilename(
architectureArg: TBaseOsImageArchitecture,
imageKindArg: TBaseOsImageKind,
) {
const architectureSuffix = architectureArg === 'amd64' ? '' : `-${architectureArg}`;
if (imageKindArg === 'balena-raw') {
return `baseos${architectureSuffix}.img.xz`;
}
return `baseos${architectureSuffix}.iso`;
}
private isCoreBuildWorkerSetting(valueArg: unknown): valueArg is ICoreBuildWorkerSetting {
return Boolean(valueArg)
&& typeof valueArg === 'object'
&& typeof (valueArg as ICoreBuildWorkerSetting).url === 'string'
&& (!(valueArg as ICoreBuildWorkerSetting).token || typeof (valueArg as ICoreBuildWorkerSetting).token === 'string')
&& (!(valueArg as ICoreBuildWorkerSetting).id || typeof (valueArg as ICoreBuildWorkerSetting).id === 'string');
}
private getCoreBuildUrl(workerUrlArg: string, pathArg: string) {
@@ -53,6 +53,9 @@ export class CloudlySettingsManager {
}
private isSensitiveSettingKey(key: string): boolean {
if (key === 'corebuildWorkersJson') {
return true;
}
const normalizedKey = key.toLowerCase();
return [
'token',
+16 -5
View File
@@ -92,15 +92,25 @@ export class CloudlyViewBaseOs extends DeesElement {
return html`
<cloudly-sectionheading>BaseOS Images</cloudly-sectionheading>
<div class="layout">
<dees-panel .title=${'Create Image'} .subtitle=${'Build a Cloudly-bound BaseOS ISO'} .variant=${'outline'}>
<dees-panel .title=${'Create Image'} .subtitle=${'Build a Cloudly-bound BaseOS artifact'} .variant=${'outline'}>
<dees-form @formData=${(eventArg: CustomEvent) => this.createBuild((eventArg.detail as any).data)}>
<dees-input-dropdown
.key=${'imageKind'}
.label=${'Image Type'}
.selectedOption=${'balena-raw'}
.options=${[
{ key: 'balena-raw', option: 'balenaOS raw image', payload: null },
{ key: 'ubuntu-iso', option: 'Ubuntu bootstrap ISO', payload: null },
]}
></dees-input-dropdown>
<dees-input-dropdown
.key=${'architecture'}
.label=${'Architecture'}
.selectedOption=${'amd64'}
.options=${[
{ key: 'amd64', option: 'amd64 ISO', payload: null },
{ key: 'arm64', option: 'arm64 ISO', payload: null },
{ key: 'amd64', option: 'amd64', payload: null },
{ key: 'arm64', option: 'arm64', payload: null },
{ key: 'rpi', option: 'Raspberry Pi', payload: null },
]}
></dees-input-dropdown>
<dees-input-text .key=${'cloudlyUrl'} .label=${'Cloudly URL'} .value=${window.location.origin} .required=${true}></dees-input-text>
@@ -108,7 +118,7 @@ export class CloudlyViewBaseOs extends DeesElement {
<dees-input-text .key=${'wifiSsid'} .label=${'WiFi SSID'} .required=${false}></dees-input-text>
<dees-input-text .key=${'wifiPassword'} .label=${'WiFi Password'} .isPasswordBool=${true} .required=${false}></dees-input-text>
<dees-input-textarea .key=${'sshPublicKey'} .label=${'SSH Public Key'} .required=${false}></dees-input-textarea>
<dees-input-text .key=${'sourceImageUrl'} .label=${'Source ISO URL'} .description=${'Optional. Defaults to Ubuntu 24.04 through isocreator.'} .required=${false}></dees-input-text>
<dees-input-text .key=${'sourceImageUrl'} .label=${'Source Image URL'} .description=${'Required for balenaOS raw images (.img, .img.xz, or .zip). Optional for Ubuntu ISO builds.'} .required=${false}></dees-input-text>
<dees-form-submit .text=${this.isLoading ? 'Creating...' : 'Create BaseOS Image'} .disabled=${this.isLoading}></dees-form-submit>
</dees-form>
</dees-panel>
@@ -128,7 +138,7 @@ export class CloudlyViewBaseOs extends DeesElement {
<div class="build-head">
<div>
<strong>${data.hostname || buildArg.id}</strong>
<div class="meta">${data.architecture} · ${data.cloudlyUrl}</div>
<div class="meta">${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}</div>
</div>
<dees-badge .text=${data.status} .type=${data.status === 'ready' ? 'success' : data.status === 'failed' ? 'error' : 'info'}></dees-badge>
</div>
@@ -158,6 +168,7 @@ export class CloudlyViewBaseOs extends DeesElement {
const response = await this.fireBaseOsRequest('createBaseOsImageBuild', {
build: {
architecture: formDataArg.architecture || 'amd64',
imageKind: formDataArg.imageKind || undefined,
cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin,
hostname: formDataArg.hostname || undefined,
sourceImageUrl: formDataArg.sourceImageUrl || undefined,
+3
View File
@@ -149,6 +149,9 @@ export class CloudlyViewSettings extends DeesElement {
<dees-input-text .key=${'corebuildWorkerUrl'} .label=${'Worker URL'} .value=${this.settings.corebuildWorkerUrl || ''} .description=${'Base URL of the corebuild worker, for example http://10.0.0.20:3060'} .required=${false}></dees-input-text>
<dees-input-text .key=${'corebuildWorkerToken'} .label=${'Worker Token'} .value=${this.settings.corebuildWorkerToken || ''} .isPasswordBool=${true} .description=${'Shared token accepted by the corebuild worker'} .required=${false}></dees-input-text>
</div>
<div class="form-grid single">
<dees-input-textarea .key=${'corebuildWorkersJson'} .label=${'Worker Registry JSON'} .value=${this.settings.corebuildWorkersJson || ''} .isPasswordBool=${true} .description=${'Optional JSON array of workers: [{"id":"builder-1","url":"http://10.0.0.20:3060","token":"secret"}]'} .required=${false}></dees-input-textarea>
</div>
</dees-panel>
<dees-panel .title=${'Amazon Web Services'} .subtitle=${'Configure AWS credentials'} .variant=${'outline'}>