feat: add baseos source presets

This commit is contained in:
2026-05-07 20:33:14 +00:00
parent 0fcf35c019
commit 60c51fbf5d
4 changed files with 238 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@serve.zone/cloudly", "name": "@serve.zone/cloudly",
"version": "5.3.0", "version": "5.4.0",
"private": false, "private": false,
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.", "description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
"type": "module", "type": "module",
@@ -2,6 +2,10 @@ 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 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 type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled';
export interface IBaseOsImageArtifact { export interface IBaseOsImageArtifact {
@@ -22,6 +26,8 @@ export interface IBaseOsImageBuildPublic {
imageKind?: TBaseOsImageKind; imageKind?: TBaseOsImageKind;
cloudlyUrl: string; cloudlyUrl: string;
sourceImageUrl?: string; sourceImageUrl?: string;
sourceImagePreset?: TBaseOsImageSourcePreset;
balenaOsVersion?: string;
ubuntuVersion?: string; ubuntuVersion?: string;
hostname?: string; hostname?: string;
wifiSsid?: string; wifiSsid?: string;
+138 -4
View File
@@ -15,8 +15,29 @@ import {
type IBaseOsImageBuildPublic, type IBaseOsImageBuildPublic,
type TBaseOsImageArchitecture, type TBaseOsImageArchitecture,
type TBaseOsImageKind, type TBaseOsImageKind,
type TBaseOsImageSourcePreset,
} from './classes.baseosimagebuild.js'; } 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 { interface IBaseOsRegisterRequest {
joinToken?: string; joinToken?: string;
nodeToken?: string; nodeToken?: string;
@@ -28,6 +49,7 @@ interface IBaseOsRegisterResponse {
nodeToken?: string; nodeToken?: string;
accepted: boolean; accepted: boolean;
message?: string; message?: string;
desiredState?: IBaseOsDesiredState;
} }
interface IBaseOsHeartbeatRequest { 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 { interface IBaseOsImageBuildRequest {
architecture: TBaseOsImageArchitecture; architecture: TBaseOsImageArchitecture;
imageKind?: TBaseOsImageKind; imageKind?: TBaseOsImageKind;
cloudlyUrl?: string; cloudlyUrl?: string;
sourceImageUrl?: string; sourceImageUrl?: string;
sourceImagePreset?: TBaseOsImageSourcePreset;
balenaOsVersion?: string;
ubuntuVersion?: string; ubuntuVersion?: string;
hostname?: string; hostname?: string;
wifi?: { wifi?: {
@@ -122,6 +158,7 @@ interface ICoreBuildCapabilitiesResponse {
supportedBuildTypes: string[]; supportedBuildTypes: string[];
supportedArchitectures: TBaseOsImageArchitecture[]; supportedArchitectures: TBaseOsImageArchitecture[];
supportedImageKinds?: TBaseOsImageKind[]; supportedImageKinds?: TBaseOsImageKind[];
supportedSourcePresets?: TBaseOsImageSourcePreset[];
} }
interface ICoreBuildWorkerSetting { interface ICoreBuildWorkerSetting {
@@ -180,6 +217,24 @@ export class CloudlyBaseOsManager {
), ),
); );
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<IRequestSetBaseOsNodeDesiredState>(
'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( this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<IRequestGetBaseOsImageBuilds>( new plugins.typedrequest.TypedHandler<IRequestGetBaseOsImageBuilds>(
'getBaseOsImageBuilds', 'getBaseOsImageBuilds',
@@ -335,6 +390,7 @@ export class CloudlyBaseOsManager {
return { return {
accepted: true, accepted: true,
nodeId: existingNode.id, nodeId: existingNode.id,
desiredState: existingNode.data.desiredState || {},
}; };
} }
} }
@@ -347,6 +403,7 @@ export class CloudlyBaseOsManager {
accepted: true, accepted: true,
nodeId: node.id, nodeId: node.id,
nodeToken, nodeToken,
desiredState: node.data.desiredState || {},
}; };
} }
@@ -371,6 +428,7 @@ export class CloudlyBaseOsManager {
accepted: true, accepted: true,
nodeId: node.id, nodeId: node.id,
nodeToken, nodeToken,
desiredState: node.data.desiredState || {},
}; };
} }
@@ -410,6 +468,23 @@ export class CloudlyBaseOsManager {
return nodes.map((nodeArg) => nodeArg.toPublicNode()); 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) { public async createImageBuild(buildRequestArg: IBaseOsImageBuildRequest) {
const s3Descriptor = this.cloudlyRef.config.data.s3Descriptor; const s3Descriptor = this.cloudlyRef.config.data.s3Descriptor;
if (!s3Descriptor?.bucketName) { if (!s3Descriptor?.bucketName) {
@@ -417,10 +492,14 @@ export class CloudlyBaseOsManager {
} }
const imageKind = this.getImageKind(buildRequestArg); const imageKind = this.getImageKind(buildRequestArg);
if (imageKind === 'balena-raw' && !buildRequestArg.sourceImageUrl) { if (buildRequestArg.architecture === 'rpi' && imageKind === 'ubuntu-iso') {
throw new plugins.typedrequest.TypedResponseError('sourceImageUrl is required for balena-raw BaseOS image builds'); 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 now = Date.now();
const buildId = await this.CBaseOsImageBuild.getNewId(); const buildId = await this.CBaseOsImageBuild.getNewId();
@@ -435,6 +514,8 @@ export class CloudlyBaseOsManager {
imageKind, imageKind,
cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(), cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(),
sourceImageUrl: buildRequestArg.sourceImageUrl, sourceImageUrl: buildRequestArg.sourceImageUrl,
sourceImagePreset,
balenaOsVersion,
ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04', ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04',
hostname: buildRequestArg.hostname, hostname: buildRequestArg.hostname,
wifiSsid: buildRequestArg.wifi?.ssid, wifiSsid: buildRequestArg.wifi?.ssid,
@@ -596,6 +677,8 @@ export class CloudlyBaseOsManager {
cloudlyUrl: buildArg.data.cloudlyUrl, cloudlyUrl: buildArg.data.cloudlyUrl,
provisioningToken: provisioningTokenArg, provisioningToken: provisioningTokenArg,
sourceImageUrl: buildArg.data.sourceImageUrl, sourceImageUrl: buildArg.data.sourceImageUrl,
sourceImagePreset: buildArg.data.sourceImagePreset,
balenaOsVersion: buildArg.data.balenaOsVersion,
ubuntuVersion: buildArg.data.ubuntuVersion, ubuntuVersion: buildArg.data.ubuntuVersion,
hostname: buildArg.data.hostname, hostname: buildArg.data.hostname,
wifi: buildArg.data.wifiSsid wifi: buildArg.data.wifiSsid
@@ -632,6 +715,7 @@ export class CloudlyBaseOsManager {
private async selectCoreBuildWorker( private async selectCoreBuildWorker(
architectureArg: TBaseOsImageArchitecture, architectureArg: TBaseOsImageArchitecture,
imageKindArg: TBaseOsImageKind, imageKindArg: TBaseOsImageKind,
sourceImagePresetArg?: TBaseOsImageSourcePreset,
): Promise<ISelectedCoreBuildWorker> { ): Promise<ISelectedCoreBuildWorker> {
const workers = await this.getConfiguredCoreBuildWorkers(); const workers = await this.getConfiguredCoreBuildWorkers();
if (workers.length === 0) { if (workers.length === 0) {
@@ -656,6 +740,10 @@ export class CloudlyBaseOsManager {
rejectionReasons.push(`${workerLabel}: missing ${imageKindArg} support`); rejectionReasons.push(`${workerLabel}: missing ${imageKindArg} support`);
continue; continue;
} }
if (sourceImagePresetArg && !capabilities.supportedSourcePresets?.includes(sourceImagePresetArg)) {
rejectionReasons.push(`${workerLabel}: missing ${sourceImagePresetArg} source preset support`);
continue;
}
return { return {
...worker, ...worker,
capabilities, capabilities,
@@ -738,12 +826,58 @@ export class CloudlyBaseOsManager {
if (buildRequestArg.architecture === 'rpi') { if (buildRequestArg.architecture === 'rpi') {
return 'balena-raw'; 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 'balena-raw';
} }
return 'ubuntu-iso'; 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( private getArtifactFilename(
architectureArg: TBaseOsImageArchitecture, architectureArg: TBaseOsImageArchitecture,
imageKindArg: TBaseOsImageKind, imageKindArg: TBaseOsImageKind,
+93 -5
View File
@@ -12,6 +12,16 @@ import {
} from '@design.estate/dees-element'; } from '@design.estate/dees-element';
type TBaseOsImageBuild = any; type TBaseOsImageBuild = any;
type TBaseOsImageSourcePreset =
| 'balena-generic-amd64'
| 'balena-generic-aarch64'
| 'balena-raspberrypi4-64';
const sourcePresetArchitectures: Record<TBaseOsImageSourcePreset, string> = {
'balena-generic-amd64': 'amd64',
'balena-generic-aarch64': 'arm64',
'balena-raspberrypi4-64': 'rpi',
};
@customElement('cloudly-view-baseos') @customElement('cloudly-view-baseos')
export class CloudlyViewBaseOs extends DeesElement { export class CloudlyViewBaseOs extends DeesElement {
@@ -115,10 +125,24 @@ export class CloudlyViewBaseOs extends DeesElement {
></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>
<dees-input-text .key=${'hostname'} .label=${'Hostname'} .value=${'baseos-node'} .required=${false}></dees-input-text> <dees-input-text .key=${'hostname'} .label=${'Hostname'} .value=${'baseos-node'} .required=${false}></dees-input-text>
<dees-input-dropdown
.key=${'sourceImagePreset'}
.label=${'balenaOS Source Preset'}
.selectedOption=${'auto'}
.options=${[
{ key: 'auto', option: 'Auto by architecture', payload: null },
{ key: 'balena-generic-amd64', option: 'Generic x86_64 (GPT)', payload: null },
{ key: 'balena-generic-aarch64', option: 'Generic AARCH64', payload: null },
{ key: 'balena-raspberrypi4-64', option: 'Raspberry Pi 4 64-bit', payload: null },
{ key: 'custom-url', option: 'Custom source URL', payload: null },
]}
.description=${'Used for balenaOS raw images when no custom source URL is provided.'}
></dees-input-dropdown>
<dees-input-text .key=${'balenaOsVersion'} .label=${'balenaOS Version'} .value=${'latest'} .description=${'Use latest, or an explicit balenaOS raw_version such as 2026.1.0.'} .required=${false}></dees-input-text>
<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 Image URL'} .description=${'Required for balenaOS raw images (.img, .img.xz, or .zip). Optional for Ubuntu ISO builds.'} .required=${false}></dees-input-text> <dees-input-text .key=${'sourceImageUrl'} .label=${'Custom Source Image URL'} .description=${'Optional override for balenaOS raw images (.img, .img.xz, .zip, or balena download URL with fileType=.zip).'} .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>
@@ -139,6 +163,7 @@ export class CloudlyViewBaseOs extends DeesElement {
<div> <div>
<strong>${data.hostname || buildArg.id}</strong> <strong>${data.hostname || buildArg.id}</strong>
<div class="meta">${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}</div> <div class="meta">${data.imageKind || 'ubuntu-iso'} · ${data.architecture} · ${data.cloudlyUrl}</div>
${data.sourceImagePreset ? html`<div class="meta">${data.sourceImagePreset} · balenaOS ${data.balenaOsVersion || 'latest'}</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>
@@ -163,15 +188,34 @@ export class CloudlyViewBaseOs extends DeesElement {
} }
private async createBuild(formDataArg: any) { private async createBuild(formDataArg: any) {
this.isLoading = true;
try { 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', { const response = await this.fireBaseOsRequest('createBaseOsImageBuild', {
build: { build: {
architecture: formDataArg.architecture || 'amd64', architecture,
imageKind: formDataArg.imageKind || undefined, imageKind,
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,
sourceImagePreset,
balenaOsVersion,
wifi: formDataArg.wifiSsid wifi: formDataArg.wifiSsid
? { ? {
ssid: 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) { private async downloadBuild(buildIdArg: string) {
const response = await this.fireBaseOsRequest('createBaseOsImageDownloadUrl', { const response = await this.fireBaseOsRequest('createBaseOsImageDownloadUrl', {
buildId: buildIdArg, buildId: buildIdArg,