Files
cloudly/ts/manager.appstore/classes.appstoremanager.ts
T

1070 lines
39 KiB
TypeScript
Raw Normal View History

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';
import { commitinfo } from '../00_commitinfo_data.js';
2026-05-25 03:03:03 +00:00
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 IAppStorePublishedPort = plugins.servezoneInterfaces.appstore.IAppStorePublishedPort;
type IUpgradeableAppStoreService = Omit<plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService, 'serviceId'> & {
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;
}
2026-05-25 03:03:03 +00:00
export class CloudlyAppStoreManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
2026-05-25 03:03:03 +00:00
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<string, IAppStoreUpgradeOperation>();
constructor(private cloudlyRef: Cloudly) {
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
public async start() {}
public async stop() {}
private registerHandlers() {
2026-05-25 03:03:03 +00:00
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreTemplates>(
'getAppStoreTemplates',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { apps: await this.getApps() };
2026-05-25 03:03:03 +00:00
},
),
);
2026-05-25 03:03:03 +00:00
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetAppStoreConfig>(
'getAppStoreConfig',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return {
config: await this.getAppVersionConfig(dataArg.appId, dataArg.version),
appMeta: await this.getAppMeta(dataArg.appId),
};
2026-05-25 03:03:03 +00:00
},
),
);
2026-05-25 03:03:03 +00:00
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_InstallAppStoreApp>(
'installAppStoreApp',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
2026-05-25 03:03:03 +00:00
const service = await this.installApp(dataArg.install);
return { service: await service.createSavableObject() };
2026-05-25 03:03:03 +00:00
},
),
);
2026-05-25 03:03:03 +00:00
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.appstore.IReq_Any_GetUpgradeableAppStoreServices>(
'getUpgradeableAppStoreServices',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
2026-05-25 03:03:03 +00:00
return { services: await this.getUpgradeableAppStoreServices() };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(
'getAppStoreUpgradePreview',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return {
preview: await this.getAppStoreUpgradePreview(dataArg.serviceId, dataArg.targetVersion),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(
'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<any>(
'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<any>(
'getAppStoreUpgradeOperations',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return { operations: this.getUpgradeOperations() };
},
),
);
}
2026-05-25 03:03:03 +00:00
public async getAppStore(): Promise<IAppStoreIndex> {
return await this.appStoreResolver.getAppStoreIndex();
}
2026-05-25 03:03:03 +00:00
public async getApps(): Promise<IAppStoreApp[]> {
return await this.appStoreResolver.getApps();
}
2026-05-25 03:03:03 +00:00
public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
return await this.appStoreResolver.getAppMeta(appIdArg);
}
2026-05-25 03:03:03 +00:00
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppStoreVersionConfig> {
if (!versionArg) {
versionArg = (await this.getAppMeta(appIdArg)).latestVersion;
}
2026-05-25 03:03:03 +00:00
return await this.appStoreResolver.getAppVersionConfig(appIdArg, versionArg);
}
2026-05-25 03:03:03 +00:00
public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
const appStore = await this.getAppStore();
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
2026-05-25 03:03:03 +00:00
const upgradeableServices: IUpgradeableAppStoreService[] = [];
for (const service of services) {
const serviceData = service.data as TExtendedServiceData;
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
continue;
}
2026-05-25 03:03:03 +00:00
const appStoreApp = appStore.apps.find((appArg) => appArg.id === serviceData.appTemplateId);
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,
2026-05-25 03:03:03 +00:00
latestVersion: appStoreApp.latestVersion,
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<IAppStoreUpgradePreview> {
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<IAppStoreUpgradeOperation> {
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<IAppStoreUpgradeOperation> = {},
): Promise<IAppStoreUpgradeOperation> {
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<Service> {
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;
}
}
2026-05-25 03:03:03 +00:00
public async installApp(optionsArg: IAppStoreInstallOptions): Promise<Service> {
const appMeta = await this.getAppMeta(optionsArg.appId);
const version = optionsArg.version || appMeta.latestVersion;
const config = await this.getAppVersionConfig(optionsArg.appId, version);
2026-05-25 03:03:03 +00:00
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);
2026-05-25 03:03:03 +00:00
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,
config.resolvedImageDigest,
);
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,
2026-05-25 03:03:03 +00:00
appTemplateVersion: appStoreVersion,
appStoreUpgradePolicy: 'manual',
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),
publishedPorts,
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
deploymentIds: [],
} as TExtendedServiceData;
const service = await Service.createService(serviceData);
secretBundle.data.serviceId = service.id;
await secretBundle.save();
await this.reconcilePlatformBindings(service, config);
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
return service;
}
private async createAppStoreImage(
serviceNameArg: string,
imageRefArg: string,
descriptionArg: string,
digestArg?: string,
): Promise<Image> {
const image = new Image();
image.id = await Image.getNewId();
image.data = {
2026-05-25 03:03:03 +00:00
name: `${serviceNameArg}-appstore-image`,
description: descriptionArg,
location: {
internal: false,
externalRegistryId: '',
externalImageTag: imageRefArg,
externalImageRef: imageRefArg,
},
versions: [{
versionString: this.getImageTag(imageRefArg),
digest: digestArg,
source: 'registry',
registryRepository: imageRefArg,
registryTag: this.getImageTag(imageRefArg),
size: 0,
createdAt: Date.now(),
}],
};
await image.save();
return image;
}
private async upsertAppStoreImageForService(
serviceArg: Service,
imageRefArg: string,
descriptionArg: string,
digestArg?: string,
): Promise<Image> {
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<SecretBundle> {
const secretBundle = new SecretBundle();
secretBundle.id = plugins.smartunique.shortId(8);
secretBundle.data = {
2026-05-25 03:03:03 +00:00
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 reconcilePlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
const requirements = configArg.platformRequirements || {};
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 (!enabledArg) {
const bindingToDisable = existingBinding!;
await PlatformBinding.upsertBinding({
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,
});
}
2026-05-25 03:03:03 +00:00
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
return volumesArg.map((volumeArg) => {
if (typeof volumeArg === 'string') {
return { mountPath: volumeArg };
}
return volumeArg;
}).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<string>();
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<string>();
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;
}
2026-05-25 03:03:03 +00:00
private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
const envVars: Record<string, string> = {};
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<string, string>, templateNameArg: string): boolean {
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
}
private applyServiceDomainEnv(envVarsArg: Record<string, string>, serviceDomainArg?: string) {
if (serviceDomainArg && this.requiresTemplateValue(envVarsArg, 'SERVICE_DOMAIN')) {
envVarsArg.SERVICE_DOMAIN = serviceDomainArg;
}
}
2026-05-25 03:03:03 +00:00
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
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<string>();
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<boolean> {
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<string, string> {
const envVars: Record<string, string> = {
...(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<string> {
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<string, unknown>)
.sort()
.map((keyArg) => `${JSON.stringify(keyArg)}:${this.stableStringify((valueArg as Record<string, unknown>)[keyArg])}`)
.join(',')}}`;
}
return JSON.stringify(valueArg);
}
private async pushUpgradeProgress(operationArg: IAppStoreUpgradeOperation): Promise<void> {
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<any>('pushAppStoreUpgradeProgress', connectionArg)
.fire({ operation: operationArg })),
);
}
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,
]);
}
}