From 8eea6c36eadf44a934d05cea00f1ce59d47211f7 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sat, 2 May 2026 15:01:41 +0000 Subject: [PATCH] feat: provision corestore bindings --- ts/coreflow.classes.clustermanager.ts | 143 ++++++++++++++++++++++-- ts/coreflow.classes.platformmanager.ts | 146 +++++++++++++++++++++++++ 2 files changed, 279 insertions(+), 10 deletions(-) diff --git a/ts/coreflow.classes.clustermanager.ts b/ts/coreflow.classes.clustermanager.ts index 48be5ec..a6722de 100644 --- a/ts/coreflow.classes.clustermanager.ts +++ b/ts/coreflow.classes.clustermanager.ts @@ -2,6 +2,7 @@ import * as plugins from './coreflow.plugins.js'; import { logger } from './coreflow.logging.js'; import { Coreflow } from './coreflow.classes.coreflow.js'; import type { IExternalGatewayConfig } from './coreflow.connector.externalgateway.js'; +import * as crypto from 'node:crypto'; export class ClusterManager { public coreflowRef: Coreflow; @@ -49,6 +50,7 @@ export class ClusterManager { private getWorkloadServiceDeploymentLabels( serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService, containerImageFromCloudly: plugins.servezoneInterfaces.data.IImage, + secretHashArg = '', ) { const desiredImageVersion = serviceArgFromCloudly.data.imageVersion || @@ -69,9 +71,27 @@ export class ClusterManager { 'serve.zone.imageVersion': desiredImageVersion, 'serve.zone.registryImageUrl': serviceArgFromCloudly.data.registryTarget?.imageUrl || '', 'serve.zone.registryDigest': desiredRegistryDigest || '', + 'serve.zone.secretHash': secretHashArg, }; } + private stableStringify(valueArg: unknown): string { + if (Array.isArray(valueArg)) { + return `[${valueArg.map((itemArg) => this.stableStringify(itemArg)).join(',')}]`; + } + if (valueArg && typeof valueArg === 'object') { + return `{${Object.keys(valueArg as Record) + .sort() + .map((keyArg) => `${JSON.stringify(keyArg)}:${this.stableStringify((valueArg as Record)[keyArg])}`) + .join(',')}}`; + } + return JSON.stringify(valueArg); + } + + private hashSecretObject(secretObjectArg: Record) { + return crypto.createHash('sha256').update(this.stableStringify(secretObjectArg)).digest('hex'); + } + private async pullRegistryTargetImage( registryTargetArg: plugins.servezoneInterfaces.data.IRegistryTarget, ): Promise { @@ -103,6 +123,70 @@ export class ClusterManager { return localDockerImage; } + private async createCorestoreGlobalService( + corestoreImageArg: plugins.docker.DockerImage, + networksArg: Array, + ) { + const corestoreImage = corestoreImageArg as unknown as { + RepoTags?: string[]; + Labels?: Record; + }; + const imageRef = corestoreImage.RepoTags?.[0] || 'code.foss.global/serve.zone/corestore:latest'; + const corestoreEnv = [ + 'CORESTORE_DATA_DIR=/data/corestore', + 'CORESTORE_PUBLIC_HOST=corestore', + ...(process.env.CORESTORE_API_TOKEN + ? [`CORESTORE_API_TOKEN=${process.env.CORESTORE_API_TOKEN}`] + : []), + ]; + const response = await this.coreflowRef.dockerHost.request('POST', '/services/create', { + Name: 'corestore', + Labels: { + version: corestoreImage.Labels?.version || '', + 'serve.zone.serviceCategory': 'base', + 'serve.zone.provides': 'database,objectstorage', + }, + TaskTemplate: { + ContainerSpec: { + Image: imageRef, + Labels: { + 'serve.zone.serviceCategory': 'base', + 'serve.zone.provides': 'database,objectstorage', + }, + Env: corestoreEnv, + Mounts: [ + { + Target: '/data/corestore', + Source: '/var/lib/serve.zone/corestore', + Type: 'bind', + ReadOnly: false, + Consistency: 'default', + }, + ], + }, + Networks: networksArg.map((networkArg) => ({ + Target: networkArg.Name, + Aliases: ['corestore'], + })), + RestartPolicy: { + Condition: 'any', + }, + Resources: { + Limits: { + MemoryBytes: 700 * 1000000, + }, + }, + }, + Mode: { + Global: {}, + }, + }); + if (response.statusCode >= 300) { + throw new Error(`Failed to create corestore service: ${JSON.stringify(response.body)}`); + } + return this.getDockerServiceByName('corestore'); + } + /** * starts the cluster manager */ @@ -181,6 +265,10 @@ export class ClusterManager { imageUrl: 'code.foss.global/serve.zone/corelog', }); + const corestoreImage = await this.coreflowRef.dockerHost.createImageFromRegistry({ + imageUrl: 'code.foss.global/serve.zone/corestore', + }); + // SERVICES // lets deploy the base services // coretraffic @@ -249,6 +337,32 @@ export class ClusterManager { } logger.log('info', 'waiting for corelog to be up and running'); await plugins.smartdelay.delayFor(10000); + + // corestore + let corestoreService: plugins.docker.DockerService | null; + corestoreService = await this.getDockerServiceByName('corestore'); + + if ( + corestoreService && + (((corestoreService.Spec as any).Mode && !(corestoreService.Spec as any).Mode.Global) || + (await corestoreService.needsUpdate())) + ) { + await corestoreService.remove(); + corestoreService = null; + } else { + logger.log('ok', `corestore service is up to date`); + } + + if (!corestoreService) { + corestoreService = await this.createCorestoreGlobalService(corestoreImage, [ + sznCorechatNetwork, + sznWebgatewayNetwork, + ]); + } else { + logger.log('ok', 'corestore service is already present'); + } + logger.log('info', 'waiting for corestore to be up and running'); + await plugins.smartdelay.delayFor(10000); } public async provisionWorkloadService( @@ -268,10 +382,6 @@ export class ClusterManager { await this.coreflowRef.cloudlyConnector.cloudlyApiClient.image.getImageById( serviceArgFromCloudly.data.imageId, ); - const deploymentLabels = this.getWorkloadServiceDeploymentLabels( - serviceArgFromCloudly, - containerImageFromCloudly, - ); let localDockerImage: plugins.docker.DockerImage; // lets get the docker image for the service @@ -330,6 +440,24 @@ export class ClusterManager { dockerSecretName, ); + const secretBundle = + await this.coreflowRef.cloudlyConnector.cloudlyApiClient.secretbundle.getSecretBundleById( + serviceArgFromCloudly.data.secretBundleId, + ); + const platformEnvObject = await this.coreflowRef.platformManager.provisionBindingsForService( + serviceArgFromCloudly, + ); + const secretObject = { + ...platformEnvObject, + ...(await secretBundle.getFlatKeyValueObjectForEnvironment()), + }; + const secretHash = this.hashSecretObject(secretObject); + const deploymentLabels = this.getWorkloadServiceDeploymentLabels( + serviceArgFromCloudly, + containerImageFromCloudly, + secretHash, + ); + // existing network to connect to const webGatewayNetwork = await this.coreflowRef.dockerHost.getNetworkByName( this.commonDockerData.networkNames.sznWebgateway, @@ -366,15 +494,10 @@ export class ClusterManager { await containerSecret.remove(); } - const secretBundle = - await this.coreflowRef.cloudlyConnector.cloudlyApiClient.secretbundle.getSecretBundleById( - serviceArgFromCloudly.data.secretBundleId, - ); - // lets create the relevant stuff on the docker side containerSecret = await this.coreflowRef.dockerHost.createSecret({ name: dockerSecretName, - contentArg: JSON.stringify(await secretBundle.getFlatKeyValueObjectForEnvironment()), + contentArg: JSON.stringify(secretObject), labels: {}, version: serviceArgFromCloudly.data.imageVersion, }); diff --git a/ts/coreflow.classes.platformmanager.ts b/ts/coreflow.classes.platformmanager.ts index 4756bbc..7fbca2a 100644 --- a/ts/coreflow.classes.platformmanager.ts +++ b/ts/coreflow.classes.platformmanager.ts @@ -9,6 +9,18 @@ type TPlatformDesiredState = { services?: plugins.servezoneInterfaces.data.IService[]; }; +type TCoreStoreProvisionResponse = { + serviceId: string; + serviceName?: string; + resources: Array<{ + capability: 'database' | 'objectstorage'; + provider: 'smartdb' | 'smartstorage'; + resourceName: string; + env: Record; + }>; + env: Record; +}; + export class PlatformManager { public coreflowRef: Coreflow; private configSubscription?: { unsubscribe: () => void }; @@ -52,6 +64,28 @@ export class PlatformManager { logger.log('info', `Platform service reconciliation completed for ${desiredState.bindings.length} bindings`); } + public async provisionBindingsForService( + serviceArg: plugins.servezoneInterfaces.data.IService, + ): Promise> { + const desiredState = this.currentDesiredState || (await this.getDesiredState()); + this.currentDesiredState = desiredState; + const bindings = desiredState.bindings.filter((bindingArg) => { + return ( + bindingArg.desiredState !== 'disabled' && + this.bindingMatchesService(bindingArg, serviceArg) && + this.isCoreStoreCapability(bindingArg.capability) + ); + }); + + const env: Record = {}; + for (const binding of bindings) { + const providerConfig = this.getProviderConfig(binding, desiredState.providerConfigs); + const provisionedEnv = await this.provisionCoreStoreBinding(binding, serviceArg, providerConfig); + Object.assign(env, provisionedEnv); + } + return env; + } + private async getDesiredState( desiredStateArg: Partial = {}, ): Promise { @@ -107,6 +141,15 @@ export class PlatformManager { return; } + if (this.isCoreStoreCapability(bindingArg.capability)) { + try { + await this.provisionCoreStoreBinding(bindingArg, service, providerConfig); + } catch (error) { + await this.failBinding(bindingArg, `CoreStore provisioning failed: ${(error as Error).message}`); + } + return; + } + if (!providerConfig) { await this.failBinding(bindingArg, `No enabled provider config found for ${bindingArg.capability}`); return; @@ -138,6 +181,109 @@ export class PlatformManager { ); } + private bindingMatchesService( + bindingArg: plugins.servezoneInterfaces.platform.IPlatformBinding, + serviceArg: plugins.servezoneInterfaces.data.IService, + ) { + return bindingArg.serviceId === serviceArg.id || bindingArg.serviceId === serviceArg.data.name; + } + + private isCoreStoreCapability( + capabilityArg: plugins.servezoneInterfaces.platform.TPlatformCapability, + ): capabilityArg is 'database' | 'objectstorage' { + return capabilityArg === 'database' || capabilityArg === 'objectstorage'; + } + + private getCoreStoreControlUrl( + providerConfigArg?: plugins.servezoneInterfaces.platform.IPlatformProviderConfig, + ) { + const configuredUrl = this.getStringConfigValue(providerConfigArg?.config || {}, 'controlUrl'); + return configuredUrl || process.env.CORESTORE_CONTROL_URL || 'http://corestore:3000'; + } + + private getCoreStoreApiToken( + providerConfigArg?: plugins.servezoneInterfaces.platform.IPlatformProviderConfig, + ) { + return this.getStringConfigValue(providerConfigArg?.config || {}, 'apiToken') || process.env.CORESTORE_API_TOKEN; + } + + private async provisionCoreStoreBinding( + bindingArg: plugins.servezoneInterfaces.platform.IPlatformBinding, + serviceArg: plugins.servezoneInterfaces.data.IService, + providerConfigArg?: plugins.servezoneInterfaces.platform.IPlatformProviderConfig, + ): Promise> { + if (!this.isCoreStoreCapability(bindingArg.capability)) { + throw new Error(`CoreStore cannot provision ${bindingArg.capability}`); + } + const capability = bindingArg.capability; + const controlUrl = this.getCoreStoreControlUrl(providerConfigArg); + const response = await this.postCoreStore( + `${controlUrl.replace(/\/+$/, '')}/resources/provision`, + { + serviceId: serviceArg.id, + serviceName: serviceArg.data.name, + capabilities: [capability], + }, + providerConfigArg, + ); + const resource = response.resources.find((resourceArg) => resourceArg.capability === capability); + if (!resource) { + throw new Error(`CoreStore did not return a ${capability} resource`); + } + await this.updateBindingStatus(bindingArg, { + status: 'ready', + endpoints: [this.getCoreStoreEndpoint(capability, resource.env)], + credentials: [{ env: resource.env }], + errorText: '', + }); + return resource.env; + } + + private getCoreStoreEndpoint( + capabilityArg: 'database' | 'objectstorage', + envArg: Record, + ): plugins.servezoneInterfaces.platform.IPlatformServiceEndpoint { + if (capabilityArg === 'database') { + return { + name: 'corestore-smartdb', + capability: 'database', + protocol: 'mongodb', + internalUrl: envArg.MONGODB_URI, + networkAlias: envArg.MONGODB_HOST || 'corestore', + port: Number(envArg.MONGODB_PORT || '27017'), + }; + } + return { + name: 'corestore-smartstorage', + capability: 'objectstorage', + protocol: 's3', + internalUrl: envArg.AWS_ENDPOINT_URL || envArg.S3_ENDPOINT, + networkAlias: envArg.S3_ENDPOINT_HOST || 'corestore', + port: Number(envArg.S3_PORT || '9000'), + }; + } + + private async postCoreStore( + urlArg: string, + bodyArg: unknown, + providerConfigArg?: plugins.servezoneInterfaces.platform.IPlatformProviderConfig, + ): Promise { + const token = this.getCoreStoreApiToken(providerConfigArg); + const response = await fetch(urlArg, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(token ? { authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(bodyArg), + }); + const responseText = await response.text(); + if (!response.ok) { + throw new Error(`CoreStore request failed ${response.status}: ${responseText}`); + } + return responseText ? JSON.parse(responseText) as T : ({} as T); + } + private getEndpointsForBinding( bindingArg: plugins.servezoneInterfaces.platform.IPlatformBinding, providerConfigArg: plugins.servezoneInterfaces.platform.IPlatformProviderConfig,