feat: add baseos image builds
This commit is contained in:
@@ -121,6 +121,12 @@ export class CloudlyServer {
|
||||
await this.cloudlyRef.baseOsManager.handleHeartbeatHttpRequest(req, res);
|
||||
}),
|
||||
);
|
||||
this.typedServer.server.addRoute(
|
||||
'/baseos/v1/images/:buildId/download',
|
||||
new plugins.typedserver.servertools.Handler('GET', async (req, res) => {
|
||||
await this.cloudlyRef.baseOsManager.handleImageDownloadHttpRequest(req, res);
|
||||
}),
|
||||
);
|
||||
await this.typedServer.start();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi';
|
||||
export type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled';
|
||||
|
||||
export interface IBaseOsImageArtifact {
|
||||
bucketName: string;
|
||||
key: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface IBaseOsImageBuildPublic {
|
||||
id: string;
|
||||
data: {
|
||||
status: TBaseOsImageBuildStatus;
|
||||
architecture: TBaseOsImageArchitecture;
|
||||
cloudlyUrl: string;
|
||||
sourceImageUrl?: string;
|
||||
ubuntuVersion?: string;
|
||||
hostname?: string;
|
||||
wifiSsid?: string;
|
||||
sshPublicKey?: string;
|
||||
artifact?: IBaseOsImageArtifact;
|
||||
errorText?: string;
|
||||
logs: string[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
expiresAt?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@plugins.smartdata.managed()
|
||||
export class BaseOsImageBuild extends plugins.smartdata.SmartDataDbDoc<
|
||||
BaseOsImageBuild,
|
||||
IBaseOsImageBuildPublic
|
||||
> {
|
||||
constructor(optionsArg?: IBaseOsImageBuildPublic & {
|
||||
provisioningTokenHash?: string;
|
||||
provisioningTokenConsumedAt?: number;
|
||||
downloadTokenHash?: string;
|
||||
downloadTokenExpiresAt?: number;
|
||||
}) {
|
||||
super();
|
||||
if (optionsArg) {
|
||||
Object.assign(this, optionsArg);
|
||||
}
|
||||
}
|
||||
|
||||
@plugins.smartdata.unI()
|
||||
public id!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public provisioningTokenHash!: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public provisioningTokenConsumedAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public downloadTokenHash?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public downloadTokenExpiresAt?: number;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public data!: IBaseOsImageBuildPublic['data'];
|
||||
|
||||
public toPublicBuild(): IBaseOsImageBuildPublic {
|
||||
return {
|
||||
id: this.id,
|
||||
data: this.data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as nodeStream from 'node:stream';
|
||||
import type { Cloudly } from '../classes.cloudly.js';
|
||||
import { logger } from '../logger.js';
|
||||
import {
|
||||
@@ -7,6 +9,12 @@ import {
|
||||
type IBaseOsNodePublic,
|
||||
type IBaseOsRuntimeInfo,
|
||||
} from './classes.baseosnode.js';
|
||||
import {
|
||||
BaseOsImageBuild,
|
||||
type IBaseOsImageArtifact,
|
||||
type IBaseOsImageBuildPublic,
|
||||
type TBaseOsImageArchitecture,
|
||||
} from './classes.baseosimagebuild.js';
|
||||
|
||||
interface IBaseOsRegisterRequest {
|
||||
joinToken?: string;
|
||||
@@ -42,6 +50,71 @@ interface IRequestGetBaseOsNodes {
|
||||
};
|
||||
}
|
||||
|
||||
interface IBaseOsImageBuildRequest {
|
||||
architecture: TBaseOsImageArchitecture;
|
||||
cloudlyUrl?: string;
|
||||
sourceImageUrl?: string;
|
||||
ubuntuVersion?: string;
|
||||
hostname?: string;
|
||||
wifi?: {
|
||||
ssid: string;
|
||||
password?: string;
|
||||
};
|
||||
sshPublicKey?: string;
|
||||
artifactRetentionMs?: number;
|
||||
}
|
||||
|
||||
interface IRequestCreateBaseOsImageBuild {
|
||||
method: 'createBaseOsImageBuild';
|
||||
request: {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
build: IBaseOsImageBuildRequest;
|
||||
};
|
||||
response: {
|
||||
build: IBaseOsImageBuildPublic;
|
||||
};
|
||||
}
|
||||
|
||||
interface IRequestGetBaseOsImageBuilds {
|
||||
method: 'getBaseOsImageBuilds';
|
||||
request: {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
};
|
||||
response: {
|
||||
builds: IBaseOsImageBuildPublic[];
|
||||
};
|
||||
}
|
||||
|
||||
interface IRequestGetBaseOsImageBuildById {
|
||||
method: 'getBaseOsImageBuildById';
|
||||
request: {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
buildId: string;
|
||||
};
|
||||
response: {
|
||||
build: IBaseOsImageBuildPublic;
|
||||
};
|
||||
}
|
||||
|
||||
interface IRequestCreateBaseOsImageDownloadUrl {
|
||||
method: 'createBaseOsImageDownloadUrl';
|
||||
request: {
|
||||
identity: plugins.servezoneInterfaces.data.IIdentity;
|
||||
buildId: string;
|
||||
};
|
||||
response: {
|
||||
url: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ICoreBuildBaseOsImageResponse {
|
||||
success: boolean;
|
||||
artifact?: IBaseOsImageArtifact;
|
||||
logs: string[];
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
export class CloudlyBaseOsManager {
|
||||
public cloudlyRef: Cloudly;
|
||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||
@@ -51,6 +124,7 @@ export class CloudlyBaseOsManager {
|
||||
}
|
||||
|
||||
public CBaseOsNode = plugins.smartdata.setDefaultManagerForDoc(this, BaseOsNode);
|
||||
public CBaseOsImageBuild = plugins.smartdata.setDefaultManagerForDoc(this, BaseOsImageBuild);
|
||||
|
||||
constructor(cloudlyRefArg: Cloudly) {
|
||||
this.cloudlyRef = cloudlyRefArg;
|
||||
@@ -70,6 +144,64 @@ export class CloudlyBaseOsManager {
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<IRequestCreateBaseOsImageBuild>(
|
||||
'createBaseOsImageBuild',
|
||||
async (requestDataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(
|
||||
{ identity: requestDataArg.identity },
|
||||
[this.cloudlyRef.authManager.adminIdentityGuard],
|
||||
);
|
||||
return {
|
||||
build: await this.createImageBuild(requestDataArg.build),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<IRequestGetBaseOsImageBuilds>(
|
||||
'getBaseOsImageBuilds',
|
||||
async (requestDataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(
|
||||
{ identity: requestDataArg.identity },
|
||||
[this.cloudlyRef.authManager.adminIdentityGuard],
|
||||
);
|
||||
return {
|
||||
builds: await this.getPublicImageBuilds(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<IRequestGetBaseOsImageBuildById>(
|
||||
'getBaseOsImageBuildById',
|
||||
async (requestDataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(
|
||||
{ identity: requestDataArg.identity },
|
||||
[this.cloudlyRef.authManager.adminIdentityGuard],
|
||||
);
|
||||
return {
|
||||
build: (await this.getImageBuildById(requestDataArg.buildId)).toPublicBuild(),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedRouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<IRequestCreateBaseOsImageDownloadUrl>(
|
||||
'createBaseOsImageDownloadUrl',
|
||||
async (requestDataArg) => {
|
||||
await plugins.smartguard.passGuardsOrReject(
|
||||
{ identity: requestDataArg.identity },
|
||||
[this.cloudlyRef.authManager.adminIdentityGuard],
|
||||
);
|
||||
return await this.createImageDownloadUrl(requestDataArg.buildId);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public async start() {
|
||||
@@ -112,6 +244,47 @@ export class CloudlyBaseOsManager {
|
||||
}
|
||||
}
|
||||
|
||||
public async handleImageDownloadHttpRequest(
|
||||
reqArg: plugins.typedserver.Request,
|
||||
resArg: plugins.typedserver.Response,
|
||||
) {
|
||||
try {
|
||||
const requestUrl = new URL((reqArg as any).originalUrl || reqArg.url || '/', 'http://localhost');
|
||||
const buildId = requestUrl.pathname.split('/').at(-2);
|
||||
const token = requestUrl.searchParams.get('token');
|
||||
if (!buildId || !token) {
|
||||
this.sendJson(resArg, 400, { errorText: 'build id or download token missing' });
|
||||
return;
|
||||
}
|
||||
const build = await this.getImageBuildById(buildId);
|
||||
if (build.downloadTokenHash !== this.hashSecret(token) || (build.downloadTokenExpiresAt || 0) < Date.now()) {
|
||||
this.sendJson(resArg, 403, { errorText: 'download token is invalid or expired' });
|
||||
return;
|
||||
}
|
||||
if (build.data.status !== 'ready' || !build.data.artifact) {
|
||||
this.sendJson(resArg, 409, { errorText: 'image build is not ready' });
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = build.data.artifact;
|
||||
const smartbucket = new plugins.smartbucket.SmartBucket({
|
||||
...this.cloudlyRef.config.data.s3Descriptor,
|
||||
port: Number((this.cloudlyRef.config.data.s3Descriptor as any).port || 443),
|
||||
} as any);
|
||||
const bucket = await smartbucket.getBucketByName(artifact.bucketName);
|
||||
const artifactStream = await bucket.fastGetStream({ path: artifact.key }, 'nodestream');
|
||||
resArg.status(200);
|
||||
resArg.setHeader('Content-Type', artifact.contentType || 'application/octet-stream');
|
||||
resArg.setHeader('Content-Length', String(artifact.size));
|
||||
resArg.setHeader('Content-Disposition', `attachment; filename="${artifact.filename}"`);
|
||||
(artifactStream as nodeStream.Readable).pipe(resArg as any);
|
||||
} catch (error) {
|
||||
this.sendJson(resArg, 500, {
|
||||
errorText: `BaseOS image download failed: ${(error as Error).message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async registerNode(
|
||||
requestDataArg: IBaseOsRegisterRequest,
|
||||
): Promise<IBaseOsRegisterResponse> {
|
||||
@@ -133,6 +306,17 @@ export class CloudlyBaseOsManager {
|
||||
}
|
||||
}
|
||||
|
||||
const acceptedBuild = await this.consumeProvisioningToken(requestDataArg.joinToken);
|
||||
if (acceptedBuild) {
|
||||
const nodeToken = await this.cloudlyRef.authManager.createNewSecureToken();
|
||||
const node = await this.upsertNode(requestDataArg.status, nodeToken);
|
||||
return {
|
||||
accepted: true,
|
||||
nodeId: node.id,
|
||||
nodeToken,
|
||||
};
|
||||
}
|
||||
|
||||
const configuredJoinToken = await this.cloudlyRef.settingsManager.getSetting('baseosJoinToken');
|
||||
if (!configuredJoinToken) {
|
||||
return {
|
||||
@@ -193,6 +377,77 @@ export class CloudlyBaseOsManager {
|
||||
return nodes.map((nodeArg) => nodeArg.toPublicNode());
|
||||
}
|
||||
|
||||
public async createImageBuild(buildRequestArg: IBaseOsImageBuildRequest) {
|
||||
const s3Descriptor = this.cloudlyRef.config.data.s3Descriptor;
|
||||
if (!s3Descriptor?.bucketName) {
|
||||
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 now = Date.now();
|
||||
const buildId = await this.CBaseOsImageBuild.getNewId();
|
||||
const provisioningToken = await this.cloudlyRef.authManager.createNewSecureToken();
|
||||
const artifactRetentionMs = buildRequestArg.artifactRetentionMs || 1000 * 60 * 60 * 24 * 7;
|
||||
const build = new this.CBaseOsImageBuild({
|
||||
id: buildId,
|
||||
provisioningTokenHash: this.hashSecret(provisioningToken),
|
||||
data: {
|
||||
status: 'queued',
|
||||
architecture: buildRequestArg.architecture,
|
||||
cloudlyUrl: buildRequestArg.cloudlyUrl || this.getPublicCloudlyUrl(),
|
||||
sourceImageUrl: buildRequestArg.sourceImageUrl,
|
||||
ubuntuVersion: buildRequestArg.ubuntuVersion || '24.04',
|
||||
hostname: buildRequestArg.hostname,
|
||||
wifiSsid: buildRequestArg.wifi?.ssid,
|
||||
sshPublicKey: buildRequestArg.sshPublicKey,
|
||||
logs: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
expiresAt: now + artifactRetentionMs,
|
||||
},
|
||||
});
|
||||
await build.save();
|
||||
|
||||
this.executeImageBuild(build, provisioningToken, buildRequestArg, workerUrl, workerToken).catch(async (error) => {
|
||||
build.data.status = 'failed';
|
||||
build.data.errorText = (error as Error).message;
|
||||
build.data.updatedAt = Date.now();
|
||||
build.data.completedAt = Date.now();
|
||||
await build.save();
|
||||
});
|
||||
|
||||
return build.toPublicBuild();
|
||||
}
|
||||
|
||||
public async getPublicImageBuilds() {
|
||||
const builds = await this.CBaseOsImageBuild.getInstances({});
|
||||
return builds
|
||||
.sort((a, b) => b.data.createdAt - a.data.createdAt)
|
||||
.map((buildArg) => buildArg.toPublicBuild());
|
||||
}
|
||||
|
||||
public async createImageDownloadUrl(buildIdArg: string) {
|
||||
const build = await this.getImageBuildById(buildIdArg);
|
||||
if (build.data.status !== 'ready' || !build.data.artifact) {
|
||||
throw new plugins.typedrequest.TypedResponseError('BaseOS image build is not ready');
|
||||
}
|
||||
const token = await this.cloudlyRef.authManager.createNewSecureToken();
|
||||
const expiresAt = Date.now() + 1000 * 60 * 15;
|
||||
build.downloadTokenHash = this.hashSecret(token);
|
||||
build.downloadTokenExpiresAt = expiresAt;
|
||||
build.data.updatedAt = Date.now();
|
||||
await build.save();
|
||||
return {
|
||||
url: `/baseos/v1/images/${build.id}/download?token=${encodeURIComponent(token)}`,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async upsertNode(statusArg: IBaseOsRuntimeInfo, nodeTokenArg: string) {
|
||||
const now = Date.now();
|
||||
let node = await this.CBaseOsNode.getInstance({
|
||||
@@ -224,6 +479,113 @@ export class CloudlyBaseOsManager {
|
||||
return node;
|
||||
}
|
||||
|
||||
private async executeImageBuild(
|
||||
buildArg: BaseOsImageBuild,
|
||||
provisioningTokenArg: string,
|
||||
buildRequestArg: IBaseOsImageBuildRequest,
|
||||
workerUrlArg: string,
|
||||
workerTokenArg?: string,
|
||||
) {
|
||||
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 artifactKey = `corebuild/baseos/${buildArg.id}/${artifactFilename}`;
|
||||
const response = await fetch(
|
||||
new URL('/corebuild/v1/jobs/baseos-image', workerUrlArg.endsWith('/') ? workerUrlArg : `${workerUrlArg}/`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(workerTokenArg ? { authorization: `Bearer ${workerTokenArg}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiToken: workerTokenArg,
|
||||
job: {
|
||||
id: buildArg.id,
|
||||
architecture: buildArg.data.architecture,
|
||||
cloudlyUrl: buildArg.data.cloudlyUrl,
|
||||
provisioningToken: provisioningTokenArg,
|
||||
sourceImageUrl: buildArg.data.sourceImageUrl,
|
||||
ubuntuVersion: buildArg.data.ubuntuVersion,
|
||||
hostname: buildArg.data.hostname,
|
||||
wifi: buildArg.data.wifiSsid
|
||||
? {
|
||||
ssid: buildArg.data.wifiSsid,
|
||||
password: buildRequestArg.wifi?.password,
|
||||
}
|
||||
: undefined,
|
||||
sshPublicKey: buildArg.data.sshPublicKey,
|
||||
s3Descriptor: this.cloudlyRef.config.data.s3Descriptor,
|
||||
artifactKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseBody = await response.json() as ICoreBuildBaseOsImageResponse;
|
||||
buildArg.data.logs = responseBody.logs || [];
|
||||
if (!response.ok || !responseBody.success || !responseBody.artifact) {
|
||||
buildArg.data.status = 'failed';
|
||||
buildArg.data.errorText = responseBody.errorText || `CoreBuild failed with HTTP ${response.status}`;
|
||||
buildArg.data.updatedAt = Date.now();
|
||||
buildArg.data.completedAt = Date.now();
|
||||
await buildArg.save();
|
||||
return;
|
||||
}
|
||||
|
||||
buildArg.data.status = 'ready';
|
||||
buildArg.data.artifact = responseBody.artifact;
|
||||
buildArg.data.updatedAt = Date.now();
|
||||
buildArg.data.completedAt = Date.now();
|
||||
await buildArg.save();
|
||||
}
|
||||
|
||||
private async getImageBuildById(buildIdArg: string) {
|
||||
const build = await this.CBaseOsImageBuild.getInstance({ id: buildIdArg });
|
||||
if (!build) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`BaseOS image build ${buildIdArg} not found`);
|
||||
}
|
||||
return build;
|
||||
}
|
||||
|
||||
private async consumeProvisioningToken(joinTokenArg?: string) {
|
||||
if (!joinTokenArg) {
|
||||
return null;
|
||||
}
|
||||
const tokenHash = this.hashSecret(joinTokenArg);
|
||||
const builds = await this.CBaseOsImageBuild.getInstances({
|
||||
provisioningTokenHash: tokenHash,
|
||||
});
|
||||
const build = builds.find((buildArg) => {
|
||||
return !buildArg.provisioningTokenConsumedAt && (!buildArg.data.expiresAt || buildArg.data.expiresAt > Date.now());
|
||||
});
|
||||
if (!build) {
|
||||
return null;
|
||||
}
|
||||
build.provisioningTokenConsumedAt = Date.now();
|
||||
build.data.updatedAt = Date.now();
|
||||
await build.save();
|
||||
return build;
|
||||
}
|
||||
|
||||
private hashSecret(secretArg: string) {
|
||||
return crypto.createHash('sha256').update(secretArg).digest('hex');
|
||||
}
|
||||
|
||||
private getPublicCloudlyUrl() {
|
||||
const sslMode = this.cloudlyRef.config.data.sslMode;
|
||||
const protocol = sslMode === 'none' ? 'http' : 'https';
|
||||
const port = this.cloudlyRef.config.data.publicPort;
|
||||
const includePort = sslMode === 'none' && port && !['80', '443'].includes(port);
|
||||
return `${protocol}://${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${port}` : ''}`;
|
||||
}
|
||||
|
||||
private async updateNodeRuntimeInfo(
|
||||
nodeArg: BaseOsNode,
|
||||
statusArg: IBaseOsRuntimeInfo,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
state
|
||||
} from '@design.estate/dees-element';
|
||||
import { CloudlyViewBackups } from './views/backups/index.js';
|
||||
import { CloudlyViewBaseOs } from './views/baseos/index.js';
|
||||
import { CloudlyViewClusters } from './views/clusters/index.js';
|
||||
import { CloudlyViewDbs } from './views/dbs/index.js';
|
||||
import { CloudlyViewDeployments } from './views/deployments/index.js';
|
||||
@@ -136,6 +137,11 @@ export class CloudlyDashboard extends DeesElement {
|
||||
iconName: 'lucide:Save',
|
||||
element: CloudlyViewBackups,
|
||||
},
|
||||
{
|
||||
name: 'BaseOS',
|
||||
iconName: 'lucide:HardDriveDownload',
|
||||
element: CloudlyViewBaseOs,
|
||||
},
|
||||
{
|
||||
name: 'Fleet',
|
||||
iconName: 'lucide:Truck',
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './shared/index.js';
|
||||
export * from './cloudly-dashboard.js';
|
||||
export * from './views/secretgroups/index.js';
|
||||
export * from './views/secretbundles/index.js';
|
||||
export * from './views/baseos/index.js';
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as appstate from '../../../appstate.js';
|
||||
import * as shared from '../../shared/index.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
type TBaseOsImageBuild = any;
|
||||
|
||||
@customElement('cloudly-view-baseos')
|
||||
export class CloudlyViewBaseOs extends DeesElement {
|
||||
@state() private builds: TBaseOsImageBuild[] = [];
|
||||
@state() private isLoading = false;
|
||||
|
||||
private refreshTimer?: number;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
css`
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 420px 1fr;
|
||||
gap: 16px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.builds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.build {
|
||||
border: 1px solid #2a2f3a;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
background: #10151f;
|
||||
}
|
||||
|
||||
.build-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: #9aa4b2;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.logs {
|
||||
margin-top: 12px;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #bac4d1;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await this.loadBuilds();
|
||||
this.refreshTimer = window.setInterval(() => this.loadBuilds(), 5000);
|
||||
}
|
||||
|
||||
public async disconnectedCallback() {
|
||||
await super.disconnectedCallback();
|
||||
if (this.refreshTimer) {
|
||||
window.clearInterval(this.refreshTimer);
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
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-form @formData=${(eventArg: CustomEvent) => this.createBuild((eventArg.detail as any).data)}>
|
||||
<dees-input-dropdown
|
||||
.key=${'architecture'}
|
||||
.label=${'Architecture'}
|
||||
.selectedOption=${'amd64'}
|
||||
.options=${[
|
||||
{ key: 'amd64', option: 'amd64 ISO', payload: null },
|
||||
{ key: 'arm64', option: 'arm64 ISO', payload: null },
|
||||
]}
|
||||
></dees-input-dropdown>
|
||||
<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=${'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-form-submit .text=${this.isLoading ? 'Creating...' : 'Create BaseOS Image'} .disabled=${this.isLoading}></dees-form-submit>
|
||||
</dees-form>
|
||||
</dees-panel>
|
||||
<div class="builds">
|
||||
${this.builds.length === 0
|
||||
? html`<dees-panel .title=${'No image builds yet'} .subtitle=${'Create an image to start a corebuild job.'}></dees-panel>`
|
||||
: this.builds.map((buildArg) => this.renderBuild(buildArg))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBuild(buildArg: TBaseOsImageBuild) {
|
||||
const data = buildArg.data;
|
||||
return html`
|
||||
<div class="build">
|
||||
<div class="build-head">
|
||||
<div>
|
||||
<strong>${data.hostname || buildArg.id}</strong>
|
||||
<div class="meta">${data.architecture} · ${data.cloudlyUrl}</div>
|
||||
</div>
|
||||
<dees-badge .text=${data.status} .type=${data.status === 'ready' ? 'success' : data.status === 'failed' ? 'error' : 'info'}></dees-badge>
|
||||
</div>
|
||||
<div class="meta">
|
||||
${data.artifact ? `${data.artifact.filename} · ${Math.round(data.artifact.size / 1024 / 1024)} MB · ${data.artifact.sha256.slice(0, 12)}...` : data.errorText || 'Waiting for artifact'}
|
||||
</div>
|
||||
${data.status === 'ready'
|
||||
? html`<dees-button .text=${'Download'} .type=${'primary'} @click=${() => this.downloadBuild(buildArg.id)}></dees-button>`
|
||||
: ''}
|
||||
${data.logs?.length ? html`<div class="logs">${data.logs.slice(-8).join('\n')}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async loadBuilds() {
|
||||
try {
|
||||
const response = await this.fireBaseOsRequest('getBaseOsImageBuilds', {});
|
||||
this.builds = response.builds || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load BaseOS image builds:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async createBuild(formDataArg: any) {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await this.fireBaseOsRequest('createBaseOsImageBuild', {
|
||||
build: {
|
||||
architecture: formDataArg.architecture || 'amd64',
|
||||
cloudlyUrl: formDataArg.cloudlyUrl || window.location.origin,
|
||||
hostname: formDataArg.hostname || undefined,
|
||||
sourceImageUrl: formDataArg.sourceImageUrl || undefined,
|
||||
wifi: formDataArg.wifiSsid
|
||||
? {
|
||||
ssid: formDataArg.wifiSsid,
|
||||
password: formDataArg.wifiPassword || undefined,
|
||||
}
|
||||
: undefined,
|
||||
sshPublicKey: formDataArg.sshPublicKey || undefined,
|
||||
},
|
||||
});
|
||||
this.builds = [response.build, ...this.builds];
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'BaseOS image build queued', type: 'success' });
|
||||
} catch (error: any) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to create image build: ${error.message}`, type: 'error' });
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadBuild(buildIdArg: string) {
|
||||
const response = await this.fireBaseOsRequest('createBaseOsImageDownloadUrl', {
|
||||
buildId: buildIdArg,
|
||||
});
|
||||
window.location.href = response.url;
|
||||
}
|
||||
|
||||
private async fireBaseOsRequest(methodArg: string, payloadArg: Record<string, unknown>) {
|
||||
appstate.apiClient.identity = appstate.loginStatePart.getState()?.identity || null as any;
|
||||
if (!appstate.apiClient.typedsocketClient) {
|
||||
await appstate.apiClient.start();
|
||||
}
|
||||
const request = appstate.apiClient.typedsocketClient.createTypedRequest<any>(methodArg);
|
||||
return await request.fire({
|
||||
identity: appstate.apiClient.identity,
|
||||
...payloadArg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'cloudly-view-baseos': CloudlyViewBaseOs;
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,13 @@ export class CloudlyViewSettings extends DeesElement {
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'CoreBuild Worker'} .subtitle=${'Build BaseOS images on a capable worker node'} .variant=${'outline'}>
|
||||
<div class="form-grid">
|
||||
<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>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'Amazon Web Services'} .subtitle=${'Configure AWS credentials'} .variant=${'outline'}>
|
||||
<div class="test-status">
|
||||
${this.renderProviderStatus('aws')}
|
||||
|
||||
Reference in New Issue
Block a user