diff --git a/.smartconfig.json b/.smartconfig.json index d3e786c..777787c 100644 --- a/.smartconfig.json +++ b/.smartconfig.json @@ -102,6 +102,10 @@ }, "release": { "targets": { + "git": { + "enabled": true, + "remote": "origin" + }, "docker": { "enabled": true, "engine": "tsdocker", diff --git a/changelog.md b/changelog.md index 8a0bd37..d2e6f97 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/package.json b/package.json index a310e30..160dbd5 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99dc2ed..7e890e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..64e70f8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -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 diff --git a/readme.md b/readme.md index 98de65b..35d1216 100644 --- a/readme.md +++ b/readme.md @@ -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/' | sudo bash +``` + +The public `/jump/` 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. diff --git a/test/test.apiclient.ts b/test/test.apiclient.ts index 4517d44..ba4a2dc 100644 --- a/test/test.apiclient.ts +++ b/test/test.apiclient.ts @@ -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('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/`, diff --git a/ts/classes.cloudly.ts b/ts/classes.cloudly.ts index 57d445e..f06426a 100644 --- a/ts/classes.cloudly.ts +++ b/ts/classes.cloudly.ts @@ -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(); } } diff --git a/ts/classes.server.ts b/ts/classes.server.ts index d4a4439..e658422 100644 --- a/ts/classes.server.ts +++ b/ts/classes.server.ts @@ -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', diff --git a/ts/manager.appcatalog/classes.appcatalogmanager.ts b/ts/manager.appcatalog/classes.appcatalogmanager.ts new file mode 100644 index 0000000..03cd674 --- /dev/null +++ b/ts/manager.appcatalog/classes.appcatalogmanager.ts @@ -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(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(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(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(methodArg, async (dataArg) => { + await this.passAdminIdentity(dataArg); + return { services: await this.getUpgradeableServices() }; + }), + ); + }; + addUpgradeableHandler('getUpgradeableAppCatalogServices'); + addUpgradeableHandler('getUpgradeableServices'); + } + + public async getCatalog(): Promise { + 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 { + return (await this.getCatalog()).apps; + } + + public async getAppMeta(appIdArg: string): Promise { + return await this.fetchJson(`apps/${appIdArg}/app.json`) as IAppMeta; + } + + public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise { + if (!versionArg) { + versionArg = (await this.getAppMeta(appIdArg)).latestVersion; + } + return await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as IAppVersionConfig; + } + + public async getUpgradeableServices(): Promise { + 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 { + 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 { + 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 { + 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): Record { + const envVars: Record = {}; + 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, 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 { + 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(); + } +} diff --git a/ts/manager.coreflow/coreflowmanager.ts b/ts/manager.coreflow/coreflowmanager.ts index fcbf4ca..247d8c2 100644 --- a/ts/manager.coreflow/coreflowmanager.ts +++ b/ts/manager.coreflow/coreflowmanager.ts @@ -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; + +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 { + 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( + '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(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['request'], + ) { + return await this.fireCoreflowRequestUntilFound(methodArg, payloadArg); + } + + private async fireCoreflowRequestUntilFound( + methodArg: TRequest['method'], + payloadArg: TRequest['request'], + ): Promise { + 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( + 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; + }); + } } diff --git a/ts/manager.deployment/classes.deploymentmanager.ts b/ts/manager.deployment/classes.deploymentmanager.ts index 8eb0564..389481d 100644 --- a/ts/manager.deployment/classes.deploymentmanager.ts +++ b/ts/manager.deployment/classes.deploymentmanager.ts @@ -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( + '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( @@ -254,6 +279,31 @@ export class DeploymentManager { } ) ); + + const addDeploymentWorkspaceHandler = (methodArg: string, coreflowMethodArg: TCoreflowDeploymentWorkspaceMethod) => { + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler(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'); } /** diff --git a/ts/manager.jump/classes.jumpcode.ts b/ts/manager.jump/classes.jumpcode.ts new file mode 100644 index 0000000..046147c --- /dev/null +++ b/ts/manager.jump/classes.jumpcode.ts @@ -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 { + 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, + }; + } +} diff --git a/ts/manager.jump/classes.jumpmanager.ts b/ts/manager.jump/classes.jumpmanager.ts new file mode 100644 index 0000000..1da98f6 --- /dev/null +++ b/ts/manager.jump/classes.jumpmanager.ts @@ -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('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 { + 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 { + 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 { + return await this.createSetupScriptResponse(this.getCodeFromContext(ctxArg)); + } + + public async handleClaimHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise { + try { + const requestData = await this.readJsonBody(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 { + 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 = ` + + + + + Cloudly Jump + + + +
+
+
Cloudly Jump
+

Connect System

+

Cluster: ${this.escapeHtml(clusterName)}

+

Run this command on the Linux system you want to connect:

+
${this.escapeHtml(command)}
+
${isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used'}
+
+
+ +`; + 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(ctxArg: plugins.typedserver.IRequestContext): Promise { + 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("'", "'\\''"); + } +} diff --git a/ts_web/elements/views/clusters/index.ts b/ts_web/elements/views/clusters/index.ts index 2cb3d96..35712d9 100644 --- a/ts_web/elements/views/clusters/index.ts +++ b/ts_web/elements/views/clusters/index.ts @@ -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` +
+
+ Connect a Linux system to ${clusterArg.data.name} by running this command as an administrator. +
+
${response.command}
+
+ Jump URL: ${response.jumpUrl}
+ Expires: ${new Date(response.expiresAt).toLocaleString()} +
+
+ `, + 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[]} > `; diff --git a/ts_web/elements/views/services/index.ts b/ts_web/elements/views/services/index.ts index f08ebde..af6251e 100644 --- a/ts_web/elements/views/services/index.ts +++ b/ts_web/elements/views/services/index.ts @@ -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`${strategy}`; } - 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` Services { return { - Name: itemArg.data.name, + Name: html``, 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 { > `; } + + private renderDetailView(): TemplateResult { + const service = this.selectedService; + if (!service) { + return html` + Service Details + + `; + } + + 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` + Service Details +
+
+

${service.data.name}

+
${service.data.description || 'No description configured'}
+
+ +
+ + ${this.upgradeInfo ? html` +
+
+
App catalog update available
+
${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}
+
+ +
+ ` : ''} + +
+
+
Running Deployments
+
${runningDeployments}/${desiredReplicas}
+
+
+
Image
+
${service.data.imageId}:${service.data.imageVersion}
+
+
+
Strategy
+
${service.data.deploymentStrategy}
+
+
+
Category
+
${service.data.serviceCategory}
+
+
+ +
+
+
+
Deployments
+
Container-level runtime actions happen here.
+
+ +
+ ${this.deploymentsLoading ? html`
Loading deployments...
` : html` + ({ + 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[]} + > + `} +
+ +
+
+
Service Configuration
+
+
Service ID${service.id}
+
Image ID${service.data.imageId}
+
Image Version${service.data.imageVersion}
+
Web Port${service.data.ports?.web || '-'}
+
Deploy on Push${service.data.deployOnPush === false ? 'disabled' : 'enabled'}
+
App Template${serviceData.appTemplateId ? `${serviceData.appTemplateId}@${serviceData.appTemplateVersion}` : '-'}
+
Registry Target${service.data.registryTarget?.imageUrl || '-'}
+
+
+
+
Routes, Volumes, Secrets
+
+
Domains${domains.length ? domains.map((domainArg) => domainArg.name).join(', ') : '-'}
+
Volumes${volumes.length ? volumes.map((volumeArg) => volumeArg.mountPath).join(', ') : '-'}
+
Secret Bundle${service.data.secretBundleId || '-'}
+
Extra Bundles${service.data.additionalSecretBundleIds?.length || 0}
+
Env Keys${Object.keys(service.data.environment || {}).join(', ') || '-'}
+
+
+
+ `; + } + + private renderWorkspaceView(): TemplateResult { + return html` + Deployment IDE +
+
+
+
${this.selectedService?.data.name || 'Deployment'} workspace
+
${this.workspaceDeployment?.containerId || this.workspaceDeployment?.id || ''}
+
+ +
+ ${this.workspaceEnvironment + ? html`` + : html`
Workspace is not available.
`} +
+ `; + } + + private renderStatusBadge(statusArg: string): TemplateResult { + return html`${statusArg || 'scheduled'}`; + } + + 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` +
+ This kills the running container for deployment ${deploymentArg.id}. + Docker Swarm may create a replacement task if the service still desires a replica. +
+ `, + 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) { + const identity = appstate.loginStatePart.getState()?.identity; + if (!identity) { + throw new Error('Not logged in'); + } + const typedRequest = new plugins.typedrequest.TypedRequest( + '/typedrequest', + methodArg, + ); + return await typedRequest.fire({ + identity, + ...dataArg, + }); + } } declare global { diff --git a/ts_web/environments/deployment-environment.ts b/ts_web/environments/deployment-environment.ts new file mode 100644 index 0000000..23f8705 --- /dev/null +++ b/ts_web/environments/deployment-environment.ts @@ -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; + response: Record; +}; + +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 { + 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 { + this.readyState = false; + } + + public async readFile(pathArg: string): Promise { + const result = await this.fireRequest('deploymentWorkspaceReadFile', { path: pathArg }) as { content: string }; + return result.content; + } + + public async writeFile(pathArg: string, contentsArg: string): Promise { + await this.fireRequest('deploymentWorkspaceWriteFile', { path: pathArg, content: contentsArg }); + } + + public async readDir(pathArg: string): Promise { + const result = await this.fireRequest('deploymentWorkspaceReadDir', { path: pathArg }) as { entries: IFileEntry[] }; + return result.entries; + } + + public async mkdir(pathArg: string): Promise { + await this.fireRequest('deploymentWorkspaceMkdir', { path: pathArg }); + } + + public async rm(pathArg: string, optionsArg?: { recursive?: boolean }): Promise { + await this.fireRequest('deploymentWorkspaceRm', { + path: pathArg, + recursive: optionsArg?.recursive, + }); + } + + public async exists(pathArg: string): Promise { + 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 { + const result = await this.fireRequest('deploymentWorkspaceExec', { + command: commandArg, + args: argsArg, + }) as { stdout?: string; stderr?: string; exitCode: number }; + + const output = new ReadableStream({ + start(controllerArg) { + if (result.stdout) controllerArg.enqueue(result.stdout); + if (result.stderr) controllerArg.enqueue(result.stderr); + controllerArg.close(); + }, + }); + + return { + output, + input: new WritableStream(), + exit: Promise.resolve(result.exitCode), + kill: () => {}, + }; + } + + private async fireRequest(methodArg: string, dataArg: Record) { + const typedRequest = new plugins.typedrequest.TypedRequest( + '/typedrequest', + methodArg, + ); + return await typedRequest.fire({ + identity: this.identity, + deploymentId: this.deploymentId, + ...dataArg, + }); + } +} diff --git a/ts_web/plugins.ts b/ts_web/plugins.ts index 4e22629..94a6816 100644 --- a/ts_web/plugins.ts +++ b/ts_web/plugins.ts @@ -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';