From 60c51fbf5d432048de6f8cdda82d811a04db0683 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 7 May 2026 20:33:14 +0000 Subject: [PATCH] feat: add baseos source presets --- package.json | 2 +- ts/manager.baseos/classes.baseosimagebuild.ts | 6 + ts/manager.baseos/classes.baseosmanager.ts | 142 +++++++++++++++++- ts_web/elements/views/baseos/index.ts | 98 +++++++++++- 4 files changed, 238 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index bd276a3..e80c75a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@serve.zone/cloudly", - "version": "5.3.0", + "version": "5.4.0", "private": false, "description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.", "type": "module", diff --git a/ts/manager.baseos/classes.baseosimagebuild.ts b/ts/manager.baseos/classes.baseosimagebuild.ts index c681397..322f59c 100644 --- a/ts/manager.baseos/classes.baseosimagebuild.ts +++ b/ts/manager.baseos/classes.baseosimagebuild.ts @@ -2,6 +2,10 @@ import * as plugins from '../plugins.js'; export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi'; export type TBaseOsImageKind = 'ubuntu-iso' | 'balena-raw'; +export type TBaseOsImageSourcePreset = + | 'balena-generic-amd64' + | 'balena-generic-aarch64' + | 'balena-raspberrypi4-64'; export type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled'; export interface IBaseOsImageArtifact { @@ -22,6 +26,8 @@ export interface IBaseOsImageBuildPublic { imageKind?: TBaseOsImageKind; cloudlyUrl: string; sourceImageUrl?: string; + sourceImagePreset?: TBaseOsImageSourcePreset; + balenaOsVersion?: string; ubuntuVersion?: string; hostname?: string; wifiSsid?: string; diff --git a/ts/manager.baseos/classes.baseosmanager.ts b/ts/manager.baseos/classes.baseosmanager.ts index 3c65877..5ad5e56 100644 --- a/ts/manager.baseos/classes.baseosmanager.ts +++ b/ts/manager.baseos/classes.baseosmanager.ts @@ -15,8 +15,29 @@ import { type IBaseOsImageBuildPublic, type TBaseOsImageArchitecture, type TBaseOsImageKind, + type TBaseOsImageSourcePreset, } from './classes.baseosimagebuild.js'; +interface IBalenaSourcePreset { + preset: TBaseOsImageSourcePreset; + architecture: TBaseOsImageArchitecture; +} + +const balenaSourcePresets: IBalenaSourcePreset[] = [ + { + preset: 'balena-generic-amd64', + architecture: 'amd64', + }, + { + preset: 'balena-generic-aarch64', + architecture: 'arm64', + }, + { + preset: 'balena-raspberrypi4-64', + architecture: 'rpi', + }, +]; + interface IBaseOsRegisterRequest { joinToken?: string; nodeToken?: string; @@ -28,6 +49,7 @@ interface IBaseOsRegisterResponse { nodeToken?: string; accepted: boolean; message?: string; + desiredState?: IBaseOsDesiredState; } interface IBaseOsHeartbeatRequest { @@ -51,11 +73,25 @@ interface IRequestGetBaseOsNodes { }; } +interface IRequestSetBaseOsNodeDesiredState { + method: 'setBaseOsNodeDesiredState'; + request: { + identity: plugins.servezoneInterfaces.data.IIdentity; + nodeId: string; + desiredState: IBaseOsDesiredState; + }; + response: { + node: IBaseOsNodePublic; + }; +} + interface IBaseOsImageBuildRequest { architecture: TBaseOsImageArchitecture; imageKind?: TBaseOsImageKind; cloudlyUrl?: string; sourceImageUrl?: string; + sourceImagePreset?: TBaseOsImageSourcePreset; + balenaOsVersion?: string; ubuntuVersion?: string; hostname?: string; wifi?: { @@ -122,6 +158,7 @@ interface ICoreBuildCapabilitiesResponse { supportedBuildTypes: string[]; supportedArchitectures: TBaseOsImageArchitecture[]; supportedImageKinds?: TBaseOsImageKind[]; + supportedSourcePresets?: TBaseOsImageSourcePreset[]; } interface ICoreBuildWorkerSetting { @@ -180,6 +217,24 @@ export class CloudlyBaseOsManager { ), ); + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'setBaseOsNodeDesiredState', + async (requestDataArg) => { + await plugins.smartguard.passGuardsOrReject( + { identity: requestDataArg.identity }, + [this.cloudlyRef.authManager.adminIdentityGuard], + ); + return { + node: (await this.setNodeDesiredState( + requestDataArg.nodeId, + requestDataArg.desiredState, + )).toPublicNode(), + }; + }, + ), + ); + this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getBaseOsImageBuilds', @@ -335,6 +390,7 @@ export class CloudlyBaseOsManager { return { accepted: true, nodeId: existingNode.id, + desiredState: existingNode.data.desiredState || {}, }; } } @@ -347,6 +403,7 @@ export class CloudlyBaseOsManager { accepted: true, nodeId: node.id, nodeToken, + desiredState: node.data.desiredState || {}, }; } @@ -371,6 +428,7 @@ export class CloudlyBaseOsManager { accepted: true, nodeId: node.id, nodeToken, + desiredState: node.data.desiredState || {}, }; } @@ -410,6 +468,23 @@ export class CloudlyBaseOsManager { return nodes.map((nodeArg) => nodeArg.toPublicNode()); } + public async setNodeDesiredState(nodeIdArg: string, desiredStateArg: IBaseOsDesiredState) { + const node = await this.CBaseOsNode.getInstance({ id: nodeIdArg }); + if (!node) { + throw new plugins.typedrequest.TypedResponseError(`BaseOS node ${nodeIdArg} not found`); + } + node.data = { + ...node.data, + desiredState: { + ...desiredStateArg, + updatedAt: desiredStateArg.updatedAt || Date.now(), + }, + updatedAt: Date.now(), + }; + await node.save(); + return node; + } + public async createImageBuild(buildRequestArg: IBaseOsImageBuildRequest) { const s3Descriptor = this.cloudlyRef.config.data.s3Descriptor; if (!s3Descriptor?.bucketName) { @@ -417,10 +492,14 @@ export class CloudlyBaseOsManager { } 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'); + if (buildRequestArg.architecture === 'rpi' && imageKind === 'ubuntu-iso') { + throw new plugins.typedrequest.TypedResponseError('Raspberry Pi BaseOS images require balena-raw image builds'); } - const worker = await this.selectCoreBuildWorker(buildRequestArg.architecture, imageKind); + const sourceImagePreset = this.getSourceImagePreset(buildRequestArg, imageKind); + const balenaOsVersion = imageKind === 'balena-raw' && !buildRequestArg.sourceImageUrl + ? buildRequestArg.balenaOsVersion?.trim() || 'latest' + : undefined; + const worker = await this.selectCoreBuildWorker(buildRequestArg.architecture, imageKind, sourceImagePreset); const now = Date.now(); const buildId = await this.CBaseOsImageBuild.getNewId(); @@ -435,6 +514,8 @@ export class CloudlyBaseOsManager { imageKind, cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(), sourceImageUrl: buildRequestArg.sourceImageUrl, + sourceImagePreset, + balenaOsVersion, ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04', hostname: buildRequestArg.hostname, wifiSsid: buildRequestArg.wifi?.ssid, @@ -596,6 +677,8 @@ export class CloudlyBaseOsManager { cloudlyUrl: buildArg.data.cloudlyUrl, provisioningToken: provisioningTokenArg, sourceImageUrl: buildArg.data.sourceImageUrl, + sourceImagePreset: buildArg.data.sourceImagePreset, + balenaOsVersion: buildArg.data.balenaOsVersion, ubuntuVersion: buildArg.data.ubuntuVersion, hostname: buildArg.data.hostname, wifi: buildArg.data.wifiSsid @@ -632,6 +715,7 @@ export class CloudlyBaseOsManager { private async selectCoreBuildWorker( architectureArg: TBaseOsImageArchitecture, imageKindArg: TBaseOsImageKind, + sourceImagePresetArg?: TBaseOsImageSourcePreset, ): Promise { const workers = await this.getConfiguredCoreBuildWorkers(); if (workers.length === 0) { @@ -656,6 +740,10 @@ export class CloudlyBaseOsManager { rejectionReasons.push(`${workerLabel}: missing ${imageKindArg} support`); continue; } + if (sourceImagePresetArg && !capabilities.supportedSourcePresets?.includes(sourceImagePresetArg)) { + rejectionReasons.push(`${workerLabel}: missing ${sourceImagePresetArg} source preset support`); + continue; + } return { ...worker, capabilities, @@ -738,12 +826,58 @@ export class CloudlyBaseOsManager { if (buildRequestArg.architecture === 'rpi') { return 'balena-raw'; } - if (buildRequestArg.sourceImageUrl && /\.(img|img\.xz|zip)(\?|$)/i.test(buildRequestArg.sourceImageUrl)) { + if (buildRequestArg.sourceImagePreset || buildRequestArg.balenaOsVersion) { + return 'balena-raw'; + } + if (buildRequestArg.sourceImageUrl && this.isRawImageUrl(buildRequestArg.sourceImageUrl)) { return 'balena-raw'; } return 'ubuntu-iso'; } + private getSourceImagePreset( + buildRequestArg: IBaseOsImageBuildRequest, + imageKindArg: TBaseOsImageKind, + ) { + if (imageKindArg === 'ubuntu-iso') { + if (buildRequestArg.sourceImagePreset || buildRequestArg.balenaOsVersion) { + throw new plugins.typedrequest.TypedResponseError('balenaOS source presets only apply to balena-raw builds'); + } + return undefined; + } + + if (buildRequestArg.sourceImageUrl) { + return undefined; + } + + const preset = buildRequestArg.sourceImagePreset + ? balenaSourcePresets.find((presetArg) => presetArg.preset === buildRequestArg.sourceImagePreset) + : balenaSourcePresets.find((presetArg) => presetArg.architecture === buildRequestArg.architecture); + if (!preset) { + throw new plugins.typedrequest.TypedResponseError( + `No balenaOS source preset is available for ${buildRequestArg.architecture}`, + ); + } + if (preset.architecture !== buildRequestArg.architecture) { + throw new plugins.typedrequest.TypedResponseError( + `${preset.preset} is only valid for ${preset.architecture} BaseOS images`, + ); + } + return preset.preset; + } + + private isRawImageUrl(sourceImageUrlArg: string) { + if (/\.(img|img\.xz|zip)(\?|$)/i.test(sourceImageUrlArg)) { + return true; + } + try { + const sourceUrl = new URL(sourceImageUrlArg); + return sourceUrl.searchParams.get('fileType') === '.zip'; + } catch { + return false; + } + } + private getArtifactFilename( architectureArg: TBaseOsImageArchitecture, imageKindArg: TBaseOsImageKind, diff --git a/ts_web/elements/views/baseos/index.ts b/ts_web/elements/views/baseos/index.ts index 2f7ec48..60fdd19 100644 --- a/ts_web/elements/views/baseos/index.ts +++ b/ts_web/elements/views/baseos/index.ts @@ -12,6 +12,16 @@ import { } from '@design.estate/dees-element'; type TBaseOsImageBuild = any; +type TBaseOsImageSourcePreset = + | 'balena-generic-amd64' + | 'balena-generic-aarch64' + | 'balena-raspberrypi4-64'; + +const sourcePresetArchitectures: Record = { + 'balena-generic-amd64': 'amd64', + 'balena-generic-aarch64': 'arm64', + 'balena-raspberrypi4-64': 'rpi', +}; @customElement('cloudly-view-baseos') export class CloudlyViewBaseOs extends DeesElement { @@ -115,10 +125,24 @@ export class CloudlyViewBaseOs extends DeesElement { > + + - + @@ -139,6 +163,7 @@ export class CloudlyViewBaseOs extends DeesElement {
${data.hostname || buildArg.id}
${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}
+ ${data.sourceImagePreset ? html`
${data.sourceImagePreset} · balenaOS ${data.balenaOsVersion || 'latest'}
` : ''}
@@ -163,15 +188,34 @@ export class CloudlyViewBaseOs extends DeesElement { } private async createBuild(formDataArg: any) { - this.isLoading = true; try { + const architecture = formDataArg.architecture || 'amd64'; + const imageKind = formDataArg.imageKind || 'balena-raw'; + const sourceImageUrl = formDataArg.sourceImageUrl?.trim() || undefined; + const selectedSourceImagePreset = formDataArg.sourceImagePreset || 'auto'; + const sourceImagePreset = this.getSourceImagePreset(selectedSourceImagePreset, imageKind, sourceImageUrl); + const balenaOsVersion = imageKind === 'balena-raw' && !sourceImageUrl + ? formDataArg.balenaOsVersion?.trim() || 'latest' + : undefined; + this.validateBuildForm({ + architecture, + imageKind, + selectedSourceImagePreset, + sourceImagePreset, + sourceImageUrl, + wifiSsid: formDataArg.wifiSsid, + wifiPassword: formDataArg.wifiPassword, + }); + this.isLoading = true; const response = await this.fireBaseOsRequest('createBaseOsImageBuild', { build: { - architecture: formDataArg.architecture || 'amd64', - imageKind: formDataArg.imageKind || undefined, + architecture, + imageKind, cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin, hostname: formDataArg.hostname || undefined, - sourceImageUrl: formDataArg.sourceImageUrl || undefined, + sourceImageUrl, + sourceImagePreset, + balenaOsVersion, wifi: formDataArg.wifiSsid ? { ssid: formDataArg.wifiSsid, @@ -190,6 +234,50 @@ export class CloudlyViewBaseOs extends DeesElement { } } + private getSourceImagePreset( + sourceImagePresetArg: string | undefined, + imageKindArg: string, + sourceImageUrlArg?: string, + ) { + if (imageKindArg !== 'balena-raw' || sourceImageUrlArg || sourceImagePresetArg === 'auto') { + return undefined; + } + if (sourceImagePresetArg === 'custom-url') { + return undefined; + } + return sourceImagePresetArg as TBaseOsImageSourcePreset | undefined; + } + + private validateBuildForm(optionsArg: { + architecture: string; + imageKind: string; + selectedSourceImagePreset?: string; + sourceImagePreset?: TBaseOsImageSourcePreset; + sourceImageUrl?: string; + wifiSsid?: string; + wifiPassword?: string; + }) { + if (optionsArg.architecture === 'rpi' && optionsArg.imageKind === 'ubuntu-iso') { + throw new Error('Raspberry Pi BaseOS images require the balenaOS raw image type.'); + } + if ( + optionsArg.imageKind === 'balena-raw' + && optionsArg.selectedSourceImagePreset === 'custom-url' + && !optionsArg.sourceImageUrl + ) { + throw new Error('A custom source image URL is required when the custom source preset is selected.'); + } + if (optionsArg.imageKind === 'balena-raw' && optionsArg.sourceImagePreset) { + const expectedArchitecture = sourcePresetArchitectures[optionsArg.sourceImagePreset]; + if (expectedArchitecture !== optionsArg.architecture) { + throw new Error(`${optionsArg.sourceImagePreset} is only valid for ${expectedArchitecture} images.`); + } + } + if (optionsArg.wifiPassword && !optionsArg.wifiSsid) { + throw new Error('A WiFi SSID is required when a WiFi password is set.'); + } + } + private async downloadBuild(buildIdArg: string) { const response = await this.fireBaseOsRequest('createBaseOsImageDownloadUrl', { buildId: buildIdArg,