diff --git a/ts/manager.baseos/classes.baseosimagebuild.ts b/ts/manager.baseos/classes.baseosimagebuild.ts index 7a05174..c681397 100644 --- a/ts/manager.baseos/classes.baseosimagebuild.ts +++ b/ts/manager.baseos/classes.baseosimagebuild.ts @@ -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; diff --git a/ts/manager.baseos/classes.baseosmanager.ts b/ts/manager.baseos/classes.baseosmanager.ts index 48ef16e..3c65877 100644 --- a/ts/manager.baseos/classes.baseosmanager.ts +++ b/ts/manager.baseos/classes.baseosmanager.ts @@ -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 { + 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 { + 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(); + 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) { diff --git a/ts/manager.settings/classes.settingsmanager.ts b/ts/manager.settings/classes.settingsmanager.ts index 5c2d8f2..f89073b 100644 --- a/ts/manager.settings/classes.settingsmanager.ts +++ b/ts/manager.settings/classes.settingsmanager.ts @@ -53,6 +53,9 @@ export class CloudlySettingsManager { } private isSensitiveSettingKey(key: string): boolean { + if (key === 'corebuildWorkersJson') { + return true; + } const normalizedKey = key.toLowerCase(); return [ 'token', diff --git a/ts_web/elements/views/baseos/index.ts b/ts_web/elements/views/baseos/index.ts index 5a4d2bc..2f7ec48 100644 --- a/ts_web/elements/views/baseos/index.ts +++ b/ts_web/elements/views/baseos/index.ts @@ -92,15 +92,25 @@ export class CloudlyViewBaseOs extends DeesElement { return html` BaseOS Images
- + this.createBuild((eventArg.detail as any).data)}> + @@ -108,7 +118,7 @@ export class CloudlyViewBaseOs extends DeesElement { - + @@ -128,7 +138,7 @@ export class CloudlyViewBaseOs extends DeesElement {
${data.hostname || buildArg.id} -
${data.architecture} · ${data.cloudlyUrl}
+
${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}
@@ -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, diff --git a/ts_web/elements/views/settings/index.ts b/ts_web/elements/views/settings/index.ts index b73703c..6f8ee6e 100644 --- a/ts_web/elements/views/settings/index.ts +++ b/ts_web/elements/views/settings/index.ts @@ -149,6 +149,9 @@ export class CloudlyViewSettings extends DeesElement {
+
+ +