feat(cloudly): add service runtime and onboarding
This commit is contained in:
@@ -102,6 +102,10 @@
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker",
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Pending
|
||||
|
||||
### Features
|
||||
|
||||
- add service detail runtime actions and app catalog onboarding
|
||||
- Adds service detail pages with live deployments, restart, kill, and deployment IDE access
|
||||
- Adds app catalog install/update detection contracts and Cloudly handlers
|
||||
- Adds node jump codes for connecting systems to clusters
|
||||
- Updates Cloudly to pnpm 11 and @serve.zone/interfaces 5.9.0
|
||||
|
||||
## 2026-05-21 - 5.7.1
|
||||
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@
|
||||
"@push.rocks/taskbuffer": "^8.0.2",
|
||||
"@push.rocks/webjwt": "^1.0.10",
|
||||
"@serve.zone/api": "^5.3.7",
|
||||
"@serve.zone/interfaces": "^5.6.0",
|
||||
"@serve.zone/interfaces": "^5.9.0",
|
||||
"@tsclass/tsclass": "^9.5.1"
|
||||
},
|
||||
"files": [
|
||||
@@ -134,5 +134,5 @@
|
||||
"backend",
|
||||
"security"
|
||||
],
|
||||
"packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6"
|
||||
"packageManager": "pnpm@11.2.2"
|
||||
}
|
||||
|
||||
Generated
+11
-2
@@ -144,8 +144,8 @@ importers:
|
||||
specifier: ^5.3.7
|
||||
version: 5.3.7(@push.rocks/smartserve@2.0.4)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0
|
||||
specifier: ^5.9.0
|
||||
version: 5.9.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -1697,6 +1697,9 @@ packages:
|
||||
'@serve.zone/interfaces@5.6.0':
|
||||
resolution: {integrity: sha512-4ewYkGZU0rxWuCs32M/UtENWNzyPqpAad0YlFZ74h3IW1mKjryPMMHcEjdWqR5yArJAUMJR1oGk71XkGElNzDQ==}
|
||||
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
resolution: {integrity: sha512-XMXyTXTMcB8AX6zYOMO+Jt5bOv9ujyXj5miE6lrgyT8g+eJ/I6sUFqVNUKJ3LiMk/yFWsPln7HtZeZKDEhaCwQ==}
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||
|
||||
@@ -7175,6 +7178,12 @@ snapshots:
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
|
||||
'@shikijs/engine-oniguruma@3.23.0':
|
||||
dependencies:
|
||||
'@shikijs/types': 3.23.0
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
allowBuilds:
|
||||
'@design.estate/dees-catalog': false
|
||||
cpu-features: true
|
||||
esbuild: true
|
||||
mongodb-memory-server: false
|
||||
puppeteer: false
|
||||
sharp: false
|
||||
ssh2: true
|
||||
@@ -52,6 +52,7 @@ Cloudly currently coordinates these areas:
|
||||
| `CloudflareConnector` | Optional Cloudflare account used by ACME DNS-01 when `cloudflareToken` is configured in settings. |
|
||||
| `LetsencryptConnector` | SmartACME certificate issuance and certificate lookup. |
|
||||
| `CloudlyCoreflowManager` | Authenticates Coreflow, returns cluster config payloads, and pushes config updates to connected Coreflow clients. |
|
||||
| `CloudlyJumpManager` | Creates short-lived Jump Codes for onboarding existing systems into clusters. |
|
||||
| `CloudlyRegistryManager` | Embedded OCI registry backed by configured S3 storage, including deploy-on-push metadata updates. |
|
||||
| `CloudlyBaseOsManager` | BaseOS registration, heartbeat, image build orchestration, worker selection, and artifact downloads. |
|
||||
| `CloudlyBackupManager` | Service backup/restore orchestration and remote archive object replication. |
|
||||
@@ -194,6 +195,18 @@ The implemented cluster flow is intentionally simple:
|
||||
|
||||
When service, platform, or gateway settings change, Cloudly pushes updated config to connected Coreflow clients where supported.
|
||||
|
||||
### Jump Codes for Existing Systems
|
||||
|
||||
Admins can generate a short-lived, single-use Jump Code for a cluster. The dashboard displays a command in this form:
|
||||
|
||||
```sh
|
||||
curl -fsSL 'https://cloudly.example.com/jump/<code>' | sudo bash
|
||||
```
|
||||
|
||||
The public `/jump/<code>` URL renders a browser landing page for humans and a shell bootstrap script for `curl`/CLI clients. The script installs the required host tooling, claims the code through `POST /jump/v1/claim`, receives the cluster runtime token, and starts Spark in `coreflow-node` mode. The long-lived cluster token is never displayed in the dashboard command.
|
||||
|
||||
Jump Codes expire by default after 30 minutes and are consumed on first successful claim.
|
||||
|
||||
## Registry and Deploy-On-Push
|
||||
|
||||
Cloudly serves an OCI registry under `/v2` through `CloudlyRegistryManager`. The registry uses configured S3 storage and issues OCI tokens from Cloudly authentication state.
|
||||
|
||||
@@ -92,6 +92,58 @@ tap.test('should get an identity', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should create and consume node jump codes', async () => {
|
||||
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
|
||||
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
|
||||
const jumpCommand = await createJumpCommandTR.fire({
|
||||
identity: testClient.identity,
|
||||
clusterId: cluster.id,
|
||||
});
|
||||
|
||||
expect(jumpCommand.jumpUrl.includes('/jump/')).toBeTrue();
|
||||
expect(jumpCommand.command.includes(jumpCommand.jumpUrl)).toBeTrue();
|
||||
|
||||
const setupResponse = await fetch(jumpCommand.jumpUrl, {
|
||||
headers: {
|
||||
accept: '*/*',
|
||||
'user-agent': 'curl/8.0',
|
||||
},
|
||||
});
|
||||
const setupScript = await setupResponse.text();
|
||||
expect(setupResponse.status).toEqual(200);
|
||||
expect(setupScript.includes('spark installdaemon --mode=coreflow-node')).toBeTrue();
|
||||
|
||||
const claimResponse = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode, hostname: 'jump-code-test-node' }),
|
||||
},
|
||||
);
|
||||
const claimBody = await claimResponse.json();
|
||||
expect(claimResponse.status).toEqual(200);
|
||||
expect(claimBody.accepted).toBeTrue();
|
||||
expect(claimBody.nodeId).toBeTruthy();
|
||||
expect(claimBody.coreflowJumpCode).toBeTruthy();
|
||||
|
||||
const secondClaimResponse = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode }),
|
||||
},
|
||||
);
|
||||
const secondClaimBody = await secondClaimResponse.json();
|
||||
expect(secondClaimResponse.status).toEqual(400);
|
||||
expect(secondClaimBody.accepted).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('should expose the OCI registry endpoint', async () => {
|
||||
const response = await fetch(
|
||||
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/`,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
private escapeShellValue(valueArg: string) {
|
||||
return valueArg.replaceAll("'", "'\\''");
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,51 @@ export class CloudlyViewClusters extends DeesElement {
|
||||
this.rxSubscriptions.push(subecription);
|
||||
}
|
||||
|
||||
private async createJumpCommand(clusterArg: plugins.interfaces.data.ICluster) {
|
||||
const identity = appstate.loginStatePart.getState()?.identity;
|
||||
if (!identity) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Login required to create a jump code', type: 'error' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appstate.apiClient.identity = identity;
|
||||
const apiClient = appstate.apiClient as any;
|
||||
const response = apiClient.node?.createNodeJumpCommand
|
||||
? await apiClient.node.createNodeJumpCommand({ clusterId: clusterArg.id })
|
||||
: await apiClient.typedsocketClient
|
||||
.createTypedRequest('createNodeJumpCommand')
|
||||
.fire({ identity, clusterId: clusterArg.id });
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Connect System',
|
||||
content: html`
|
||||
<div style="display: grid; gap: 16px; min-width: min(680px, 80vw);">
|
||||
<div>
|
||||
Connect a Linux system to <strong>${clusterArg.data.name}</strong> by running this command as an administrator.
|
||||
</div>
|
||||
<pre style="white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787;">${response.command}</pre>
|
||||
<div style="color: #9aa4b2; font-size: 13px;">
|
||||
Jump URL: ${response.jumpUrl}<br>
|
||||
Expires: ${new Date(response.expiresAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{
|
||||
name: 'copy command',
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(response.command);
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: 'Jump command copied', type: 'success' });
|
||||
},
|
||||
},
|
||||
{ name: 'close', action: async (modalArg: any) => modalArg.destroy() },
|
||||
],
|
||||
});
|
||||
} catch (error: any) {
|
||||
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to create jump code: ${error.message}`, type: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
@@ -97,6 +142,14 @@ export class CloudlyViewClusters extends DeesElement {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'connect system',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.createJumpCommand(actionDataArg.item as plugins.interfaces.data.ICluster);
|
||||
},
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as plugins from '../../../plugins.js';
|
||||
import * as shared from '../../shared/index.js';
|
||||
import { DeploymentExecutionEnvironment } from '../../../environments/deployment-environment.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as appstate from '../../../appstate.js';
|
||||
@@ -17,6 +19,27 @@ export class CloudlyViewServices extends DeesElement {
|
||||
@state()
|
||||
private accessor data: appstate.IDataState = {} as any;
|
||||
|
||||
@state()
|
||||
private accessor currentView: 'list' | 'detail' | 'workspace' = 'list';
|
||||
|
||||
@state()
|
||||
private accessor selectedService: plugins.interfaces.data.IService | null = null;
|
||||
|
||||
@state()
|
||||
private accessor serviceDeployments: plugins.interfaces.data.IDeployment[] = [];
|
||||
|
||||
@state()
|
||||
private accessor deploymentsLoading = false;
|
||||
|
||||
@state()
|
||||
private accessor upgradeInfo: any = null;
|
||||
|
||||
@state()
|
||||
private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null;
|
||||
|
||||
@state()
|
||||
private accessor workspaceDeployment: any = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const subscription = appstate.dataState
|
||||
@@ -36,6 +59,33 @@ export class CloudlyViewServices extends DeesElement {
|
||||
.category-distributed { background: #9c27b0; color: white; }
|
||||
.category-workload { background: #4caf50; color: white; }
|
||||
.strategy-badge { padding: 2px 8px; border-radius: 4px; font-size: 0.85em; background: #444; color: #ccc; margin-left: 4px; }
|
||||
.link-button { border: none; background: transparent; color: var(--ci-color-primary, #60a5fa); cursor: pointer; padding: 0; font: inherit; }
|
||||
.detail-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.detail-title { margin: 0; font-size: 26px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); }
|
||||
.detail-subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; }
|
||||
.back-button, .primary-button, .danger-button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); }
|
||||
.primary-button { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; }
|
||||
.danger-button { color: #ef4444; border-color: rgba(239, 68, 68, 0.35); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 18px; }
|
||||
.summary-card, .detail-card, .update-card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; }
|
||||
.summary-label { font-size: 12px; color: var(--ci-shade-4, #71717a); margin-bottom: 6px; }
|
||||
.summary-value { font-size: 20px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
|
||||
.section-title { font-size: 14px; font-weight: 700; color: var(--ci-shade-7, #e4e4e7); margin-bottom: 10px; }
|
||||
.details-grid { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 14px; margin-top: 14px; }
|
||||
.kv-list { display: grid; gap: 8px; }
|
||||
.kv-row { display: grid; grid-template-columns: 150px 1fr; gap: 10px; font-size: 13px; }
|
||||
.kv-key { color: var(--ci-shade-4, #71717a); }
|
||||
.kv-value { color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere; }
|
||||
.status-badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||
.status-running { background: rgba(34, 197, 94, 0.16); color: #22c55e; }
|
||||
.status-starting, .status-scheduled { background: rgba(59, 130, 246, 0.16); color: #60a5fa; }
|
||||
.status-stopped { background: rgba(161, 161, 170, 0.16); color: #a1a1aa; }
|
||||
.status-failed { background: rgba(239, 68, 68, 0.16); color: #ef4444; }
|
||||
.update-card { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; border-color: rgba(59, 130, 246, 0.35); background: linear-gradient(135deg, rgba(59, 130, 246, 0.10), rgba(139, 92, 246, 0.10)); }
|
||||
.workspace-shell { display: grid; grid-template-rows: auto 1fr; height: calc(100vh - 120px); min-height: 560px; }
|
||||
.workspace-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
dees-workspace { min-height: 0; }
|
||||
@media (max-width: 900px) { .summary-grid, .details-grid { grid-template-columns: 1fr; } .detail-header { flex-direction: column; } }
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -57,7 +107,17 @@ export class CloudlyViewServices extends DeesElement {
|
||||
return html`<span class="strategy-badge">${strategy}</span>`;
|
||||
}
|
||||
|
||||
public render() {
|
||||
public render(): TemplateResult {
|
||||
if (this.currentView === 'workspace') {
|
||||
return this.renderWorkspaceView();
|
||||
}
|
||||
if (this.currentView === 'detail') {
|
||||
return this.renderDetailView();
|
||||
}
|
||||
return this.renderListView();
|
||||
}
|
||||
|
||||
private renderListView(): TemplateResult {
|
||||
return html`
|
||||
<cloudly-sectionheading>Services</cloudly-sectionheading>
|
||||
<dees-table
|
||||
@@ -66,7 +126,7 @@ export class CloudlyViewServices extends DeesElement {
|
||||
.data=${this.data.services || []}
|
||||
.displayFunction=${(itemArg: plugins.interfaces.data.IService) => {
|
||||
return {
|
||||
Name: itemArg.data.name,
|
||||
Name: html`<button class="link-button" @click=${() => this.openServiceDetail(itemArg)}>${itemArg.data.name}</button>`,
|
||||
Description: itemArg.data.description,
|
||||
Category: this.getCategoryBadgeHtml(itemArg.data.serviceCategory || 'workload'),
|
||||
'Deployment Strategy': html`
|
||||
@@ -81,6 +141,14 @@ export class CloudlyViewServices extends DeesElement {
|
||||
};
|
||||
}}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Details',
|
||||
iconName: 'eye',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openServiceDetail(actionDataArg.item as plugins.interfaces.data.IService);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Add Service',
|
||||
iconName: 'plus',
|
||||
@@ -216,6 +284,254 @@ export class CloudlyViewServices extends DeesElement {
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDetailView(): TemplateResult {
|
||||
const service = this.selectedService;
|
||||
if (!service) {
|
||||
return html`
|
||||
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
||||
`;
|
||||
}
|
||||
|
||||
const runningDeployments = this.serviceDeployments.filter((deploymentArg) => deploymentArg.status === 'running').length;
|
||||
const desiredReplicas = service.data.maxReplicas || service.data.scaleFactor || 1;
|
||||
const domains = service.data.domains || [];
|
||||
const volumes = service.data.volumes || [];
|
||||
const serviceData = service.data as plugins.interfaces.data.IService['data'] & {
|
||||
appTemplateId?: string;
|
||||
appTemplateVersion?: string;
|
||||
};
|
||||
|
||||
return html`
|
||||
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2 class="detail-title">${service.data.name}</h2>
|
||||
<div class="detail-subtitle">${service.data.description || 'No description configured'}</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
||||
</div>
|
||||
|
||||
${this.upgradeInfo ? html`
|
||||
<div class="update-card">
|
||||
<div>
|
||||
<div class="section-title" style="margin-bottom: 3px;">App catalog update available</div>
|
||||
<div class="detail-subtitle">${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}</div>
|
||||
</div>
|
||||
<button class="primary-button" disabled title="Cloudly does not yet have catalog upgrade apply support">Detected</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Running Deployments</div>
|
||||
<div class="summary-value">${runningDeployments}/${desiredReplicas}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Image</div>
|
||||
<div class="summary-value" style="font-size: 15px;">${service.data.imageId}:${service.data.imageVersion}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Strategy</div>
|
||||
<div class="summary-value" style="font-size: 16px;">${service.data.deploymentStrategy}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">Category</div>
|
||||
<div class="summary-value" style="font-size: 16px;">${service.data.serviceCategory}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-card">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px;">
|
||||
<div>
|
||||
<div class="section-title">Deployments</div>
|
||||
<div class="detail-subtitle">Container-level runtime actions happen here.</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => this.loadDeploymentsForService(service)}>Refresh</button>
|
||||
</div>
|
||||
${this.deploymentsLoading ? html`<div class="detail-subtitle">Loading deployments...</div>` : html`
|
||||
<dees-table
|
||||
.heading1=${'Live Deployments'}
|
||||
.heading2=${this.serviceDeployments.length ? 'Docker Swarm tasks reported by connected Coreflows' : 'No live deployments reported'}
|
||||
.data=${this.serviceDeployments}
|
||||
.displayFunction=${(deploymentArg: any) => ({
|
||||
Status: this.renderStatusBadge(deploymentArg.status),
|
||||
Node: deploymentArg.nodeName || deploymentArg.nodeId || '-',
|
||||
Slot: deploymentArg.slot || '-',
|
||||
Version: deploymentArg.version || service.data.imageVersion,
|
||||
Container: deploymentArg.containerId ? deploymentArg.containerId.slice(0, 12) : '-',
|
||||
CPU: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.cpuUsagePercent.toFixed(1)}%` : '-',
|
||||
Memory: deploymentArg.resourceUsage ? `${deploymentArg.resourceUsage.memoryUsedMB} MB` : '-',
|
||||
Updated: deploymentArg.updatedAt ? new Date(deploymentArg.updatedAt).toLocaleString() : '-',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Open IDE',
|
||||
iconName: 'terminal',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.openDeploymentWorkspace(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Restart',
|
||||
iconName: 'refresh-cw',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.restartDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Kill Container',
|
||||
iconName: 'skull',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
actionFunc: async (actionDataArg: any) => {
|
||||
await this.confirmKillDeployment(actionDataArg.item);
|
||||
},
|
||||
},
|
||||
] as plugins.deesCatalog.ITableAction[]}
|
||||
></dees-table>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Service Configuration</div>
|
||||
<div class="kv-list">
|
||||
<div class="kv-row"><span class="kv-key">Service ID</span><span class="kv-value">${service.id}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Image ID</span><span class="kv-value">${service.data.imageId}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Image Version</span><span class="kv-value">${service.data.imageVersion}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Web Port</span><span class="kv-value">${service.data.ports?.web || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Deploy on Push</span><span class="kv-value">${service.data.deployOnPush === false ? 'disabled' : 'enabled'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">App Template</span><span class="kv-value">${serviceData.appTemplateId ? `${serviceData.appTemplateId}@${serviceData.appTemplateVersion}` : '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Registry Target</span><span class="kv-value">${service.data.registryTarget?.imageUrl || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-card">
|
||||
<div class="section-title">Routes, Volumes, Secrets</div>
|
||||
<div class="kv-list">
|
||||
<div class="kv-row"><span class="kv-key">Domains</span><span class="kv-value">${domains.length ? domains.map((domainArg) => domainArg.name).join(', ') : '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Volumes</span><span class="kv-value">${volumes.length ? volumes.map((volumeArg) => volumeArg.mountPath).join(', ') : '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Secret Bundle</span><span class="kv-value">${service.data.secretBundleId || '-'}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Extra Bundles</span><span class="kv-value">${service.data.additionalSecretBundleIds?.length || 0}</span></div>
|
||||
<div class="kv-row"><span class="kv-key">Env Keys</span><span class="kv-value">${Object.keys(service.data.environment || {}).join(', ') || '-'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWorkspaceView(): TemplateResult {
|
||||
return html`
|
||||
<cloudly-sectionheading>Deployment IDE</cloudly-sectionheading>
|
||||
<div class="workspace-shell">
|
||||
<div class="workspace-toolbar">
|
||||
<div>
|
||||
<div class="section-title">${this.selectedService?.data.name || 'Deployment'} workspace</div>
|
||||
<div class="detail-subtitle">${this.workspaceDeployment?.containerId || this.workspaceDeployment?.id || ''}</div>
|
||||
</div>
|
||||
<button class="back-button" @click=${() => { this.currentView = 'detail'; }}>Back to Deployments</button>
|
||||
</div>
|
||||
${this.workspaceEnvironment
|
||||
? html`<dees-workspace .executionEnvironment=${this.workspaceEnvironment}></dees-workspace>`
|
||||
: html`<div class="detail-subtitle">Workspace is not available.</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderStatusBadge(statusArg: string): TemplateResult {
|
||||
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
|
||||
}
|
||||
|
||||
private async openServiceDetail(serviceArg: plugins.interfaces.data.IService) {
|
||||
this.selectedService = serviceArg;
|
||||
this.serviceDeployments = [];
|
||||
this.upgradeInfo = null;
|
||||
this.currentView = 'detail';
|
||||
await Promise.all([
|
||||
this.loadDeploymentsForService(serviceArg),
|
||||
this.loadUpgradeInfo(serviceArg),
|
||||
]);
|
||||
}
|
||||
|
||||
private async loadDeploymentsForService(serviceArg: plugins.interfaces.data.IService) {
|
||||
this.deploymentsLoading = true;
|
||||
try {
|
||||
const response = await this.fireTypedRequest('getDeploymentsByService', {
|
||||
serviceId: serviceArg.id,
|
||||
}) as { deployments: plugins.interfaces.data.IDeployment[] };
|
||||
this.serviceDeployments = response.deployments || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load service deployments:', error);
|
||||
this.serviceDeployments = [];
|
||||
} finally {
|
||||
this.deploymentsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
|
||||
try {
|
||||
const response = await this.fireTypedRequest('getUpgradeableServices', {}) as { services: any[] };
|
||||
this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null;
|
||||
} catch {
|
||||
this.upgradeInfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
|
||||
await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id });
|
||||
if (this.selectedService) {
|
||||
await this.loadDeploymentsForService(this.selectedService);
|
||||
}
|
||||
}
|
||||
|
||||
private async confirmKillDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
|
||||
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||
heading: 'Kill Deployment Container',
|
||||
content: html`
|
||||
<div style="text-align: center; max-width: 520px;">
|
||||
This kills the running container for deployment <strong>${deploymentArg.id}</strong>.
|
||||
Docker Swarm may create a replacement task if the service still desires a replica.
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||
{ name: 'Kill Container', action: async (modalArg: any) => {
|
||||
await this.fireTypedRequest('killDeployment', { deploymentId: deploymentArg.id });
|
||||
await modalArg.destroy();
|
||||
if (this.selectedService) {
|
||||
await this.loadDeploymentsForService(this.selectedService);
|
||||
}
|
||||
} },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async openDeploymentWorkspace(deploymentArg: any) {
|
||||
const identity = appstate.loginStatePart.getState()?.identity;
|
||||
if (!identity) return;
|
||||
const environment = new DeploymentExecutionEnvironment(deploymentArg.id, identity);
|
||||
await environment.init();
|
||||
this.workspaceDeployment = deploymentArg;
|
||||
this.workspaceEnvironment = environment;
|
||||
this.currentView = 'workspace';
|
||||
}
|
||||
|
||||
private async fireTypedRequest(methodArg: string, dataArg: Record<string, unknown>) {
|
||||
const identity = appstate.loginStatePart.getState()?.identity;
|
||||
if (!identity) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
|
||||
'/typedrequest',
|
||||
methodArg,
|
||||
);
|
||||
return await typedRequest.fire({
|
||||
identity,
|
||||
...dataArg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnvironment;
|
||||
type IFileEntry = import('@design.estate/dees-catalog').IFileEntry;
|
||||
type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher;
|
||||
type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle;
|
||||
|
||||
type TTypedRequestShape = {
|
||||
method: string;
|
||||
request: Record<string, unknown>;
|
||||
response: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export class DeploymentExecutionEnvironment implements IExecutionEnvironment {
|
||||
public readonly type = 'backend' as const;
|
||||
private readyState = false;
|
||||
|
||||
constructor(
|
||||
private deploymentId: string,
|
||||
private identity: plugins.interfaces.data.IIdentity,
|
||||
) {}
|
||||
|
||||
get ready(): boolean {
|
||||
return this.readyState;
|
||||
}
|
||||
|
||||
public async init(): Promise<void> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceExists', { path: '/' }) as { exists: boolean };
|
||||
if (!result.exists) {
|
||||
throw new Error(`Cannot access deployment filesystem for ${this.deploymentId}`);
|
||||
}
|
||||
this.readyState = true;
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
this.readyState = false;
|
||||
}
|
||||
|
||||
public async readFile(pathArg: string): Promise<string> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceReadFile', { path: pathArg }) as { content: string };
|
||||
return result.content;
|
||||
}
|
||||
|
||||
public async writeFile(pathArg: string, contentsArg: string): Promise<void> {
|
||||
await this.fireRequest('deploymentWorkspaceWriteFile', { path: pathArg, content: contentsArg });
|
||||
}
|
||||
|
||||
public async readDir(pathArg: string): Promise<IFileEntry[]> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceReadDir', { path: pathArg }) as { entries: IFileEntry[] };
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
public async mkdir(pathArg: string): Promise<void> {
|
||||
await this.fireRequest('deploymentWorkspaceMkdir', { path: pathArg });
|
||||
}
|
||||
|
||||
public async rm(pathArg: string, optionsArg?: { recursive?: boolean }): Promise<void> {
|
||||
await this.fireRequest('deploymentWorkspaceRm', {
|
||||
path: pathArg,
|
||||
recursive: optionsArg?.recursive,
|
||||
});
|
||||
}
|
||||
|
||||
public async exists(pathArg: string): Promise<boolean> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceExists', { path: pathArg }) as { exists: boolean };
|
||||
return result.exists;
|
||||
}
|
||||
|
||||
public watch(
|
||||
_pathArg: string,
|
||||
_callbackArg: (eventArg: 'rename' | 'change', filenameArg: string | null) => void,
|
||||
_optionsArg?: { recursive?: boolean },
|
||||
): IFileWatcher {
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
public async spawn(commandArg: string, argsArg: string[] = []): Promise<IProcessHandle> {
|
||||
const result = await this.fireRequest('deploymentWorkspaceExec', {
|
||||
command: commandArg,
|
||||
args: argsArg,
|
||||
}) as { stdout?: string; stderr?: string; exitCode: number };
|
||||
|
||||
const output = new ReadableStream<string>({
|
||||
start(controllerArg) {
|
||||
if (result.stdout) controllerArg.enqueue(result.stdout);
|
||||
if (result.stderr) controllerArg.enqueue(result.stderr);
|
||||
controllerArg.close();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
output,
|
||||
input: new WritableStream<string>(),
|
||||
exit: Promise.resolve(result.exitCode),
|
||||
kill: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
private async fireRequest(methodArg: string, dataArg: Record<string, unknown>) {
|
||||
const typedRequest = new plugins.typedrequest.TypedRequest<TTypedRequestShape>(
|
||||
'/typedrequest',
|
||||
methodArg,
|
||||
);
|
||||
return await typedRequest.fire({
|
||||
identity: this.identity,
|
||||
deploymentId: this.deploymentId,
|
||||
...dataArg,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
export { deesDomtools, deesElement, deesCatalog };
|
||||
|
||||
// @api.global scope
|
||||
import * as typedrequest from '@api.global/typedrequest';
|
||||
|
||||
export { typedrequest };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as webjwt from '@push.rocks/webjwt';
|
||||
import * as smartstate from '@push.rocks/smartstate';
|
||||
|
||||
Reference in New Issue
Block a user