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-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 IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;
|
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-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-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) {
|
|
|
|
|
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
|
|
|
|
|
appTemplateId?: string;
|
|
|
|
|
appTemplateVersion?: string;
|
|
|
|
|
};
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
upgradeableServices.push({
|
|
|
|
|
serviceName: serviceData.name,
|
|
|
|
|
appTemplateId: serviceData.appTemplateId,
|
|
|
|
|
currentVersion: serviceData.appTemplateVersion,
|
2026-05-25 03:03:03 +00:00
|
|
|
latestVersion: appStoreApp.latestVersion,
|
2026-05-23 10:46:52 +00:00
|
|
|
hasMigration: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return upgradeableServices;
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
this.assertSupportedPlatformRequirements(config);
|
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-25 03:03:03 +00:00
|
|
|
const image = await this.createAppStoreImage(optionsArg.serviceName, config.image, appMeta.description);
|
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-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),
|
|
|
|
|
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
|
|
|
|
|
deploymentIds: [],
|
|
|
|
|
} as plugins.servezoneInterfaces.data.IService['data'] & {
|
|
|
|
|
appTemplateId: string;
|
|
|
|
|
appTemplateVersion: string;
|
|
|
|
|
};
|
|
|
|
|
const service = await Service.createService(serviceData);
|
|
|
|
|
secretBundle.data.serviceId = service.id;
|
|
|
|
|
await secretBundle.save();
|
|
|
|
|
await this.createPlatformBindings(service, config);
|
|
|
|
|
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
|
|
|
|
return service;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 03:03:03 +00:00
|
|
|
private async createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: 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),
|
|
|
|
|
source: 'registry',
|
|
|
|
|
registryRepository: imageRefArg,
|
|
|
|
|
registryTag: this.getImageTag(imageRefArg),
|
|
|
|
|
size: 0,
|
|
|
|
|
createdAt: Date.now(),
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
await image.save();
|
|
|
|
|
return image;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise<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-25 03:03:03 +00:00
|
|
|
private async createPlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
|
2026-05-23 10:46:52 +00:00
|
|
|
const requirements = configArg.platformRequirements || {};
|
|
|
|
|
if (requirements.mongodb) {
|
|
|
|
|
await PlatformBinding.upsertBinding({
|
|
|
|
|
id: await PlatformBinding.getNewId(),
|
|
|
|
|
serviceId: serviceArg.id,
|
|
|
|
|
capability: 'database',
|
|
|
|
|
desiredState: 'enabled',
|
|
|
|
|
status: 'requested',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (requirements.s3) {
|
|
|
|
|
await PlatformBinding.upsertBinding({
|
|
|
|
|
id: await PlatformBinding.getNewId(),
|
|
|
|
|
serviceId: serviceArg.id,
|
|
|
|
|
capability: 'objectstorage',
|
|
|
|
|
desiredState: 'enabled',
|
|
|
|
|
status: 'requested',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-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-25 03:03:03 +00:00
|
|
|
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
|
2026-05-23 10:46:52 +00:00
|
|
|
const unsupported = Object.entries(configArg.platformRequirements || {})
|
|
|
|
|
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
|
|
|
|
|
.map(([key]) => key);
|
|
|
|
|
if (unsupported.length > 0) {
|
2026-05-25 03:03:03 +00:00
|
|
|
throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
|
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,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|