feat(cloudly): add service runtime and onboarding

This commit is contained in:
2026-05-23 10:46:52 +00:00
parent 59043b7281
commit bef236cd86
18 changed files with 1500 additions and 19 deletions
+10
View File
@@ -34,6 +34,8 @@ import { CloudlySettingsManager } from './manager.settings/classes.settingsmanag
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
import { CloudlyAppCatalogManager } from './manager.appcatalog/classes.appcatalogmanager.js';
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
/**
* Cloudly class can be used to instantiate a cloudly server.
@@ -79,6 +81,8 @@ export class Cloudly {
public nodeManager: CloudlyNodeManager;
public baremetalManager: CloudlyBaremetalManager;
public baseOsManager: CloudlyBaseOsManager;
public appCatalogManager: CloudlyAppCatalogManager;
public jumpManager: CloudlyJumpManager;
private readyDeferred = new plugins.smartpromise.Deferred();
@@ -115,8 +119,10 @@ export class Cloudly {
this.backupManager = new CloudlyBackupManager(this);
this.baseOsManager = new CloudlyBaseOsManager(this);
this.secretManager = new CloudlySecretManager(this);
this.appCatalogManager = new CloudlyAppCatalogManager(this);
this.nodeManager = new CloudlyNodeManager(this);
this.baremetalManager = new CloudlyBaremetalManager(this);
this.jumpManager = new CloudlyJumpManager(this);
}
/**
@@ -138,12 +144,14 @@ export class Cloudly {
await this.secretManager.start();
await this.nodeManager.start();
await this.baremetalManager.start();
await this.jumpManager.start();
await this.serviceManager.start();
await this.platformManager.start();
await this.deploymentManager.start();
await this.taskManager.init();
await this.backupManager.start();
await this.baseOsManager.start();
await this.appCatalogManager.start();
await this.registryManager.start();
await this.domainManager.init();
@@ -173,10 +181,12 @@ export class Cloudly {
await this.serviceManager.stop();
await this.platformManager.stop();
await this.deploymentManager.stop();
await this.jumpManager.stop();
await this.taskManager.stop();
await this.backupManager.stop();
await this.baseOsManager.stop();
await this.registryManager.stop();
await this.appCatalogManager.stop();
await this.externalRegistryManager.stop();
}
}
+15
View File
@@ -95,6 +95,21 @@ export class CloudlyServer {
'ALL',
async (ctx) => this.cloudlyRef.nodeManager.curlfreshInstance.handleRequest(ctx),
);
this.typedServer.addRoute(
'/jump/v1/claim',
'POST',
async (ctx) => this.cloudlyRef.jumpManager.handleClaimHttpRequest(ctx),
);
this.typedServer.addRoute(
'/jump/:code/setup.sh',
'GET',
async (ctx) => this.cloudlyRef.jumpManager.handleSetupScriptHttpRequest(ctx),
);
this.typedServer.addRoute(
'/jump/:code',
'GET',
async (ctx) => this.cloudlyRef.jumpManager.handleJumpHttpRequest(ctx),
);
this.typedServer.addRoute(
'/baseos/v1/nodes/register',
'POST',
@@ -0,0 +1,306 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { Image } from '../manager.image/classes.image.js';
import { Service } from '../manager.service/classes.service.js';
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
type ICatalogApp = plugins.servezoneInterfaces.appcatalog.ICatalogApp;
type ICatalog = plugins.servezoneInterfaces.appcatalog.ICatalog;
type IAppMeta = plugins.servezoneInterfaces.appcatalog.IAppMeta;
type IAppVersionConfig = plugins.servezoneInterfaces.appcatalog.IAppVersionConfig;
type IInstallOptions = plugins.servezoneInterfaces.appcatalog.IAppInstallRequest;
type IUpgradeableCatalogService = plugins.servezoneInterfaces.appcatalog.IUpgradeableAppService;
export class CloudlyAppCatalogManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
private catalogCache: ICatalog | null = null;
private lastFetchTime = 0;
private readonly repoBaseUrl = process.env.APPCATALOG_URL || 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
private readonly cacheTtlMs = 5 * 60 * 1000;
constructor(private cloudlyRef: Cloudly) {
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
public async start() {}
public async stop() {}
private registerHandlers() {
const addCatalogListHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { apps: await this.getApps() };
}),
);
};
addCatalogListHandler('getAppCatalogTemplates');
addCatalogListHandler('getAppTemplates');
const addConfigHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
await this.passAdminIdentity(dataArg);
return {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
appMeta: await this.getAppMeta(dataArg.appId),
};
}),
);
};
addConfigHandler('getAppCatalogConfig');
addConfigHandler('getAppConfig');
const addInstallHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
await this.passAdminIdentity(dataArg);
const service = await this.installApp(dataArg.install || dataArg);
return { service: await service.createSavableObject() };
}),
);
};
addInstallHandler('installAppCatalogApp');
addInstallHandler('installAppTemplate');
const addUpgradeableHandler = (methodArg: string) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { services: await this.getUpgradeableServices() };
}),
);
};
addUpgradeableHandler('getUpgradeableAppCatalogServices');
addUpgradeableHandler('getUpgradeableServices');
}
public async getCatalog(): Promise<ICatalog> {
const now = Date.now();
if (this.catalogCache && now - this.lastFetchTime < this.cacheTtlMs) {
return this.catalogCache;
}
const catalog = await this.fetchJson('catalog.json') as ICatalog;
if (!catalog || !Array.isArray(catalog.apps)) {
throw new Error('Invalid app catalog format');
}
this.catalogCache = catalog;
this.lastFetchTime = now;
return catalog;
}
public async getApps(): Promise<ICatalogApp[]> {
return (await this.getCatalog()).apps;
}
public async getAppMeta(appIdArg: string): Promise<IAppMeta> {
return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta;
}
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppVersionConfig> {
if (!versionArg) {
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
}
return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig;
}
public async getUpgradeableServices(): Promise<IUpgradeableCatalogService[]> {
const catalog = await this.getCatalog();
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const upgradeableServices: IUpgradeableCatalogService[] = [];
for (const service of services) {
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
appTemplateId?: string;
appTemplateVersion?: string;
};
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
continue;
}
const catalogApp = catalog.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
if (!catalogApp || catalogApp.latestVersion === serviceData.appTemplateVersion) {
continue;
}
upgradeableServices.push({
serviceName: serviceData.name,
appTemplateId: serviceData.appTemplateId,
currentVersion: serviceData.appTemplateVersion,
latestVersion: catalogApp.latestVersion,
hasMigration: false,
});
}
return upgradeableServices;
}
public async installApp(optionsArg: IInstallOptions): Promise<Service> {
const appMeta = await this.getAppMeta(optionsArg.appId);
const version = optionsArg.version || appMeta.latestVersion;
const config = await this.getAppVersionConfig(optionsArg.appId, version);
const webPort = optionsArg.port || config.port;
this.assertSupportedPlatformRequirements(config);
const envVars = this.getCatalogEnvVars(config, optionsArg.envVars || {});
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
}
const image = await this.createCatalogImage(optionsArg.serviceName, config.image, appMeta.description);
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
const serviceData = {
name: optionsArg.serviceName,
description: appMeta.description,
imageId: image.id,
imageVersion: this.getImageTag(config.image),
deployOnPush: false,
appTemplateId: optionsArg.appId,
appTemplateVersion: version,
environment: envVars,
secretBundleId: secretBundle.id,
additionalSecretBundleIds: [],
serviceCategory: 'workload',
deploymentStrategy: 'limited-replicas',
maxReplicas: 1,
antiAffinity: false,
scaleFactor: 1,
balancingStrategy: 'round-robin',
ports: { web: webPort },
volumes: this.normalizeVolumes(config.volumes),
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
deploymentIds: [],
} as plugins.servezoneInterfaces.data.IService['data'] & {
appTemplateId: string;
appTemplateVersion: string;
};
const service = await Service.createService(serviceData);
secretBundle.data.serviceId = service.id;
await secretBundle.save();
await this.createPlatformBindings(service, config);
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
return service;
}
private async createCatalogImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
const image = new Image();
image.id = await Image.getNewId();
image.data = {
name: `${serviceNameArg}-catalog-image`,
description: descriptionArg,
location: {
internal: false,
externalRegistryId: '',
externalImageTag: imageRefArg,
externalImageRef: imageRefArg,
},
versions: [{
versionString: this.getImageTag(imageRefArg),
source: 'registry',
registryRepository: imageRefArg,
registryTag: this.getImageTag(imageRefArg),
size: 0,
createdAt: Date.now(),
}],
};
await image.save();
return image;
}
private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise<SecretBundle> {
const secretBundle = new SecretBundle();
secretBundle.id = plugins.smartunique.shortId(8);
secretBundle.data = {
name: `${serviceNameArg} catalog secrets`,
description: `Generated catalog secret bundle for ${serviceNameArg}`,
type: 'service',
includedSecretGroupIds: [],
includedTags: [],
imageClaims: [{ imageId: imageIdArg, permissions: ['read'] }],
authorizations: [],
};
await secretBundle.save();
return secretBundle;
}
private async createPlatformBindings(serviceArg: Service, configArg: IAppVersionConfig) {
const requirements = configArg.platformRequirements || {};
if (requirements.mongodb) {
await PlatformBinding.upsertBinding({
id: await PlatformBinding.getNewId(),
serviceId: serviceArg.id,
capability: 'database',
desiredState: 'enabled',
status: 'requested',
});
}
if (requirements.s3) {
await PlatformBinding.upsertBinding({
id: await PlatformBinding.getNewId(),
serviceId: serviceArg.id,
capability: 'objectstorage',
desiredState: 'enabled',
status: 'requested',
});
}
}
private normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []) {
return volumesArg.map((volumeArg) => {
if (typeof volumeArg === 'string') {
return { mountPath: volumeArg };
}
return volumeArg;
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
}
private getCatalogEnvVars(configArg: IAppVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
const envVars: Record<string, string> = {};
const missingRequiredEnvVars: string[] = [];
for (const envVar of configArg.envVars || []) {
const value = overridesArg[envVar.key] ?? envVar.value ?? '';
if (envVar.required && !value) {
missingRequiredEnvVars.push(envVar.key);
}
envVars[envVar.key] = value;
}
Object.assign(envVars, overridesArg);
if (missingRequiredEnvVars.length > 0) {
throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`);
}
return envVars;
}
private requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
private assertSupportedPlatformRequirements(configArg: IAppVersionConfig) {
const unsupported = Object.entries(configArg.platformRequirements || {})
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
.map(([key]) => key);
if (unsupported.length > 0) {
throw new Error(`Cloudly catalog install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
}
}
private getImageTag(imageRefArg: string) {
const lastSlashIndex = imageRefArg.lastIndexOf('/');
const lastColonIndex = imageRefArg.lastIndexOf(':');
return lastColonIndex > lastSlashIndex ? imageRefArg.slice(lastColonIndex + 1) || 'latest' : 'latest';
}
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
}
private async fetchJson(pathArg: string): Promise<unknown> {
const url = `${this.repoBaseUrl.replace(/\/+$/, '')}/${pathArg}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
}
+107
View File
@@ -3,6 +3,30 @@ import { Cloudly } from '../classes.cloudly.js';
import type { Cluster } from '../manager.cluster/classes.cluster.js';
import { logger } from '../logger.js';
type TCoreflowDeploymentRequest =
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadFile
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceWriteFile
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadDir
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceMkdir
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceRm
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExists
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec;
type TCoreflowDeploymentActionMethod =
| 'coreflowRestartDeployment'
| 'coreflowKillDeployment';
type TCoreflowDeploymentActionRequest = Extract<TCoreflowDeploymentRequest, {
method: TCoreflowDeploymentActionMethod;
}>;
export type TCoreflowDeploymentWorkspaceMethod = Exclude<
TCoreflowDeploymentRequest['method'],
TCoreflowDeploymentActionMethod
>;
/**
* in charge of talking to coreflow services on clusters
* coreflow runs on a server when ServerManager is done.
@@ -159,4 +183,87 @@ export class CloudlyCoreflowManager {
return connections.length;
}
public async getRuntimeDeploymentsForService(
serviceArg: plugins.servezoneInterfaces.data.IService,
): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
const connections = await this.getConnectedCoreflowConnections();
const deployments: plugins.servezoneInterfaces.data.IDeployment[] = [];
for (const connection of connections) {
try {
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_GetServiceDeployments>(
'coreflowGetServiceDeployments',
connection,
);
const response = await request.fire({ service: serviceArg });
deployments.push(...(response.deployments || []));
} catch (error) {
logger.log('warn', `failed to query coreflow deployments: ${(error as Error).message}`);
}
}
return deployments;
}
public async fireDeploymentRuntimeAction(
methodArg: TCoreflowDeploymentActionMethod,
deploymentIdArg: string,
): Promise<{ deployment: plugins.servezoneInterfaces.data.IDeployment }> {
const response = await this.fireCoreflowRequestUntilFound<TCoreflowDeploymentActionRequest>(methodArg, {
deploymentId: deploymentIdArg,
});
if (!response.deployment) {
throw new plugins.typedrequest.TypedResponseError('Coreflow did not return deployment data');
}
return { deployment: response.deployment };
}
public async fireDeploymentWorkspaceRequest(
methodArg: TCoreflowDeploymentWorkspaceMethod,
payloadArg: Extract<TCoreflowDeploymentRequest, { method: typeof methodArg }>['request'],
) {
return await this.fireCoreflowRequestUntilFound(methodArg, payloadArg);
}
private async fireCoreflowRequestUntilFound<TRequest extends TCoreflowDeploymentRequest>(
methodArg: TRequest['method'],
payloadArg: TRequest['request'],
): Promise<TRequest['response']> {
const connections = await this.getConnectedCoreflowConnections();
if (connections.length === 0) {
throw new plugins.typedrequest.TypedResponseError('No connected coreflow');
}
let lastError: Error | undefined;
for (const connection of connections) {
try {
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<TRequest>(
methodArg,
connection,
);
const response = await request.fire(payloadArg);
if (response?.found) {
return response;
}
} catch (error) {
lastError = error as Error;
}
}
throw new plugins.typedrequest.TypedResponseError(
lastError?.message || 'No connected coreflow found the requested deployment',
);
}
private async getConnectedCoreflowConnections() {
const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket;
if (!typedsocket) {
return [];
}
return await typedsocket.findAllTargetConnections(async (connectionArg) => {
const identityTag = await connectionArg.getTagById('identity');
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined;
return identity?.role === 'cluster' && !!identity.userId;
});
}
}
@@ -1,6 +1,7 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { Deployment } from './classes.deployment.js';
import type { TCoreflowDeploymentWorkspaceMethod } from '../manager.coreflow/coreflowmanager.js';
export class DeploymentManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -71,6 +72,18 @@ export class DeploymentManager {
this.cloudlyRef.authManager.validIdentityGuard,
]);
const service = await this.cloudlyRef.serviceManager.CService.getInstance({
id: reqArg.serviceId,
});
if (service) {
const runtimeDeployments = await this.cloudlyRef.coreflowManager.getRuntimeDeploymentsForService(
await service.createSavableObject(),
);
if (runtimeDeployments.length > 0) {
return { deployments: runtimeDeployments };
}
}
const deployments = await this.CDeployment.getInstances({
serviceId: reqArg.serviceId,
});
@@ -204,29 +217,41 @@ export class DeploymentManager {
'restartDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const deployment = await this.CDeployment.getInstance({
id: reqArg.deploymentId,
});
if (!deployment) {
throw new Error('Deployment not found');
}
// TODO: Implement actual restart logic with Docker/container runtime
deployment.status = 'starting';
await deployment.save();
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
'coreflowRestartDeployment',
reqArg.deploymentId,
);
return {
success: true,
deployment: await deployment.createSavableObject(),
deployment: result.deployment,
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_KillDeployment>(
'killDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
'coreflowKillDeployment',
reqArg.deploymentId,
);
return {
success: true,
deployment: result.deployment,
};
},
),
);
// Scale deployment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_ScaleDeployment>(
@@ -254,6 +279,31 @@ export class DeploymentManager {
}
)
);
const addDeploymentWorkspaceHandler = (methodArg: string, coreflowMethodArg: TCoreflowDeploymentWorkspaceMethod) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const { identity: _identity, ...payload } = reqArg;
const response = await this.cloudlyRef.coreflowManager.fireDeploymentWorkspaceRequest(
coreflowMethodArg,
payload,
);
const { found: _found, ...publicResponse } = response;
return publicResponse;
}),
);
};
addDeploymentWorkspaceHandler('deploymentWorkspaceReadFile', 'coreflowDeploymentWorkspaceReadFile');
addDeploymentWorkspaceHandler('deploymentWorkspaceWriteFile', 'coreflowDeploymentWorkspaceWriteFile');
addDeploymentWorkspaceHandler('deploymentWorkspaceReadDir', 'coreflowDeploymentWorkspaceReadDir');
addDeploymentWorkspaceHandler('deploymentWorkspaceMkdir', 'coreflowDeploymentWorkspaceMkdir');
addDeploymentWorkspaceHandler('deploymentWorkspaceRm', 'coreflowDeploymentWorkspaceRm');
addDeploymentWorkspaceHandler('deploymentWorkspaceExists', 'coreflowDeploymentWorkspaceExists');
addDeploymentWorkspaceHandler('deploymentWorkspaceExec', 'coreflowDeploymentWorkspaceExec');
}
/**
+43
View File
@@ -0,0 +1,43 @@
import * as plugins from '../plugins.js';
export interface IJumpCodeData {
clusterId: string;
createdBy: string;
role: plugins.servezoneInterfaces.data.IClusterNode['data']['role'];
nodeType: plugins.servezoneInterfaces.data.IClusterNode['data']['nodeType'];
createdAt: number;
expiresAt: number;
consumedAt?: number;
consumedByNodeId?: string;
}
export interface IJumpCodePublic {
id: string;
data: IJumpCodeData;
}
@plugins.smartdata.Manager()
export class JumpCode extends plugins.smartdata.SmartDataDbDoc<JumpCode, IJumpCodePublic> {
constructor(optionsArg?: IJumpCodePublic & { tokenHash?: string }) {
super();
if (optionsArg) {
Object.assign(this, optionsArg);
}
}
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public tokenHash!: string;
@plugins.smartdata.svDb()
public data!: IJumpCodeData;
public toPublicObject(): IJumpCodePublic {
return {
id: this.id,
data: this.data,
};
}
}
+373
View File
@@ -0,0 +1,373 @@
import * as plugins from '../plugins.js';
import type { Cloudly } from '../classes.cloudly.js';
import { logger } from '../logger.js';
import { JumpCode } from './classes.jumpcode.js';
type IReqCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['request'];
type IResCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['response'];
interface IClaimJumpCodeRequest {
jumpCode?: string;
hostname?: string;
}
interface IClaimJumpCodeResponse {
accepted: boolean;
message?: string;
nodeId?: string;
cloudlyUrl?: string;
coreflowJumpCode?: string;
}
export class CloudlyJumpManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public CJumpCode = plugins.smartdata.setDefaultManagerForDoc(this, JumpCode);
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
private defaultTtlMs = 1000 * 60 * 30;
private maxTtlMs = 1000 * 60 * 60 * 24;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand>('createNodeJumpCommand', async (requestDataArg) => {
await plugins.smartguard.passGuardsOrReject(
{ identity: requestDataArg.identity },
[this.cloudlyRef.authManager.adminIdentityGuard],
);
return await this.createNodeJumpCommand(requestDataArg);
}),
);
}
public async start() {
logger.log('info', 'Jump manager started');
}
public async stop() {
logger.log('info', 'Jump manager stopped');
}
public async createNodeJumpCommand(optionsArg: IReqCreateNodeJumpCommand): Promise<IResCreateNodeJumpCommand> {
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
id: optionsArg.clusterId,
});
if (!cluster) {
throw new plugins.typedrequest.TypedResponseError(`Cluster ${optionsArg.clusterId} not found`);
}
const now = Date.now();
const ttlMs = this.normalizeTtl(optionsArg.ttlMs);
const jumpCode = this.createJumpCode();
const jumpCodeDoc = new this.CJumpCode({
id: await this.CJumpCode.getNewId(),
tokenHash: this.hashSecret(jumpCode),
data: {
clusterId: cluster.id,
createdBy: optionsArg.identity.userId,
role: optionsArg.role || 'worker',
nodeType: optionsArg.nodeType || 'baremetal',
createdAt: now,
expiresAt: now + ttlMs,
},
});
await jumpCodeDoc.save();
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCode)}`;
const setupUrl = `${jumpUrl}/setup.sh`;
return {
jumpCode,
jumpUrl,
setupUrl,
command: `curl -fsSL '${jumpUrl}' | sudo bash`,
expiresAt: jumpCodeDoc.data.expiresAt,
};
}
public async handleJumpHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
const jumpCode = this.getCodeFromContext(ctxArg);
if (this.shouldRenderHtml(ctxArg)) {
return await this.createLandingPageResponse(jumpCode);
}
return await this.createSetupScriptResponse(jumpCode);
}
public async handleSetupScriptHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
return await this.createSetupScriptResponse(this.getCodeFromContext(ctxArg));
}
public async handleClaimHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
try {
const requestData = await this.readJsonBody<IClaimJumpCodeRequest>(ctxArg);
const response = await this.claimJumpCode(requestData);
return this.createJsonResponse(200, response);
} catch (error) {
return this.createJsonResponse(400, {
accepted: false,
message: (error as Error).message,
} satisfies IClaimJumpCodeResponse);
}
}
public async claimJumpCode(requestDataArg: IClaimJumpCodeRequest): Promise<IClaimJumpCodeResponse> {
if (!requestDataArg.jumpCode) {
throw new Error('Jump code is missing');
}
const jumpCodeDoc = await this.getJumpCodeByCode(requestDataArg.jumpCode);
if (!jumpCodeDoc) {
throw new Error('Jump code is invalid');
}
if (jumpCodeDoc.data.consumedAt) {
throw new Error('Jump code has already been used');
}
if (jumpCodeDoc.data.expiresAt <= Date.now()) {
throw new Error('Jump code has expired');
}
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
id: jumpCodeDoc.data.clusterId,
});
if (!cluster) {
throw new Error('Jump code references a missing cluster');
}
const clusterUser = await this.cloudlyRef.authManager.CUser.getInstance({
id: cluster.data.userId,
});
const coreflowJumpCode = clusterUser?.data.tokens?.find((tokenArg) => tokenArg.expiresAt > Date.now())?.token;
if (!coreflowJumpCode) {
throw new Error('Cluster runtime token is missing or expired');
}
const nodeId = plugins.smartunique.shortId(8);
const now = Date.now();
const node = new this.cloudlyRef.nodeManager.CClusterNode();
node.id = nodeId;
node.data = {
clusterId: cluster.id,
nodeType: jumpCodeDoc.data.nodeType,
status: 'initializing',
role: jumpCodeDoc.data.role,
joinedAt: now,
lastHealthCheck: now,
sshKeys: [],
requiredDebianPackages: [],
};
await node.save();
cluster.data.nodes = [
...(cluster.data.nodes || []).filter((nodeArg) => nodeArg.id !== node.id),
await node.createSavableObject(),
];
await cluster.save();
jumpCodeDoc.data = {
...jumpCodeDoc.data,
consumedAt: now,
consumedByNodeId: node.id,
};
await jumpCodeDoc.save();
return {
accepted: true,
nodeId: node.id,
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
coreflowJumpCode,
};
}
private async createLandingPageResponse(jumpCodeArg: string) {
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
let clusterName = 'Unknown cluster';
let isUsable = false;
if (jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now()) {
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
id: jumpCodeDoc.data.clusterId,
});
clusterName = cluster?.data.name || jumpCodeDoc.data.clusterId;
isUsable = true;
}
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCodeArg)}`;
const command = `curl -fsSL '${jumpUrl}' | sudo bash`;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloudly Jump</title>
<style>
body { margin: 0; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1117; color: #f0f6fc; }
main { max-width: 760px; margin: 10vh auto; padding: 32px; }
.card { border: 1px solid #30363d; border-radius: 18px; background: #161b22; padding: 28px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
.label { color: #8b949e; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; }
h1 { margin: 8px 0 12px; font-size: 34px; }
p { color: #c9d1d9; line-height: 1.55; }
pre { white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787; }
.status { display: inline-block; margin-top: 16px; padding: 6px 10px; border-radius: 999px; background: ${isUsable ? '#17391f' : '#3d1d1d'}; color: ${isUsable ? '#7ee787' : '#ff7b72'}; }
</style>
</head>
<body>
<main>
<div class="card">
<div class="label">Cloudly Jump</div>
<h1>Connect System</h1>
<p>Cluster: <strong>${this.escapeHtml(clusterName)}</strong></p>
<p>Run this command on the Linux system you want to connect:</p>
<pre>${this.escapeHtml(command)}</pre>
<div class="status">${isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used'}</div>
</div>
</main>
</body>
</html>`;
return new Response(html, {
status: isUsable ? 200 : 404,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
private async createSetupScriptResponse(jumpCodeArg: string) {
if (!jumpCodeArg || !(await this.isJumpCodeUsable(jumpCodeArg))) {
return new Response('jump code is invalid, expired, or already used\n', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
return new Response(this.createSetupScript(jumpCodeArg), {
headers: {
'Content-Type': 'application/x-sh; charset=utf-8',
},
});
}
private createSetupScript(jumpCodeArg: string) {
const claimUrl = `${this.getPublicCloudlyUrl()}/jump/v1/claim`;
return `#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Cloudly jump setup must run as root. Re-run with sudo." >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
export JUMP_CODE='${this.escapeShellValue(jumpCodeArg)}'
export CLAIM_URL='${this.escapeShellValue(claimUrl)}'
echo "Preparing system for Cloudly jump..."
apt-get update
apt-get install -y --force-yes curl ca-certificates git
if ! command -v docker >/dev/null 2>&1; then
curl -sSL https://get.docker.com/ | sh
fi
if ! command -v node >/dev/null 2>&1; then
curl -sL https://deb.nodesource.com/setup_18.x | bash
apt-get install -y --force-yes nodejs
fi
if ! command -v pnpm >/dev/null 2>&1; then
curl -fsSL https://get.pnpm.io/install.sh | sh -
fi
export PNPM_HOME="\${PNPM_HOME:-/root/.local/share/pnpm}"
export PATH="\${PNPM_HOME}:\${PATH}"
pnpm install -g @serve.zone/spark
REQUEST_BODY="$(node -e 'process.stdout.write(JSON.stringify({ jumpCode: process.env.JUMP_CODE, hostname: require("os").hostname() }))')"
CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: application/json' --data "\${REQUEST_BODY}")"
export CLAIM_RESPONSE
CLOUDLY_URL="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
COREFLOW_JUMPCODE="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}"
echo "Cloudly jump completed. This system is now connected."
`;
}
private async getJumpCodeByCode(jumpCodeArg: string) {
const jumpCodes = await this.CJumpCode.getInstances({
tokenHash: this.hashSecret(jumpCodeArg),
});
return jumpCodes[0] || null;
}
private async isJumpCodeUsable(jumpCodeArg: string) {
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
return Boolean(jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now());
}
private getCodeFromContext(ctxArg: plugins.typedserver.IRequestContext) {
return ctxArg.params.code || ctxArg.url.pathname.split('/').filter(Boolean)[1] || '';
}
private shouldRenderHtml(ctxArg: plugins.typedserver.IRequestContext) {
const acceptHeader = ctxArg.headers.get('accept') || '';
const userAgent = ctxArg.headers.get('user-agent') || '';
return acceptHeader.includes('text/html') && !/(curl|wget|httpie|fetch)/i.test(userAgent);
}
private createJumpCode() {
return plugins.crypto.randomBytes(12).toString('base64url');
}
private normalizeTtl(ttlMsArg?: number) {
if (!ttlMsArg || !Number.isFinite(ttlMsArg)) {
return this.defaultTtlMs;
}
return Math.min(Math.max(ttlMsArg, 1000 * 60), this.maxTtlMs);
}
private hashSecret(secretArg: string) {
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
}
private getPublicCloudlyUrl() {
const sslMode = this.cloudlyRef.config.data.sslMode;
const protocol = sslMode === 'none' ? 'http' : 'https';
const port = String(this.cloudlyRef.config.data.publicPort || (protocol === 'https' ? '443' : '80'));
const includePort = !((protocol === 'https' && port === '443') || (protocol === 'http' && port === '80'));
return `${protocol}://${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${port}` : ''}`;
}
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
const bodyString = (await ctxArg.text()).trim();
return bodyString ? JSON.parse(bodyString) as T : {} as T;
}
private createJsonResponse(statusCodeArg: number, bodyArg: object): Response {
return new Response(JSON.stringify(bodyArg), {
status: statusCodeArg,
headers: {
'Content-Type': 'application/json',
},
});
}
private escapeHtml(valueArg: string) {
return valueArg
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
private escapeShellValue(valueArg: string) {
return valueArg.replaceAll("'", "'\\''");
}
}