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'; import * as plugins from '../plugins.js';
export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi'; export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi';
export type TBaseOsImageKind = 'ubuntu-iso' | 'balena-raw';
export type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled'; export type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled';
export interface IBaseOsImageArtifact { export interface IBaseOsImageArtifact {
@@ -18,6 +19,7 @@ export interface IBaseOsImageBuildPublic {
data: { data: {
status: TBaseOsImageBuildStatus; status: TBaseOsImageBuildStatus;
architecture: TBaseOsImageArchitecture; architecture: TBaseOsImageArchitecture;
imageKind?: TBaseOsImageKind;
cloudlyUrl: string; cloudlyUrl: string;
sourceImageUrl?: string; sourceImageUrl?: string;
ubuntuVersion?: string; ubuntuVersion?: string;
+147 -31
View File
@@ -14,6 +14,7 @@ import {
type IBaseOsImageArtifact, type IBaseOsImageArtifact,
type IBaseOsImageBuildPublic, type IBaseOsImageBuildPublic,
type TBaseOsImageArchitecture, type TBaseOsImageArchitecture,
type TBaseOsImageKind,
} from './classes.baseosimagebuild.js'; } from './classes.baseosimagebuild.js';
interface IBaseOsRegisterRequest { interface IBaseOsRegisterRequest {
@@ -52,6 +53,7 @@ interface IRequestGetBaseOsNodes {
interface IBaseOsImageBuildRequest { interface IBaseOsImageBuildRequest {
architecture: TBaseOsImageArchitecture; architecture: TBaseOsImageArchitecture;
imageKind?: TBaseOsImageKind;
cloudlyUrl?: string; cloudlyUrl?: string;
sourceImageUrl?: string; sourceImageUrl?: string;
ubuntuVersion?: string; ubuntuVersion?: string;
@@ -119,6 +121,17 @@ interface ICoreBuildCapabilitiesResponse {
workerId: string; workerId: string;
supportedBuildTypes: string[]; supportedBuildTypes: string[];
supportedArchitectures: TBaseOsImageArchitecture[]; supportedArchitectures: TBaseOsImageArchitecture[];
supportedImageKinds?: TBaseOsImageKind[];
}
interface ICoreBuildWorkerSetting {
id?: string;
url: string;
token?: string;
}
interface ISelectedCoreBuildWorker extends ICoreBuildWorkerSetting {
capabilities: ICoreBuildCapabilitiesResponse;
} }
export class CloudlyBaseOsManager { export class CloudlyBaseOsManager {
@@ -403,12 +416,11 @@ export class CloudlyBaseOsManager {
throw new plugins.typedrequest.TypedResponseError('Cloudly S3 storage is required for BaseOS image builds'); throw new plugins.typedrequest.TypedResponseError('Cloudly S3 storage is required for BaseOS image builds');
} }
const workerUrl = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkerUrl'); const imageKind = this.getImageKind(buildRequestArg);
const workerToken = await this.cloudlyRef.settingsManager.getSetting('corebuildWorkerToken'); if (imageKind === 'balena-raw' && !buildRequestArg.sourceImageUrl) {
if (!workerUrl) { throw new plugins.typedrequest.TypedResponseError('sourceImageUrl is required for balena-raw BaseOS image builds');
throw new plugins.typedrequest.TypedResponseError('corebuildWorkerUrl is not configured in Cloudly settings');
} }
await this.assertWorkerSupportsImageBuild(workerUrl, workerToken, buildRequestArg.architecture); const worker = await this.selectCoreBuildWorker(buildRequestArg.architecture, imageKind);
const now = Date.now(); const now = Date.now();
const buildId = await this.CBaseOsImageBuild.getNewId(); const buildId = await this.CBaseOsImageBuild.getNewId();
@@ -420,6 +432,7 @@ export class CloudlyBaseOsManager {
data: { data: {
status: 'queued', status: 'queued',
architecture: buildRequestArg.architecture, architecture: buildRequestArg.architecture,
imageKind,
cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(), cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(),
sourceImageUrl: buildRequestArg.sourceImageUrl, sourceImageUrl: buildRequestArg.sourceImageUrl,
ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04', ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04',
@@ -434,7 +447,7 @@ export class CloudlyBaseOsManager {
}); });
await build.save(); 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.status = 'failed';
build.data.errorText = (error as Error).message; build.data.errorText = (error as Error).message;
build.data.updatedAt = Date.now(); build.data.updatedAt = Date.now();
@@ -554,33 +567,32 @@ export class CloudlyBaseOsManager {
buildArg: BaseOsImageBuild, buildArg: BaseOsImageBuild,
provisioningTokenArg: string, provisioningTokenArg: string,
buildRequestArg: IBaseOsImageBuildRequest, buildRequestArg: IBaseOsImageBuildRequest,
workerUrlArg: string, workerArg: ISelectedCoreBuildWorker,
workerTokenArg?: string,
) { ) {
buildArg.data.status = 'building'; buildArg.data.status = 'building';
buildArg.data.startedAt = Date.now(); buildArg.data.startedAt = Date.now();
buildArg.data.updatedAt = Date.now(); buildArg.data.updatedAt = Date.now();
await buildArg.save(); await buildArg.save();
const artifactFilename = buildArg.data.architecture === 'amd64' const artifactFilename = this.getArtifactFilename(
? 'baseos.iso' buildArg.data.architecture,
: buildArg.data.architecture === 'arm64' buildArg.data.imageKind || 'ubuntu-iso',
? 'baseos-arm64.iso' );
: 'baseos-rpi.img';
const artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`; const artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`;
const response = await fetch( const response = await fetch(
this.getCoreBuildUrl(workerUrlArg, '/corebuild/v1/jobs/baseos-image'), this.getCoreBuildUrl(workerArg.url, '/corebuild/v1/jobs/baseos-image'),
{ {
method: 'POST', method: 'POST',
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...(workerTokenArg ? { authorization: `Bearer ${workerTokenArg}` } : {}), ...(workerArg.token ? { authorization: `Bearer ${workerArg.token}` } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
apiToken: workerTokenArg, apiToken: workerArg.token,
job: { job: {
id: buildArg.id, id: buildArg.id,
architecture: buildArg.data.architecture, architecture: buildArg.data.architecture,
imageKind: buildArg.data.imageKind,
cloudlyUrl: buildArg.data.cloudlyUrl, cloudlyUrl: buildArg.data.cloudlyUrl,
provisioningToken: provisioningTokenArg, provisioningToken: provisioningTokenArg,
sourceImageUrl: buildArg.data.sourceImageUrl, sourceImageUrl: buildArg.data.sourceImageUrl,
@@ -617,16 +629,98 @@ export class CloudlyBaseOsManager {
await buildArg.save(); await buildArg.save();
} }
private async assertWorkerSupportsImageBuild( private async selectCoreBuildWorker(
workerUrlArg: string,
workerTokenArg: string | undefined,
architectureArg: TBaseOsImageArchitecture, 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( const response = await fetch(
this.getCoreBuildUrl(workerUrlArg, '/corebuild/v1/capabilities'), this.getCoreBuildUrl(workerArg.url, '/corebuild/v1/capabilities'),
{ {
method: 'GET', method: 'GET',
headers: workerTokenArg ? { authorization: `Bearer ${workerTokenArg}` } : {}, headers: workerArg.token ? { authorization: `Bearer ${workerArg.token}` } : {},
}, },
); );
if (!response.ok) { if (!response.ok) {
@@ -634,17 +728,39 @@ export class CloudlyBaseOsManager {
`CoreBuild capabilities request failed with HTTP ${response.status}`, `CoreBuild capabilities request failed with HTTP ${response.status}`,
); );
} }
const capabilities = await response.json() as ICoreBuildCapabilitiesResponse; return 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`,
);
} }
if (!capabilities.supportedArchitectures?.includes(architectureArg)) {
throw new plugins.typedrequest.TypedResponseError( private getImageKind(buildRequestArg: IBaseOsImageBuildRequest): TBaseOsImageKind {
`CoreBuild worker ${capabilities.workerId} does not support BaseOS ${architectureArg} builds`, if (buildRequestArg.imageKind) {
); return buildRequestArg.imageKind;
} }
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) { private getCoreBuildUrl(workerUrlArg: string, pathArg: string) {
@@ -53,6 +53,9 @@ export class CloudlySettingsManager {
} }
private isSensitiveSettingKey(key: string): boolean { private isSensitiveSettingKey(key: string): boolean {
if (key === 'corebuildWorkersJson') {
return true;
}
const normalizedKey = key.toLowerCase(); const normalizedKey = key.toLowerCase();
return [ return [
'token', 'token',
+16 -5
View File
@@ -92,15 +92,25 @@ export class CloudlyViewBaseOs extends DeesElement {
return html` return html`
<cloudly-sectionheading>BaseOS Images</cloudly-sectionheading> <cloudly-sectionheading>BaseOS Images</cloudly-sectionheading>
<div class="layout"> <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-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 <dees-input-dropdown
.key=${'architecture'} .key=${'architecture'}
.label=${'Architecture'} .label=${'Architecture'}
.selectedOption=${'amd64'} .selectedOption=${'amd64'}
.options=${[ .options=${[
{ key: 'amd64', option: 'amd64 ISO', payload: null }, { key: 'amd64', option: 'amd64', payload: null },
{ key: 'arm64', option: 'arm64 ISO', payload: null }, { key: 'arm64', option: 'arm64', payload: null },
{ key: 'rpi', option: 'Raspberry Pi', payload: null },
]} ]}
></dees-input-dropdown> ></dees-input-dropdown>
<dees-input-text .key=${'cloudlyUrl'} .label=${'Cloudly URL'} .value=${window.location.origin} .required=${true}></dees-input-text> <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=${'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-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-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-submit .text=${this.isLoading ? 'Creating...' : 'Create BaseOS Image'} .disabled=${this.isLoading}></dees-form-submit>
</dees-form> </dees-form>
</dees-panel> </dees-panel>
@@ -128,7 +138,7 @@ export class CloudlyViewBaseOs extends DeesElement {
<div class="build-head"> <div class="build-head">
<div> <div>
<strong>${data.hostname || buildArg.id}</strong> <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> </div>
<dees-badge .text=${data.status} .type=${data.status === 'ready' ? 'success' : data.status === 'failed' ? 'error' : 'info'}></dees-badge> <dees-badge .text=${data.status} .type=${data.status === 'ready' ? 'success' : data.status === 'failed' ? 'error' : 'info'}></dees-badge>
</div> </div>
@@ -158,6 +168,7 @@ export class CloudlyViewBaseOs extends DeesElement {
const response = await this.fireBaseOsRequest('createBaseOsImageBuild', { const response = await this.fireBaseOsRequest('createBaseOsImageBuild', {
build: { build: {
architecture: formDataArg.architecture || 'amd64', architecture: formDataArg.architecture || 'amd64',
imageKind: formDataArg.imageKind || undefined,
cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin, cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin,
hostname: formDataArg.hostname || undefined, hostname: formDataArg.hostname || undefined,
sourceImageUrl: formDataArg.sourceImageUrl || 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=${'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> <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>
<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>
<dees-panel .title=${'Amazon Web Services'} .subtitle=${'Configure AWS credentials'} .variant=${'outline'}> <dees-panel .title=${'Amazon Web Services'} .subtitle=${'Configure AWS credentials'} .variant=${'outline'}>