2026-05-23 10:46:52 +00:00
|
|
|
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';
|
2026-05-26 11:06:21 +00:00
|
|
|
import { commitinfo } from '../00_commitinfo_data.js';
|
2026-05-23 10:46:52 +00:00
|
|
|
|
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;
|
2026-05-26 11:06:21 +00:00
|
|
|
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-23 10:46:52 +00:00
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
export class CloudlyAppStoreManager {
|
2026-05-23 10:46:52 +00:00
|
|
|
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',
|
|
|
|
|
});
|
2026-05-26 11:06:21 +00:00
|
|
|
private readonly upgradeOperations = new Map<string, IAppStoreUpgradeOperation>();
|
2026-05-23 10:46:52 +00:00
|
|
|
|
|
|
|
|
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) => {
|
2026-05-23 10:46:52 +00:00
|
|
|
await this.passAdminIdentity(dataArg);
|
|
|
|
|
return { apps: await this.getApps() };
|
2026-05-25 03:03:03 +00:00
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-23 10:46:52 +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) => {
|
2026-05-23 10:46:52 +00:00
|
|
|
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-23 10:46:52 +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) => {
|
2026-05-23 10:46:52 +00:00
|
|
|
await this.passAdminIdentity(dataArg);
|
2026-05-25 03:03:03 +00:00
|
|
|
const service = await this.installApp(dataArg.install);
|
2026-05-23 10:46:52 +00:00
|
|
|
return { service: await service.createSavableObject() };
|
2026-05-25 03:03:03 +00:00
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-23 10:46:52 +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) => {
|
2026-05-23 10:46:52 +00:00
|
|
|
await this.passAdminIdentity(dataArg);
|
2026-05-25 03:03:03 +00:00
|
|
|
return { services: await this.getUpgradeableAppStoreServices() };
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|
2026-05-26 11:06:21 +00:00
|
|
|
|
|
|
|
|
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-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
public async getAppStore(): Promise<IAppStoreIndex> {
|
|
|
|
|
return await this.appStoreResolver.getAppStoreIndex();
|
2026-05-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
public async getApps(): Promise<IAppStoreApp[]> {
|
|
|
|
|
return await this.appStoreResolver.getApps();
|
2026-05-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
|
|
|
|
|
return await this.appStoreResolver.getAppMeta(appIdArg);
|
2026-05-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise<IAppStoreVersionConfig> {
|
2026-05-23 10:46:52 +00:00
|
|
|
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-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
public async getUpgradeableAppStoreServices(): Promise<IUpgradeableAppStoreService[]> {
|
|
|
|
|
const appStore = await this.getAppStore();
|
2026-05-23 10:46:52 +00:00
|
|
|
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
2026-05-25 03:03:03 +00:00
|
|
|
const upgradeableServices: IUpgradeableAppStoreService[] = [];
|
2026-05-23 10:46:52 +00:00
|
|
|
|
|
|
|
|
for (const service of services) {
|
2026-05-26 11:06:21 +00:00
|
|
|
const serviceData = service.data as TExtendedServiceData;
|
2026-05-23 10:46:52 +00:00
|
|
|
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) {
|
2026-05-23 10:46:52 +00:00
|
|
|
continue;
|
|
|
|
|
}
|
2026-05-26 11:06:21 +00:00
|
|
|
let hasMigration = false;
|
|
|
|
|
try {
|
|
|
|
|
hasMigration = await this.hasMigrationScript(
|
|
|
|
|
serviceData.appTemplateId,
|
|
|
|
|
serviceData.appTemplateVersion,
|
|
|
|
|
appStoreApp.latestVersion,
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
hasMigration = true;
|
|
|
|
|
}
|
2026-05-23 10:46:52 +00:00
|
|
|
upgradeableServices.push({
|
2026-05-26 11:06:21 +00:00
|
|
|
serviceId: service.id,
|
2026-05-23 10:46:52 +00:00
|
|
|
serviceName: serviceData.name,
|
|
|
|
|
appTemplateId: serviceData.appTemplateId,
|
|
|
|
|
currentVersion: serviceData.appTemplateVersion,
|
2026-05-25 03:03:03 +00:00
|
|
|
latestVersion: appStoreApp.latestVersion,
|
2026-05-26 11:06:21 +00:00
|
|
|
hasMigration,
|
2026-05-23 10:46:52 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return upgradeableServices;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
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> {
|
2026-05-23 10:46:52 +00:00
|
|
|
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;
|
2026-05-23 10:46:52 +00:00
|
|
|
const webPort = optionsArg.port || config.port;
|
2026-05-26 11:06:21 +00:00
|
|
|
const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || [];
|
|
|
|
|
this.assertRuntimeCompatibility(config);
|
2026-05-23 10:46:52 +00:00
|
|
|
this.assertSupportedPlatformRequirements(config);
|
2026-05-26 11:06:21 +00:00
|
|
|
this.assertSupportedPublishedPorts(publishedPorts);
|
2026-05-25 03:03:03 +00:00
|
|
|
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
|
2026-05-23 10:46:52 +00:00
|
|
|
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
|
|
|
|
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
|
|
|
|
}
|
2026-05-26 11:06:21 +00:00
|
|
|
this.applyServiceDomainEnv(envVars, optionsArg.domain);
|
2026-05-23 10:46:52 +00:00
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
const image = await this.createAppStoreImage(
|
|
|
|
|
optionsArg.serviceName,
|
|
|
|
|
config.image,
|
|
|
|
|
appMeta.description,
|
|
|
|
|
config.resolvedImageDigest,
|
|
|
|
|
);
|
2026-05-23 10:46:52 +00:00
|
|
|
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,
|
2026-05-26 11:06:21 +00:00
|
|
|
appStoreUpgradePolicy: 'manual',
|
2026-05-23 10:46:52 +00:00
|
|
|
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),
|
2026-05-26 11:06:21 +00:00
|
|
|
publishedPorts,
|
2026-05-23 10:46:52 +00:00
|
|
|
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
|
|
|
|
|
deploymentIds: [],
|
2026-05-26 11:06:21 +00:00
|
|
|
} as TExtendedServiceData;
|
2026-05-23 10:46:52 +00:00
|
|
|
const service = await Service.createService(serviceData);
|
|
|
|
|
secretBundle.data.serviceId = service.id;
|
|
|
|
|
await secretBundle.save();
|
2026-05-26 11:06:21 +00:00
|
|
|
await this.reconcilePlatformBindings(service, config);
|
2026-05-23 10:46:52 +00:00
|
|
|
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
|
|
|
|
return service;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
private async createAppStoreImage(
|
|
|
|
|
serviceNameArg: string,
|
|
|
|
|
imageRefArg: string,
|
|
|
|
|
descriptionArg: string,
|
|
|
|
|
digestArg?: string,
|
|
|
|
|
): Promise<Image> {
|
2026-05-23 10:46:52 +00:00
|
|
|
const image = new Image();
|
|
|
|
|
image.id = await Image.getNewId();
|
|
|
|
|
image.data = {
|
2026-05-25 03:03:03 +00:00
|
|
|
name: `${serviceNameArg}-appstore-image`,
|
2026-05-23 10:46:52 +00:00
|
|
|
description: descriptionArg,
|
|
|
|
|
location: {
|
|
|
|
|
internal: false,
|
|
|
|
|
externalRegistryId: '',
|
|
|
|
|
externalImageTag: imageRefArg,
|
|
|
|
|
externalImageRef: imageRefArg,
|
|
|
|
|
},
|
|
|
|
|
versions: [{
|
|
|
|
|
versionString: this.getImageTag(imageRefArg),
|
2026-05-26 11:06:21 +00:00
|
|
|
digest: digestArg,
|
2026-05-23 10:46:52 +00:00
|
|
|
source: 'registry',
|
|
|
|
|
registryRepository: imageRefArg,
|
|
|
|
|
registryTag: this.getImageTag(imageRefArg),
|
|
|
|
|
size: 0,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
await image.save();
|
|
|
|
|
return image;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 10:46:52 +00:00
|
|
|
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}`,
|
2026-05-23 10:46:52 +00:00
|
|
|
type: 'service',
|
|
|
|
|
includedSecretGroupIds: [],
|
|
|
|
|
includedTags: [],
|
|
|
|
|
imageClaims: [{ imageId: imageIdArg, permissions: ['read'] }],
|
|
|
|
|
authorizations: [],
|
|
|
|
|
};
|
|
|
|
|
await secretBundle.save();
|
|
|
|
|
return secretBundle;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
private async reconcilePlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
|
2026-05-23 10:46:52 +00:00
|
|
|
const requirements = configArg.platformRequirements || {};
|
2026-05-26 11:06:21 +00:00
|
|
|
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,
|
2026-05-23 10:46:52 +00:00
|
|
|
});
|
2026-05-26 11:06:21 +00:00
|
|
|
} catch {}
|
|
|
|
|
|
|
|
|
|
if (!enabledArg && !existingBinding) {
|
|
|
|
|
return;
|
2026-05-23 10:46:52 +00:00
|
|
|
}
|
2026-05-26 11:06:21 +00:00
|
|
|
|
|
|
|
|
if (!enabledArg) {
|
|
|
|
|
const bindingToDisable = existingBinding!;
|
2026-05-23 10:46:52 +00:00
|
|
|
await PlatformBinding.upsertBinding({
|
2026-05-26 11:06:21 +00:00
|
|
|
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,
|
2026-05-23 10:46:52 +00:00
|
|
|
});
|
2026-05-26 11:06:21 +00:00
|
|
|
return;
|
2026-05-23 10:46:52 +00:00
|
|
|
}
|
2026-05-26 11:06:21 +00:00
|
|
|
|
|
|
|
|
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-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
|
2026-05-23 10:46:52 +00:00
|
|
|
return volumesArg.map((volumeArg) => {
|
|
|
|
|
if (typeof volumeArg === 'string') {
|
|
|
|
|
return { mountPath: volumeArg };
|
|
|
|
|
}
|
|
|
|
|
return volumeArg;
|
|
|
|
|
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
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> {
|
2026-05-23 10:46:52 +00:00
|
|
|
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}}`));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
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) {
|
2026-05-26 11:06:21 +00:00
|
|
|
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 || {})
|
2026-05-23 10:46:52 +00:00
|
|
|
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
|
|
|
|
|
.map(([key]) => key);
|
2026-05-26 11:06:21 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private assertSupportedPublishedPorts(publishedPortsArg: IAppStoreVersionConfig['publishedPorts'] = []) {
|
|
|
|
|
const unsupported = this.getUnsupportedPublishedPorts(publishedPortsArg);
|
2026-05-23 10:46:52 +00:00
|
|
|
if (unsupported.length > 0) {
|
2026-05-26 11:06:21 +00:00
|
|
|
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}`);
|
2026-05-23 10:46:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:06:21 +00:00
|
|
|
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 })),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 10:46:52 +00:00
|
|
|
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,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|