feat(appstore): add App Store install and upgrade workflows
This commit is contained in:
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add App Store install and upgrade workflows (appstore)
|
||||||
|
- Add an App Store dashboard for browsing templates, viewing version configs, editing install inputs, and installing services
|
||||||
|
- Add App Store state actions, routing, and live upgrade operation progress handling in the web app
|
||||||
|
- Implement upgrade previews, asynchronous service upgrade operations, platform binding reconciliation, and preservation of service volume and published port overrides
|
||||||
|
- Enable service detail upgrades with preview confirmation, progress display, and refreshed service data
|
||||||
|
- Bump @serve.zone/interfaces to ^6.0.1 and add App Store upgrade merge tests
|
||||||
|
|
||||||
## 2026-05-26 - 6.1.0
|
## 2026-05-26 - 6.1.0
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@
|
|||||||
"@push.rocks/webjwt": "^1.0.10",
|
"@push.rocks/webjwt": "^1.0.10",
|
||||||
"@serve.zone/api": "^5.3.8",
|
"@serve.zone/api": "^5.3.8",
|
||||||
"@serve.zone/appstore": "^0.2.0",
|
"@serve.zone/appstore": "^0.2.0",
|
||||||
"@serve.zone/interfaces": "^6.0.0",
|
"@serve.zone/interfaces": "^6.0.1",
|
||||||
"@tsclass/tsclass": "^9.5.1"
|
"@tsclass/tsclass": "^9.5.1"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
Generated
+6
-6
@@ -147,8 +147,8 @@ importers:
|
|||||||
specifier: ^0.2.0
|
specifier: ^0.2.0
|
||||||
version: 0.2.0
|
version: 0.2.0
|
||||||
'@serve.zone/interfaces':
|
'@serve.zone/interfaces':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.1
|
||||||
version: 6.0.0
|
version: 6.0.1
|
||||||
'@tsclass/tsclass':
|
'@tsclass/tsclass':
|
||||||
specifier: ^9.5.1
|
specifier: ^9.5.1
|
||||||
version: 9.5.1
|
version: 9.5.1
|
||||||
@@ -1838,8 +1838,8 @@ packages:
|
|||||||
'@serve.zone/interfaces@5.10.0':
|
'@serve.zone/interfaces@5.10.0':
|
||||||
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==}
|
||||||
|
|
||||||
'@serve.zone/interfaces@6.0.0':
|
'@serve.zone/interfaces@6.0.1':
|
||||||
resolution: {integrity: sha512-nCidhOH0XlX+7e6xaJDq6fwnwaWasB/4w2LHkV7A96G+m+7EXZqbbaKSboUlaiGDly0dWNajk2FrYFo64ZucPA==}
|
resolution: {integrity: sha512-ZeLi0Bge8qRMoZMN5/xQ/8VRI4ep9ImitpZtNuLmeNHu0pGICcBGQE4g1aMmi+E3JynKOAphH4dnVmRULZV/RA==}
|
||||||
|
|
||||||
'@shikijs/engine-oniguruma@3.23.0':
|
'@shikijs/engine-oniguruma@3.23.0':
|
||||||
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
|
||||||
@@ -7364,7 +7364,7 @@ snapshots:
|
|||||||
|
|
||||||
'@serve.zone/appstore@0.2.0':
|
'@serve.zone/appstore@0.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@serve.zone/interfaces': 6.0.0
|
'@serve.zone/interfaces': 6.0.1
|
||||||
|
|
||||||
'@serve.zone/interfaces@5.10.0':
|
'@serve.zone/interfaces@5.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7372,7 +7372,7 @@ snapshots:
|
|||||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
'@tsclass/tsclass': 9.5.1
|
'@tsclass/tsclass': 9.5.1
|
||||||
|
|
||||||
'@serve.zone/interfaces@6.0.0':
|
'@serve.zone/interfaces@6.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { CloudlyAppStoreManager } from '../ts/manager.appstore/classes.appstoremanager.js';
|
||||||
|
|
||||||
|
const createManager = () => Object.create(CloudlyAppStoreManager.prototype) as any;
|
||||||
|
|
||||||
|
tap.test('should preserve service volume overrides during App Store upgrades', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const volumes = manager.mergeUpgradeVolumes(
|
||||||
|
[
|
||||||
|
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
|
||||||
|
{ mountPath: '/cache', name: 'custom-cache' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/data',
|
||||||
|
{ mountPath: '/config', readOnly: true },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(volumes).toEqual([
|
||||||
|
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
|
||||||
|
{ mountPath: '/config', readOnly: true },
|
||||||
|
{ mountPath: '/cache', name: 'custom-cache' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should preserve service published port overrides during App Store upgrades', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const publishedPorts = manager.mergeUpgradePublishedPorts(
|
||||||
|
[
|
||||||
|
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
|
||||||
|
{ targetPort: 9999, publishedPort: 19999 },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ targetPort: 5432, publishedPort: 5432 },
|
||||||
|
{ targetPort: 6379 },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(publishedPorts).toEqual([
|
||||||
|
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
|
||||||
|
{ targetPort: 6379, protocol: 'tcp' },
|
||||||
|
{ targetPort: 9999, publishedPort: 19999, protocol: 'tcp' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should report unsupported App Store published port configs', async () => {
|
||||||
|
const manager = createManager();
|
||||||
|
const unsupported = manager.getUnsupportedPublishedPorts([
|
||||||
|
{ targetPort: 80, publishedPort: 80 },
|
||||||
|
{ targetPort: 81, publishedPort: 80 },
|
||||||
|
{ targetPort: 82, publishedPort: 82, hostIp: '127.0.0.1' },
|
||||||
|
{ targetPort: 9000, targetPortEnd: 9001, publishedPort: 19000, publishedPortEnd: 19002 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(unsupported.some((messageArg: string) => messageArg.includes('duplicates published port 80/tcp'))).toBeTrue();
|
||||||
|
expect(unsupported.some((messageArg: string) => messageArg.includes('unsupported hostIp'))).toBeTrue();
|
||||||
|
expect(unsupported.some((messageArg: string) => messageArg.includes('mismatched target and published port ranges'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -4,19 +4,77 @@ import { Image } from '../manager.image/classes.image.js';
|
|||||||
import { Service } from '../manager.service/classes.service.js';
|
import { Service } from '../manager.service/classes.service.js';
|
||||||
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
|
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
|
||||||
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
|
import { PlatformBinding } from '../manager.platform/classes.platformbinding.js';
|
||||||
|
import { commitinfo } from '../00_commitinfo_data.js';
|
||||||
|
|
||||||
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
|
type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp;
|
||||||
type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
|
type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex;
|
||||||
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
|
type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta;
|
||||||
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
|
type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig;
|
||||||
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest;
|
type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest;
|
||||||
type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export class CloudlyAppStoreManager {
|
export class CloudlyAppStoreManager {
|
||||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({
|
private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({
|
||||||
baseUrl: process.env.APPSTORE_URL || 'https://code.foss.global/serve.zone/appstore/raw/branch/main',
|
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) {
|
constructor(private cloudlyRef: Cloudly) {
|
||||||
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
@@ -70,6 +128,54 @@ export class CloudlyAppStoreManager {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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() };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAppStore(): Promise<IAppStoreIndex> {
|
public async getAppStore(): Promise<IAppStoreIndex> {
|
||||||
@@ -97,10 +203,7 @@ export class CloudlyAppStoreManager {
|
|||||||
const upgradeableServices: IUpgradeableAppStoreService[] = [];
|
const upgradeableServices: IUpgradeableAppStoreService[] = [];
|
||||||
|
|
||||||
for (const service of services) {
|
for (const service of services) {
|
||||||
const serviceData = service.data as plugins.servezoneInterfaces.data.IService['data'] & {
|
const serviceData = service.data as TExtendedServiceData;
|
||||||
appTemplateId?: string;
|
|
||||||
appTemplateVersion?: string;
|
|
||||||
};
|
|
||||||
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -108,31 +211,352 @@ export class CloudlyAppStoreManager {
|
|||||||
if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) {
|
if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let hasMigration = false;
|
||||||
|
try {
|
||||||
|
hasMigration = await this.hasMigrationScript(
|
||||||
|
serviceData.appTemplateId,
|
||||||
|
serviceData.appTemplateVersion,
|
||||||
|
appStoreApp.latestVersion,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
hasMigration = true;
|
||||||
|
}
|
||||||
upgradeableServices.push({
|
upgradeableServices.push({
|
||||||
|
serviceId: service.id,
|
||||||
serviceName: serviceData.name,
|
serviceName: serviceData.name,
|
||||||
appTemplateId: serviceData.appTemplateId,
|
appTemplateId: serviceData.appTemplateId,
|
||||||
currentVersion: serviceData.appTemplateVersion,
|
currentVersion: serviceData.appTemplateVersion,
|
||||||
latestVersion: appStoreApp.latestVersion,
|
latestVersion: appStoreApp.latestVersion,
|
||||||
hasMigration: false,
|
hasMigration,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return upgradeableServices;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async installApp(optionsArg: IAppStoreInstallOptions): Promise<Service> {
|
public async installApp(optionsArg: IAppStoreInstallOptions): Promise<Service> {
|
||||||
const appMeta = await this.getAppMeta(optionsArg.appId);
|
const appMeta = await this.getAppMeta(optionsArg.appId);
|
||||||
const version = optionsArg.version || appMeta.latestVersion;
|
const version = optionsArg.version || appMeta.latestVersion;
|
||||||
const config = await this.getAppVersionConfig(optionsArg.appId, version);
|
const config = await this.getAppVersionConfig(optionsArg.appId, version);
|
||||||
const appStoreVersion = config.appStoreVersion || version;
|
const appStoreVersion = config.appStoreVersion || version;
|
||||||
const webPort = optionsArg.port || config.port;
|
const webPort = optionsArg.port || config.port;
|
||||||
|
const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || [];
|
||||||
|
this.assertRuntimeCompatibility(config);
|
||||||
this.assertSupportedPlatformRequirements(config);
|
this.assertSupportedPlatformRequirements(config);
|
||||||
|
this.assertSupportedPublishedPorts(publishedPorts);
|
||||||
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
|
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
|
||||||
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
||||||
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
||||||
}
|
}
|
||||||
|
this.applyServiceDomainEnv(envVars, optionsArg.domain);
|
||||||
|
|
||||||
const image = await this.createAppStoreImage(optionsArg.serviceName, config.image, appMeta.description);
|
const image = await this.createAppStoreImage(
|
||||||
|
optionsArg.serviceName,
|
||||||
|
config.image,
|
||||||
|
appMeta.description,
|
||||||
|
config.resolvedImageDigest,
|
||||||
|
);
|
||||||
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
|
const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id);
|
||||||
const serviceData = {
|
const serviceData = {
|
||||||
name: optionsArg.serviceName,
|
name: optionsArg.serviceName,
|
||||||
@@ -142,6 +566,7 @@ export class CloudlyAppStoreManager {
|
|||||||
deployOnPush: false,
|
deployOnPush: false,
|
||||||
appTemplateId: optionsArg.appId,
|
appTemplateId: optionsArg.appId,
|
||||||
appTemplateVersion: appStoreVersion,
|
appTemplateVersion: appStoreVersion,
|
||||||
|
appStoreUpgradePolicy: 'manual',
|
||||||
environment: envVars,
|
environment: envVars,
|
||||||
secretBundleId: secretBundle.id,
|
secretBundleId: secretBundle.id,
|
||||||
additionalSecretBundleIds: [],
|
additionalSecretBundleIds: [],
|
||||||
@@ -153,21 +578,24 @@ export class CloudlyAppStoreManager {
|
|||||||
balancingStrategy: 'round-robin',
|
balancingStrategy: 'round-robin',
|
||||||
ports: { web: webPort },
|
ports: { web: webPort },
|
||||||
volumes: this.normalizeVolumes(config.volumes),
|
volumes: this.normalizeVolumes(config.volumes),
|
||||||
|
publishedPorts,
|
||||||
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
|
domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [],
|
||||||
deploymentIds: [],
|
deploymentIds: [],
|
||||||
} as plugins.servezoneInterfaces.data.IService['data'] & {
|
} as TExtendedServiceData;
|
||||||
appTemplateId: string;
|
|
||||||
appTemplateVersion: string;
|
|
||||||
};
|
|
||||||
const service = await Service.createService(serviceData);
|
const service = await Service.createService(serviceData);
|
||||||
secretBundle.data.serviceId = service.id;
|
secretBundle.data.serviceId = service.id;
|
||||||
await secretBundle.save();
|
await secretBundle.save();
|
||||||
await this.createPlatformBindings(service, config);
|
await this.reconcilePlatformBindings(service, config);
|
||||||
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createAppStoreImage(serviceNameArg: string, imageRefArg: string, descriptionArg: string): Promise<Image> {
|
private async createAppStoreImage(
|
||||||
|
serviceNameArg: string,
|
||||||
|
imageRefArg: string,
|
||||||
|
descriptionArg: string,
|
||||||
|
digestArg?: string,
|
||||||
|
): Promise<Image> {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.id = await Image.getNewId();
|
image.id = await Image.getNewId();
|
||||||
image.data = {
|
image.data = {
|
||||||
@@ -181,6 +609,7 @@ export class CloudlyAppStoreManager {
|
|||||||
},
|
},
|
||||||
versions: [{
|
versions: [{
|
||||||
versionString: this.getImageTag(imageRefArg),
|
versionString: this.getImageTag(imageRefArg),
|
||||||
|
digest: digestArg,
|
||||||
source: 'registry',
|
source: 'registry',
|
||||||
registryRepository: imageRefArg,
|
registryRepository: imageRefArg,
|
||||||
registryTag: this.getImageTag(imageRefArg),
|
registryTag: this.getImageTag(imageRefArg),
|
||||||
@@ -192,6 +621,65 @@ export class CloudlyAppStoreManager {
|
|||||||
return image;
|
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> {
|
private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise<SecretBundle> {
|
||||||
const secretBundle = new SecretBundle();
|
const secretBundle = new SecretBundle();
|
||||||
secretBundle.id = plugins.smartunique.shortId(8);
|
secretBundle.id = plugins.smartunique.shortId(8);
|
||||||
@@ -208,26 +696,72 @@ export class CloudlyAppStoreManager {
|
|||||||
return secretBundle;
|
return secretBundle;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createPlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
|
private async reconcilePlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) {
|
||||||
const requirements = configArg.platformRequirements || {};
|
const requirements = configArg.platformRequirements || {};
|
||||||
if (requirements.mongodb) {
|
await this.setPlatformBindingForCapability(serviceArg.id, 'database', Boolean(requirements.mongodb));
|
||||||
await PlatformBinding.upsertBinding({
|
await this.setPlatformBindingForCapability(serviceArg.id, 'objectstorage', Boolean(requirements.s3));
|
||||||
id: await PlatformBinding.getNewId(),
|
}
|
||||||
serviceId: serviceArg.id,
|
|
||||||
capability: 'database',
|
private async setPlatformBindingForCapability(
|
||||||
desiredState: 'enabled',
|
serviceIdArg: string,
|
||||||
status: 'requested',
|
capabilityArg: 'database' | 'objectstorage',
|
||||||
|
enabledArg: boolean,
|
||||||
|
) {
|
||||||
|
let existingBinding: PlatformBinding | undefined;
|
||||||
|
try {
|
||||||
|
existingBinding = await PlatformBinding.getInstance({
|
||||||
|
serviceId: serviceIdArg,
|
||||||
|
capability: capabilityArg,
|
||||||
});
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!enabledArg && !existingBinding) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (requirements.s3) {
|
|
||||||
|
if (!enabledArg) {
|
||||||
|
const bindingToDisable = existingBinding!;
|
||||||
await PlatformBinding.upsertBinding({
|
await PlatformBinding.upsertBinding({
|
||||||
id: await PlatformBinding.getNewId(),
|
id: bindingToDisable.id,
|
||||||
serviceId: serviceArg.id,
|
serviceId: serviceIdArg,
|
||||||
capability: 'objectstorage',
|
capability: capabilityArg,
|
||||||
desiredState: 'enabled',
|
desiredState: 'disabled',
|
||||||
status: 'requested',
|
status: 'disabled',
|
||||||
|
providerConfigId: bindingToDisable.providerConfigId,
|
||||||
|
config: bindingToDisable.config,
|
||||||
|
endpoints: bindingToDisable.endpoints,
|
||||||
|
credentials: bindingToDisable.credentials,
|
||||||
|
createdAt: bindingToDisable.createdAt,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.upsertPlatformBindingForCapability(serviceIdArg, capabilityArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertPlatformBindingForCapability(
|
||||||
|
serviceIdArg: string,
|
||||||
|
capabilityArg: 'database' | 'objectstorage',
|
||||||
|
) {
|
||||||
|
let existingBinding: PlatformBinding | undefined;
|
||||||
|
try {
|
||||||
|
existingBinding = await PlatformBinding.getInstance({
|
||||||
|
serviceId: serviceIdArg,
|
||||||
|
capability: capabilityArg,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
await PlatformBinding.upsertBinding({
|
||||||
|
id: existingBinding?.id || await PlatformBinding.getNewId(),
|
||||||
|
serviceId: serviceIdArg,
|
||||||
|
capability: capabilityArg,
|
||||||
|
desiredState: 'enabled',
|
||||||
|
status: existingBinding?.desiredState === 'disabled' ? 'requested' : existingBinding?.status || 'requested',
|
||||||
|
providerConfigId: existingBinding?.providerConfigId,
|
||||||
|
config: existingBinding?.config,
|
||||||
|
endpoints: existingBinding?.endpoints,
|
||||||
|
credentials: existingBinding?.credentials,
|
||||||
|
createdAt: existingBinding?.createdAt,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
|
private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) {
|
||||||
@@ -239,6 +773,78 @@ export class CloudlyAppStoreManager {
|
|||||||
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
}).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;
|
||||||
|
}
|
||||||
|
|
||||||
private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
|
private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record<string, string>): Record<string, string> {
|
||||||
const envVars: Record<string, string> = {};
|
const envVars: Record<string, string> = {};
|
||||||
const missingRequiredEnvVars: string[] = [];
|
const missingRequiredEnvVars: string[] = [];
|
||||||
@@ -260,15 +866,195 @@ export class CloudlyAppStoreManager {
|
|||||||
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
|
private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) {
|
||||||
const unsupported = Object.entries(configArg.platformRequirements || {})
|
const unsupported = this.getUnsupportedPlatformRequirements(configArg);
|
||||||
.filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3')
|
|
||||||
.map(([key]) => key);
|
|
||||||
if (unsupported.length > 0) {
|
if (unsupported.length > 0) {
|
||||||
throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`);
|
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) {
|
private getImageTag(imageRefArg: string) {
|
||||||
const lastSlashIndex = imageRefArg.lastIndexOf('/');
|
const lastSlashIndex = imageRefArg.lastIndexOf('/');
|
||||||
const lastColonIndex = imageRefArg.lastIndexOf(':');
|
const lastColonIndex = imageRefArg.lastIndexOf(':');
|
||||||
|
|||||||
@@ -35,6 +35,63 @@ export interface IDataState {
|
|||||||
backups?: any[];
|
backups?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TAppStoreUpgradeStatus = 'running' | 'success' | 'failed';
|
||||||
|
export type TAppStoreUpgradeStep =
|
||||||
|
| 'queued'
|
||||||
|
| 'validating'
|
||||||
|
| 'migration'
|
||||||
|
| 'applying'
|
||||||
|
| 'updating-service'
|
||||||
|
| 'pushing-config'
|
||||||
|
| 'complete'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface IAppStoreUpgradeChange {
|
||||||
|
field: string;
|
||||||
|
currentValue: string;
|
||||||
|
targetValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAppStoreUpgradePreview {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
appTemplateId: string;
|
||||||
|
fromVersion: string;
|
||||||
|
targetVersion: string;
|
||||||
|
resolvedTargetVersion: string;
|
||||||
|
hasMigration: boolean;
|
||||||
|
requiresManualReview: boolean;
|
||||||
|
changes: IAppStoreUpgradeChange[];
|
||||||
|
warnings: string[];
|
||||||
|
blockers: string[];
|
||||||
|
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
|
||||||
|
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAppStoreUpgradeOperation {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
appTemplateId: string;
|
||||||
|
fromVersion: string;
|
||||||
|
targetVersion: string;
|
||||||
|
status: TAppStoreUpgradeStatus;
|
||||||
|
step: TAppStoreUpgradeStep;
|
||||||
|
progressLines: string[];
|
||||||
|
warnings: string[];
|
||||||
|
error?: string;
|
||||||
|
startedAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
service?: plugins.interfaces.data.IService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAppStoreState {
|
||||||
|
apps: plugins.interfaces.appstore.IAppStoreApp[];
|
||||||
|
upgradeableServices: Array<plugins.interfaces.appstore.IUpgradeableAppStoreService & { serviceId?: string }>;
|
||||||
|
upgradeOperations: IAppStoreUpgradeOperation[];
|
||||||
|
}
|
||||||
|
|
||||||
const emptyDataState: IDataState = {
|
const emptyDataState: IDataState = {
|
||||||
secretGroups: [],
|
secretGroups: [],
|
||||||
secretBundles: [],
|
secretBundles: [],
|
||||||
@@ -54,6 +111,12 @@ const emptyDataState: IDataState = {
|
|||||||
backups: [],
|
backups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emptyAppStoreState: IAppStoreState = {
|
||||||
|
apps: [],
|
||||||
|
upgradeableServices: [],
|
||||||
|
upgradeOperations: [],
|
||||||
|
};
|
||||||
|
|
||||||
interface IReq_AdminValidateIdentity {
|
interface IReq_AdminValidateIdentity {
|
||||||
method: 'adminValidateIdentity';
|
method: 'adminValidateIdentity';
|
||||||
request: {
|
request: {
|
||||||
@@ -119,6 +182,7 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) =>
|
|||||||
try {
|
try {
|
||||||
apiClient.identity = null;
|
apiClient.identity = null;
|
||||||
dataState.setState({ ...emptyDataState });
|
dataState.setState({ ...emptyDataState });
|
||||||
|
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||||
} catch {}
|
} catch {}
|
||||||
return {
|
return {
|
||||||
...currentState,
|
...currentState,
|
||||||
@@ -132,6 +196,12 @@ export const dataState = await appstate.getStatePart<IDataState>(
|
|||||||
'soft'
|
'soft'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const appStoreStatePart = await appstate.getStatePart<IAppStoreState>(
|
||||||
|
'appstore',
|
||||||
|
{ ...emptyAppStoreState },
|
||||||
|
'soft',
|
||||||
|
);
|
||||||
|
|
||||||
// Shared API client instance (used by UI actions)
|
// Shared API client instance (used by UI actions)
|
||||||
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
type TCloudlyApiClientWithNullableIdentity = Omit<plugins.servezoneApi.CloudlyApiClient, 'identity'> & {
|
||||||
identity: plugins.interfaces.data.IIdentity | null;
|
identity: plugins.interfaces.data.IIdentity | null;
|
||||||
@@ -142,6 +212,54 @@ export const apiClient = new plugins.servezoneApi.CloudlyApiClient({
|
|||||||
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
|
cloudlyUrl: (typeof window !== 'undefined' && window.location?.origin) ? window.location.origin : undefined,
|
||||||
}) as TCloudlyApiClientWithNullableIdentity;
|
}) as TCloudlyApiClientWithNullableIdentity;
|
||||||
|
|
||||||
|
const upsertUpgradeOperation = (
|
||||||
|
operationsArg: IAppStoreUpgradeOperation[],
|
||||||
|
operationArg: IAppStoreUpgradeOperation,
|
||||||
|
) => {
|
||||||
|
const operations = operationsArg.filter((existingOperation) => existingOperation.id !== operationArg.id);
|
||||||
|
operations.unshift(operationArg);
|
||||||
|
return operations.slice(0, 25);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertService = (
|
||||||
|
servicesArg: plugins.interfaces.data.IService[] = [],
|
||||||
|
serviceArg: plugins.interfaces.data.IService,
|
||||||
|
) => {
|
||||||
|
const services = servicesArg.filter((existingService) => existingService.id !== serviceArg.id);
|
||||||
|
services.unshift(serviceArg);
|
||||||
|
return services;
|
||||||
|
};
|
||||||
|
|
||||||
|
apiClient.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<any>(
|
||||||
|
'pushAppStoreUpgradeProgress',
|
||||||
|
async (dataArg: { operation: IAppStoreUpgradeOperation }) => {
|
||||||
|
const appStoreState = appStoreStatePart.getState() || {
|
||||||
|
apps: [],
|
||||||
|
upgradeableServices: [],
|
||||||
|
upgradeOperations: [],
|
||||||
|
};
|
||||||
|
appStoreStatePart.setState({
|
||||||
|
...appStoreState,
|
||||||
|
upgradeOperations: upsertUpgradeOperation(appStoreState.upgradeOperations, dataArg.operation),
|
||||||
|
upgradeableServices: dataArg.operation.status === 'success'
|
||||||
|
? appStoreState.upgradeableServices.filter((serviceArg) => {
|
||||||
|
return serviceArg.serviceId !== dataArg.operation.serviceId && serviceArg.serviceName !== dataArg.operation.serviceName;
|
||||||
|
})
|
||||||
|
: appStoreState.upgradeableServices,
|
||||||
|
});
|
||||||
|
if (dataArg.operation.service) {
|
||||||
|
const currentDataState = dataState.getState() || {};
|
||||||
|
dataState.setState({
|
||||||
|
...currentDataState,
|
||||||
|
services: upsertService(currentDataState.services, dataArg.operation.service),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
let identityExpiryTimer: number | undefined;
|
let identityExpiryTimer: number | undefined;
|
||||||
let identityInvalidationRunning = false;
|
let identityInvalidationRunning = false;
|
||||||
|
|
||||||
@@ -184,6 +302,7 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P
|
|||||||
identity: null,
|
identity: null,
|
||||||
});
|
});
|
||||||
dataState.setState({ ...emptyDataState });
|
dataState.setState({ ...emptyDataState });
|
||||||
|
appStoreStatePart.setState({ ...emptyAppStoreState });
|
||||||
} finally {
|
} finally {
|
||||||
identityInvalidationRunning = false;
|
identityInvalidationRunning = false;
|
||||||
}
|
}
|
||||||
@@ -737,3 +856,94 @@ export const addClusterAction = dataState.createAction(
|
|||||||
return await context.dispatch(getAllDataAction, null);
|
return await context.dispatch(getAllDataAction, null);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getIdentityForRequest = () => {
|
||||||
|
const identity = loginStatePart.getState()?.identity ?? null;
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error('No Cloudly identity is available');
|
||||||
|
}
|
||||||
|
return identity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction(
|
||||||
|
async (statePartArg) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreTemplates');
|
||||||
|
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||||
|
return {
|
||||||
|
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||||
|
apps: response.apps || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchUpgradeableAppStoreServicesAction = appStoreStatePart.createAction(
|
||||||
|
async (statePartArg) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getUpgradeableAppStoreServices');
|
||||||
|
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||||
|
return {
|
||||||
|
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||||
|
upgradeableServices: response.services || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const fetchAppStoreUpgradeOperationsAction = appStoreStatePart.createAction(
|
||||||
|
async (statePartArg) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradeOperations');
|
||||||
|
const response = await request.fire({ identity: getIdentityForRequest() });
|
||||||
|
return {
|
||||||
|
...(statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] }),
|
||||||
|
upgradeOperations: response.operations || [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const startAppStoreServiceUpgradeAction = appStoreStatePart.createAction<{
|
||||||
|
serviceId: string;
|
||||||
|
targetVersion: string;
|
||||||
|
}>(
|
||||||
|
async (statePartArg, payloadArg) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'startAppStoreServiceUpgrade');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: getIdentityForRequest(),
|
||||||
|
serviceId: payloadArg.serviceId,
|
||||||
|
targetVersion: payloadArg.targetVersion,
|
||||||
|
});
|
||||||
|
const currentState = statePartArg.getState() || { apps: [], upgradeableServices: [], upgradeOperations: [] };
|
||||||
|
return {
|
||||||
|
...currentState,
|
||||||
|
upgradeOperations: upsertUpgradeOperation(currentState.upgradeOperations, response.operation),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAppStoreConfig = async (appIdArg: string, versionArg: string) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreConfig');
|
||||||
|
return await request.fire({
|
||||||
|
identity: getIdentityForRequest(),
|
||||||
|
appId: appIdArg,
|
||||||
|
version: versionArg,
|
||||||
|
}) as {
|
||||||
|
config: plugins.interfaces.appstore.IAppStoreVersionConfig;
|
||||||
|
appMeta: plugins.interfaces.appstore.IAppStoreAppMeta;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAppStoreUpgradePreview = async (serviceIdArg: string, targetVersionArg?: string) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'getAppStoreUpgradePreview');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: getIdentityForRequest(),
|
||||||
|
serviceId: serviceIdArg,
|
||||||
|
targetVersion: targetVersionArg,
|
||||||
|
});
|
||||||
|
return response.preview as IAppStoreUpgradePreview;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const installAppStoreApp = async (installArg: plugins.interfaces.appstore.IAppStoreInstallRequest) => {
|
||||||
|
const request = new plugins.typedrequest.TypedRequest<any>('/typedrequest', 'installAppStoreApp');
|
||||||
|
const response = await request.fire({
|
||||||
|
identity: getIdentityForRequest(),
|
||||||
|
install: installArg,
|
||||||
|
});
|
||||||
|
return response.service as plugins.interfaces.data.IService;
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { CloudlyViewDbs } from './views/dbs/index.js';
|
|||||||
import { CloudlyViewDeployments } from './views/deployments/index.js';
|
import { CloudlyViewDeployments } from './views/deployments/index.js';
|
||||||
import { CloudlyViewDns } from './views/dns/index.js';
|
import { CloudlyViewDns } from './views/dns/index.js';
|
||||||
import { CloudlyViewDomains } from './views/domains/index.js';
|
import { CloudlyViewDomains } from './views/domains/index.js';
|
||||||
|
import { CloudlyViewAppStore } from './views/appstore/index.js';
|
||||||
import { CloudlyViewImages } from './views/images/index.js';
|
import { CloudlyViewImages } from './views/images/index.js';
|
||||||
import { CloudlyViewLogs } from './views/logs/index.js';
|
import { CloudlyViewLogs } from './views/logs/index.js';
|
||||||
import { CloudlyViewMails } from './views/mails/index.js';
|
import { CloudlyViewMails } from './views/mails/index.js';
|
||||||
@@ -79,6 +80,7 @@ export class CloudlyDashboard extends DeesElement {
|
|||||||
iconName: 'lucide:Network',
|
iconName: 'lucide:Network',
|
||||||
subViews: [
|
subViews: [
|
||||||
{ slug: 'clusters', name: 'Clusters', iconName: 'lucide:Network', element: CloudlyViewClusters },
|
{ slug: 'clusters', name: 'Clusters', iconName: 'lucide:Network', element: CloudlyViewClusters },
|
||||||
|
{ slug: 'appstore', name: 'App Store', iconName: 'lucide:Store', element: CloudlyViewAppStore },
|
||||||
{ slug: 'services', name: 'Services', iconName: 'lucide:Layers', element: CloudlyViewServices },
|
{ slug: 'services', name: 'Services', iconName: 'lucide:Layers', element: CloudlyViewServices },
|
||||||
{ slug: 'images', name: 'Images', iconName: 'lucide:Image', element: CloudlyViewImages },
|
{ slug: 'images', name: 'Images', iconName: 'lucide:Image', element: CloudlyViewImages },
|
||||||
{ slug: 'deployments', name: 'Deployments', iconName: 'lucide:Rocket', element: CloudlyViewDeployments },
|
{ slug: 'deployments', name: 'Deployments', iconName: 'lucide:Rocket', element: CloudlyViewDeployments },
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import * as plugins from '../../../plugins.js';
|
||||||
|
import * as shared from '../../shared/index.js';
|
||||||
|
import * as appstate from '../../../appstate.js';
|
||||||
|
import { appRouter } from '../../../router.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DeesElement,
|
||||||
|
customElement,
|
||||||
|
html,
|
||||||
|
state,
|
||||||
|
css,
|
||||||
|
cssManager,
|
||||||
|
type TemplateResult,
|
||||||
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
|
type TEditableEnvVar = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
required?: boolean;
|
||||||
|
platformInjected?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('cloudly-view-appstore')
|
||||||
|
export class CloudlyViewAppStore extends DeesElement {
|
||||||
|
@state()
|
||||||
|
private accessor appStoreState: appstate.IAppStoreState = {
|
||||||
|
apps: [],
|
||||||
|
upgradeableServices: [],
|
||||||
|
upgradeOperations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor currentView: 'grid' | 'detail' = 'grid';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedApp: plugins.interfaces.appstore.IAppStoreApp | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedAppMeta: plugins.interfaces.appstore.IAppStoreAppMeta | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedAppConfig: plugins.interfaces.appstore.IAppStoreVersionConfig | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor configLoadError = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor selectedVersion = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor editableEnvVars: TEditableEnvVar[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor serviceName = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor serviceDomain = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor deployMode = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor loading = false;
|
||||||
|
|
||||||
|
private configRequestToken = 0;
|
||||||
|
|
||||||
|
public static styles = [
|
||||||
|
cssManager.defaultStyles,
|
||||||
|
shared.viewHostCss,
|
||||||
|
css`
|
||||||
|
.card { background: var(--ci-shade-1, #09090b); border: 1px solid var(--ci-shade-2, #27272a); border-radius: 9px; padding: 16px; margin-bottom: 14px; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||||
|
.title { margin: 0; color: var(--ci-shade-7, #e4e4e7); font-size: 24px; font-weight: 700; }
|
||||||
|
.subtitle { margin-top: 6px; color: var(--ci-shade-4, #71717a); font-size: 14px; line-height: 1.5; }
|
||||||
|
.section-title { color: var(--ci-shade-7, #e4e4e7); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||||||
|
.badge { display: inline-flex; padding: 3px 9px; border-radius: 999px; background: rgba(59, 130, 246, 0.16); color: #60a5fa; font-size: 12px; margin: 0 6px 6px 0; }
|
||||||
|
.button { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 7px; padding: 9px 13px; font-size: 13px; cursor: pointer; background: var(--ci-shade-1, #09090b); color: var(--ci-shade-7, #e4e4e7); }
|
||||||
|
.button.primary { background: var(--ci-color-primary, #2563eb); border-color: var(--ci-color-primary, #2563eb); color: white; }
|
||||||
|
.button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
.actions { display: flex; gap: 10px; align-items: center; margin-top: 14px; }
|
||||||
|
.field { display: grid; gap: 6px; margin-top: 12px; }
|
||||||
|
.field label { color: var(--ci-shade-5, #a1a1aa); font-size: 12px; font-weight: 600; }
|
||||||
|
input, select { width: 100%; box-sizing: border-box; background: var(--ci-shade-2, #27272a); border: 1px solid var(--ci-shade-3, #3f3f46); border-radius: 6px; padding: 9px 10px; color: var(--ci-shade-7, #e4e4e7); }
|
||||||
|
.env-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.env-table th, .env-table td { text-align: left; padding: 7px 8px 7px 0; border-bottom: 1px solid var(--ci-shade-2, #27272a); vertical-align: top; }
|
||||||
|
.env-key, .mono { font-family: monospace; color: var(--ci-shade-6, #d4d4d8); overflow-wrap: anywhere; }
|
||||||
|
.muted { color: var(--ci-shade-4, #71717a); font-size: 12px; }
|
||||||
|
.warning { margin-top: 10px; padding: 10px 12px; border-radius: 7px; background: rgba(245, 158, 11, 0.12); color: #fbbf24; font-size: 12px; }
|
||||||
|
.operation { display: grid; gap: 7px; }
|
||||||
|
.operation-log { max-height: 120px; overflow: auto; white-space: pre-wrap; font-family: monospace; font-size: 12px; color: var(--ci-shade-5, #a1a1aa); background: var(--ci-shade-0, #030305); border-radius: 6px; padding: 10px; }
|
||||||
|
@media (max-width: 760px) { .header { flex-direction: column; } .actions { flex-direction: column; align-items: stretch; } }
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const subscription = appstate.appStoreStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((stateArg) => {
|
||||||
|
this.appStoreState = stateArg;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(subscription);
|
||||||
|
const loginSubscription = appstate.loginStatePart
|
||||||
|
.select((stateArg) => stateArg.identity)
|
||||||
|
.subscribe((identityArg) => {
|
||||||
|
if (identityArg) {
|
||||||
|
void this.refreshAppStoreData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(loginSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
await this.refreshAppStoreData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAppStoreData() {
|
||||||
|
if (!appstate.loginStatePart.getState()?.identity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.allSettled([
|
||||||
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreTemplatesAction, null),
|
||||||
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
||||||
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): TemplateResult {
|
||||||
|
if (this.currentView === 'detail') {
|
||||||
|
return this.renderDetailView();
|
||||||
|
}
|
||||||
|
return this.renderGridView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderGridView(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
||||||
|
${this.renderOperations()}
|
||||||
|
<dees-table
|
||||||
|
.heading1=${'App Store Apps'}
|
||||||
|
.heading2=${'Install workload services that follow a serve.zone App Store template'}
|
||||||
|
.data=${this.appStoreState.apps}
|
||||||
|
.displayFunction=${(appArg: plugins.interfaces.appstore.IAppStoreApp) => ({
|
||||||
|
Name: appArg.name,
|
||||||
|
Category: html`<span class="badge">${appArg.category}</span>`,
|
||||||
|
Version: appArg.latestVersion,
|
||||||
|
Source: appArg.source?.type || 'curated',
|
||||||
|
Tags: appArg.tags?.join(', ') || '-',
|
||||||
|
})}
|
||||||
|
.dataActions=${[
|
||||||
|
{
|
||||||
|
name: 'Details',
|
||||||
|
iconName: 'lucide:Eye',
|
||||||
|
type: ['contextmenu', 'inRow', 'doubleClick'],
|
||||||
|
actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Install',
|
||||||
|
iconName: 'lucide:Download',
|
||||||
|
type: ['contextmenu', 'inRow'],
|
||||||
|
actionFunc: async (actionDataArg: any) => this.openApp(actionDataArg.item, true),
|
||||||
|
},
|
||||||
|
] as plugins.deesCatalog.ITableAction[]}
|
||||||
|
></dees-table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderOperations(): TemplateResult | '' {
|
||||||
|
const operations = this.appStoreState.upgradeOperations
|
||||||
|
.slice(0, 3);
|
||||||
|
if (operations.length === 0) return '';
|
||||||
|
return html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">Recent Upgrade Operations</div>
|
||||||
|
${operations.map((operationArg) => html`
|
||||||
|
<div class="operation">
|
||||||
|
<div class="mono">${operationArg.serviceName}: ${operationArg.fromVersion} -> ${operationArg.targetVersion} (${operationArg.status}/${operationArg.step})</div>
|
||||||
|
<div class="operation-log">${operationArg.progressLines.slice(-6).join('\n')}</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderDetailView(): TemplateResult {
|
||||||
|
const app = this.selectedApp;
|
||||||
|
const meta = this.selectedAppMeta;
|
||||||
|
const config = this.selectedAppConfig;
|
||||||
|
if (this.configLoadError) {
|
||||||
|
return html`
|
||||||
|
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
||||||
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back to App Store</button>
|
||||||
|
<div class="card" style="margin-top: 14px;">
|
||||||
|
<div class="section-title">Could not load app details</div>
|
||||||
|
<div class="warning">${this.configLoadError}</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back</button>
|
||||||
|
${this.selectedApp ? html`<button class="button primary" @click=${async () => {
|
||||||
|
this.loading = true;
|
||||||
|
await this.fetchVersionConfig(this.selectedApp!.id, this.selectedVersion || this.selectedApp!.latestVersion);
|
||||||
|
this.loading = false;
|
||||||
|
}}>Retry</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (this.loading || !app || !config) {
|
||||||
|
return html`<cloudly-sectionheading>App Store</cloudly-sectionheading><div class="card">Loading app details...</div>`;
|
||||||
|
}
|
||||||
|
const platformRequirements = config.platformRequirements || {};
|
||||||
|
const enabledRequirements = Object.entries(platformRequirements).filter(([, enabled]) => enabled);
|
||||||
|
const volumes = this.getConfigVolumes(config);
|
||||||
|
const publishedPorts = config.publishedPorts || [];
|
||||||
|
return html`
|
||||||
|
<cloudly-sectionheading>App Store</cloudly-sectionheading>
|
||||||
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back to App Store</button>
|
||||||
|
<div class="card" style="margin-top: 14px;">
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h2 class="title">${app.name}</h2>
|
||||||
|
<div class="subtitle">${app.description}</div>
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<span class="badge">${app.category}</span>
|
||||||
|
${app.tags?.map((tagArg) => html`<span class="badge">${tagArg}</span>`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">${config.image}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">Version</div>
|
||||||
|
<select @change=${(eventArg: Event) => this.changeVersion((eventArg.target as HTMLSelectElement).value)}>
|
||||||
|
${(meta?.versions || [this.selectedVersion]).map((versionArg) => html`
|
||||||
|
<option value=${versionArg} ?selected=${versionArg === this.selectedVersion}>${versionArg}${versionArg === app.latestVersion ? ' (latest)' : ''}</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
${config.minCloudlyVersion ? html`<div class="muted" style="margin-top: 8px;">Requires Cloudly >= ${config.minCloudlyVersion}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${enabledRequirements.length ? html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">Platform Requirements</div>
|
||||||
|
${enabledRequirements.map(([key]) => html`<span class="badge">${key}</span>`)}
|
||||||
|
<div class="muted">Cloudly currently provisions MongoDB and S3 requirements through platform bindings.</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${(volumes.length || publishedPorts.length) ? html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">Deployment Footprint</div>
|
||||||
|
${volumes.map((volumeArg) => html`<div class="mono">Volume: ${volumeArg.source || volumeArg.name || 'managed'} -> ${volumeArg.mountPath}</div>`)}
|
||||||
|
${publishedPorts.map((portArg) => html`<div class="mono">Published port: ${this.formatPublishedPort(portArg)}</div>`)}
|
||||||
|
${publishedPorts.length ? html`<div class="warning">This app publishes raw host ports outside the HTTP proxy.</div>` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.editableEnvVars.length ? html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">Environment</div>
|
||||||
|
<table class="env-table">
|
||||||
|
<thead><tr><th>Key</th><th>Value</th><th>Description</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
${this.editableEnvVars.map((envVarArg, indexArg) => html`
|
||||||
|
<tr>
|
||||||
|
<td class="env-key">${envVarArg.key}${envVarArg.required ? html` <span class="badge">required</span>` : ''}</td>
|
||||||
|
<td><input .value=${envVarArg.value} ?disabled=${envVarArg.platformInjected || !this.deployMode} @input=${(eventArg: Event) => this.updateEnvVar(indexArg, (eventArg.target as HTMLInputElement).value)} /></td>
|
||||||
|
<td class="muted">${envVarArg.description}${envVarArg.platformInjected ? ' Auto-injected by platform.' : ''}</td>
|
||||||
|
</tr>
|
||||||
|
`)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${this.deployMode ? html`
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">Install Service</div>
|
||||||
|
<div class="field"><label>Service name</label><input .value=${this.serviceName} @input=${(eventArg: Event) => { this.serviceName = (eventArg.target as HTMLInputElement).value; }} /></div>
|
||||||
|
<div class="field"><label>Domain</label><input .value=${this.serviceDomain} @input=${(eventArg: Event) => { this.serviceDomain = this.normalizeDomain((eventArg.target as HTMLInputElement).value); }} /></div>
|
||||||
|
<div class="muted" style="margin-top: 8px;">Domain is required when the template uses SERVICE_DOMAIN.</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button" @click=${() => { this.deployMode = false; }}>Cancel</button>
|
||||||
|
<button class="button primary" @click=${() => this.installSelectedApp()}>Install ${this.selectedVersion}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : html`
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button" @click=${() => { this.currentView = 'grid'; }}>Back</button>
|
||||||
|
<button class="button primary" @click=${() => { this.deployMode = true; }}>Install this App</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openApp(appArg: plugins.interfaces.appstore.IAppStoreApp, deployModeArg: boolean) {
|
||||||
|
this.selectedApp = appArg;
|
||||||
|
this.selectedAppMeta = null;
|
||||||
|
this.selectedAppConfig = null;
|
||||||
|
this.configLoadError = '';
|
||||||
|
this.selectedVersion = appArg.latestVersion;
|
||||||
|
this.serviceName = appArg.id;
|
||||||
|
this.serviceDomain = '';
|
||||||
|
this.deployMode = deployModeArg;
|
||||||
|
this.loading = true;
|
||||||
|
this.currentView = 'detail';
|
||||||
|
await this.fetchVersionConfig(appArg.id, appArg.latestVersion);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async changeVersion(versionArg: string) {
|
||||||
|
if (!this.selectedApp || this.selectedVersion === versionArg) return;
|
||||||
|
this.selectedVersion = versionArg;
|
||||||
|
this.loading = true;
|
||||||
|
await this.fetchVersionConfig(this.selectedApp.id, versionArg);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchVersionConfig(appIdArg: string, versionArg: string): Promise<boolean> {
|
||||||
|
const requestToken = ++this.configRequestToken;
|
||||||
|
this.configLoadError = '';
|
||||||
|
this.selectedAppConfig = null;
|
||||||
|
try {
|
||||||
|
const response = await appstate.getAppStoreConfig(appIdArg, versionArg);
|
||||||
|
if (requestToken !== this.configRequestToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.selectedAppMeta = response.appMeta;
|
||||||
|
this.selectedAppConfig = response.config;
|
||||||
|
this.editableEnvVars = (response.config.envVars || []).map((envVarArg) => ({
|
||||||
|
key: envVarArg.key,
|
||||||
|
value: envVarArg.value || '',
|
||||||
|
description: envVarArg.description || '',
|
||||||
|
required: envVarArg.required,
|
||||||
|
platformInjected: Boolean(envVarArg.value?.includes('${') && !envVarArg.value.includes('${SERVICE_DOMAIN}')),
|
||||||
|
}));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (requestToken === this.configRequestToken) {
|
||||||
|
this.configLoadError = (error as Error).message;
|
||||||
|
this.editableEnvVars = [];
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Failed to load app config: ${(error as Error).message}`, type: 'error' });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateEnvVar(indexArg: number, valueArg: string) {
|
||||||
|
const envVars = [...this.editableEnvVars];
|
||||||
|
envVars[indexArg] = { ...envVars[indexArg], value: valueArg };
|
||||||
|
this.editableEnvVars = envVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installSelectedApp() {
|
||||||
|
if (!this.selectedApp || !this.selectedAppConfig) return;
|
||||||
|
const missingEnvVars = this.editableEnvVars.filter((envVarArg) => envVarArg.required && !envVarArg.platformInjected && !envVarArg.value.trim());
|
||||||
|
if (missingEnvVars.length) {
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Missing env vars: ${missingEnvVars.map((envVarArg) => envVarArg.key).join(', ')}`, type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const needsDomain = (this.selectedAppConfig.envVars || []).some((envVarArg) => envVarArg.value?.includes('${SERVICE_DOMAIN}'));
|
||||||
|
if (needsDomain && !this.serviceDomain) {
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'A domain is required for this app.', type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
for (const envVar of this.editableEnvVars) {
|
||||||
|
if (envVar.key && envVar.value) {
|
||||||
|
envVars[envVar.key] = envVar.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await appstate.installAppStoreApp({
|
||||||
|
appId: this.selectedApp.id,
|
||||||
|
version: this.selectedVersion,
|
||||||
|
serviceName: this.serviceName || this.selectedApp.id,
|
||||||
|
domain: this.serviceDomain || undefined,
|
||||||
|
envVars,
|
||||||
|
});
|
||||||
|
await Promise.allSettled([
|
||||||
|
appstate.dataState.dispatchAction(appstate.getAllDataAction, null),
|
||||||
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
||||||
|
]);
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: 'App Store service installed', type: 'success' });
|
||||||
|
appRouter.navigateToView('runtime', 'services');
|
||||||
|
} catch (error) {
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Install failed: ${(error as Error).message}`, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConfigVolumes(configArg: plugins.interfaces.appstore.IAppStoreVersionConfig) {
|
||||||
|
return (configArg.volumes || []).map((volumeArg) => {
|
||||||
|
if (typeof volumeArg === 'string') {
|
||||||
|
return { mountPath: volumeArg };
|
||||||
|
}
|
||||||
|
return volumeArg;
|
||||||
|
}).filter((volumeArg) => Boolean(volumeArg.mountPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatPublishedPort(portArg: plugins.interfaces.appstore.IAppStorePublishedPort): string {
|
||||||
|
const protocol = portArg.protocol || 'tcp';
|
||||||
|
const target = portArg.targetPortEnd ? `${portArg.targetPort}-${portArg.targetPortEnd}` : String(portArg.targetPort);
|
||||||
|
const publishedStart = portArg.publishedPort || portArg.targetPort;
|
||||||
|
const publishedEnd = portArg.publishedPortEnd || (portArg.targetPortEnd ? publishedStart + (portArg.targetPortEnd - portArg.targetPort) : undefined);
|
||||||
|
const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart);
|
||||||
|
return `${portArg.hostIp || '0.0.0.0'}:${published}/${protocol} -> ${target}/${protocol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDomain(valueArg: string) {
|
||||||
|
return valueArg.trim().replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'cloudly-view-appstore': CloudlyViewAppStore;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,13 @@ export class CloudlyViewServices extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
private accessor upgradeInfo: any = null;
|
private accessor upgradeInfo: any = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor appStoreState: appstate.IAppStoreState = {
|
||||||
|
apps: [],
|
||||||
|
upgradeableServices: [],
|
||||||
|
upgradeOperations: [],
|
||||||
|
};
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null;
|
private accessor workspaceEnvironment: DeploymentExecutionEnvironment | null = null;
|
||||||
|
|
||||||
@@ -46,8 +53,20 @@ export class CloudlyViewServices extends DeesElement {
|
|||||||
.select((stateArg) => stateArg)
|
.select((stateArg) => stateArg)
|
||||||
.subscribe((dataArg) => {
|
.subscribe((dataArg) => {
|
||||||
this.data = dataArg;
|
this.data = dataArg;
|
||||||
|
if (this.selectedService) {
|
||||||
|
const updatedService = dataArg.services?.find((serviceArg) => serviceArg.id === this.selectedService!.id);
|
||||||
|
if (updatedService) {
|
||||||
|
this.selectedService = updatedService;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.rxSubscriptions.push(subscription);
|
this.rxSubscriptions.push(subscription);
|
||||||
|
const appStoreSubscription = appstate.appStoreStatePart
|
||||||
|
.select((stateArg) => stateArg)
|
||||||
|
.subscribe((stateArg) => {
|
||||||
|
this.appStoreState = stateArg;
|
||||||
|
});
|
||||||
|
this.rxSubscriptions.push(appStoreSubscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
@@ -301,6 +320,8 @@ export class CloudlyViewServices extends DeesElement {
|
|||||||
appTemplateId?: string;
|
appTemplateId?: string;
|
||||||
appTemplateVersion?: string;
|
appTemplateVersion?: string;
|
||||||
};
|
};
|
||||||
|
const upgradeOperation = this.getUpgradeOperationForService(service);
|
||||||
|
const upgradeInfo = this.getUpgradeInfoForService(service);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
<cloudly-sectionheading>Service Details</cloudly-sectionheading>
|
||||||
@@ -312,13 +333,19 @@ export class CloudlyViewServices extends DeesElement {
|
|||||||
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
<button class="back-button" @click=${() => { this.currentView = 'list'; }}>Back to Services</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${this.upgradeInfo ? html`
|
${upgradeOperation ? this.renderUpgradeOperation(upgradeOperation) : ''}
|
||||||
|
|
||||||
|
${upgradeInfo ? html`
|
||||||
<div class="update-card">
|
<div class="update-card">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-title" style="margin-bottom: 3px;">App catalog update available</div>
|
<div class="section-title" style="margin-bottom: 3px;">App catalog update available</div>
|
||||||
<div class="detail-subtitle">${this.upgradeInfo.appTemplateId}: ${this.upgradeInfo.currentVersion} -> ${this.upgradeInfo.latestVersion}</div>
|
<div class="detail-subtitle">${upgradeInfo.appTemplateId}: ${upgradeInfo.currentVersion} -> ${upgradeInfo.latestVersion}</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="primary-button" disabled title="Cloudly does not yet have catalog upgrade apply support">Detected</button>
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
?disabled=${upgradeOperation?.status === 'running'}
|
||||||
|
@click=${() => this.startUpgradeForService(service)}
|
||||||
|
>${upgradeOperation?.status === 'running' ? 'Upgrading...' : 'Upgrade'}</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
@@ -447,6 +474,46 @@ export class CloudlyViewServices extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUpgradeOperationForService(serviceArg: plugins.interfaces.data.IService): appstate.IAppStoreUpgradeOperation | null {
|
||||||
|
return this.appStoreState.upgradeOperations.find((operationArg) => {
|
||||||
|
return operationArg.serviceId === serviceArg.id || operationArg.serviceName === serviceArg.data.name;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUpgradeInfoForService(serviceArg: plugins.interfaces.data.IService): any | null {
|
||||||
|
const operation = this.getUpgradeOperationForService(serviceArg);
|
||||||
|
if (operation?.status === 'success') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const liveUpgradeInfo = this.appStoreState.upgradeableServices.find((upgradeArg) => {
|
||||||
|
return upgradeArg.serviceId === serviceArg.id || upgradeArg.serviceName === serviceArg.data.name;
|
||||||
|
});
|
||||||
|
if (liveUpgradeInfo) {
|
||||||
|
return liveUpgradeInfo;
|
||||||
|
}
|
||||||
|
if (this.upgradeInfo?.serviceId === serviceArg.id || this.upgradeInfo?.serviceName === serviceArg.data.name) {
|
||||||
|
return this.upgradeInfo;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderUpgradeOperation(operationArg: appstate.IAppStoreUpgradeOperation): TemplateResult {
|
||||||
|
const color = operationArg.status === 'failed' ? '#f87171' : '#60a5fa';
|
||||||
|
return html`
|
||||||
|
<div class="update-card" style="border-color: ${color}; background: var(--ci-shade-1, #09090b); display: block;">
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 16px; align-items: flex-start;">
|
||||||
|
<div>
|
||||||
|
<div class="section-title" style="margin-bottom: 3px;">Upgrade ${operationArg.fromVersion} -> ${operationArg.targetVersion}</div>
|
||||||
|
<div class="detail-subtitle">${operationArg.status} / ${operationArg.step}${operationArg.error ? `: ${operationArg.error}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
<span style="color: ${color}; font-size: 12px; text-transform: uppercase;">${operationArg.status}</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; padding: 10px 12px; background: var(--ci-shade-0, #030305); border-radius: 6px; color: var(--ci-shade-5, #a1a1aa); font-family: monospace; font-size: 12px; line-height: 1.5; max-height: 130px; overflow: auto; white-space: pre-wrap;">${operationArg.progressLines.slice(-8).join('\n')}</div>
|
||||||
|
${operationArg.warnings.length ? html`<div style="margin-top: 10px; color: #fbbf24; font-size: 12px;">${operationArg.warnings.join(' | ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderStatusBadge(statusArg: string): TemplateResult {
|
private renderStatusBadge(statusArg: string): TemplateResult {
|
||||||
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
|
return html`<span class="status-badge status-${statusArg || 'scheduled'}">${statusArg || 'scheduled'}</span>`;
|
||||||
}
|
}
|
||||||
@@ -518,13 +585,72 @@ export class CloudlyViewServices extends DeesElement {
|
|||||||
|
|
||||||
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
|
private async loadUpgradeInfo(serviceArg: plugins.interfaces.data.IService) {
|
||||||
try {
|
try {
|
||||||
const response = await this.fireTypedRequest('getUpgradeableAppStoreServices', {}) as { services: any[] };
|
await Promise.all([
|
||||||
this.upgradeInfo = response.services?.find((upgradeArg) => upgradeArg.serviceName === serviceArg.data.name) || null;
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchUpgradeableAppStoreServicesAction, null),
|
||||||
|
appstate.appStoreStatePart.dispatchAction(appstate.fetchAppStoreUpgradeOperationsAction, null),
|
||||||
|
]);
|
||||||
|
this.upgradeInfo = this.getUpgradeInfoForService(serviceArg);
|
||||||
} catch {
|
} catch {
|
||||||
this.upgradeInfo = null;
|
this.upgradeInfo = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async startUpgradeForService(serviceArg: plugins.interfaces.data.IService) {
|
||||||
|
const upgradeInfo = this.getUpgradeInfoForService(serviceArg);
|
||||||
|
if (!upgradeInfo?.latestVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const preview = await appstate.getAppStoreUpgradePreview(serviceArg.id, upgradeInfo.latestVersion);
|
||||||
|
if (preview.blockers.length > 0) {
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: preview.blockers.join('; '), type: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let upgradeStarting = false;
|
||||||
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
||||||
|
heading: `Upgrade ${serviceArg.data.name}`,
|
||||||
|
content: html`
|
||||||
|
<div style="width: min(720px, calc(100vw - 48px)); max-width: 100%;">
|
||||||
|
<div class="detail-subtitle" style="margin-bottom: 12px;">${preview.fromVersion} -> ${preview.resolvedTargetVersion}</div>
|
||||||
|
<div style="display: grid; gap: 8px;">
|
||||||
|
${preview.changes.map((changeArg) => html`
|
||||||
|
<div style="display: grid; grid-template-columns: minmax(120px, 0.35fr) 1fr; gap: 10px; font-size: 13px;">
|
||||||
|
<span style="color: var(--ci-shade-4, #71717a);">${changeArg.field}</span>
|
||||||
|
<span style="color: var(--ci-shade-7, #e4e4e7); overflow-wrap: anywhere;">${changeArg.currentValue} -> ${changeArg.targetValue}</span>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
${preview.warnings.length ? html`<div style="margin-top: 12px; color: #fbbf24; font-size: 12px;">${preview.warnings.join(' | ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
menuOptions: [
|
||||||
|
{
|
||||||
|
name: 'Start Upgrade',
|
||||||
|
action: async (modalArg: any) => {
|
||||||
|
if (upgradeStarting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upgradeStarting = true;
|
||||||
|
try {
|
||||||
|
await appstate.appStoreStatePart.dispatchAction(appstate.startAppStoreServiceUpgradeAction, {
|
||||||
|
serviceId: serviceArg.id,
|
||||||
|
targetVersion: preview.resolvedTargetVersion,
|
||||||
|
});
|
||||||
|
await modalArg.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
upgradeStarting = false;
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
plugins.deesCatalog.DeesToast.createAndShow({ message: `Upgrade failed: ${(error as Error).message}`, type: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
|
private async restartDeployment(deploymentArg: plugins.interfaces.data.IDeployment) {
|
||||||
await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id });
|
await this.fireTypedRequest('restartDeployment', { deploymentId: deploymentArg.id });
|
||||||
if (this.selectedService) {
|
if (this.selectedService) {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ const flatViews = ['overview', 'logs'] as const;
|
|||||||
|
|
||||||
const subviewMap: Record<string, readonly string[]> = {
|
const subviewMap: Record<string, readonly string[]> = {
|
||||||
platform: ['settings', 'baseos', 'fleet'] as const,
|
platform: ['settings', 'baseos', 'fleet'] as const,
|
||||||
runtime: ['clusters', 'services', 'images', 'deployments', 'tasks'] as const,
|
runtime: ['clusters', 'appstore', 'services', 'images', 'deployments', 'tasks'] as const,
|
||||||
registry: ['externalregistries', 'testing'] as const,
|
registry: ['externalregistries', 'testing'] as const,
|
||||||
secrets: ['secretgroups', 'secretbundles'] as const,
|
secrets: ['secretgroups', 'secretbundles'] as const,
|
||||||
domains: ['domains', 'dns', 'mails'] as const,
|
domains: ['domains', 'dns', 'mails'] as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user