diff --git a/changelog.md b/changelog.md index 0ae51fc..ec54d72 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,14 @@ ## Pending +### Features + +- add App Store install and upgrade workflows (appstore) + - Add an App Store dashboard for browsing templates, viewing version configs, editing install inputs, and installing services + - Add App Store state actions, routing, and live upgrade operation progress handling in the web app + - Implement upgrade previews, asynchronous service upgrade operations, platform binding reconciliation, and preservation of service volume and published port overrides + - Enable service detail upgrades with preview confirmation, progress display, and refreshed service data + - Bump @serve.zone/interfaces to ^6.0.1 and add App Store upgrade merge tests ## 2026-05-26 - 6.1.0 diff --git a/package.json b/package.json index 6cf8abc..c656771 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@push.rocks/webjwt": "^1.0.10", "@serve.zone/api": "^5.3.8", "@serve.zone/appstore": "^0.2.0", - "@serve.zone/interfaces": "^6.0.0", + "@serve.zone/interfaces": "^6.0.1", "@tsclass/tsclass": "^9.5.1" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c0105c..0c78e61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,8 +147,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@serve.zone/interfaces': - specifier: ^6.0.0 - version: 6.0.0 + specifier: ^6.0.1 + version: 6.0.1 '@tsclass/tsclass': specifier: ^9.5.1 version: 9.5.1 @@ -1838,8 +1838,8 @@ packages: '@serve.zone/interfaces@5.10.0': resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==} - '@serve.zone/interfaces@6.0.0': - resolution: {integrity: sha512-nCidhOH0XlX+7e6xaJDq6fwnwaWasB/4w2LHkV7A96G+m+7EXZqbbaKSboUlaiGDly0dWNajk2FrYFo64ZucPA==} + '@serve.zone/interfaces@6.0.1': + resolution: {integrity: sha512-ZeLi0Bge8qRMoZMN5/xQ/8VRI4ep9ImitpZtNuLmeNHu0pGICcBGQE4g1aMmi+E3JynKOAphH4dnVmRULZV/RA==} '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -7364,7 +7364,7 @@ snapshots: '@serve.zone/appstore@0.2.0': dependencies: - '@serve.zone/interfaces': 6.0.0 + '@serve.zone/interfaces': 6.0.1 '@serve.zone/interfaces@5.10.0': dependencies: @@ -7372,7 +7372,7 @@ snapshots: '@push.rocks/smartlog-interfaces': 3.0.2 '@tsclass/tsclass': 9.5.1 - '@serve.zone/interfaces@6.0.0': + '@serve.zone/interfaces@6.0.1': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartlog-interfaces': 3.0.2 diff --git a/test/test.appstore.node.ts b/test/test.appstore.node.ts new file mode 100644 index 0000000..c552da9 --- /dev/null +++ b/test/test.appstore.node.ts @@ -0,0 +1,61 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; + +import { CloudlyAppStoreManager } from '../ts/manager.appstore/classes.appstoremanager.js'; + +const createManager = () => Object.create(CloudlyAppStoreManager.prototype) as any; + +tap.test('should preserve service volume overrides during App Store upgrades', async () => { + const manager = createManager(); + const volumes = manager.mergeUpgradeVolumes( + [ + { mountPath: '/data', name: 'custom-data', driver: 'local' }, + { mountPath: '/cache', name: 'custom-cache' }, + ], + [ + '/data', + { mountPath: '/config', readOnly: true }, + ], + ); + + expect(volumes).toEqual([ + { mountPath: '/data', name: 'custom-data', driver: 'local' }, + { mountPath: '/config', readOnly: true }, + { mountPath: '/cache', name: 'custom-cache' }, + ]); +}); + +tap.test('should preserve service published port overrides during App Store upgrades', async () => { + const manager = createManager(); + const publishedPorts = manager.mergeUpgradePublishedPorts( + [ + { targetPort: 5432, publishedPort: 15432, protocol: 'tcp' }, + { targetPort: 9999, publishedPort: 19999 }, + ], + [ + { targetPort: 5432, publishedPort: 5432 }, + { targetPort: 6379 }, + ], + ); + + expect(publishedPorts).toEqual([ + { targetPort: 5432, publishedPort: 15432, protocol: 'tcp' }, + { targetPort: 6379, protocol: 'tcp' }, + { targetPort: 9999, publishedPort: 19999, protocol: 'tcp' }, + ]); +}); + +tap.test('should report unsupported App Store published port configs', async () => { + const manager = createManager(); + const unsupported = manager.getUnsupportedPublishedPorts([ + { targetPort: 80, publishedPort: 80 }, + { targetPort: 81, publishedPort: 80 }, + { targetPort: 82, publishedPort: 82, hostIp: '127.0.0.1' }, + { targetPort: 9000, targetPortEnd: 9001, publishedPort: 19000, publishedPortEnd: 19002 }, + ]); + + expect(unsupported.some((messageArg: string) => messageArg.includes('duplicates published port 80/tcp'))).toBeTrue(); + expect(unsupported.some((messageArg: string) => messageArg.includes('unsupported hostIp'))).toBeTrue(); + expect(unsupported.some((messageArg: string) => messageArg.includes('mismatched target and published port ranges'))).toBeTrue(); +}); + +export default tap.start(); diff --git a/ts/manager.appstore/classes.appstoremanager.ts b/ts/manager.appstore/classes.appstoremanager.ts index bd5e5d4..8408be3 100644 --- a/ts/manager.appstore/classes.appstoremanager.ts +++ b/ts/manager.appstore/classes.appstoremanager.ts @@ -4,19 +4,77 @@ 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'; +import { commitinfo } from '../00_commitinfo_data.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; +type IAppStorePublishedPort = plugins.servezoneInterfaces.appstore.IAppStorePublishedPort; +type IUpgradeableAppStoreService = Omit & { + serviceId: string; +}; +type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & { + appTemplateId?: string; + appTemplateVersion?: string; + appStoreUpgradePolicy?: 'manual' | 'notify' | 'auto'; + publishedPorts?: IAppStoreVersionConfig['publishedPorts']; +}; +type TAppStoreUpgradeStatus = 'running' | 'success' | 'failed'; +type TAppStoreUpgradeStep = + | 'queued' + | 'validating' + | 'migration' + | 'applying' + | 'updating-service' + | 'pushing-config' + | 'complete' + | 'failed'; +interface IAppStoreUpgradeChange { + field: string; + currentValue: string; + targetValue: string; +} +interface IAppStoreUpgradePreview { + serviceId: string; + serviceName: string; + appTemplateId: string; + fromVersion: string; + targetVersion: string; + resolvedTargetVersion: string; + hasMigration: boolean; + requiresManualReview: boolean; + changes: IAppStoreUpgradeChange[]; + warnings: string[]; + blockers: string[]; + config: IAppStoreVersionConfig; + appMeta: IAppStoreAppMeta; +} +interface IAppStoreUpgradeOperation { + id: string; + serviceId: string; + serviceName: string; + appTemplateId: string; + fromVersion: string; + targetVersion: string; + status: TAppStoreUpgradeStatus; + step: TAppStoreUpgradeStep; + progressLines: string[]; + warnings: string[]; + error?: string; + startedAt: number; + updatedAt: number; + completedAt?: number; + service?: plugins.servezoneInterfaces.data.IService; +} 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', }); + private readonly upgradeOperations = new Map(); constructor(private cloudlyRef: Cloudly) { this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); @@ -70,6 +128,54 @@ export class CloudlyAppStoreManager { }, ), ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getAppStoreUpgradePreview', + async (dataArg) => { + await this.passAdminIdentity(dataArg); + return { + preview: await this.getAppStoreUpgradePreview(dataArg.serviceId, dataArg.targetVersion), + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'upgradeAppStoreService', + async (dataArg) => { + await this.passAdminIdentity(dataArg); + const { service, warnings } = await this.applyUpgrade(dataArg.serviceId, dataArg.targetVersion); + return { + service: await service.createSavableObject(), + warnings, + }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'startAppStoreServiceUpgrade', + async (dataArg) => { + await this.passAdminIdentity(dataArg); + const operation = await this.createUpgradeOperation(dataArg.serviceId, dataArg.targetVersion); + void this.performUpgrade(operation.id).catch(() => {}); + return { operation }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getAppStoreUpgradeOperations', + async (dataArg) => { + await this.passAdminIdentity(dataArg); + return { operations: this.getUpgradeOperations() }; + }, + ), + ); } public async getAppStore(): Promise { @@ -97,10 +203,7 @@ export class CloudlyAppStoreManager { const upgradeableServices: IUpgradeableAppStoreService[] = []; for (const service of services) { - const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & { - appTemplateId?: string; - appTemplateVersion?: string; - }; + const serviceData = service.data as TExtendedServiceData; if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) { continue; } @@ -108,31 +211,352 @@ export class CloudlyAppStoreManager { if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) { continue; } + let hasMigration = false; + try { + hasMigration = await this.hasMigrationScript( + serviceData.appTemplateId, + serviceData.appTemplateVersion, + appStoreApp.latestVersion, + ); + } catch { + hasMigration = true; + } upgradeableServices.push({ + serviceId: service.id, serviceName: serviceData.name, appTemplateId: serviceData.appTemplateId, currentVersion: serviceData.appTemplateVersion, latestVersion: appStoreApp.latestVersion, - hasMigration: false, + hasMigration, }); } return upgradeableServices; } + public getUpgradeOperations(): IAppStoreUpgradeOperation[] { + return Array.from(this.upgradeOperations.values()) + .sort((a, b) => b.startedAt - a.startedAt) + .slice(0, 25); + } + + public async getAppStoreUpgradePreview( + serviceIdArg: string, + targetVersionArg?: string, + ): Promise { + const service = await Service.getInstance({ id: serviceIdArg }); + if (!service) { + throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceIdArg}`); + } + + const serviceData = service.data as TExtendedServiceData; + if (!serviceData.appTemplateId) { + throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an App Store app'); + } + if (!serviceData.appTemplateVersion) { + throw new plugins.typedrequest.TypedResponseError('Service has no tracked App Store version'); + } + + const appMeta = await this.getAppMeta(serviceData.appTemplateId); + const targetVersion = targetVersionArg || appMeta.latestVersion; + const config = await this.getAppVersionConfig(serviceData.appTemplateId, targetVersion); + const resolvedTargetVersion = config.appStoreVersion || targetVersion; + const blockers: string[] = []; + const warnings: string[] = []; + const changes: IAppStoreUpgradeChange[] = []; + let hasMigration = false; + try { + hasMigration = await this.hasMigrationScript( + serviceData.appTemplateId, + serviceData.appTemplateVersion, + resolvedTargetVersion, + ); + } catch (error) { + blockers.push((error as Error).message); + } + const requiresManualReview = Boolean( + config.requiresManualReview || config.breaking || config.migrationRequired || hasMigration, + ); + + try { + this.assertRuntimeCompatibility(config); + } catch (error) { + blockers.push((error as Error).message); + } + + const unsupportedPlatformRequirements = this.getUnsupportedPlatformRequirements(config); + if (unsupportedPlatformRequirements.length > 0) { + blockers.push( + `Cloudly App Store install does not yet support platform requirement(s): ${unsupportedPlatformRequirements.join(', ')}`, + ); + } + + if (config.migrationRequired) { + blockers.push('This upgrade declares migrationRequired and needs a Cloudly migration implementation before it can be applied.'); + } else if (hasMigration) { + blockers.push('A migration script exists for this upgrade. Cloudly migration execution must be implemented before applying it.'); + } + if (config.breaking) { + warnings.push('This App Store version is marked as breaking.'); + } + if (config.requiresManualReview) { + warnings.push('This App Store version requires manual review.'); + } + if (config.backupBeforeUpgrade) { + warnings.push('The template recommends taking a backup before upgrading.'); + } + + const nextEnvVars = this.getMergedUpgradeEnvVars(serviceData, config, blockers); + const serviceDomain = serviceData.domains?.[0]?.name; + if (this.requiresTemplateValue(nextEnvVars, 'SERVICE_DOMAIN') && !serviceDomain) { + blockers.push('A domain is required because the target app template uses ${SERVICE_DOMAIN}'); + } + this.applyServiceDomainEnv(nextEnvVars, serviceDomain); + const nextVolumes = this.mergeUpgradeVolumes(serviceData.volumes || [], config.volumes); + const nextPublishedPorts = this.mergeUpgradePublishedPorts(serviceData.publishedPorts || [], config.publishedPorts || []); + const unsupportedPublishedPorts = this.getUnsupportedPublishedPorts(nextPublishedPorts); + if (unsupportedPublishedPorts.length > 0) { + blockers.push(`Cloudly cannot apply published port setting(s): ${unsupportedPublishedPorts.join(', ')}`); + } + const currentImageRef = await this.getServiceImageReference(service); + this.pushChange(changes, 'image', currentImageRef, config.image); + this.pushChange(changes, 'appTemplateVersion', serviceData.appTemplateVersion, resolvedTargetVersion); + this.pushChange(changes, 'webPort', String(serviceData.ports?.web || ''), String(config.port)); + this.pushChange( + changes, + 'environment', + this.stableStringify(serviceData.environment || {}), + this.stableStringify(nextEnvVars), + ); + this.pushChange( + changes, + 'volumes', + this.stableStringify(serviceData.volumes || []), + this.stableStringify(nextVolumes), + ); + this.pushChange( + changes, + 'publishedPorts', + this.stableStringify(serviceData.publishedPorts || []), + this.stableStringify(nextPublishedPorts), + ); + this.pushChange( + changes, + 'platformRequirements', + this.stableStringify(await this.getServicePlatformRequirements(service.id)), + this.stableStringify(config.platformRequirements || {}), + ); + + return { + serviceId: service.id, + serviceName: serviceData.name, + appTemplateId: serviceData.appTemplateId, + fromVersion: serviceData.appTemplateVersion, + targetVersion, + resolvedTargetVersion, + hasMigration, + requiresManualReview, + changes, + warnings, + blockers, + config, + appMeta, + }; + } + + public async applyUpgrade(serviceIdArg: string, targetVersionArg: string): Promise<{ + service: Service; + warnings: string[]; + }> { + const preview = await this.getAppStoreUpgradePreview(serviceIdArg, targetVersionArg); + if (preview.blockers.length > 0) { + throw new plugins.typedrequest.TypedResponseError(preview.blockers.join('; ')); + } + + const service = await Service.getInstance({ id: serviceIdArg }); + const serviceData = service.data as TExtendedServiceData; + const envVars = this.getMergedUpgradeEnvVars(serviceData, preview.config, []); + const oldWebPort = serviceData.ports?.web; + const image = await this.upsertAppStoreImageForService( + service, + preview.config.image, + preview.appMeta.description, + preview.config.resolvedImageDigest, + ); + const webPort = preview.config.port; + const nextDomains = (serviceData.domains || []).map((domainArg) => ({ + ...domainArg, + port: !domainArg.port || domainArg.port === oldWebPort ? webPort : domainArg.port, + })); + this.applyServiceDomainEnv(envVars, nextDomains[0]?.name); + const nextVolumes = this.mergeUpgradeVolumes(serviceData.volumes || [], preview.config.volumes); + const nextPublishedPorts = this.mergeUpgradePublishedPorts(serviceData.publishedPorts || [], preview.config.publishedPorts || []); + + service.data = { + ...serviceData, + description: serviceData.description || preview.appMeta.description, + imageId: image.id, + imageVersion: this.getImageTag(preview.config.image), + appTemplateId: preview.appTemplateId, + appTemplateVersion: preview.resolvedTargetVersion, + environment: envVars, + ports: { + ...serviceData.ports, + web: webPort, + }, + volumes: nextVolumes, + publishedPorts: nextPublishedPorts, + domains: nextDomains, + } as plugins.servezoneInterfaces.data.IService['data']; + await service.save(); + await this.reconcilePlatformBindings(service, preview.config); + await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); + return { + service, + warnings: preview.warnings, + }; + } + + private async createUpgradeOperation( + serviceIdArg: string, + targetVersionArg: string, + ): Promise { + const existingRunning = this.getRunningUpgrade(serviceIdArg); + if (existingRunning) { + throw new plugins.typedrequest.TypedResponseError( + `An upgrade is already running for ${existingRunning.serviceName}`, + ); + } + + const preview = await this.getAppStoreUpgradePreview(serviceIdArg, targetVersionArg); + if (preview.blockers.length > 0) { + throw new plugins.typedrequest.TypedResponseError(preview.blockers.join('; ')); + } + const now = Date.now(); + const operation: IAppStoreUpgradeOperation = { + id: plugins.smartunique.shortId(12), + serviceId: preview.serviceId, + serviceName: preview.serviceName, + appTemplateId: preview.appTemplateId, + fromVersion: preview.fromVersion, + targetVersion: preview.resolvedTargetVersion, + status: 'running', + step: 'queued', + progressLines: [`Queued upgrade ${preview.fromVersion} -> ${preview.resolvedTargetVersion}`], + warnings: preview.warnings, + startedAt: now, + updatedAt: now, + }; + this.upgradeOperations.set(operation.id, operation); + await this.pushUpgradeProgress(operation); + return operation; + } + + private getRunningUpgrade(serviceIdArg: string): IAppStoreUpgradeOperation | null { + for (const operation of this.upgradeOperations.values()) { + if (operation.serviceId === serviceIdArg && operation.status === 'running') { + return operation; + } + } + return null; + } + + private async updateUpgradeOperation( + operationIdArg: string, + stepArg: TAppStoreUpgradeStep, + messageArg: string, + updatesArg: Partial = {}, + ): Promise { + const existing = this.upgradeOperations.get(operationIdArg); + if (!existing) { + throw new Error(`Upgrade operation not found: ${operationIdArg}`); + } + const operation: IAppStoreUpgradeOperation = { + ...existing, + ...updatesArg, + step: stepArg, + updatedAt: Date.now(), + progressLines: [...existing.progressLines, messageArg].slice(-200), + }; + this.upgradeOperations.set(operationIdArg, operation); + await this.pushUpgradeProgress(operation); + return operation; + } + + private async performUpgrade(operationIdArg: string): Promise { + let operation = this.upgradeOperations.get(operationIdArg); + if (!operation) { + throw new Error(`Upgrade operation not found: ${operationIdArg}`); + } + + try { + operation = await this.updateUpgradeOperation( + operation.id, + 'validating', + `Validating ${operation.serviceName} for App Store upgrade`, + ); + const preview = await this.getAppStoreUpgradePreview(operation.serviceId, operation.targetVersion); + if (preview.blockers.length > 0) { + throw new Error(preview.blockers.join('; ')); + } + + await this.updateUpgradeOperation( + operation.id, + 'migration', + preview.hasMigration ? 'Migration script detected; applying config-only upgrade' : 'No migration script detected', + { warnings: preview.warnings }, + ); + await this.updateUpgradeOperation(operation.id, 'applying', `Applying upgrade to ${operation.serviceName}`); + const { service, warnings } = await this.applyUpgrade(operation.serviceId, operation.targetVersion); + await this.updateUpgradeOperation( + operation.id, + 'complete', + `Upgrade completed for ${operation.serviceName}`, + { + status: 'success', + completedAt: Date.now(), + warnings, + service: await service.createSavableObject(), + }, + ); + return service; + } catch (error) { + await this.updateUpgradeOperation( + operation.id, + 'failed', + `Upgrade failed: ${(error as Error).message}`, + { + status: 'failed', + completedAt: Date.now(), + error: (error as Error).message, + }, + ); + throw error; + } + } + 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; + const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || []; + this.assertRuntimeCompatibility(config); this.assertSupportedPlatformRequirements(config); + this.assertSupportedPublishedPorts(publishedPorts); 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}'); } + this.applyServiceDomainEnv(envVars, optionsArg.domain); - const image = await this.createAppStoreImage(optionsArg.serviceName, config.image, appMeta.description); + const image = await this.createAppStoreImage( + optionsArg.serviceName, + config.image, + appMeta.description, + config.resolvedImageDigest, + ); const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id); const serviceData = { name: optionsArg.serviceName, @@ -142,6 +566,7 @@ export class CloudlyAppStoreManager { deployOnPush: false, appTemplateId: optionsArg.appId, appTemplateVersion: appStoreVersion, + appStoreUpgradePolicy: 'manual', environment: envVars, secretBundleId: secretBundle.id, additionalSecretBundleIds: [], @@ -153,21 +578,24 @@ export class CloudlyAppStoreManager { balancingStrategy: 'round-robin', ports: { web: webPort }, volumes: this.normalizeVolumes(config.volumes), + publishedPorts, domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [], deploymentIds: [], - } as plugins.servezoneInterfaces.data.IService['data'] & { - appTemplateId: string; - appTemplateVersion: string; - }; + } as TExtendedServiceData; const service = await Service.createService(serviceData); secretBundle.data.serviceId = service.id; await secretBundle.save(); - await this.createPlatformBindings(service, config); + await this.reconcilePlatformBindings(service, config); await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); return service; } - private async createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise { + private async createAppStoreImage( + serviceNameArg: string, + imageRefArg: string, + descriptionArg: string, + digestArg?: string, + ): Promise { const image = new Image(); image.id = await Image.getNewId(); image.data = { @@ -181,6 +609,7 @@ export class CloudlyAppStoreManager { }, versions: [{ versionString: this.getImageTag(imageRefArg), + digest: digestArg, source: 'registry', registryRepository: imageRefArg, registryTag: this.getImageTag(imageRefArg), @@ -192,6 +621,65 @@ export class CloudlyAppStoreManager { return image; } + private async upsertAppStoreImageForService( + serviceArg: Service, + imageRefArg: string, + descriptionArg: string, + digestArg?: string, + ): Promise { + let image: Image | undefined; + try { + image = await Image.getInstance({ id: serviceArg.data.imageId }); + } catch {} + if (!image) { + return await this.createAppStoreImage(serviceArg.data.name, imageRefArg, descriptionArg, digestArg); + } + + const imageTag = this.getImageTag(imageRefArg); + image.data = { + ...image.data, + description: image.data.description || descriptionArg, + location: { + internal: false, + externalRegistryId: '', + externalImageTag: imageRefArg, + externalImageRef: imageRefArg, + }, + versions: this.upsertImageVersion(image.data.versions || [], imageRefArg, imageTag, digestArg), + }; + await image.save(); + return image; + } + + private upsertImageVersion( + versionsArg: plugins.servezoneInterfaces.data.IImage['data']['versions'], + imageRefArg: string, + imageTagArg: string, + digestArg?: string, + ): plugins.servezoneInterfaces.data.IImage['data']['versions'] { + const nextVersions = [...versionsArg]; + const existingIndex = nextVersions.findIndex((versionArg) => versionArg.versionString === imageTagArg); + const versionData = { + versionString: imageTagArg, + digest: digestArg, + source: 'registry' as const, + registryRepository: imageRefArg, + registryTag: imageTagArg, + size: 0, + createdAt: Date.now(), + }; + if (existingIndex >= 0) { + nextVersions[existingIndex] = { + ...nextVersions[existingIndex], + ...versionData, + createdAt: nextVersions[existingIndex].createdAt || versionData.createdAt, + }; + } else { + nextVersions.push(versionData); + } + return nextVersions; + } + private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise { const secretBundle = new SecretBundle(); secretBundle.id = plugins.smartunique.shortId(8); @@ -208,26 +696,72 @@ export class CloudlyAppStoreManager { return secretBundle; } - private async createPlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) { + private async reconcilePlatformBindings(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', + await this.setPlatformBindingForCapability(serviceArg.id, 'database', Boolean(requirements.mongodb)); + await this.setPlatformBindingForCapability(serviceArg.id, 'objectstorage', Boolean(requirements.s3)); + } + + private async setPlatformBindingForCapability( + serviceIdArg: string, + capabilityArg: 'database' | 'objectstorage', + enabledArg: boolean, + ) { + let existingBinding: PlatformBinding | undefined; + try { + existingBinding = await PlatformBinding.getInstance({ + serviceId: serviceIdArg, + capability: capabilityArg, }); + } catch {} + + if (!enabledArg && !existingBinding) { + return; } - if (requirements.s3) { + + if (!enabledArg) { + const bindingToDisable = existingBinding!; await PlatformBinding.upsertBinding({ - id: await PlatformBinding.getNewId(), - serviceId: serviceArg.id, - capability: 'objectstorage', - desiredState: 'enabled', - status: 'requested', + id: bindingToDisable.id, + serviceId: serviceIdArg, + capability: capabilityArg, + desiredState: 'disabled', + status: 'disabled', + providerConfigId: bindingToDisable.providerConfigId, + config: bindingToDisable.config, + endpoints: bindingToDisable.endpoints, + credentials: bindingToDisable.credentials, + createdAt: bindingToDisable.createdAt, }); + return; } + + await this.upsertPlatformBindingForCapability(serviceIdArg, capabilityArg); + } + + private async upsertPlatformBindingForCapability( + serviceIdArg: string, + capabilityArg: 'database' | 'objectstorage', + ) { + let existingBinding: PlatformBinding | undefined; + try { + existingBinding = await PlatformBinding.getInstance({ + serviceId: serviceIdArg, + capability: capabilityArg, + }); + } catch {} + await PlatformBinding.upsertBinding({ + id: existingBinding?.id || await PlatformBinding.getNewId(), + serviceId: serviceIdArg, + capability: capabilityArg, + desiredState: 'enabled', + status: existingBinding?.desiredState === 'disabled' ? 'requested' : existingBinding?.status || 'requested', + providerConfigId: existingBinding?.providerConfigId, + config: existingBinding?.config, + endpoints: existingBinding?.endpoints, + credentials: existingBinding?.credentials, + createdAt: existingBinding?.createdAt, + }); } private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) { @@ -239,6 +773,78 @@ export class CloudlyAppStoreManager { }).filter((volumeArg) => Boolean(volumeArg.mountPath)); } + private mergeUpgradeVolumes( + currentVolumesArg: TExtendedServiceData['volumes'] = [], + templateVolumesArg: IAppStoreVersionConfig['volumes'] = [], + ) { + const templateVolumes = this.normalizeVolumes(templateVolumesArg); + const currentVolumes = currentVolumesArg || []; + const currentByMountPath = new Map(currentVolumes + .filter((volumeArg) => Boolean(volumeArg.mountPath)) + .map((volumeArg) => [volumeArg.mountPath, volumeArg])); + const usedMountPaths = new Set(); + const mergedVolumes = templateVolumes.map((templateVolumeArg) => { + const currentVolume = currentByMountPath.get(templateVolumeArg.mountPath); + usedMountPaths.add(templateVolumeArg.mountPath); + return { + ...templateVolumeArg, + ...currentVolume, + }; + }); + for (const currentVolume of currentVolumes) { + if (!usedMountPaths.has(currentVolume.mountPath)) { + mergedVolumes.push(currentVolume); + } + } + return mergedVolumes; + } + + private normalizePublishedPorts(publishedPortsArg: IAppStorePublishedPort[] = []): IAppStorePublishedPort[] { + return publishedPortsArg.map((portArg) => ({ + ...portArg, + protocol: portArg.protocol || 'tcp', + })); + } + + private getPublishedPortTemplateKey(portArg: IAppStorePublishedPort): string { + return [ + portArg.protocol || 'tcp', + portArg.targetPort, + portArg.targetPortEnd || portArg.targetPort, + ].join(':'); + } + + private mergeUpgradePublishedPorts( + currentPortsArg: IAppStorePublishedPort[] = [], + templatePortsArg: IAppStorePublishedPort[] = [], + ): IAppStorePublishedPort[] { + const templatePorts = this.normalizePublishedPorts(templatePortsArg); + const currentPorts = this.normalizePublishedPorts(currentPortsArg); + if (templatePorts.length === 0) { + return currentPorts; + } + + const currentByTemplateKey = new Map(currentPorts.map((portArg) => [this.getPublishedPortTemplateKey(portArg), portArg])); + const usedKeys = new Set(); + const mergedPorts = templatePorts.map((templatePortArg) => { + const key = this.getPublishedPortTemplateKey(templatePortArg); + const currentPort = currentByTemplateKey.get(key); + usedKeys.add(key); + return { + ...templatePortArg, + ...currentPort, + }; + }); + + for (const currentPort of currentPorts) { + const key = this.getPublishedPortTemplateKey(currentPort); + if (!usedKeys.has(key)) { + mergedPorts.push(currentPort); + } + } + return mergedPorts; + } + private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record): Record { const envVars: Record = {}; const missingRequiredEnvVars: string[] = []; @@ -260,15 +866,195 @@ export class CloudlyAppStoreManager { return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`)); } + private applyServiceDomainEnv(envVarsArg: Record, serviceDomainArg?: string) { + if (serviceDomainArg && this.requiresTemplateValue(envVarsArg, 'SERVICE_DOMAIN')) { + envVarsArg.SERVICE_DOMAIN = serviceDomainArg; + } + } + private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) { - const unsupported = Object.entries(configArg.platformRequirements || {}) - .filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3') - .map(([key]) => key); + const unsupported = this.getUnsupportedPlatformRequirements(configArg); if (unsupported.length > 0) { throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`); } } + private getUnsupportedPlatformRequirements(configArg: IAppStoreVersionConfig) { + return Object.entries(configArg.platformRequirements || {}) + .filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3') + .map(([key]) => key); + } + + private assertSupportedPublishedPorts(publishedPortsArg: IAppStoreVersionConfig['publishedPorts'] = []) { + const unsupported = this.getUnsupportedPublishedPorts(publishedPortsArg); + if (unsupported.length > 0) { + throw new Error(`Cloudly cannot apply published port setting(s): ${unsupported.join(', ')}`); + } + } + + private getUnsupportedPublishedPorts(publishedPortsArg: IAppStoreVersionConfig['publishedPorts'] = []) { + const unsupported: string[] = []; + const seenPublishedPorts = new Set(); + for (const portArg of publishedPortsArg) { + const protocol = portArg.protocol || 'tcp'; + const targetStart = portArg.targetPort; + const targetEnd = portArg.targetPortEnd || targetStart; + const publishedStart = portArg.publishedPort || targetStart; + const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart)); + const description = this.formatPublishedPortDescription(portArg); + + if (portArg.hostIp && portArg.hostIp !== '0.0.0.0') { + unsupported.push(`${description} uses unsupported hostIp ${portArg.hostIp}`); + } + for (const [label, value] of Object.entries({ targetStart, targetEnd, publishedStart, publishedEnd })) { + if (!Number.isInteger(value) || value < 1 || value > 65535) { + unsupported.push(`${description} has invalid ${label} ${value}`); + } + } + if (targetEnd < targetStart) { + unsupported.push(`${description} has targetPortEnd before targetPort`); + } + if (publishedEnd < publishedStart) { + unsupported.push(`${description} has publishedPortEnd before publishedPort`); + } + if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) { + unsupported.push(`${description} has mismatched target and published port ranges`); + } + if (targetEnd >= targetStart && publishedEnd >= publishedStart && (targetEnd - targetStart) === (publishedEnd - publishedStart)) { + for (let offset = 0; offset <= targetEnd - targetStart; offset++) { + const publishedKey = `${protocol}:${publishedStart + offset}`; + if (seenPublishedPorts.has(publishedKey)) { + unsupported.push(`${description} duplicates published port ${publishedStart + offset}/${protocol}`); + } + seenPublishedPorts.add(publishedKey); + } + } + } + return unsupported; + } + + private formatPublishedPortDescription(portArg: IAppStorePublishedPort) { + const protocol = portArg.protocol || 'tcp'; + const target = portArg.targetPortEnd ? `${portArg.targetPort}-${portArg.targetPortEnd}` : String(portArg.targetPort); + const publishedStart = portArg.publishedPort || portArg.targetPort; + const publishedEnd = portArg.publishedPortEnd || (portArg.targetPortEnd ? publishedStart + (portArg.targetPortEnd - portArg.targetPort) : undefined); + const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart); + return `${published}/${protocol} -> ${target}/${protocol}`; + } + + private assertRuntimeCompatibility(configArg: IAppStoreVersionConfig) { + if (configArg.minCloudlyVersion && this.compareVersions(commitinfo.version, configArg.minCloudlyVersion) < 0) { + throw new Error(`App requires Cloudly >= ${configArg.minCloudlyVersion}; current version is ${commitinfo.version}`); + } + } + + private compareVersions(versionAArg: string, versionBArg: string): number { + const normalize = (versionArg: string) => versionArg.replace(/^v/, '').split('.').map((partArg) => Number(partArg) || 0); + const versionA = normalize(versionAArg); + const versionB = normalize(versionBArg); + for (let i = 0; i < Math.max(versionA.length, versionB.length); i++) { + const diff = (versionA[i] || 0) - (versionB[i] || 0); + if (diff !== 0) { + return diff > 0 ? 1 : -1; + } + } + return 0; + } + + private async hasMigrationScript(appIdArg: string, fromVersionArg: string, toVersionArg: string): Promise { + const path = `apps/${appIdArg}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`; + const baseUrl = this.appStoreResolver.baseUrl.replace(/\/+$/, ''); + const response = await fetch(`${baseUrl}/${path}`); + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error(`Could not check App Store migration script: HTTP ${response.status} for ${path}`); + } + return true; + } + + private getMergedUpgradeEnvVars( + serviceDataArg: TExtendedServiceData, + configArg: IAppStoreVersionConfig, + blockersArg: string[], + ): Record { + const envVars: Record = { + ...(serviceDataArg.environment || {}), + }; + for (const envVar of configArg.envVars || []) { + const value = envVars[envVar.key] ?? envVar.value ?? ''; + if (envVar.required && !value) { + blockersArg.push(`Missing required app env var: ${envVar.key}`); + } + envVars[envVar.key] = value; + } + return envVars; + } + + private async getServiceImageReference(serviceArg: Service): Promise { + try { + const image = await Image.getInstance({ id: serviceArg.data.imageId }); + return image?.data.location?.externalImageRef || image?.data.location?.externalImageTag || `${serviceArg.data.imageId}:${serviceArg.data.imageVersion}`; + } catch { + return `${serviceArg.data.imageId}:${serviceArg.data.imageVersion}`; + } + } + + private async getServicePlatformRequirements(serviceIdArg: string) { + const bindings = await PlatformBinding.getInstances({ serviceId: serviceIdArg }); + return { + mongodb: bindings.some((bindingArg) => bindingArg.capability === 'database' && bindingArg.desiredState !== 'disabled'), + s3: bindings.some((bindingArg) => bindingArg.capability === 'objectstorage' && bindingArg.desiredState !== 'disabled'), + }; + } + + private pushChange( + changesArg: IAppStoreUpgradeChange[], + fieldArg: string, + currentValueArg: string, + targetValueArg: string, + ) { + if (currentValueArg === targetValueArg) { + return; + } + changesArg.push({ + field: fieldArg, + currentValue: currentValueArg, + targetValue: targetValueArg, + }); + } + + 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 async pushUpgradeProgress(operationArg: IAppStoreUpgradeOperation): Promise { + const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket; + if (!typedsocket) { + return; + } + const connections = await typedsocket.findAllTargetConnections(async (connectionArg) => { + const identityTag = await connectionArg.getTagById('identity'); + const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined; + return identity?.role === 'admin'; + }); + await Promise.allSettled( + connections.map((connectionArg) => typedsocket + .createTypedRequest('pushAppStoreUpgradeProgress', connectionArg) + .fire({ operation: operationArg })), + ); + } + private getImageTag(imageRefArg: string) { const lastSlashIndex = imageRefArg.lastIndexOf('/'); const lastColonIndex = imageRefArg.lastIndexOf(':'); diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index aba9c13..b8e13a0 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -35,6 +35,63 @@ export interface IDataState { backups?: any[]; } +export type TAppStoreUpgradeStatus = 'running' | 'success' | 'failed'; +export type TAppStoreUpgradeStep = + | 'queued' + | 'validating' + | 'migration' + | 'applying' + | 'updating-service' + | 'pushing-config' + | 'complete' + | 'failed'; + +export interface IAppStoreUpgradeChange { + field: string; + currentValue: string; + targetValue: string; +} + +export interface IAppStoreUpgradePreview { + serviceId: string; + serviceName: string; + appTemplateId: string; + fromVersion: string; + targetVersion: string; + resolvedTargetVersion: string; + hasMigration: boolean; + requiresManualReview: boolean; + changes: IAppStoreUpgradeChange[]; + warnings: string[]; + blockers: string[]; + config: plugins.interfaces.appstore.IAppStoreVersionConfig; + appMeta: plugins.interfaces.appstore.IAppStoreAppMeta; +} + +export interface IAppStoreUpgradeOperation { + id: string; + serviceId: string; + serviceName: string; + appTemplateId: string; + fromVersion: string; + targetVersion: string; + status: TAppStoreUpgradeStatus; + step: TAppStoreUpgradeStep; + progressLines: string[]; + warnings: string[]; + error?: string; + startedAt: number; + updatedAt: number; + completedAt?: number; + service?: plugins.interfaces.data.IService; +} + +export interface IAppStoreState { + apps: plugins.interfaces.appstore.IAppStoreApp[]; + upgradeableServices: Array; + upgradeOperations: IAppStoreUpgradeOperation[]; +} + const emptyDataState: IDataState = { secretGroups: [], secretBundles: [], @@ -54,6 +111,12 @@ const emptyDataState: IDataState = { backups: [], }; +const emptyAppStoreState: IAppStoreState = { + apps: [], + upgradeableServices: [], + upgradeOperations: [], +}; + interface IReq_AdminValidateIdentity { method: 'adminValidateIdentity'; request: { @@ -119,6 +182,7 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) => try { apiClient.identity = null; dataState.setState({ ...emptyDataState }); + appStoreStatePart.setState({ ...emptyAppStoreState }); } catch {} return { ...currentState, @@ -132,6 +196,12 @@ export const dataState = await appstate.getStatePart( 'soft' ); +export const appStoreStatePart = await appstate.getStatePart( + 'appstore', + { ...emptyAppStoreState }, + 'soft', +); + // Shared API client instance (used by UI actions) type TCloudlyApiClientWithNullableIdentity = Omit & { identity: plugins.interfaces.data.IIdentity | null; @@ -142,6 +212,54 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({ cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined, }) as TCloudlyApiClientWithNullableIdentity; +const upsertUpgradeOperation = ( + operationsArg: IAppStoreUpgradeOperation[], + operationArg: IAppStoreUpgradeOperation, +) => { + const operations = operationsArg.filter((existingOperation) => existingOperation.id !== operationArg.id); + operations.unshift(operationArg); + return operations.slice(0, 25); +}; + +const upsertService = ( + servicesArg: plugins.interfaces.data.IService[] = [], + serviceArg: plugins.interfaces.data.IService, +) => { + const services = servicesArg.filter((existingService) => existingService.id !== serviceArg.id); + services.unshift(serviceArg); + return services; +}; + +apiClient.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'pushAppStoreUpgradeProgress', + async (dataArg: { operation: IAppStoreUpgradeOperation }) => { + const appStoreState = appStoreStatePart.getState() || { + apps: [], + upgradeableServices: [], + upgradeOperations: [], + }; + appStoreStatePart.setState({ + ...appStoreState, + upgradeOperations: upsertUpgradeOperation(appStoreState.upgradeOperations, dataArg.operation), + upgradeableServices: dataArg.operation.status === 'success' + ? appStoreState.upgradeableServices.filter((serviceArg) => { + return serviceArg.serviceId !== dataArg.operation.serviceId && serviceArg.serviceName !== dataArg.operation.serviceName; + }) + : appStoreState.upgradeableServices, + }); + if (dataArg.operation.service) { + const currentDataState = dataState.getState() || {}; + dataState.setState({ + ...currentDataState, + services: upsertService(currentDataState.services, dataArg.operation.service), + }); + } + return {}; + }, + ), +); + let identityExpiryTimer: number | undefined; let identityInvalidationRunning = false; @@ -184,6 +302,7 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P identity: null, }); dataState.setState({ ...emptyDataState }); + appStoreStatePart.setState({ ...emptyAppStoreState }); } finally { identityInvalidationRunning = false; } @@ -737,3 +856,94 @@ export const addClusterAction = dataState.createAction( return await context.dispatch(getAllDataAction, null); } ); + +const getIdentityForRequest = () => { + const identity = loginStatePart.getState()?.identity ?? null; + if (!identity) { + throw new Error('No Cloudly identity is available'); + } + return identity; +}; + +export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction( + async (statePartArg) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreTemplates'); + const response = await request.fire({ identity: getIdentityForRequest() }); + return { + ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), + apps: response.apps || [], + }; + }, +); + +export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAction( + async (statePartArg) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getUpgradeableAppStoreServices'); + const response = await request.fire({ identity: getIdentityForRequest() }); + return { + ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), + upgradeableServices: response.services || [], + }; + }, +); + +export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createAction( + async (statePartArg) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreUpgradeOperations'); + const response = await request.fire({ identity: getIdentityForRequest() }); + return { + ...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }), + upgradeOperations: response.operations || [], + }; + }, +); + +export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction<{ + serviceId: string; + targetVersion: string; +}>( + async (statePartArg, payloadArg) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'startAppStoreServiceUpgrade'); + const response = await request.fire({ + identity: getIdentityForRequest(), + serviceId: payloadArg.serviceId, + targetVersion: payloadArg.targetVersion, + }); + const currentState = statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }; + return { + ...currentState, + upgradeOperations: upsertUpgradeOperation(currentState.upgradeOperations, response.operation), + }; + }, +); + +export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreConfig'); + return await request.fire({ + identity: getIdentityForRequest(), + appId: appIdArg, + version: versionArg, + }) as { + config: plugins.interfaces.appstore.IAppStoreVersionConfig; + appMeta: plugins.interfaces.appstore.IAppStoreAppMeta; + }; +}; + +export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreUpgradePreview'); + const response = await request.fire({ + identity: getIdentityForRequest(), + serviceId: serviceIdArg, + targetVersion: targetVersionArg, + }); + return response.preview as IAppStoreUpgradePreview; +}; + +export const installAppStoreApp = async (installArg: plugins.interfaces.appstore.IAppStoreInstallRequest) => { + const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'installAppStoreApp'); + const response = await request.fire({ + identity: getIdentityForRequest(), + install: installArg, + }); + return response.service as plugins.interfaces.data.IService; +}; diff --git a/ts_web/elements/cloudly-dashboard.ts b/ts_web/elements/cloudly-dashboard.ts index 8372f19..44b153f 100644 --- a/ts_web/elements/cloudly-dashboard.ts +++ b/ts_web/elements/cloudly-dashboard.ts @@ -19,6 +19,7 @@ import { CloudlyViewDbs } from './views/dbs/index.js'; import { CloudlyViewDeployments } from './views/deployments/index.js'; import { CloudlyViewDns } from './views/dns/index.js'; import { CloudlyViewDomains } from './views/domains/index.js'; +import { CloudlyViewAppStore } from './views/appstore/index.js'; import { CloudlyViewImages } from './views/images/index.js'; import { CloudlyViewLogs } from './views/logs/index.js'; import { CloudlyViewMails } from './views/mails/index.js'; @@ -79,6 +80,7 @@ export class CloudlyDashboard extends DeesElement { iconName: 'lucide:Network', subViews: [ { slug: 'clusters', name: 'Clusters', iconName: 'lucide:Network', element: CloudlyViewClusters }, + { slug: 'appstore', name: 'App Store', iconName: 'lucide:Store', element: CloudlyViewAppStore }, { slug: 'services', name: 'Services', iconName: 'lucide:Layers', element: CloudlyViewServices }, { slug: 'images', name: 'Images', iconName: 'lucide:Image', element: CloudlyViewImages }, { slug: 'deployments', name: 'Deployments', iconName: 'lucide:Rocket', element: CloudlyViewDeployments }, diff --git a/ts_web/elements/views/appstore/index.ts b/ts_web/elements/views/appstore/index.ts new file mode 100644 index 0000000..d222e4d --- /dev/null +++ b/ts_web/elements/views/appstore/index.ts @@ -0,0 +1,420 @@ +import * as plugins from '../../../plugins.js'; +import * as shared from '../../shared/index.js'; +import * as appstate from '../../../appstate.js'; +import { appRouter } from '../../../router.js'; + +import { + DeesElement, + customElement, + html, + state, + css, + cssManager, + type TemplateResult, +} from '@design.estate/dees-element'; + +type TEditableEnvVar = { + key: string; + value: string; + description: string; + required?: boolean; + platformInjected?: boolean; +}; + +@customElement('cloudly-view-appstore') +export class CloudlyViewAppStore extends DeesElement { + @state() + private accessor appStoreState: appstate.IAppStoreState = { + apps: [], + upgradeableServices: [], + upgradeOperations: [], + }; + + @state() + private accessor currentView: 'grid' | 'detail' = 'grid'; + + @state() + private accessor selectedApp: plugins.interfaces.appstore.IAppStoreApp | null = null; + + @state() + private accessor selectedAppMeta: plugins.interfaces.appstore.IAppStoreAppMeta | null = null; + + @state() + private accessor selectedAppConfig: plugins.interfaces.appstore.IAppStoreVersionConfig | null = null; + + @state() + private accessor configLoadError = ''; + + @state() + private accessor selectedVersion = ''; + + @state() + private accessor editableEnvVars: TEditableEnvVar[] = []; + + @state() + private accessor serviceName = ''; + + @state() + private accessor serviceDomain = ''; + + @state() + private accessor deployMode = false; + + @state() + private accessor loading = false; + + private configRequestToken = 0; + + public static styles = [ + cssManager.defaultStyles, + shared.viewHostCss, + css` + .card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; margin-bottom: 14px; } + .header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } + .title { margin: 0; color: var(--ci-shade-7, #e4e4e7); font-size: 24px; font-weight: 700; } + .subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; line-height: 1.5; } + .section-title { color: var(--ci-shade-7, #e4e4e7); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; } + .badge { display: inline-flex; padding: 3px 9px; border-radius: 999px; background: rgba(59, 130, 246, 0.16); color: #60a5fa; font-size: 12px; margin: 0 6px 6px 0; } + .button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); } + .button.primary { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; } + .button:disabled { opacity: 0.55; cursor: not-allowed; } + .actions { display: flex; gap: 10px; align-items: center; margin-top: 14px; } + .field { display: grid; gap: 6px; margin-top: 12px; } + .field label { color: var(--ci-shade-5, #a1a1aa); font-size: 12px; font-weight: 600; } + input, select { width: 100%; box-sizing: border-box; background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 6px; padding: 9px 10px; color: var(--ci-shade-7, #e4e4e7); } + .env-table { width: 100%; border-collapse: collapse; } + .env-table th, .env-table td { text-align: left; padding: 7px 8px 7px 0; border-bottom: 1px solid var(--ci-shade-2, #27272a); vertical-align: top; } + .env-key, .mono { font-family: monospace; color: var(--ci-shade-6, #d4d4d8); overflow-wrap: anywhere; } + .muted { color: var(--ci-shade-4, #71717a); font-size: 12px; } + .warning { margin-top: 10px; padding: 10px 12px; border-radius: 7px; background: rgba(245, 158, 11, 0.12); color: #fbbf24; font-size: 12px; } + .operation { display: grid; gap: 7px; } + .operation-log { max-height: 120px; overflow: auto; white-space: pre-wrap; font-family: monospace; font-size: 12px; color: var(--ci-shade-5, #a1a1aa); background: var(--ci-shade-0, #030305); border-radius: 6px; padding: 10px; } + @media (max-width: 760px) { .header { flex-direction: column; } .actions { flex-direction: column; align-items: stretch; } } + `, + ]; + + constructor() { + super(); + const subscription = appstate.appStoreStatePart + .select((stateArg) => stateArg) + .subscribe((stateArg) => { + this.appStoreState = stateArg; + }); + this.rxSubscriptions.push(subscription); + const loginSubscription = appstate.loginStatePart + .select((stateArg) => stateArg.identity) + .subscribe((identityArg) => { + if (identityArg) { + void this.refreshAppStoreData(); + } + }); + this.rxSubscriptions.push(loginSubscription); + } + + public async connectedCallback() { + super.connectedCallback(); + await this.refreshAppStoreData(); + } + + private async refreshAppStoreData() { + if (!appstate.loginStatePart.getState()?.identity) { + return; + } + await Promise.allSettled([ + appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreTemplatesAction, null), + appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null), + appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null), + ]); + } + + public render(): TemplateResult { + if (this.currentView === 'detail') { + return this.renderDetailView(); + } + return this.renderGridView(); + } + + private renderGridView(): TemplateResult { + return html` + App Store + ${this.renderOperations()} + ({ + Name: appArg.name, + Category: html`${appArg.category}`, + Version: appArg.latestVersion, + Source: appArg.source?.type || 'curated', + Tags: appArg.tags?.join(', ') || '-', + })} + .dataActions=${[ + { + name: 'Details', + iconName: 'lucide:Eye', + type: ['contextmenu', 'inRow', 'doubleClick'], + actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, false), + }, + { + name: 'Install', + iconName: 'lucide:Download', + type: ['contextmenu', 'inRow'], + actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, true), + }, + ] as plugins.deesCatalog.ITableAction[]} + > + `; + } + + private renderOperations(): TemplateResult | '' { + const operations = this.appStoreState.upgradeOperations + .slice(0, 3); + if (operations.length === 0) return ''; + return html` +
+
Recent Upgrade Operations
+ ${operations.map((operationArg) => html` +
+
${operationArg.serviceName}: ${operationArg.fromVersion} -> ${operationArg.targetVersion} (${operationArg.status}/${operationArg.step})
+
${operationArg.progressLines.slice(-6).join('\n')}
+
+ `)} +
+ `; + } + + private renderDetailView(): TemplateResult { + const app = this.selectedApp; + const meta = this.selectedAppMeta; + const config = this.selectedAppConfig; + if (this.configLoadError) { + return html` + App Store + +
+
Could not load app details
+
${this.configLoadError}
+
+ + ${this.selectedApp ? html`` : ''} +
+
+ `; + } + if (this.loading || !app || !config) { + return html`App Store
Loading app details...
`; + } + const platformRequirements = config.platformRequirements || {}; + const enabledRequirements = Object.entries(platformRequirements).filter(([, enabled]) => enabled); + const volumes = this.getConfigVolumes(config); + const publishedPorts = config.publishedPorts || []; + return html` + App Store + +
+
+
+

