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 IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp; type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex; type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta; type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig; type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest; type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService; export class CloudlyAppStoreManager { public typedrouter = new plugins.typedrequest.TypedRouter(); private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({ baseUrl: process.env.APPSTORE_URL || 'https://code.foss.global/serve.zone/appstore/raw/branch/main', }); constructor(private cloudlyRef: Cloudly) { this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } public async start() {} public async stop() {} private registerHandlers() { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreTemplates', async (dataArg) => { await this.passAdminIdentity(dataArg); return { apps: await this.getApps() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreConfig', async (dataArg) => { await this.passAdminIdentity(dataArg); return { config: await this.getAppVersionConfig(dataArg.appId, dataArg.version), appMeta: await this.getAppMeta(dataArg.appId), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'installAppStoreApp', async (dataArg) => { await this.passAdminIdentity(dataArg); const service = await this.installApp(dataArg.install); return { service: await service.createSavableObject() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getUpgradeableAppStoreServices', async (dataArg) => { await this.passAdminIdentity(dataArg); return { services: await this.getUpgradeableAppStoreServices() }; }, ), ); } public async getAppStore(): Promise { return await this.appStoreResolver.getAppStoreIndex(); } public async getApps(): Promise { return await this.appStoreResolver.getApps(); } public async getAppMeta(appIdArg: string): Promise { return await this.appStoreResolver.getAppMeta(appIdArg); } public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise { if (!versionArg) { versionArg = (await this.getAppMeta(appIdArg)).latestVersion; } return await this.appStoreResolver.getAppVersionConfig(appIdArg, versionArg); } public async getUpgradeableAppStoreServices(): Promise { const appStore = await this.getAppStore(); const services = await this.cloudlyRef.serviceManager.CService.getInstances({}); const upgradeableServices: IUpgradeableAppStoreService[] = []; 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 appStoreApp = appStore.apps.find((appArg) => appArg.id === serviceData.appTemplateId); if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) { continue; } upgradeableServices.push({ serviceName: serviceData.name, appTemplateId: serviceData.appTemplateId, currentVersion: serviceData.appTemplateVersion, latestVersion: appStoreApp.latestVersion, hasMigration: false, }); } return upgradeableServices; } public async installApp(optionsArg: IAppStoreInstallOptions): Promise { const appMeta = await this.getAppMeta(optionsArg.appId); const version = optionsArg.version || appMeta.latestVersion; const config = await this.getAppVersionConfig(optionsArg.appId, version); const appStoreVersion = config.appStoreVersion || version; const webPort = optionsArg.port || config.port; this.assertSupportedPlatformRequirements(config); const envVars = this.getAppStoreEnvVars(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.createAppStoreImage(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: appStoreVersion, 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 createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise { const image = new Image(); image.id = await Image.getNewId(); image.data = { name: `${serviceNameArg}-appstore-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} appstore secrets`, description: `Generated appstore 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: IAppStoreVersionConfig) { 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: IAppStoreVersionConfig['volumes'] = []) { return volumesArg.map((volumeArg) => { if (typeof volumeArg === 'string') { return { mountPath: volumeArg }; } return volumeArg; }).filter((volumeArg) => Boolean(volumeArg.mountPath)); } private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, 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: IAppStoreVersionConfig) { 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 App Store 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, ]); } }