diff --git a/changelog.md b/changelog.md index c53ac20..9721f9a 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,18 @@ ## 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 diff --git a/package.json b/package.json index a94877e..9185a95 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@push.rocks/webjwt": "^1.0.10", "@serve.zone/api": "^5.3.8", "@serve.zone/appstore": "^0.2.0", - "@serve.zone/interfaces": "^6.0.1", + "@serve.zone/interfaces": "^6.1.0", "@tsclass/tsclass": "^9.5.1" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c78e61..f85066f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,8 +147,8 @@ importers: specifier: ^0.2.0 version: 0.2.0 '@serve.zone/interfaces': - specifier: ^6.0.1 - version: 6.0.1 + specifier: ^6.1.0 + version: 6.1.0 '@tsclass/tsclass': specifier: ^9.5.1 version: 9.5.1 @@ -1838,8 +1838,8 @@ packages: '@serve.zone/interfaces@5.10.0': resolution: {integrity: sha512-8ZnP1A43UZlYwfd2j+S0Yin//didacIX2Rou9MobRuSFFgi1RQOqQcIWqOINcDk80wBDuYkyMCwHygYxD5i+Ig==} - '@serve.zone/interfaces@6.0.1': - resolution: {integrity: sha512-ZeLi0Bge8qRMoZMN5/xQ/8VRI4ep9ImitpZtNuLmeNHu0pGICcBGQE4g1aMmi+E3JynKOAphH4dnVmRULZV/RA==} + '@serve.zone/interfaces@6.1.0': + resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==} '@shikijs/engine-oniguruma@3.23.0': resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} @@ -7364,7 +7364,7 @@ snapshots: '@serve.zone/appstore@0.2.0': dependencies: - '@serve.zone/interfaces': 6.0.1 + '@serve.zone/interfaces': 6.1.0 '@serve.zone/interfaces@5.10.0': dependencies: @@ -7372,7 +7372,7 @@ snapshots: '@push.rocks/smartlog-interfaces': 3.0.2 '@tsclass/tsclass': 9.5.1 - '@serve.zone/interfaces@6.0.1': + '@serve.zone/interfaces@6.1.0': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartlog-interfaces': 3.0.2 diff --git a/servezone.appstore.json b/servezone.appstore.json index f4a53fc..d80f0e9 100644 --- a/servezone.appstore.json +++ b/servezone.appstore.json @@ -47,12 +47,6 @@ "description": "Use external TLS termination through Onebox or dcrouter.", "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", "value": "${MONGODB_URI}", @@ -118,7 +112,7 @@ "mongodb": true, "s3": true }, - "minOneboxVersion": "1.24.2", + "minOneboxVersion": "2.2.0", "backupBeforeUpgrade": true, "healthCheck": { "path": "/status", diff --git a/ts/classes.cloudly.ts b/ts/classes.cloudly.ts index 2c28d45..4dd030d 100644 --- a/ts/classes.cloudly.ts +++ b/ts/classes.cloudly.ts @@ -35,6 +35,7 @@ import { CloudlyPlatformManager } from './manager.platform/classes.platformmanag import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js'; import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.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'; /** @@ -82,6 +83,7 @@ export class Cloudly { public baremetalManager: CloudlyBaremetalManager; public baseOsManager: CloudlyBaseOsManager; public appStoreManager: CloudlyAppStoreManager; + public hostedAppManager: CloudlyHostedAppManager; public jumpManager: CloudlyJumpManager; private readyDeferred = new plugins.smartpromise.Deferred(); @@ -119,6 +121,7 @@ export class Cloudly { this.backupManager = new CloudlyBackupManager(this); this.baseOsManager = new CloudlyBaseOsManager(this); this.secretManager = new CloudlySecretManager(this); + this.hostedAppManager = new CloudlyHostedAppManager(this); this.appStoreManager = new CloudlyAppStoreManager(this); this.nodeManager = new CloudlyNodeManager(this); this.baremetalManager = new CloudlyBaremetalManager(this); @@ -151,6 +154,7 @@ export class Cloudly { await this.taskManager.init(); await this.backupManager.start(); await this.baseOsManager.start(); + await this.hostedAppManager.start(); await this.appStoreManager.start(); await this.registryManager.start(); await this.domainManager.init(); @@ -186,6 +190,7 @@ export class Cloudly { await this.backupManager.stop(); await this.baseOsManager.stop(); await this.registryManager.stop(); + await this.hostedAppManager.stop(); await this.appStoreManager.stop(); await this.externalRegistryManager.stop(); } diff --git a/ts/manager.appstore/classes.appstoremanager.ts b/ts/manager.appstore/classes.appstoremanager.ts index 8408be3..3ac2958 100644 --- a/ts/manager.appstore/classes.appstoremanager.ts +++ b/ts/manager.appstore/classes.appstoremanager.ts @@ -240,6 +240,12 @@ export class CloudlyAppStoreManager { .slice(0, 25); } + public async startHostedAppUpgrade(serviceIdArg: string, targetVersionArg: string): Promise { + const operation = await this.createUpgradeOperation(serviceIdArg, targetVersionArg); + void this.performUpgrade(operation.id).catch(() => {}); + return operation; + } + public async getAppStoreUpgradePreview( serviceIdArg: string, targetVersionArg?: string, @@ -545,7 +551,11 @@ export class CloudlyAppStoreManager { this.assertRuntimeCompatibility(config); this.assertSupportedPlatformRequirements(config); 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) { throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}'); } @@ -567,6 +577,7 @@ export class CloudlyAppStoreManager { appTemplateId: optionsArg.appId, appTemplateVersion: appStoreVersion, appStoreUpgradePolicy: 'manual', + hostedAppLifecycle: hostedAppRuntime.lifecycle, environment: envVars, secretBundleId: secretBundle.id, additionalSecretBundleIds: [], diff --git a/ts/manager.auth/classes.authmanager.ts b/ts/manager.auth/classes.authmanager.ts index ed32fd2..1cfe6a2 100644 --- a/ts/manager.auth/classes.authmanager.ts +++ b/ts/manager.auth/classes.authmanager.ts @@ -113,19 +113,28 @@ export class CloudlyAuthManager { } const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount; - if (!adminAccount) { - throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap'); - } + let username: string; + 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(':'); - if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) { - throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format'); - } - - const username = adminAccount.slice(0, separatorIndex).trim(); - const password = adminAccount.slice(separatorIndex + 1); - if (!username || !password) { - throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password'); + username = adminAccount.slice(0, separatorIndex).trim(); + password = adminAccount.slice(separatorIndex + 1); + if (!username || !password) { + throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password'); + } + } else { + const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap(); + if (!hostedBootstrap) { + 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({ @@ -139,6 +148,14 @@ export class CloudlyAuthManager { }); await user.save(); 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() {} diff --git a/ts/manager.hostedapp/classes.hostedappmanager.ts b/ts/manager.hostedapp/classes.hostedappmanager.ts new file mode 100644 index 0000000..8f49444 --- /dev/null +++ b/ts/manager.hostedapp/classes.hostedappmanager.ts @@ -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(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest | null { + const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL; + if (!runtimeUrl) { + return null; + } + return new plugins.typedrequest.TypedRequest( + `${runtimeUrl.replace(/\/+$/, '')}/typedrequest`, + methodArg, + ); + } + + public async requestParentInitialAdminBootstrap(): Promise<{ + username: string; + password: string; + actionId: string; + } | null> { + const identity = this.getParentRuntimeIdentity(); + const request = this.createParentRuntimeTypedRequest( + '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 { + const identity = this.getParentRuntimeIdentity(); + const request = this.createParentRuntimeTypedRequest( + 'hostedAppCompleteBootstrapAction', + ); + if (!identity || !request) { + return; + } + await request.fire({ + identity, + actionId: actionIdArg, + message: messageArg, + }); + } + + public createHostedAppRuntimeEnvVars(serviceNameArg: string): { + appInstanceId: string; + appControlToken: string; + envVars: Record; + 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 { + 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 { + 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 { + 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 { + 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( + '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( + 'hostedAppGetLifecycleState', + async (dataArg) => { + const service = await this.requireHostedAppIdentity(dataArg.identity); + return { state: await this.getLifecycleState(service) }; + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + '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( + '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( + '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( + 'hostedAppGetManagedUpgradeStatus', + async (dataArg) => { + const service = await this.requireHostedAppIdentity(dataArg.identity); + return { upgradeState: await this.getUpgradeState(service) }; + }, + ), + ); + } +} diff --git a/ts/plugins.ts b/ts/plugins.ts index 2f937c1..1db2113 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -8,9 +8,10 @@ export { path, crypto, stream, fsPromises }; // @apiglobal scope import * as typedrequest from '@api.global/typedrequest'; +import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces'; import * as typedsocket from '@api.global/typedsocket'; -export { typedrequest, typedsocket }; +export { typedrequest, typedrequestInterfaces, typedsocket }; // @apiclient.xyz scope import * as cloudflare from '@apiclient.xyz/cloudflare';