From 3080075811731403f736352294936501a92eb8f8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 28 Apr 2026 12:18:12 +0000 Subject: [PATCH] feat: add platform desired state manager --- package.json | 4 +- pnpm-lock.yaml | 28 +-- test/test.apiclient.ts | 54 +++++ ts/classes.cloudly.ts | 5 + ts/manager.coreflow/coreflowmanager.ts | 6 +- .../classes.platformbinding.ts | 68 ++++++ .../classes.platformmanager.ts | 228 ++++++++++++++++++ .../classes.platformproviderconfig.ts | 47 ++++ 8 files changed, 422 insertions(+), 18 deletions(-) create mode 100644 ts/manager.platform/classes.platformbinding.ts create mode 100644 ts/manager.platform/classes.platformmanager.ts create mode 100644 ts/manager.platform/classes.platformproviderconfig.ts diff --git a/package.json b/package.json index 1479393..f11315a 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "@push.rocks/smartunique": "^3.0.9", "@push.rocks/taskbuffer": "^3.4.0", "@push.rocks/webjwt": "^1.0.9", - "@serve.zone/api": "^5.3.1", - "@serve.zone/interfaces": "^5.4.3", + "@serve.zone/api": "^5.3.2", + "@serve.zone/interfaces": "^5.4.5", "@tsclass/tsclass": "^9.2.0" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a06eba..98b3a4e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,11 +135,11 @@ importers: specifier: ^1.0.9 version: 1.0.9 '@serve.zone/api': - specifier: ^5.3.1 - version: 5.3.1 + specifier: ^5.3.2 + version: 5.3.2 '@serve.zone/interfaces': - specifier: ^5.4.3 - version: 5.4.3 + specifier: ^5.4.5 + version: 5.4.5 '@tsclass/tsclass': specifier: ^9.2.0 version: 9.2.0 @@ -1950,11 +1950,11 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - '@serve.zone/api@5.3.1': - resolution: {integrity: sha512-P6f3VWr2ljM8dwEtWYBROSZVtcW1HMc5oiorOCcvDeWY6roJbZobK6UFDlcdop02TxGEneJD+jVKoCwBoBLJVw==} + '@serve.zone/api@5.3.2': + resolution: {integrity: sha512-ETQ4KSNfhDP7O1WxXXLcMn/A+jZtDfd7FjuQ0k3n8tnXG9hExh8ZmqvMwVj8eT2CnXO+xQVlbAgT0HLMLnxCfA==} - '@serve.zone/interfaces@5.4.3': - resolution: {integrity: sha512-9ijFhHoC7GYyyAUJbBoDYmcoCmIXTFPiD6fI3x68SWiC0xA+2LG0nOe14D32c1QN9X/3i2Ac5/1sUibfjHsIGg==} + '@serve.zone/interfaces@5.4.5': + resolution: {integrity: sha512-asqUUjem3MGfIbseovHR8SxE+6FvjeQEYtV+PxcyY8YRXJ/vE3hNCDs7ePXgBbh4JXa+vNMaXHsFfz5Vrk6Ggg==} '@shikijs/engine-oniguruma@3.12.2': resolution: {integrity: sha512-hozwnFHsLvujK4/CPVHNo3Bcg2EsnG8krI/ZQ2FlBlCRpPZW4XAEQmEwqegJsypsTAN9ehu2tEYe30lYKSZW/w==} @@ -7150,10 +7150,8 @@ snapshots: transitivePeerDependencies: - '@nuxt/kit' - '@swc/helpers' - - bufferutil - react - supports-color - - utf-8-validate - vue '@happy-dom/global-registrator@15.11.7': @@ -9034,7 +9032,7 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} - '@serve.zone/api@5.3.1': + '@serve.zone/api@5.3.2': dependencies: '@api.global/typedrequest': 3.1.10 '@api.global/typedrequest-interfaces': 3.0.19 @@ -9043,14 +9041,14 @@ snapshots: '@push.rocks/smartpromise': 4.2.3 '@push.rocks/smartrx': 3.0.10 '@push.rocks/smartstream': 3.2.5 - '@serve.zone/interfaces': 5.4.3 - '@tsclass/tsclass': 9.2.0 + '@serve.zone/interfaces': 5.4.5 + '@tsclass/tsclass': 9.5.0 - '@serve.zone/interfaces@5.4.3': + '@serve.zone/interfaces@5.4.5': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartlog-interfaces': 3.0.2 - '@tsclass/tsclass': 9.2.0 + '@tsclass/tsclass': 9.5.0 '@shikijs/engine-oniguruma@3.12.2': dependencies: diff --git a/test/test.apiclient.ts b/test/test.apiclient.ts index d09f222..ff57ed5 100644 --- a/test/test.apiclient.ts +++ b/test/test.apiclient.ts @@ -84,6 +84,60 @@ tap.test('should get an identity', async () => { } }); +tap.test('should expose platform desired state', async () => { + const capabilitiesResponse = await testClient.platform.getPlatformCapabilities(); + expect(capabilitiesResponse.capabilities.find((capability) => capability.id === 'database')).toBeTruthy(); + + const desiredState = await testClient.platform.getPlatformDesiredState(); + expect(desiredState.capabilities).toBeTruthy(); + expect(desiredState.providerConfigs).toBeTruthy(); + expect(desiredState.bindings).toBeTruthy(); +}); + +let platformProviderConfigId: string; +let platformBindingId: string; +tap.test('should upsert platform provider config and binding', async () => { + const providerConfigResponse = await testClient.platform.upsertPlatformProviderConfig({ + id: '', + capability: 'database', + providerType: 'docker', + name: 'Local Docker Database', + enabled: true, + }); + platformProviderConfigId = providerConfigResponse.providerConfig.id; + expect(platformProviderConfigId).toBeTruthy(); + + const bindingResponse = await testClient.platform.upsertPlatformBinding({ + id: '', + serviceId: 'test-service', + capability: 'database', + desiredState: 'enabled', + status: 'requested', + providerConfigId: platformProviderConfigId, + }); + platformBindingId = bindingResponse.binding.id; + expect(platformBindingId).toBeTruthy(); + + const statusResponse = await testClient.platform.updatePlatformBindingStatus({ + bindingId: platformBindingId, + status: 'ready', + endpoints: [ + { + name: 'primary', + capability: 'database', + protocol: 'mongodb', + internalUrl: 'mongodb://platform-database:27017/test-service', + }, + ], + }); + expect(statusResponse.binding.status).toEqual('ready'); + + const bindingsResponse = await testClient.platform.getPlatformBindings({ + serviceId: 'test-service', + }); + expect(bindingsResponse.bindings.find((binding) => binding.id === platformBindingId)).toBeTruthy(); +}); + let image: any; tap.test('should create and upload an image', async () => { console.log('🔵 Test: Creating and uploading image...'); diff --git a/ts/classes.cloudly.ts b/ts/classes.cloudly.ts index 025c693..dc207cb 100644 --- a/ts/classes.cloudly.ts +++ b/ts/classes.cloudly.ts @@ -30,6 +30,7 @@ import { DomainManager } from './manager.domain/classes.domainmanager.js'; import { logger } from './logger.js'; import { CloudlyAuthManager } from './manager.auth/classes.authmanager.js'; import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js'; +import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js'; /** * Cloudly class can be used to instantiate a cloudly server. @@ -59,6 +60,7 @@ export class Cloudly { public authManager: CloudlyAuthManager; public secretManager: CloudlySecretManager; public settingsManager: CloudlySettingsManager; + public platformManager: CloudlyPlatformManager; public clusterManager: ClusterManager; public coreflowManager: CloudlyCoreflowManager; public externalApiManager: ExternalApiManager; @@ -92,6 +94,7 @@ export class Cloudly { // managers this.authManager = new CloudlyAuthManager(this); this.settingsManager = new CloudlySettingsManager(this); + this.platformManager = new CloudlyPlatformManager(this); this.clusterManager = new ClusterManager(this); this.coreflowManager = new CloudlyCoreflowManager(this); this.externalApiManager = new ExternalApiManager(this); @@ -127,6 +130,7 @@ export class Cloudly { await this.nodeManager.start(); await this.baremetalManager.start(); await this.serviceManager.start(); + await this.platformManager.start(); await this.deploymentManager.start(); await this.taskManager.init(); @@ -150,6 +154,7 @@ export class Cloudly { await this.mongodbConnector.stop(); await this.secretManager.stop(); await this.serviceManager.stop(); + await this.platformManager.stop(); await this.deploymentManager.stop(); await this.taskManager.stop(); await this.externalRegistryManager.stop(); diff --git a/ts/manager.coreflow/coreflowmanager.ts b/ts/manager.coreflow/coreflowmanager.ts index 70017bd..de3dff6 100644 --- a/ts/manager.coreflow/coreflowmanager.ts +++ b/ts/manager.coreflow/coreflowmanager.ts @@ -72,10 +72,14 @@ export class CloudlyCoreflowManager { console.log('trying to get clusterConfigSet'); console.log(dataArg); const cluster = await this.cloudlyRef.clusterManager.getClusterBy_Identity(identity); + const services = await this.cloudlyRef.serviceManager.CService.getInstances({}); + const platformDesiredState = await this.cloudlyRef.platformManager.getPlatformDesiredState(); console.log('got cluster config and sending it back to coreflow'); return { configData: await cluster.createSavableObject(), - services: [], + services: await Promise.all(services.map((service) => service.createSavableObject())), + platformProviderConfigs: platformDesiredState.providerConfigs, + platformBindings: platformDesiredState.bindings, }; } ) diff --git a/ts/manager.platform/classes.platformbinding.ts b/ts/manager.platform/classes.platformbinding.ts new file mode 100644 index 0000000..b30af25 --- /dev/null +++ b/ts/manager.platform/classes.platformbinding.ts @@ -0,0 +1,68 @@ +import * as plugins from '../plugins.js'; +import type { CloudlyPlatformManager } from './classes.platformmanager.js'; + +@plugins.smartdata.managed() +export class PlatformBinding extends plugins.smartdata.SmartDataDbDoc< + PlatformBinding, + plugins.servezoneInterfaces.platform.IPlatformBinding, + CloudlyPlatformManager +> { + public static async upsertBinding( + bindingArg: plugins.servezoneInterfaces.platform.IPlatformBinding, + ) { + const existingBinding = + bindingArg.id && + (await this.getInstance({ + id: bindingArg.id, + })); + const binding = existingBinding || new PlatformBinding(); + const timestamp = Date.now(); + + Object.assign(binding, { + ...bindingArg, + id: bindingArg.id || (await this.getNewId()), + status: bindingArg.status || 'requested', + desiredState: bindingArg.desiredState || 'enabled', + createdAt: bindingArg.createdAt || existingBinding?.createdAt || timestamp, + updatedAt: timestamp, + }); + await binding.save(); + return binding; + } + + @plugins.smartdata.unI() + public id!: string; + + @plugins.smartdata.svDb() + public serviceId!: string; + + @plugins.smartdata.svDb() + public capability!: plugins.servezoneInterfaces.platform.TPlatformCapability; + + @plugins.smartdata.svDb() + public desiredState!: plugins.servezoneInterfaces.platform.TPlatformDesiredState; + + @plugins.smartdata.svDb() + public status!: plugins.servezoneInterfaces.platform.TPlatformBindingStatus; + + @plugins.smartdata.svDb() + public providerConfigId?: string; + + @plugins.smartdata.svDb() + public config?: { [key: string]: plugins.servezoneInterfaces.platform.TPlatformConfigValue }; + + @plugins.smartdata.svDb() + public endpoints?: plugins.servezoneInterfaces.platform.IPlatformServiceEndpoint[]; + + @plugins.smartdata.svDb() + public credentials?: plugins.servezoneInterfaces.platform.IPlatformCredentialRef[]; + + @plugins.smartdata.svDb() + public createdAt?: number; + + @plugins.smartdata.svDb() + public updatedAt?: number; + + @plugins.smartdata.svDb() + public errorText?: string; +} diff --git a/ts/manager.platform/classes.platformmanager.ts b/ts/manager.platform/classes.platformmanager.ts new file mode 100644 index 0000000..462d54c --- /dev/null +++ b/ts/manager.platform/classes.platformmanager.ts @@ -0,0 +1,228 @@ +import type { Cloudly } from '../classes.cloudly.js'; +import * as plugins from '../plugins.js'; +import { PlatformBinding } from './classes.platformbinding.js'; +import { PlatformProviderConfig } from './classes.platformproviderconfig.js'; + +export class CloudlyPlatformManager { + public typedrouter = new plugins.typedrequest.TypedRouter(); + public cloudlyRef: Cloudly; + + public capabilities: plugins.servezoneInterfaces.platform.IPlatformCapability[] = [ + { id: 'email', title: 'Email', accessMode: 'rpc', defaultProviderType: 'cloudly' }, + { id: 'sms', title: 'SMS', accessMode: 'rpc', defaultProviderType: 'cloudly' }, + { id: 'pushnotification', title: 'Push Notifications', accessMode: 'rpc', defaultProviderType: 'cloudly' }, + { id: 'letter', title: 'Letters', accessMode: 'rpc', defaultProviderType: 'cloudly' }, + { id: 'ai', title: 'AI', accessMode: 'rpc', defaultProviderType: 'cloudly' }, + { id: 'database', title: 'Database', accessMode: 'binding', defaultProviderType: 'docker' }, + { id: 'objectstorage', title: 'Object Storage', accessMode: 'binding', defaultProviderType: 's3' }, + { id: 'logging', title: 'Logging', accessMode: 'sidecar', defaultProviderType: 'corelog' }, + { id: 'backup', title: 'Backup', accessMode: 'internal', defaultProviderType: 'corebackup' }, + { id: 'sip', title: 'SIP', accessMode: 'rpc', defaultProviderType: 'cloudly' }, + ]; + + get db() { + return this.cloudlyRef.mongodbConnector.smartdataDb; + } + + public CPlatformProviderConfig = plugins.smartdata.setDefaultManagerForDoc( + this, + PlatformProviderConfig, + ); + public CPlatformBinding = plugins.smartdata.setDefaultManagerForDoc(this, PlatformBinding); + + constructor(cloudlyRefArg: Cloudly) { + this.cloudlyRef = cloudlyRefArg; + this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPlatformDesiredState', + async (requestData) => { + await this.passValidIdentity(requestData); + return await this.getPlatformDesiredState(); + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPlatformCapabilities', + async (requestData) => { + await this.passValidIdentity(requestData); + return { + capabilities: this.capabilities, + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPlatformProviderConfigs', + async (requestData) => { + await this.passValidIdentity(requestData); + const query = requestData.capability ? { capability: requestData.capability } : {}; + return { + providerConfigs: await this.getProviderConfigs(query), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'upsertPlatformProviderConfig', + async (requestData) => { + await this.passAdminIdentity(requestData); + const providerConfig = await PlatformProviderConfig.upsertProviderConfig( + requestData.providerConfig, + ); + return { + providerConfig: await providerConfig.createSavableObject(), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deletePlatformProviderConfigById', + async (requestData) => { + await this.passAdminIdentity(requestData); + const providerConfig = await PlatformProviderConfig.getInstance({ + id: requestData.providerConfigId, + }); + if (providerConfig) { + await providerConfig.delete(); + } + return { + success: true, + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPlatformBindings', + async (requestData) => { + await this.passValidIdentity(requestData); + return { + bindings: await this.getBindings({ + ...(requestData.serviceId ? { serviceId: requestData.serviceId } : {}), + ...(requestData.capability ? { capability: requestData.capability } : {}), + }), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'upsertPlatformBinding', + async (requestData) => { + await this.passAdminIdentity(requestData); + const binding = await PlatformBinding.upsertBinding(requestData.binding); + return { + binding: await binding.createSavableObject(), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'updatePlatformBindingStatus', + async (requestData) => { + await this.passAdminOrClusterIdentity(requestData); + const binding = await PlatformBinding.getInstance({ + id: requestData.bindingId, + }); + if (!binding) { + throw new plugins.typedrequest.TypedResponseError( + `Platform binding ${requestData.bindingId} not found`, + ); + } + binding.status = requestData.status; + binding.updatedAt = Date.now(); + if (requestData.endpoints) { + binding.endpoints = requestData.endpoints; + } + if (requestData.credentials) { + binding.credentials = requestData.credentials; + } + if (requestData.errorText !== undefined) { + binding.errorText = requestData.errorText; + } + await binding.save(); + return { + binding: await binding.createSavableObject(), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deletePlatformBindingById', + async (requestData) => { + await this.passAdminIdentity(requestData); + const binding = await PlatformBinding.getInstance({ + id: requestData.bindingId, + }); + if (binding) { + await binding.delete(); + } + return { + success: true, + }; + }, + ), + ); + } + + public async start() {} + + public async stop() {} + + public async getPlatformDesiredState() { + return { + capabilities: this.capabilities, + providerConfigs: await this.getProviderConfigs(), + bindings: await this.getBindings(), + }; + } + + public async getProviderConfigs(queryArg: Record = {}) { + const providerConfigs = await this.CPlatformProviderConfig.getInstances(queryArg); + return await Promise.all( + providerConfigs.map((providerConfig) => providerConfig.createSavableObject()), + ); + } + + public async getBindings(queryArg: Record = {}) { + const bindings = await this.CPlatformBinding.getInstances(queryArg); + return await Promise.all(bindings.map((binding) => binding.createSavableObject())); + } + + private async passValidIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) { + await plugins.smartguard.passGuardsOrReject(requestData, [ + this.cloudlyRef.authManager.validIdentityGuard, + ]); + } + + private async passAdminIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) { + await plugins.smartguard.passGuardsOrReject(requestData, [ + this.cloudlyRef.authManager.adminIdentityGuard, + ]); + } + + private async passAdminOrClusterIdentity(requestData: { + identity: plugins.servezoneInterfaces.data.IIdentity; + }) { + await this.passValidIdentity(requestData); + if (requestData.identity.role !== 'admin' && requestData.identity.role !== 'cluster') { + throw new plugins.typedrequest.TypedResponseError('identity must be admin or cluster'); + } + } +} diff --git a/ts/manager.platform/classes.platformproviderconfig.ts b/ts/manager.platform/classes.platformproviderconfig.ts new file mode 100644 index 0000000..2f66f0a --- /dev/null +++ b/ts/manager.platform/classes.platformproviderconfig.ts @@ -0,0 +1,47 @@ +import * as plugins from '../plugins.js'; +import type { CloudlyPlatformManager } from './classes.platformmanager.js'; + +@plugins.smartdata.managed() +export class PlatformProviderConfig extends plugins.smartdata.SmartDataDbDoc< + PlatformProviderConfig, + plugins.servezoneInterfaces.platform.IPlatformProviderConfig, + CloudlyPlatformManager +> { + public static async upsertProviderConfig( + providerConfigArg: plugins.servezoneInterfaces.platform.IPlatformProviderConfig, + ) { + const providerConfig = + (providerConfigArg.id && + (await this.getInstance({ + id: providerConfigArg.id, + }))) || new PlatformProviderConfig(); + + Object.assign(providerConfig, { + ...providerConfigArg, + id: providerConfigArg.id || (await this.getNewId()), + }); + await providerConfig.save(); + return providerConfig; + } + + @plugins.smartdata.unI() + public id!: string; + + @plugins.smartdata.svDb() + public capability!: plugins.servezoneInterfaces.platform.TPlatformCapability; + + @plugins.smartdata.svDb() + public providerType!: string; + + @plugins.smartdata.svDb() + public name!: string; + + @plugins.smartdata.svDb() + public enabled!: boolean; + + @plugins.smartdata.svDb() + public config?: { [key: string]: plugins.servezoneInterfaces.platform.TPlatformConfigValue }; + + @plugins.smartdata.svDb() + public secretBundleId?: string; +}