feat(hostedapp): add hosted app lifecycle protocol support
This commit is contained in:
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
## Pending
|
## Pending
|
||||||
|
|
||||||
|
- add hosted app lifecycle protocol support (hostedapp)
|
||||||
|
- Implements generic Hosted App TypedRequest handlers for Cloudly-hosted App Store services.
|
||||||
|
- Injects service-scoped runtime identity environment variables into Cloudly App Store installs.
|
||||||
|
- Lets Cloudly report initial admin bootstrap credentials to its parent host when `SERVEZONE_ADMINACCOUNT` is not configured.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add hosted app lifecycle protocol support (hostedapp)
|
||||||
|
- Adds a hosted app manager with lifecycle, bootstrap, and managed upgrade TypedRequest handlers.
|
||||||
|
- Injects hosted app runtime identity environment variables into App Store installs.
|
||||||
|
- Allows initial admin bootstrap credentials to be requested from the parent hosted app runtime when SERVEZONE_ADMINACCOUNT is not configured.
|
||||||
|
- Updates hosted app platform requirements and @serve.zone/interfaces for the lifecycle protocol.
|
||||||
|
|
||||||
## 2026-05-26 - 6.2.0
|
## 2026-05-26 - 6.2.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.1",
|
"@serve.zone/interfaces": "^6.1.0",
|
||||||
"@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.1
|
specifier: ^6.1.0
|
||||||
version: 6.0.1
|
version: 6.1.0
|
||||||
'@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.1':
|
'@serve.zone/interfaces@6.1.0':
|
||||||
resolution: {integrity: sha512-ZeLi0Bge8qRMoZMN5/xQ/8VRI4ep9ImitpZtNuLmeNHu0pGICcBGQE4g1aMmi+E3JynKOAphH4dnVmRULZV/RA==}
|
resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==}
|
||||||
|
|
||||||
'@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.1
|
'@serve.zone/interfaces': 6.1.0
|
||||||
|
|
||||||
'@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.1':
|
'@serve.zone/interfaces@6.1.0':
|
||||||
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
|
||||||
|
|||||||
@@ -47,12 +47,6 @@
|
|||||||
"description": "Use external TLS termination through Onebox or dcrouter.",
|
"description": "Use external TLS termination through Onebox or dcrouter.",
|
||||||
"required": true
|
"required": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"key": "SERVEZONE_ADMINACCOUNT",
|
|
||||||
"value": "",
|
|
||||||
"description": "Initial admin account in username:password format. Only used when Cloudly has no human users yet.",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"key": "MONGODB_URL",
|
"key": "MONGODB_URL",
|
||||||
"value": "${MONGODB_URI}",
|
"value": "${MONGODB_URI}",
|
||||||
@@ -118,7 +112,7 @@
|
|||||||
"mongodb": true,
|
"mongodb": true,
|
||||||
"s3": true
|
"s3": true
|
||||||
},
|
},
|
||||||
"minOneboxVersion": "1.24.2",
|
"minOneboxVersion": "2.2.0",
|
||||||
"backupBeforeUpgrade": true,
|
"backupBeforeUpgrade": true,
|
||||||
"healthCheck": {
|
"healthCheck": {
|
||||||
"path": "/status",
|
"path": "/status",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { CloudlyPlatformManager } from './manager.platform/classes.platformmanag
|
|||||||
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
|
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
|
||||||
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
|
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
|
||||||
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
|
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
|
||||||
|
import { CloudlyHostedAppManager } from './manager.hostedapp/classes.hostedappmanager.js';
|
||||||
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
|
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +83,7 @@ export class Cloudly {
|
|||||||
public baremetalManager: CloudlyBaremetalManager;
|
public baremetalManager: CloudlyBaremetalManager;
|
||||||
public baseOsManager: CloudlyBaseOsManager;
|
public baseOsManager: CloudlyBaseOsManager;
|
||||||
public appStoreManager: CloudlyAppStoreManager;
|
public appStoreManager: CloudlyAppStoreManager;
|
||||||
|
public hostedAppManager: CloudlyHostedAppManager;
|
||||||
public jumpManager: CloudlyJumpManager;
|
public jumpManager: CloudlyJumpManager;
|
||||||
|
|
||||||
private readyDeferred = new plugins.smartpromise.Deferred();
|
private readyDeferred = new plugins.smartpromise.Deferred();
|
||||||
@@ -119,6 +121,7 @@ export class Cloudly {
|
|||||||
this.backupManager = new CloudlyBackupManager(this);
|
this.backupManager = new CloudlyBackupManager(this);
|
||||||
this.baseOsManager = new CloudlyBaseOsManager(this);
|
this.baseOsManager = new CloudlyBaseOsManager(this);
|
||||||
this.secretManager = new CloudlySecretManager(this);
|
this.secretManager = new CloudlySecretManager(this);
|
||||||
|
this.hostedAppManager = new CloudlyHostedAppManager(this);
|
||||||
this.appStoreManager = new CloudlyAppStoreManager(this);
|
this.appStoreManager = new CloudlyAppStoreManager(this);
|
||||||
this.nodeManager = new CloudlyNodeManager(this);
|
this.nodeManager = new CloudlyNodeManager(this);
|
||||||
this.baremetalManager = new CloudlyBaremetalManager(this);
|
this.baremetalManager = new CloudlyBaremetalManager(this);
|
||||||
@@ -151,6 +154,7 @@ export class Cloudly {
|
|||||||
await this.taskManager.init();
|
await this.taskManager.init();
|
||||||
await this.backupManager.start();
|
await this.backupManager.start();
|
||||||
await this.baseOsManager.start();
|
await this.baseOsManager.start();
|
||||||
|
await this.hostedAppManager.start();
|
||||||
await this.appStoreManager.start();
|
await this.appStoreManager.start();
|
||||||
await this.registryManager.start();
|
await this.registryManager.start();
|
||||||
await this.domainManager.init();
|
await this.domainManager.init();
|
||||||
@@ -186,6 +190,7 @@ export class Cloudly {
|
|||||||
await this.backupManager.stop();
|
await this.backupManager.stop();
|
||||||
await this.baseOsManager.stop();
|
await this.baseOsManager.stop();
|
||||||
await this.registryManager.stop();
|
await this.registryManager.stop();
|
||||||
|
await this.hostedAppManager.stop();
|
||||||
await this.appStoreManager.stop();
|
await this.appStoreManager.stop();
|
||||||
await this.externalRegistryManager.stop();
|
await this.externalRegistryManager.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ export class CloudlyAppStoreManager {
|
|||||||
.slice(0, 25);
|
.slice(0, 25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async startHostedAppUpgrade(serviceIdArg: string, targetVersionArg: string): Promise<IAppStoreUpgradeOperation> {
|
||||||
|
const operation = await this.createUpgradeOperation(serviceIdArg, targetVersionArg);
|
||||||
|
void this.performUpgrade(operation.id).catch(() => {});
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
|
||||||
public async getAppStoreUpgradePreview(
|
public async getAppStoreUpgradePreview(
|
||||||
serviceIdArg: string,
|
serviceIdArg: string,
|
||||||
targetVersionArg?: string,
|
targetVersionArg?: string,
|
||||||
@@ -545,7 +551,11 @@ export class CloudlyAppStoreManager {
|
|||||||
this.assertRuntimeCompatibility(config);
|
this.assertRuntimeCompatibility(config);
|
||||||
this.assertSupportedPlatformRequirements(config);
|
this.assertSupportedPlatformRequirements(config);
|
||||||
this.assertSupportedPublishedPorts(publishedPorts);
|
this.assertSupportedPublishedPorts(publishedPorts);
|
||||||
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
|
const hostedAppRuntime = this.cloudlyRef.hostedAppManager.createHostedAppRuntimeEnvVars(optionsArg.serviceName);
|
||||||
|
const envVars = {
|
||||||
|
...this.getAppStoreEnvVars(config, optionsArg.envVars || {}),
|
||||||
|
...hostedAppRuntime.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}');
|
||||||
}
|
}
|
||||||
@@ -567,6 +577,7 @@ export class CloudlyAppStoreManager {
|
|||||||
appTemplateId: optionsArg.appId,
|
appTemplateId: optionsArg.appId,
|
||||||
appTemplateVersion: appStoreVersion,
|
appTemplateVersion: appStoreVersion,
|
||||||
appStoreUpgradePolicy: 'manual',
|
appStoreUpgradePolicy: 'manual',
|
||||||
|
hostedAppLifecycle: hostedAppRuntime.lifecycle,
|
||||||
environment: envVars,
|
environment: envVars,
|
||||||
secretBundleId: secretBundle.id,
|
secretBundleId: secretBundle.id,
|
||||||
additionalSecretBundleIds: [],
|
additionalSecretBundleIds: [],
|
||||||
|
|||||||
@@ -113,19 +113,28 @@ export class CloudlyAuthManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount;
|
const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount;
|
||||||
if (!adminAccount) {
|
let username: string;
|
||||||
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap');
|
let password: string;
|
||||||
}
|
let hostedBootstrapActionId: string | undefined;
|
||||||
|
if (adminAccount) {
|
||||||
|
const separatorIndex = adminAccount.indexOf(':');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
||||||
|
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
||||||
|
}
|
||||||
|
|
||||||
const separatorIndex = adminAccount.indexOf(':');
|
username = adminAccount.slice(0, separatorIndex).trim();
|
||||||
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
|
password = adminAccount.slice(separatorIndex + 1);
|
||||||
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
|
if (!username || !password) {
|
||||||
}
|
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
||||||
|
}
|
||||||
const username = adminAccount.slice(0, separatorIndex).trim();
|
} else {
|
||||||
const password = adminAccount.slice(separatorIndex + 1);
|
const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap();
|
||||||
if (!username || !password) {
|
if (!hostedBootstrap) {
|
||||||
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
|
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap unless hosted app lifecycle credentials are available');
|
||||||
|
}
|
||||||
|
username = hostedBootstrap.username;
|
||||||
|
password = hostedBootstrap.password;
|
||||||
|
hostedBootstrapActionId = hostedBootstrap.actionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = new this.CUser({
|
const user = new this.CUser({
|
||||||
@@ -139,6 +148,14 @@ export class CloudlyAuthManager {
|
|||||||
});
|
});
|
||||||
await user.save();
|
await user.save();
|
||||||
logger.log('success', `created initial admin user ${username}`);
|
logger.log('success', `created initial admin user ${username}`);
|
||||||
|
if (hostedBootstrapActionId) {
|
||||||
|
await this.cloudlyRef.hostedAppManager.completeParentBootstrapAction(
|
||||||
|
hostedBootstrapActionId,
|
||||||
|
'Cloudly created the initial admin user.',
|
||||||
|
).catch((errorArg) => {
|
||||||
|
logger.log('warn', `failed to complete hosted app bootstrap action: ${(errorArg as Error).message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stop() {}
|
public async stop() {}
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import type { Cloudly } from '../classes.cloudly.js';
|
||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { Service } from '../manager.service/classes.service.js';
|
||||||
|
|
||||||
|
type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifecycleState;
|
||||||
|
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
|
||||||
|
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
|
||||||
|
|
||||||
|
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
|
||||||
|
hostedAppLifecycle?: IHostedAppLifecycleState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CloudlyHostedAppManager {
|
||||||
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
constructor(private cloudlyRef: Cloudly) {
|
||||||
|
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||||
|
this.registerHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start() {}
|
||||||
|
public async stop() {}
|
||||||
|
|
||||||
|
private getParentRuntimeIdentity(): IHostedAppRuntimeIdentity | null {
|
||||||
|
const appInstanceId = process.env.SERVEZONE_APP_INSTANCE_ID;
|
||||||
|
const appControlToken = process.env.SERVEZONE_APP_CONTROL_TOKEN;
|
||||||
|
if (!appInstanceId || !appControlToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appInstanceId,
|
||||||
|
appControlToken,
|
||||||
|
hostType: process.env.SERVEZONE_APP_HOST_TYPE || 'onebox',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createParentRuntimeTypedRequest<TRequest extends plugins.typedrequestInterfaces.ITypedRequest>(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest<TRequest> | null {
|
||||||
|
const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL;
|
||||||
|
if (!runtimeUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new plugins.typedrequest.TypedRequest<TRequest>(
|
||||||
|
`${runtimeUrl.replace(/\/+$/, '')}/typedrequest`,
|
||||||
|
methodArg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async requestParentInitialAdminBootstrap(): Promise<{
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
actionId: string;
|
||||||
|
} | null> {
|
||||||
|
const identity = this.getParentRuntimeIdentity();
|
||||||
|
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||||
|
'hostedAppRequestBootstrapAction',
|
||||||
|
);
|
||||||
|
if (!identity || !request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = 'admin';
|
||||||
|
const password = plugins.smartunique.uniSimple('cloudlyadmin', 32);
|
||||||
|
const response = await request.fire({
|
||||||
|
identity,
|
||||||
|
action: {
|
||||||
|
type: 'credentials',
|
||||||
|
label: 'Cloudly initial admin',
|
||||||
|
url: `https://${this.cloudlyRef.config.data.publicUrl}`,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
message: 'Use these credentials to sign in to Cloudly, then change the admin password.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
actionId: response.action.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeParentBootstrapAction(actionIdArg?: string, messageArg?: string): Promise<void> {
|
||||||
|
const identity = this.getParentRuntimeIdentity();
|
||||||
|
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||||
|
'hostedAppCompleteBootstrapAction',
|
||||||
|
);
|
||||||
|
if (!identity || !request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request.fire({
|
||||||
|
identity,
|
||||||
|
actionId: actionIdArg,
|
||||||
|
message: messageArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public createHostedAppRuntimeEnvVars(serviceNameArg: string): {
|
||||||
|
appInstanceId: string;
|
||||||
|
appControlToken: string;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
lifecycle: IHostedAppLifecycleState;
|
||||||
|
} {
|
||||||
|
const appInstanceId = plugins.smartunique.uniSimple('hostedapp');
|
||||||
|
const appControlToken = plugins.smartunique.uniSimple('hostedapptoken', 64);
|
||||||
|
const runtimeUrl = `https://${this.cloudlyRef.config.data.publicUrl}`;
|
||||||
|
return {
|
||||||
|
appInstanceId,
|
||||||
|
appControlToken,
|
||||||
|
envVars: {
|
||||||
|
SERVEZONE_RUNTIME_URL: runtimeUrl,
|
||||||
|
SERVEZONE_APP_INSTANCE_ID: appInstanceId,
|
||||||
|
SERVEZONE_APP_CONTROL_TOKEN: appControlToken,
|
||||||
|
SERVEZONE_APP_HOST_TYPE: 'cloudly',
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
appInstanceId,
|
||||||
|
hostType: 'cloudly',
|
||||||
|
appName: serviceNameArg,
|
||||||
|
runtimeStatus: 'unknown',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireHostedAppIdentity(identityArg: IHostedAppRuntimeIdentity): Promise<Service> {
|
||||||
|
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
|
||||||
|
const service = services.find((serviceArg) => {
|
||||||
|
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||||
|
return (
|
||||||
|
serviceData.hostedAppLifecycle?.appInstanceId === identityArg?.appInstanceId ||
|
||||||
|
serviceData.environment?.SERVEZONE_APP_INSTANCE_ID === identityArg?.appInstanceId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!service) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Hosted app service not found');
|
||||||
|
}
|
||||||
|
const serviceData = service.data as TExtendedServiceData;
|
||||||
|
if (serviceData.environment?.SERVEZONE_APP_CONTROL_TOKEN !== identityArg?.appControlToken) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid');
|
||||||
|
}
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUpgradeState(serviceArg: Service): Promise<IHostedAppUpgradeState> {
|
||||||
|
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||||
|
const latestOperation = this.cloudlyRef.appStoreManager
|
||||||
|
.getUpgradeOperations()
|
||||||
|
.find((operationArg) => operationArg.serviceId === serviceArg.id);
|
||||||
|
if (latestOperation) {
|
||||||
|
return {
|
||||||
|
status: latestOperation.status === 'running' ? 'running' : latestOperation.status,
|
||||||
|
appTemplateId: latestOperation.appTemplateId,
|
||||||
|
currentVersion: latestOperation.fromVersion,
|
||||||
|
targetVersion: latestOperation.targetVersion,
|
||||||
|
operationId: latestOperation.id,
|
||||||
|
warnings: latestOperation.warnings,
|
||||||
|
error: latestOperation.error,
|
||||||
|
startedAt: latestOperation.startedAt,
|
||||||
|
updatedAt: latestOperation.updatedAt,
|
||||||
|
completedAt: latestOperation.completedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
|
||||||
|
return { status: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgradeableServices = await this.cloudlyRef.appStoreManager.getUpgradeableAppStoreServices();
|
||||||
|
const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceId === serviceArg.id);
|
||||||
|
if (!upgradeable) {
|
||||||
|
return {
|
||||||
|
status: 'upToDate',
|
||||||
|
appTemplateId: serviceData.appTemplateId,
|
||||||
|
currentVersion: serviceData.appTemplateVersion,
|
||||||
|
latestVersion: serviceData.appTemplateVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'available',
|
||||||
|
appTemplateId: upgradeable.appTemplateId,
|
||||||
|
currentVersion: upgradeable.currentVersion,
|
||||||
|
latestVersion: upgradeable.latestVersion,
|
||||||
|
targetVersion: upgradeable.latestVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLifecycleState(serviceArg: Service): Promise<IHostedAppLifecycleState> {
|
||||||
|
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||||
|
const appInstanceId = serviceData.hostedAppLifecycle?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID;
|
||||||
|
const state: IHostedAppLifecycleState = {
|
||||||
|
...(serviceData.hostedAppLifecycle || ({} as IHostedAppLifecycleState)),
|
||||||
|
appInstanceId: appInstanceId || '',
|
||||||
|
hostType: 'cloudly',
|
||||||
|
appName: serviceData.hostedAppLifecycle?.appName || serviceData.name,
|
||||||
|
publicUrl: serviceData.hostedAppLifecycle?.publicUrl || (serviceData.domains?.[0]?.name ? `https://${serviceData.domains[0].name}` : undefined),
|
||||||
|
upgradeState: await this.getUpgradeState(serviceArg),
|
||||||
|
};
|
||||||
|
serviceData.hostedAppLifecycle = state;
|
||||||
|
serviceArg.data = serviceData;
|
||||||
|
await serviceArg.save();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateLifecycleState(serviceArg: Service, stateArg: IHostedAppLifecycleState): Promise<IHostedAppLifecycleState> {
|
||||||
|
const serviceData = serviceArg.data as TExtendedServiceData;
|
||||||
|
serviceData.hostedAppLifecycle = stateArg;
|
||||||
|
serviceArg.data = serviceData;
|
||||||
|
await serviceArg.save();
|
||||||
|
return await this.getLifecycleState(serviceArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerHandlers() {
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_ReportLifecycleState>(
|
||||||
|
'hostedAppReportLifecycleState',
|
||||||
|
async (dataArg) => {
|
||||||
|
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||||
|
const existingState = await this.getLifecycleState(service);
|
||||||
|
const state = await this.updateLifecycleState(service, {
|
||||||
|
...existingState,
|
||||||
|
...dataArg.report,
|
||||||
|
appInstanceId: existingState.appInstanceId,
|
||||||
|
hostType: 'cloudly',
|
||||||
|
reportedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { state };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetLifecycleState>(
|
||||||
|
'hostedAppGetLifecycleState',
|
||||||
|
async (dataArg) => {
|
||||||
|
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||||
|
return { state: await this.getLifecycleState(service) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
|
||||||
|
'hostedAppRequestBootstrapAction',
|
||||||
|
async (dataArg) => {
|
||||||
|
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||||
|
const existingState = await this.getLifecycleState(service);
|
||||||
|
const now = Date.now();
|
||||||
|
const action = {
|
||||||
|
...dataArg.action,
|
||||||
|
id: dataArg.action.id || plugins.smartunique.shortId(12),
|
||||||
|
status: 'ready' as const,
|
||||||
|
label: dataArg.action.label || 'Initial setup',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const state = await this.updateLifecycleState(service, {
|
||||||
|
...existingState,
|
||||||
|
runtimeStatus: 'setupRequired',
|
||||||
|
bootstrapAction: action,
|
||||||
|
reportedAt: now,
|
||||||
|
});
|
||||||
|
return { action, state };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
|
||||||
|
'hostedAppCompleteBootstrapAction',
|
||||||
|
async (dataArg) => {
|
||||||
|
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||||
|
const existingState = await this.getLifecycleState(service);
|
||||||
|
const now = Date.now();
|
||||||
|
const bootstrapAction = existingState.bootstrapAction
|
||||||
|
? {
|
||||||
|
...existingState.bootstrapAction,
|
||||||
|
id: dataArg.actionId || existingState.bootstrapAction.id,
|
||||||
|
status: 'completed' as const,
|
||||||
|
message: dataArg.message || existingState.bootstrapAction.message,
|
||||||
|
updatedAt: now,
|
||||||
|
completedAt: now,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const state = await this.updateLifecycleState(service, {
|
||||||
|
...existingState,
|
||||||
|
runtimeStatus: existingState.runtimeStatus === 'setupRequired' ? 'running' : existingState.runtimeStatus,
|
||||||
|
bootstrapAction,
|
||||||
|
reportedAt: now,
|
||||||
|
});
|
||||||
|
return { state };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
|
||||||
|
'hostedAppStartManagedUpgrade',
|
||||||
|
async (dataArg) => {
|
||||||
|
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||||
|
const upgradeState = await this.getUpgradeState(service);
|
||||||
|
const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion;
|
||||||
|
if (!targetVersion) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available');
|
||||||
|
}
|
||||||
|
const operation = await this.cloudlyRef.appStoreManager.startHostedAppUpgrade(service.id, targetVersion);
|
||||||
|
const nextUpgradeState: IHostedAppUpgradeState = {
|
||||||
|
status: 'running',
|
||||||
|
appTemplateId: operation.appTemplateId,
|
||||||
|
currentVersion: operation.fromVersion,
|
||||||
|
targetVersion: operation.targetVersion,
|
||||||
|
operationId: operation.id,
|
||||||
|
warnings: operation.warnings,
|
||||||
|
startedAt: operation.startedAt,
|
||||||
|
updatedAt: operation.updatedAt,
|
||||||
|
};
|
||||||
|
const existingState = await this.getLifecycleState(service);
|
||||||
|
const state = await this.updateLifecycleState(service, {
|
||||||
|
...existingState,
|
||||||
|
upgradeState: nextUpgradeState,
|
||||||
|
reportedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { upgradeState: nextUpgradeState, state };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedrouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
|
||||||
|
'hostedAppGetManagedUpgradeStatus',
|
||||||
|
async (dataArg) => {
|
||||||
|
const service = await this.requireHostedAppIdentity(dataArg.identity);
|
||||||
|
return { upgradeState: await this.getUpgradeState(service) };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -8,9 +8,10 @@ export { path, crypto, stream, fsPromises };
|
|||||||
|
|
||||||
// @apiglobal scope
|
// @apiglobal scope
|
||||||
import * as typedrequest from '@api.global/typedrequest';
|
import * as typedrequest from '@api.global/typedrequest';
|
||||||
|
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
|
||||||
import * as typedsocket from '@api.global/typedsocket';
|
import * as typedsocket from '@api.global/typedsocket';
|
||||||
|
|
||||||
export { typedrequest, typedsocket };
|
export { typedrequest, typedrequestInterfaces, typedsocket };
|
||||||
|
|
||||||
// @apiclient.xyz scope
|
// @apiclient.xyz scope
|
||||||
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
|
|||||||
Reference in New Issue
Block a user