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(); } }