337 lines
13 KiB
TypeScript
337 lines
13 KiB
TypeScript
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) };
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|