${app.name}

+
${app.description}
+
+ ${app.category} + ${app.tags?.map((tagArg) => html`${tagArg}`)} +
+
+
${config.image}
+
+
+ +
+
Version
+ + ${config.minCloudlyVersion ? html`
Requires Cloudly >= ${config.minCloudlyVersion}
` : ''} +
+ + ${enabledRequirements.length ? html` +
+
Platform Requirements
+ ${enabledRequirements.map(([key]) => html`${key}`)} +
Cloudly currently provisions MongoDB and S3 requirements through platform bindings.
+
+ ` : ''} + + ${(volumes.length || publishedPorts.length) ? html` +
+
Deployment Footprint
+ ${volumes.map((volumeArg) => html`
Volume: ${volumeArg.source || volumeArg.name || 'managed'} -> ${volumeArg.mountPath}
`)} + ${publishedPorts.map((portArg) => html`
Published port: ${this.formatPublishedPort(portArg)}
`)} + ${publishedPorts.length ? html`
This app publishes raw host ports outside the HTTP proxy.
` : ''} +
+ ` : ''} + + ${this.editableEnvVars.length ? html` +
+
Environment
+ + + + ${this.editableEnvVars.map((envVarArg, indexArg) => html` + + + + + + `)} + +
KeyValueDescription
${envVarArg.key}${envVarArg.required ? html` required` : ''} this.updateEnvVar(indexArg, (eventArg.target as HTMLInputElement).value)} />${envVarArg.description}${envVarArg.platformInjected ? ' Auto-injected by platform.' : ''}
+
+ ` : ''} + + ${this.deployMode ? html` +
+
Install Service
+
{ this.serviceName = (eventArg.target as HTMLInputElement).value; }} />
+
{ this.serviceDomain = this.normalizeDomain((eventArg.target as HTMLInputElement).value); }} />
+
Domain is required when the template uses SERVICE_DOMAIN.
+
+ + +
+
+ ` : html` +
+ + +
+ `} + `; + } + + private async openApp(appArg: plugins.interfaces.appstore.IAppStoreApp, deployModeArg: boolean) { + this.selectedApp = appArg; + this.selectedAppMeta = null; + this.selectedAppConfig = null; + this.configLoadError = ''; + this.selectedVersion = appArg.latestVersion; + this.serviceName = appArg.id; + this.serviceDomain = ''; + this.deployMode = deployModeArg; + this.loading = true; + this.currentView = 'detail'; + await this.fetchVersionConfig(appArg.id, appArg.latestVersion); + this.loading = false; + } + + private async changeVersion(versionArg: string) { + if (!this.selectedApp || this.selectedVersion === versionArg) return; + this.selectedVersion = versionArg; + this.loading = true; + await this.fetchVersionConfig(this.selectedApp.id, versionArg); + this.loading = false; + } + + private async fetchVersionConfig(appIdArg: string, versionArg: string): Promise { + const requestToken = ++this.configRequestToken; + this.configLoadError = ''; + this.selectedAppConfig = null; + try { + const response = await appstate.getAppStoreConfig(appIdArg, versionArg); + if (requestToken !== this.configRequestToken) { + return false; + } + this.selectedAppMeta = response.appMeta; + this.selectedAppConfig = response.config; + this.editableEnvVars = (response.config.envVars || []).map((envVarArg) => ({ + key: envVarArg.key, + value: envVarArg.value || '', + description: envVarArg.description || '', + required: envVarArg.required, + platformInjected: Boolean(envVarArg.value?.includes('${') && !envVarArg.value.includes('${SERVICE_DOMAIN}')), + })); + return true; + } catch (error) { + if (requestToken === this.configRequestToken) { + this.configLoadError = (error as Error).message; + this.editableEnvVars = []; + plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to load app config: ${(error as Error).message}`, type: 'error' }); + } + return false; + } + } + + private updateEnvVar(indexArg: number, valueArg: string) { + const envVars = [...this.editableEnvVars]; + envVars[indexArg] = { ...envVars[indexArg], value: valueArg }; + this.editableEnvVars = envVars; + } + + private async installSelectedApp() { + if (!this.selectedApp || !this.selectedAppConfig) return; + const missingEnvVars = this.editableEnvVars.filter((envVarArg) => envVarArg.required && !envVarArg.platformInjected && !envVarArg.value.trim()); + if (missingEnvVars.length) { + plugins.deesCatalog.DeesToast.createAndShow({ message: `Missing env vars: ${missingEnvVars.map((envVarArg) => envVarArg.key).join(', ')}`, type: 'error' }); + return; + } + const needsDomain = (this.selectedAppConfig.envVars || []).some((envVarArg) => envVarArg.value?.includes('${SERVICE_DOMAIN}')); + if (needsDomain && !this.serviceDomain) { + plugins.deesCatalog.DeesToast.createAndShow({ message: 'A domain is required for this app.', type: 'error' }); + return; + } + const envVars: Record = {}; + for (const envVar of this.editableEnvVars) { + if (envVar.key && envVar.value) { + envVars[envVar.key] = envVar.value; + } + } + try { + await appstate.installAppStoreApp({ + appId: this.selectedApp.id, + version: this.selectedVersion, + serviceName: this.serviceName || this.selectedApp.id, + domain: this.serviceDomain || undefined, + envVars, + }); + await Promise.allSettled([ + appstate.dataState.dispatchAction(appstate.getAllDataAction, null), + appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null), + ]); + plugins.deesCatalog.DeesToast.createAndShow({ message: 'App Store service installed', type: 'success' }); + appRouter.navigateToView('runtime', 'services'); + } catch (error) { + plugins.deesCatalog.DeesToast.createAndShow({ message: `Install failed: ${(error as Error).message}`, type: 'error' }); + } + } + + private getConfigVolumes(configArg: plugins.interfaces.appstore.IAppStoreVersionConfig) { + return (configArg.volumes || []).map((volumeArg) => { + if (typeof volumeArg === 'string') { + return { mountPath: volumeArg }; + } + return volumeArg; + }).filter((volumeArg) => Boolean(volumeArg.mountPath)); + } + + private formatPublishedPort(portArg: plugins.interfaces.appstore.IAppStorePublishedPort): string { + const protocol = portArg.protocol || 'tcp'; + const target = portArg.targetPortEnd ? `${portArg.targetPort}-${portArg.targetPortEnd}` : String(portArg.targetPort); + const publishedStart = portArg.publishedPort || portArg.targetPort; + const publishedEnd = portArg.publishedPortEnd || (portArg.targetPortEnd ? publishedStart + (portArg.targetPortEnd - portArg.targetPort) : undefined); + const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart); + return `${portArg.hostIp || '0.0.0.0'}:${published}/${protocol} -> ${target}/${protocol}`; + } + + private normalizeDomain(valueArg: string) { + return valueArg.trim().replace(/^https?:\/\//, '').replace(/\/$/, ''); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'cloudly-view-appstore': CloudlyViewAppStore; + } +} diff --git a/ts_web/elements/views/services/index.ts b/ts_web/elements/views/services/index.ts index 7deade1..68c1d76 100644 --- a/ts_web/elements/views/services/index.ts +++ b/ts_web/elements/views/services/index.ts @@ -34,6 +34,13 @@ export class CloudlyViewServices extends DeesElement { @state() private accessor upgradeInfo: any = null; + @state() + private accessor appStoreState: appstate.IAppStoreState = { + apps: [], + upgradeableServices: [], + upgradeOperations: [], + }; + @state() private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null; @@ -46,8 +53,20 @@ export class CloudlyViewServices extends DeesElement { .select((stateArg) => stateArg) .subscribe((dataArg) => { this.data = dataArg; + if (this.selectedService) { + const updatedService = dataArg.services?.find((serviceArg) => serviceArg.id === this.selectedService!.id); + if (updatedService) { + this.selectedService = updatedService; + } + } }); this.rxSubscriptions.push(subscription); + const appStoreSubscription = appstate.appStoreStatePart + .select((stateArg) => stateArg) + .subscribe((stateArg) => { + this.appStoreState = stateArg; + }); + this.rxSubscriptions.push(appStoreSubscription); } public static styles = [ @@ -301,6 +320,8 @@ export class CloudlyViewServices extends DeesElement { appTemplateId?: string; appTemplateVersion?: string; }; + const upgradeOperation = this.getUpgradeOperationForService(service); + const upgradeInfo = this.getUpgradeInfoForService(service); return html` Service Details @@ -312,13 +333,19 @@ export class CloudlyViewServices extends DeesElement { - ${this.upgradeInfo ? html` + ${upgradeOperation ? this.renderUpgradeOperation(upgradeOperation) : ''} + + ${upgradeInfo ? html`
App catalog update available
-
${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}
+
${upgradeInfo.appTemplateId}: ${upgradeInfo.currentVersion} -> ${upgradeInfo.latestVersion}
- +
` : ''} @@ -447,6 +474,46 @@ export class CloudlyViewServices extends DeesElement { `; } + private getUpgradeOperationForService(serviceArg: plugins.interfaces.data.IService): appstate.IAppStoreUpgradeOperation | null { + return this.appStoreState.upgradeOperations.find((operationArg) => { + return operationArg.serviceId === serviceArg.id || operationArg.serviceName === serviceArg.data.name; + }) || null; + } + + private getUpgradeInfoForService(serviceArg: plugins.interfaces.data.IService): any | null { + const operation = this.getUpgradeOperationForService(serviceArg); + if (operation?.status === 'success') { + return null; + } + const liveUpgradeInfo = this.appStoreState.upgradeableServices.find((upgradeArg) => { + return upgradeArg.serviceId === serviceArg.id || upgradeArg.serviceName === serviceArg.data.name; + }); + if (liveUpgradeInfo) { + return liveUpgradeInfo; + } + if (this.upgradeInfo?.serviceId === serviceArg.id || this.upgradeInfo?.serviceName === serviceArg.data.name) { + return this.upgradeInfo; + } + return null; + } + + private renderUpgradeOperation(operationArg: appstate.IAppStoreUpgradeOperation): TemplateResult { + const color = operationArg.status === 'failed' ? '#f87171' : '#60a5fa'; + return html` +
+
+
+
Upgrade ${operationArg.fromVersion} -> ${operationArg.targetVersion}
+
${operationArg.status} / ${operationArg.step}${operationArg.error ? `: ${operationArg.error}` : ''}
+
+ ${operationArg.status} +
+
${operationArg.progressLines.slice(-8).join('\n')}
+ ${operationArg.warnings.length ? html`
${operationArg.warnings.join(' | ')}
` : ''} +
+ `; + } + private renderStatusBadge(statusArg: string): TemplateResult { return html`${statusArg || 'scheduled'}`; } @@ -518,13 +585,72 @@ export class CloudlyViewServices extends DeesElement { private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) { try { - const response = await this.fireTypedRequest('getUpgradeableAppStoreServices', {}) as { services: any[] }; - this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null; + await Promise.all([ + appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null), + appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null), + ]); + this.upgradeInfo = this.getUpgradeInfoForService(serviceArg); } catch { this.upgradeInfo = null; } } + private async startUpgradeForService(serviceArg: plugins.interfaces.data.IService) { + const upgradeInfo = this.getUpgradeInfoForService(serviceArg); + if (!upgradeInfo?.latestVersion) { + return; + } + try { + const preview = await appstate.getAppStoreUpgradePreview(serviceArg.id, upgradeInfo.latestVersion); + if (preview.blockers.length > 0) { + plugins.deesCatalog.DeesToast.createAndShow({ message: preview.blockers.join('; '), type: 'error' }); + return; + } + let upgradeStarting = false; + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: `Upgrade ${serviceArg.data.name}`, + content: html` +
+
${preview.fromVersion} -> ${preview.resolvedTargetVersion}
+
+ ${preview.changes.map((changeArg) => html` +
+ ${changeArg.field} + ${changeArg.currentValue} -> ${changeArg.targetValue} +
+ `)} +
+ ${preview.warnings.length ? html`
${preview.warnings.join(' | ')}
` : ''} +
+ `, + menuOptions: [ + { + name: 'Start Upgrade', + action: async (modalArg: any) => { + if (upgradeStarting) { + return; + } + upgradeStarting = true; + try { + await appstate.appStoreStatePart.dispatchAction(appstate.startAppStoreServiceUpgradeAction, { + serviceId: serviceArg.id, + targetVersion: preview.resolvedTargetVersion, + }); + await modalArg.destroy(); + } catch (error) { + upgradeStarting = false; + plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' }); + } + }, + }, + { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, + ], + }); + } catch (error) { + plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' }); + } + } + private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) { await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id }); if (this.selectedService) { diff --git a/ts_web/router.ts b/ts_web/router.ts index 9f44ebf..9168333 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -7,7 +7,7 @@ const flatViews = ['overview', 'logs'] as const; const subviewMap: Record = { platform: ['settings', 'baseos', 'fleet'] as const, - runtime: ['clusters', 'services', 'images', 'deployments', 'tasks'] as const, + runtime: ['clusters', 'appstore', 'services', 'images', 'deployments', 'tasks'] as const, registry: ['externalregistries', 'testing'] as const, secrets: ['secretgroups', 'secretbundles'] as const, domains: ['domains', 'dns', 'mails'] as const,