feat: add baseos node enrollment
This commit is contained in:
@@ -33,6 +33,7 @@ import { CloudlyAuthManager } from './manager.auth/classes.authmanager.js';
|
|||||||
import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js';
|
import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js';
|
||||||
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
|
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
|
||||||
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.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.
|
* Cloudly class can be used to instantiate a cloudly server.
|
||||||
@@ -77,6 +78,7 @@ export class Cloudly {
|
|||||||
public backupManager: CloudlyBackupManager;
|
public backupManager: CloudlyBackupManager;
|
||||||
public nodeManager: CloudlyNodeManager;
|
public nodeManager: CloudlyNodeManager;
|
||||||
public baremetalManager: CloudlyBaremetalManager;
|
public baremetalManager: CloudlyBaremetalManager;
|
||||||
|
public baseOsManager: CloudlyBaseOsManager;
|
||||||
|
|
||||||
private readyDeferred = new plugins.smartpromise.Deferred();
|
private readyDeferred = new plugins.smartpromise.Deferred();
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ export class Cloudly {
|
|||||||
this.domainManager = new DomainManager(this);
|
this.domainManager = new DomainManager(this);
|
||||||
this.taskManager = new CloudlyTaskManager(this);
|
this.taskManager = new CloudlyTaskManager(this);
|
||||||
this.backupManager = new CloudlyBackupManager(this);
|
this.backupManager = new CloudlyBackupManager(this);
|
||||||
|
this.baseOsManager = new CloudlyBaseOsManager(this);
|
||||||
this.secretManager = new CloudlySecretManager(this);
|
this.secretManager = new CloudlySecretManager(this);
|
||||||
this.nodeManager = new CloudlyNodeManager(this);
|
this.nodeManager = new CloudlyNodeManager(this);
|
||||||
this.baremetalManager = new CloudlyBaremetalManager(this);
|
this.baremetalManager = new CloudlyBaremetalManager(this);
|
||||||
@@ -140,6 +143,7 @@ export class Cloudly {
|
|||||||
await this.deploymentManager.start();
|
await this.deploymentManager.start();
|
||||||
await this.taskManager.init();
|
await this.taskManager.init();
|
||||||
await this.backupManager.start();
|
await this.backupManager.start();
|
||||||
|
await this.baseOsManager.start();
|
||||||
await this.registryManager.start();
|
await this.registryManager.start();
|
||||||
await this.domainManager.init();
|
await this.domainManager.init();
|
||||||
|
|
||||||
@@ -167,6 +171,7 @@ export class Cloudly {
|
|||||||
await this.deploymentManager.stop();
|
await this.deploymentManager.stop();
|
||||||
await this.taskManager.stop();
|
await this.taskManager.stop();
|
||||||
await this.backupManager.stop();
|
await this.backupManager.stop();
|
||||||
|
await this.baseOsManager.stop();
|
||||||
await this.registryManager.stop();
|
await this.registryManager.stop();
|
||||||
await this.externalRegistryManager.stop();
|
await this.externalRegistryManager.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ export class CloudlyServer {
|
|||||||
'/curlfresh/:scriptname',
|
'/curlfresh/:scriptname',
|
||||||
this.cloudlyRef.nodeManager.curlfreshInstance.handler,
|
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();
|
await this.typedServer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<IRequestGetBaseOsNodes>(
|
||||||
|
'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<IBaseOsRegisterRequest>(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<IBaseOsHeartbeatRequest>(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<IBaseOsRegisterResponse> {
|
||||||
|
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<IBaseOsHeartbeatResponse> {
|
||||||
|
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<IBaseOsNodePublic[]> {
|
||||||
|
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<IBaseOsRuntimeInfo>;
|
||||||
|
return runtimeInfo.runtime === 'baseos'
|
||||||
|
&& typeof runtimeInfo.nodeId === 'string'
|
||||||
|
&& runtimeInfo.nodeId.length > 0
|
||||||
|
&& typeof runtimeInfo.checkedAt === 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readJsonBody<T>(reqArg: plugins.typedserver.Request): Promise<T> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
stateStatus?: Record<string, unknown>;
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBaseOsDesiredState {
|
||||||
|
release?: string;
|
||||||
|
targetState?: Record<string, unknown>;
|
||||||
|
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<BaseOsNode, IBaseOsNodePublic> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user