import type { Cloudly } from '../classes.cloudly.js'; import * as plugins from '../plugins.js'; import { Service } from './classes.service.js'; type TServiceWithDomains = Service & { data: Service['data'] & { appTemplateId?: string; domains?: Array<{ name?: string }>; }; }; interface IWorkAppRouteSyncResult { success: boolean; action?: 'created' | 'updated' | 'deleted' | 'unchanged'; routeId?: string; message?: string; } export class ServiceManager { public typedrouter = new plugins.typedrequest.TypedRouter(); public cloudlyRef: Cloudly; get db() { return this.cloudlyRef.mongodbConnector.smartdataDb; } public CService = plugins.smartdata.setDefaultManagerForDoc(this, Service); constructor(cloudlyRef: Cloudly) { this.cloudlyRef = cloudlyRef; this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getServices', async (reqArg) => { await plugins.smartguard.passGuardsOrReject(reqArg, [ this.cloudlyRef.authManager.validIdentityGuard, ]); const services = await this.CService.getInstances({}); return { services: await Promise.all( services.map((service) => { return service.createSavableObject(); }) ), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getServiceById', async (dataArg) => { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.validIdentityGuard, ]); const service = await Service.getInstance({ id: dataArg.serviceId, }); return { service: await service.createSavableObject(), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getServiceSecretBundlesAsFlatObject', async (dataArg) => { const service = await Service.getInstance({ id: dataArg.serviceId, }); const flatKeyValueObject = await service.getSecretBundlesAsFlatObject(dataArg.environment); return { flatKeyValueObject: flatKeyValueObject, }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getServiceRegistryTarget', async (dataArg) => { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.validIdentityGuard, ]); const service = await Service.getInstance({ id: dataArg.serviceId, }); return { registryTarget: this.cloudlyRef.registryManager.getServiceRegistryTarget( service, dataArg.tag || service.data.imageVersion || 'latest', ), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'createService', async (dataArg) => { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.adminIdentityGuard, ]); const service = await Service.createService(dataArg.serviceData); service.data.registryTarget = this.cloudlyRef.registryManager.getServiceRegistryTarget( service, service.data.imageVersion || 'latest', ); await service.save(); await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); return { service: await service.createSavableObject(), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'updateService', async (dataArg) => { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.adminIdentityGuard, ]); const service = await Service.getInstance({ id: dataArg.serviceId, }); service.data = { ...service.data, ...dataArg.serviceData, }; service.data.registryTarget = this.cloudlyRef.registryManager.getServiceRegistryTarget( service, service.data.imageVersion || 'latest', ); await service.save(); await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); return { service: await service.createSavableObject(), }; } ) ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'deleteServiceById', async (dataArg) => { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.adminIdentityGuard, ]); await this.deleteServiceById(dataArg.serviceId); return { success: true, }; } ) ); } public async start() { // ServiceManager is ready - handlers are already registered in constructor console.log('ServiceManager started'); } public async stop() { // Cleanup if needed console.log('ServiceManager stopped'); } public async deleteServiceById(serviceIdArg: string): Promise { const service = await this.CService.getInstance({ id: serviceIdArg, }); if (!service) { throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceIdArg}`); } await this.deleteExternalGatewayRoutes(service as TServiceWithDomains); this.cloudlyRef.appStoreManager?.clearOperationsForService?.(service.id); await this.deleteDeploymentsForService(service.id); await service.removeDnsEntries(); await this.deletePlatformBindingsForService(service.id, service.data.name); await this.cloudlyRef.backupManager?.deleteBackupsForService?.(service.id); await this.cloudlyRef.registryManager?.deleteServiceRepository?.(service); await this.deleteServiceOwnedSecretBundles(service); await this.deleteServiceOwnedImage(service as TServiceWithDomains); await service.delete(); await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); } private async deleteDeploymentsForService(serviceIdArg: string): Promise { const deployments = await this.cloudlyRef.deploymentManager.CDeployment.getInstances({ serviceId: serviceIdArg, }); for (const deployment of deployments) { await deployment.delete(); } } private async deletePlatformBindingsForService( serviceIdArg: string, serviceNameArg: string, ): Promise { const bindingsById = await this.cloudlyRef.platformManager.CPlatformBinding.getInstances({ serviceId: serviceIdArg, }); const bindingsByName = serviceNameArg ? await this.cloudlyRef.platformManager.CPlatformBinding.getInstances({ serviceId: serviceNameArg }) : []; const bindings = new Map(); for (const binding of [...bindingsById, ...bindingsByName]) { bindings.set(binding.id, binding); } for (const binding of bindings.values()) { await binding.delete(); } } private async deleteServiceOwnedSecretBundles(serviceArg: Service): Promise { const secretBundleIds = [serviceArg.data.secretBundleId] .filter((secretBundleIdArg): secretBundleIdArg is string => Boolean(secretBundleIdArg)); if (secretBundleIds.length === 0) return; for (const secretBundleId of secretBundleIds) { const secretBundle = await this.cloudlyRef.secretManager.CSecretBundle.getInstance({ id: secretBundleId, }).catch(() => null); if (!secretBundle || (secretBundle.data as { serviceId?: string }).serviceId !== serviceArg.id) { continue; } const secretGroupIds = [...(secretBundle.data.includedSecretGroupIds || [])]; await secretBundle.delete(); await this.deleteUnreferencedSecretGroups(secretGroupIds); } } private async deleteUnreferencedSecretGroups(secretGroupIdsArg: string[]): Promise { if (secretGroupIdsArg.length === 0) return; const remainingBundles = await this.cloudlyRef.secretManager.CSecretBundle.getInstances({}); for (const secretGroupId of secretGroupIdsArg) { const stillReferenced = remainingBundles.some((bundleArg) => { return (bundleArg.data.includedSecretGroupIds || []).includes(secretGroupId); }); if (stillReferenced) continue; const secretGroup = await this.cloudlyRef.secretManager.CSecretGroup.getInstance({ id: secretGroupId, }).catch(() => null); if (secretGroup) { await secretGroup.delete(); } } } private async deleteServiceOwnedImage(serviceArg: TServiceWithDomains): Promise { if (!serviceArg.data.appTemplateId || !serviceArg.data.imageId) return; await this.cloudlyRef.imageManager.deleteImageIfUnreferenced(serviceArg.data.imageId, serviceArg.id); } private async deleteExternalGatewayRoutes(serviceArg: TServiceWithDomains): Promise { const domains = (serviceArg.data.domains || []) .map((domainArg) => domainArg.name?.trim().toLowerCase()) .filter((domainArg): domainArg is string => Boolean(domainArg)); if (domains.length === 0) return; const settings = await this.cloudlyRef.settingsManager.getSettings().catch(() => undefined); if (!settings?.dcrouterGatewayUrl || !settings.dcrouterGatewayApiToken) return; const clusters = await this.cloudlyRef.clusterManager.getAllClusters().catch(() => []); const workHosterIds = new Set(); if (settings.dcrouterWorkHosterId) { workHosterIds.add(settings.dcrouterWorkHosterId); } else { for (const cluster of clusters) { workHosterIds.add(cluster.id); } } if (workHosterIds.size === 0) return; for (const domain of domains) { for (const workHosterId of workHosterIds) { const result = await this.fireDcRouterRequest( settings.dcrouterGatewayUrl, 'syncWorkAppRoute', { apiToken: settings.dcrouterGatewayApiToken, ownership: { workHosterType: 'cloudly', workHosterId, workAppId: serviceArg.id || serviceArg.data.name, hostname: domain, }, delete: true, }, ); if (!result.success) { throw new Error(result.message || `dcrouter route delete failed for ${domain}`); } } } } private async fireDcRouterRequest( gatewayUrlArg: string, methodArg: string, requestDataArg: Record, ): Promise { const typedRequest = new plugins.typedrequest.TypedRequest( `${gatewayUrlArg.replace(/\/+$/, '')}/typedrequest`, methodArg, ); return await typedRequest.fire(requestDataArg) as TResponse; } }