From be7735a9c3c640cdc9022374c3e8561c17712a5a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 7 May 2026 15:53:16 +0000 Subject: [PATCH] feat: add baseos node enrollment --- ts/classes.cloudly.ts | 5 + ts/classes.server.ts | 12 + ts/manager.baseos/classes.baseosmanager.ts | 277 +++++++++++++++++++++ ts/manager.baseos/classes.baseosnode.ts | 67 +++++ 4 files changed, 361 insertions(+) create mode 100644 ts/manager.baseos/classes.baseosmanager.ts create mode 100644 ts/manager.baseos/classes.baseosnode.ts diff --git a/ts/classes.cloudly.ts b/ts/classes.cloudly.ts index a42b4b6..54a163c 100644 --- a/ts/classes.cloudly.ts +++ b/ts/classes.cloudly.ts @@ -33,6 +33,7 @@ import { CloudlyAuthManager } from './manager.auth/classes.authmanager.js'; import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js'; import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js'; import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js'; +import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js'; /** * Cloudly class can be used to instantiate a cloudly server. @@ -77,6 +78,7 @@ export class Cloudly { public backupManager: CloudlyBackupManager; public nodeManager: CloudlyNodeManager; public baremetalManager: CloudlyBaremetalManager; + public baseOsManager: CloudlyBaseOsManager; private readyDeferred = new plugins.smartpromise.Deferred(); @@ -111,6 +113,7 @@ export class Cloudly { this.domainManager = new DomainManager(this); this.taskManager = new CloudlyTaskManager(this); this.backupManager = new CloudlyBackupManager(this); + this.baseOsManager = new CloudlyBaseOsManager(this); this.secretManager = new CloudlySecretManager(this); this.nodeManager = new CloudlyNodeManager(this); this.baremetalManager = new CloudlyBaremetalManager(this); @@ -140,6 +143,7 @@ export class Cloudly { await this.deploymentManager.start(); await this.taskManager.init(); await this.backupManager.start(); + await this.baseOsManager.start(); await this.registryManager.start(); await this.domainManager.init(); @@ -167,6 +171,7 @@ export class Cloudly { await this.deploymentManager.stop(); await this.taskManager.stop(); await this.backupManager.stop(); + await this.baseOsManager.stop(); await this.registryManager.stop(); await this.externalRegistryManager.stop(); } diff --git a/ts/classes.server.ts b/ts/classes.server.ts index 2c5557b..ddf0e19 100644 --- a/ts/classes.server.ts +++ b/ts/classes.server.ts @@ -109,6 +109,18 @@ export class CloudlyServer { '/curlfresh/:scriptname', this.cloudlyRef.nodeManager.curlfreshInstance.handler, ); + this.typedServer.server.addRoute( + '/baseos/v1/nodes/register', + new plugins.typedserver.servertools.Handler('POST', async (req, res) => { + await this.cloudlyRef.baseOsManager.handleRegisterHttpRequest(req, res); + }), + ); + this.typedServer.server.addRoute( + '/baseos/v1/nodes/heartbeat', + new plugins.typedserver.servertools.Handler('POST', async (req, res) => { + await this.cloudlyRef.baseOsManager.handleHeartbeatHttpRequest(req, res); + }), + ); await this.typedServer.start(); } diff --git a/ts/manager.baseos/classes.baseosmanager.ts b/ts/manager.baseos/classes.baseosmanager.ts new file mode 100644 index 0000000..4600b25 --- /dev/null +++ b/ts/manager.baseos/classes.baseosmanager.ts @@ -0,0 +1,277 @@ +import * as plugins from '../plugins.js'; +import type { Cloudly } from '../classes.cloudly.js'; +import { logger } from '../logger.js'; +import { + BaseOsNode, + type IBaseOsDesiredState, + type IBaseOsNodePublic, + type IBaseOsRuntimeInfo, +} from './classes.baseosnode.js'; + +interface IBaseOsRegisterRequest { + joinToken?: string; + nodeToken?: string; + status?: IBaseOsRuntimeInfo; +} + +interface IBaseOsRegisterResponse { + nodeId?: string; + nodeToken?: string; + accepted: boolean; + message?: string; +} + +interface IBaseOsHeartbeatRequest { + nodeToken?: string; + status?: IBaseOsRuntimeInfo; +} + +interface IBaseOsHeartbeatResponse { + accepted: boolean; + message?: string; + desiredState?: IBaseOsDesiredState; +} + +interface IRequestGetBaseOsNodes { + method: 'getBaseOsNodes'; + request: { + identity: plugins.servezoneInterfaces.data.IIdentity; + }; + response: { + nodes: IBaseOsNodePublic[]; + }; +} + +export class CloudlyBaseOsManager { + public cloudlyRef: Cloudly; + public typedRouter = new plugins.typedrequest.TypedRouter(); + + public get db() { + return this.cloudlyRef.mongodbConnector.smartdataDb; + } + + public CBaseOsNode = plugins.smartdata.setDefaultManagerForDoc(this, BaseOsNode); + + constructor(cloudlyRefArg: Cloudly) { + this.cloudlyRef = cloudlyRefArg; + this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter); + + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getBaseOsNodes', + async (requestDataArg) => { + await plugins.smartguard.passGuardsOrReject( + { identity: requestDataArg.identity }, + [this.cloudlyRef.authManager.adminIdentityGuard], + ); + return { + nodes: await this.getPublicNodes(), + }; + }, + ), + ); + } + + public async start() { + logger.log('info', 'BaseOS manager started'); + } + + public async stop() { + logger.log('info', 'BaseOS manager stopped'); + } + + public async handleRegisterHttpRequest( + reqArg: plugins.typedserver.Request, + resArg: plugins.typedserver.Response, + ) { + try { + const requestData = await this.readJsonBody(reqArg); + const response = await this.registerNode(requestData); + this.sendJson(resArg, 200, response); + } catch (error) { + this.sendJson(resArg, 400, { + accepted: false, + message: `BaseOS registration failed: ${(error as Error).message}`, + } satisfies IBaseOsRegisterResponse); + } + } + + public async handleHeartbeatHttpRequest( + reqArg: plugins.typedserver.Request, + resArg: plugins.typedserver.Response, + ) { + try { + const requestData = await this.readJsonBody(reqArg); + const response = await this.acceptHeartbeat(requestData); + this.sendJson(resArg, 200, response); + } catch (error) { + this.sendJson(resArg, 400, { + accepted: false, + message: `BaseOS heartbeat failed: ${(error as Error).message}`, + } satisfies IBaseOsHeartbeatResponse); + } + } + + public async registerNode( + requestDataArg: IBaseOsRegisterRequest, + ): Promise { + if (!this.isRuntimeInfo(requestDataArg.status)) { + return { + accepted: false, + message: 'BaseOS runtime status is missing or invalid', + }; + } + + if (requestDataArg.nodeToken) { + const existingNode = await this.getNodeByToken(requestDataArg.nodeToken); + if (existingNode) { + await this.updateNodeRuntimeInfo(existingNode, requestDataArg.status, true); + return { + accepted: true, + nodeId: existingNode.id, + }; + } + } + + const configuredJoinToken = await this.cloudlyRef.settingsManager.getSetting('baseosJoinToken'); + if (!configuredJoinToken) { + return { + accepted: false, + message: 'BaseOS join token is not configured in Cloudly settings', + }; + } + + if (!requestDataArg.joinToken || requestDataArg.joinToken !== configuredJoinToken) { + return { + accepted: false, + message: 'BaseOS join token is invalid', + }; + } + + const nodeToken = await this.cloudlyRef.authManager.createNewSecureToken(); + const node = await this.upsertNode(requestDataArg.status, nodeToken); + return { + accepted: true, + nodeId: node.id, + nodeToken, + }; + } + + public async acceptHeartbeat( + requestDataArg: IBaseOsHeartbeatRequest, + ): Promise { + if (!requestDataArg.nodeToken) { + return { + accepted: false, + message: 'BaseOS node token is missing', + }; + } + if (!this.isRuntimeInfo(requestDataArg.status)) { + return { + accepted: false, + message: 'BaseOS runtime status is missing or invalid', + }; + } + + const node = await this.getNodeByToken(requestDataArg.nodeToken); + if (!node) { + return { + accepted: false, + message: 'BaseOS node token is invalid', + }; + } + + await this.updateNodeRuntimeInfo(node, requestDataArg.status, true); + return { + accepted: true, + desiredState: node.data.desiredState || {}, + }; + } + + public async getPublicNodes(): Promise { + const nodes = await this.CBaseOsNode.getInstances({}); + return nodes.map((nodeArg) => nodeArg.toPublicNode()); + } + + private async upsertNode(statusArg: IBaseOsRuntimeInfo, nodeTokenArg: string) { + const now = Date.now(); + let node = await this.CBaseOsNode.getInstance({ + id: statusArg.nodeId, + }).catch(() => null); + + if (!node) { + node = new this.CBaseOsNode({ + id: statusArg.nodeId, + nodeToken: nodeTokenArg, + data: { + runtimeInfo: statusArg, + createdAt: now, + updatedAt: now, + lastHeartbeatAt: now, + }, + }); + } else { + node.nodeToken = nodeTokenArg; + node.data = { + ...node.data, + runtimeInfo: statusArg, + updatedAt: now, + lastHeartbeatAt: now, + }; + } + + await node.save(); + return node; + } + + private async updateNodeRuntimeInfo( + nodeArg: BaseOsNode, + statusArg: IBaseOsRuntimeInfo, + heartbeatArg = false, + ) { + nodeArg.data = { + ...nodeArg.data, + runtimeInfo: statusArg, + updatedAt: Date.now(), + ...(heartbeatArg ? { lastHeartbeatAt: Date.now() } : {}), + }; + await nodeArg.save(); + } + + private async getNodeByToken(nodeTokenArg: string) { + const nodes = await this.CBaseOsNode.getInstances({ + nodeToken: nodeTokenArg, + }); + return nodes[0] || null; + } + + private isRuntimeInfo(valueArg: unknown): valueArg is IBaseOsRuntimeInfo { + if (!valueArg || typeof valueArg !== 'object') { + return false; + } + const runtimeInfo = valueArg as Partial; + return runtimeInfo.runtime === 'baseos' + && typeof runtimeInfo.nodeId === 'string' + && runtimeInfo.nodeId.length > 0 + && typeof runtimeInfo.checkedAt === 'number'; + } + + private async readJsonBody(reqArg: plugins.typedserver.Request): Promise { + const chunks: Buffer[] = []; + for await (const chunk of reqArg as any) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const bodyString = Buffer.concat(chunks).toString('utf8').trim(); + return bodyString ? JSON.parse(bodyString) as T : {} as T; + } + + private sendJson( + resArg: plugins.typedserver.Response, + statusCodeArg: number, + bodyArg: object, + ) { + resArg.status(statusCodeArg); + resArg.setHeader('Content-Type', 'application/json'); + resArg.end(JSON.stringify(bodyArg)); + } +} diff --git a/ts/manager.baseos/classes.baseosnode.ts b/ts/manager.baseos/classes.baseosnode.ts new file mode 100644 index 0000000..a9d071b --- /dev/null +++ b/ts/manager.baseos/classes.baseosnode.ts @@ -0,0 +1,67 @@ +import * as plugins from '../plugins.js'; + +export type TBaseOsRuntimeLevel = 'app-layer' | 'host-os' | 'target-state'; + +export type TBaseOsCloudlyConnectionStatus = + | 'not-configured' + | 'connecting' + | 'connected' + | 'failed'; + +export interface IBaseOsRuntimeInfo { + runtime: 'baseos'; + runtimeLevel: TBaseOsRuntimeLevel; + nodeId: string; + cloudlyUrl?: string; + cloudlyConnectionStatus: TBaseOsCloudlyConnectionStatus; + supervisorAvailable: boolean; + supervisorAddress?: string; + deviceState?: Record; + stateStatus?: Record; + checkedAt: number; +} + +export interface IBaseOsDesiredState { + release?: string; + targetState?: Record; + updatedAt?: number; +} + +export interface IBaseOsNodeData { + runtimeInfo: IBaseOsRuntimeInfo; + desiredState?: IBaseOsDesiredState; + createdAt: number; + updatedAt: number; + lastHeartbeatAt?: number; +} + +export interface IBaseOsNodePublic { + id: string; + data: IBaseOsNodeData; +} + +@plugins.smartdata.managed() +export class BaseOsNode extends plugins.smartdata.SmartDataDbDoc { + constructor(optionsArg?: IBaseOsNodePublic & { nodeToken?: string }) { + super(); + if (optionsArg) { + Object.assign(this, optionsArg); + } + } + + @plugins.smartdata.unI() + public id!: string; + + @plugins.smartdata.svDb() + public nodeToken!: string; + + @plugins.smartdata.svDb() + public data!: IBaseOsNodeData; + + public toPublicNode(): IBaseOsNodePublic { + return { + id: this.id, + data: this.data, + }; + } +}