From 26256c92bde847a9a3389c30b5b13c8ff6c623cc Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 26 May 2026 21:50:17 +0000 Subject: [PATCH] feat(hostedapp): add hosted Cloudly parent upgrade controls --- changelog.md | 10 ++ package.json | 2 +- pnpm-lock.yaml | 12 +- test/test.apiclient.ts | 40 +++++ .../classes.hostedappmanager.ts | 115 +++++++++++++ ts_web/appstate.ts | 108 ++++++++++++ ts_web/elements/views/settings/index.ts | 154 ++++++++++++++++++ 7 files changed, 434 insertions(+), 7 deletions(-) diff --git a/changelog.md b/changelog.md index 03c8804..1203447 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,16 @@ ## Pending +### Features + +- add hosted Cloudly parent upgrade controls (hostedapp) + - Proxy admin upgrade status and start requests to the parent hosted-app runtime with the service-scoped runtime identity. + - Add a Settings hosted runtime panel for status refresh, parent upgrade start, and running-upgrade polling. + - Update `@serve.zone/interfaces` to `^6.2.0` for the parent upgrade contracts. +- add hosted Cloudly parent upgrade controls (hostedapp) + - Proxy admin upgrade status and start requests to the parent hosted-app runtime using the service runtime identity. + - Add Settings hosted runtime status, refresh, upgrade start, and running-upgrade polling UI. + - Update @serve.zone/interfaces to ^6.2.0 for parent upgrade request contracts. ## 2026-05-26 - 6.3.1 diff --git a/package.json b/package.json index 200f3b5..f368e34 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.1.0", + "@serve.zone/interfaces": "^6.2.0", "@tsclass/tsclass": "^9.5.1" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9363c7..05c3437 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.1.0 - version: 6.1.0 + specifier: ^6.2.0 + version: 6.2.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.1.0': - resolution: {integrity: sha512-nhxMmMfemBaGM1xxFpbNM8/zPM4Y59mVsgz9XBNGZr6n7kn81QsY+Xcn5HnLywztuGHqgEZRWGmI4MPzORRktw==} + '@serve.zone/interfaces@6.2.0': + resolution: {integrity: sha512-7eZIdl0IcuiUReGetJnOFkewCWBTEVGJSyUHdQkjtr0FLfgyqgm4ItlJlWPVpFlapm6GxkHYmPBkwxrpOq1Bsw==} '@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.1.0 + '@serve.zone/interfaces': 6.2.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.1.0': + '@serve.zone/interfaces@6.2.0': dependencies: '@api.global/typedrequest-interfaces': 3.0.19 '@push.rocks/smartlog-interfaces': 3.0.2 diff --git a/test/test.apiclient.ts b/test/test.apiclient.ts index c12add5..eb55501 100644 --- a/test/test.apiclient.ts +++ b/test/test.apiclient.ts @@ -16,6 +16,28 @@ const logErrorDetails = (errorArg: unknown) => { console.error(` - Error:`, errorArg); }; +const withParentRuntimeEnvCleared = async (callbackArg: () => Promise): Promise => { + const previousEnv = { + SERVEZONE_RUNTIME_URL: process.env.SERVEZONE_RUNTIME_URL, + SERVEZONE_APP_INSTANCE_ID: process.env.SERVEZONE_APP_INSTANCE_ID, + SERVEZONE_APP_CONTROL_TOKEN: process.env.SERVEZONE_APP_CONTROL_TOKEN, + }; + delete process.env.SERVEZONE_RUNTIME_URL; + delete process.env.SERVEZONE_APP_INSTANCE_ID; + delete process.env.SERVEZONE_APP_CONTROL_TOKEN; + try { + return await callbackArg(); + } finally { + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +}; + tap.preTask('should start cloudly', async () => { testCloudly = await helpers.createCloudly(); await testCloudly.start(); @@ -92,6 +114,24 @@ tap.test('should get an identity', async () => { } }); +tap.test('should report parent hosted upgrade unavailable when not hosted', async () => { + await withParentRuntimeEnvCleared(async () => { + const statusRequest = testClient.typedsocketClient.createTypedRequest('getHostedAppParentUpgradeStatus'); + const statusResponse = await statusRequest.fire({ identity: testClient.identity }); + expect(statusResponse.isHosted).toBeFalse(); + expect(statusResponse.unavailableReason).toEqual('SERVEZONE_RUNTIME_URL is not configured.'); + expect(statusResponse.upgradeState.status).toEqual('unknown'); + + const startRequest = testClient.typedsocketClient.createTypedRequest('startHostedAppParentUpgrade'); + const startResponse = await startRequest.fire({ + identity: testClient.identity, + targetVersion: '0.0.0-test', + }); + expect(startResponse.isHosted).toBeFalse(); + expect(startResponse.upgradeState.status).toEqual('unknown'); + }); +}); + tap.test('should create and consume node jump codes', async () => { const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster'); const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest('createNodeJumpCommand'); diff --git a/ts/manager.hostedapp/classes.hostedappmanager.ts b/ts/manager.hostedapp/classes.hostedappmanager.ts index 8f49444..874a1f7 100644 --- a/ts/manager.hostedapp/classes.hostedappmanager.ts +++ b/ts/manager.hostedapp/classes.hostedappmanager.ts @@ -6,6 +6,12 @@ type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifec type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState; type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity; +interface IHostedAppParentUpgradeResponse { + isHosted: boolean; + unavailableReason?: string; + upgradeState: IHostedAppUpgradeState; +} + type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & { hostedAppLifecycle?: IHostedAppLifecycleState; }; @@ -45,6 +51,89 @@ export class CloudlyHostedAppManager { ); } + private getParentRuntimeUnavailableReason(): string | undefined { + if (!process.env.SERVEZONE_RUNTIME_URL) { + return 'SERVEZONE_RUNTIME_URL is not configured.'; + } + if (!process.env.SERVEZONE_APP_INSTANCE_ID || !process.env.SERVEZONE_APP_CONTROL_TOKEN) { + return 'Hosted app runtime identity is not configured.'; + } + return undefined; + } + + private getErrorMessage(errorArg: unknown): string { + return errorArg instanceof Error ? errorArg.message : String(errorArg); + } + + public async getParentUpgradeStatus(): Promise { + const unavailableReason = this.getParentRuntimeUnavailableReason(); + const identity = this.getParentRuntimeIdentity(); + const request = this.createParentRuntimeTypedRequest( + 'hostedAppGetManagedUpgradeStatus', + ); + if (unavailableReason || !identity || !request) { + return { + isHosted: false, + unavailableReason, + upgradeState: { status: 'unknown' }, + }; + } + + try { + const response = await request.fire({ identity }); + return { + isHosted: true, + upgradeState: response.upgradeState, + }; + } catch (error) { + const message = this.getErrorMessage(error); + return { + isHosted: true, + unavailableReason: message, + upgradeState: { + status: 'unknown', + error: message, + }, + }; + } + } + + public async startParentUpgrade(targetVersionArg?: string): Promise { + const unavailableReason = this.getParentRuntimeUnavailableReason(); + const identity = this.getParentRuntimeIdentity(); + const request = this.createParentRuntimeTypedRequest( + 'hostedAppStartManagedUpgrade', + ); + if (unavailableReason || !identity || !request) { + return { + isHosted: false, + unavailableReason, + upgradeState: { status: 'unknown' }, + }; + } + + try { + const response = await request.fire({ + identity, + targetVersion: targetVersionArg, + }); + return { + isHosted: true, + upgradeState: response.upgradeState, + }; + } catch (error) { + const message = this.getErrorMessage(error); + return { + isHosted: true, + unavailableReason: message, + upgradeState: { + status: 'failed', + error: message, + }, + }; + } + } + public async requestParentInitialAdminBootstrap(): Promise<{ username: string; password: string; @@ -332,5 +421,31 @@ export class CloudlyHostedAppManager { }, ), ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getHostedAppParentUpgradeStatus', + async (dataArg) => { + await this.passAdminIdentity(dataArg); + return await this.getParentUpgradeStatus(); + }, + ), + ); + + this.typedrouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'startHostedAppParentUpgrade', + async (dataArg) => { + await this.passAdminIdentity(dataArg); + return await this.startParentUpgrade(dataArg.targetVersion); + }, + ), + ); + } + + private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) { + await plugins.smartguard.passGuardsOrReject(dataArg, [ + this.cloudlyRef.authManager.adminIdentityGuard, + ]); } } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index b8e13a0..f7733f2 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -92,6 +92,13 @@ export interface IAppStoreState { upgradeOperations: IAppStoreUpgradeOperation[]; } +export interface IHostedRuntimeState { + isHosted: boolean; + loading: boolean; + unavailableReason?: string; + upgradeState: plugins.interfaces.data.IHostedAppUpgradeState | null; +} + const emptyDataState: IDataState = { secretGroups: [], secretBundles: [], @@ -117,6 +124,12 @@ const emptyAppStoreState: IAppStoreState = { upgradeOperations: [], }; +const emptyHostedRuntimeState: IHostedRuntimeState = { + isHosted: false, + loading: false, + upgradeState: null, +}; + interface IReq_AdminValidateIdentity { method: 'adminValidateIdentity'; request: { @@ -183,6 +196,8 @@ export const logoutAction = loginStatePart.createAction(async (statePartArg) => apiClient.identity = null; dataState.setState({ ...emptyDataState }); appStoreStatePart.setState({ ...emptyAppStoreState }); + hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState }); + clearHostedRuntimeUpgradePoll(); } catch {} return { ...currentState, @@ -202,6 +217,12 @@ export const appStoreStatePart = await appstate.getStatePart( 'soft', ); +export const hostedRuntimeStatePart = await appstate.getStatePart( + 'hostedRuntime', + { ...emptyHostedRuntimeState }, + 'soft', +); + // Shared API client instance (used by UI actions) type TCloudlyApiClientWithNullableIdentity = Omit & { identity: plugins.interfaces.data.IIdentity | null; @@ -303,6 +324,8 @@ export const invalidateIdentity = async (reasonArg = 'identity is not valid'): P }); dataState.setState({ ...emptyDataState }); appStoreStatePart.setState({ ...emptyAppStoreState }); + hostedRuntimeStatePart.setState({ ...emptyHostedRuntimeState }); + clearHostedRuntimeUpgradePoll(); } finally { identityInvalidationRunning = false; } @@ -865,6 +888,91 @@ const getIdentityForRequest = () => { return identity; }; +let hostedRuntimePollTimer: number | undefined; + +function clearHostedRuntimeUpgradePoll() { + if (hostedRuntimePollTimer) { + window.clearTimeout(hostedRuntimePollTimer); + hostedRuntimePollTimer = undefined; + } +} + +const scheduleHostedRuntimeUpgradePoll = (stateArg: IHostedRuntimeState) => { + clearHostedRuntimeUpgradePoll(); + if (stateArg.upgradeState?.status !== 'running') { + return; + } + hostedRuntimePollTimer = window.setTimeout(() => { + void hostedRuntimeStatePart.dispatchAction(fetchHostedRuntimeUpgradeStatusAction, null); + }, 5000); +}; + +export const fetchHostedRuntimeUpgradeStatusAction = hostedRuntimeStatePart.createAction( + async (statePartArg) => { + const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState }; + statePartArg.setState({ ...currentState, loading: true }); + try { + const request = new plugins.typedrequest.TypedRequest( + '/typedrequest', + 'getHostedAppParentUpgradeStatus', + ); + const response = await request.fire({ identity: getIdentityForRequest() }); + const nextState: IHostedRuntimeState = { + isHosted: response.isHosted, + loading: false, + unavailableReason: response.unavailableReason, + upgradeState: response.upgradeState, + }; + scheduleHostedRuntimeUpgradePoll(nextState); + return nextState; + } catch (error) { + const nextState: IHostedRuntimeState = { + ...currentState, + loading: false, + unavailableReason: getErrorText(error) || 'Could not load hosted runtime status.', + }; + scheduleHostedRuntimeUpgradePoll(nextState); + return nextState; + } + }, +); + +export const startHostedRuntimeParentUpgradeAction = hostedRuntimeStatePart.createAction<{ + targetVersion?: string; +} | null>( + async (statePartArg, payloadArg) => { + const currentState = statePartArg.getState() || { ...emptyHostedRuntimeState }; + statePartArg.setState({ ...currentState, loading: true }); + try { + const request = new plugins.typedrequest.TypedRequest( + '/typedrequest', + 'startHostedAppParentUpgrade', + ); + const response = await request.fire({ + identity: getIdentityForRequest(), + targetVersion: payloadArg?.targetVersion, + }); + const nextState: IHostedRuntimeState = { + isHosted: response.isHosted, + loading: false, + unavailableReason: response.unavailableReason, + upgradeState: response.upgradeState, + }; + scheduleHostedRuntimeUpgradePoll(nextState); + return nextState; + } catch (error) { + const nextState: IHostedRuntimeState = { + ...currentState, + loading: false, + unavailableReason: getErrorText(error) || 'Could not start hosted runtime upgrade.', + }; + statePartArg.setState(nextState); + scheduleHostedRuntimeUpgradePoll(nextState); + throw error; + } + }, +); + export const fetchAppStoreTemplatesAction = appStoreStatePart.createAction( async (statePartArg) => { const request = new plugins.typedrequest.TypedRequest('/typedrequest', 'getAppStoreTemplates'); diff --git a/ts_web/elements/views/settings/index.ts b/ts_web/elements/views/settings/index.ts index 49b1790..ab50b8b 100644 --- a/ts_web/elements/views/settings/index.ts +++ b/ts_web/elements/views/settings/index.ts @@ -23,8 +23,29 @@ export class CloudlyViewSettings extends DeesElement { @state() private accessor testResults: {[key: string]: {success: boolean; message: string}} = {}; + @state() + private accessor hostedRuntime: appstate.IHostedRuntimeState = { + isHosted: false, + loading: false, + upgradeState: null, + }; + constructor() { super(); + const hostedRuntimeSubscription = appstate.hostedRuntimeStatePart + .select((stateArg) => stateArg) + .subscribe((stateArg) => { + this.hostedRuntime = stateArg; + }); + this.rxSubscriptions.push(hostedRuntimeSubscription); + const loginSubscription = appstate.loginStatePart + .select((stateArg) => stateArg.identity) + .subscribe((identityArg) => { + if (identityArg) { + void this.refreshHostedRuntimeStatus(); + } + }); + this.rxSubscriptions.push(loginSubscription); this.loadSettings(); } @@ -41,10 +62,24 @@ export class CloudlyViewSettings extends DeesElement { dees-panel { margin-bottom: 16px; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .form-grid.single { grid-template-columns: 1fr; } + .runtime-panel { display: grid; gap: 16px; } + .runtime-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } + .runtime-card { border: 1px solid var(--ci-shade-2, #27272a); border-radius: 8px; padding: 12px; background: var(--ci-shade-1, #09090b); } + .runtime-label { color: var(--ci-shade-4, #71717a); font-size: 12px; margin-bottom: 6px; } + .runtime-value { color: var(--ci-shade-7, #e4e4e7); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; } + .runtime-message { color: var(--ci-shade-5, #a1a1aa); font-size: 13px; line-height: 1.5; } + .runtime-message.error { color: #ef4444; } + .runtime-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } @media (max-width: 768px) { .form-grid { grid-template-columns: 1fr; } } + @media (max-width: 768px) { .runtime-grid { grid-template-columns: 1fr; } } `, ]; + public async connectedCallback() { + super.connectedCallback(); + await this.refreshHostedRuntimeStatus(); + } + private async loadSettings() { this.isLoading = true; try { @@ -102,6 +137,124 @@ export class CloudlyViewSettings extends DeesElement { return html``; } + private async refreshHostedRuntimeStatus() { + if (!appstate.loginStatePart.getState()?.identity) { + return; + } + await appstate.hostedRuntimeStatePart.dispatchAction(appstate.fetchHostedRuntimeUpgradeStatusAction, null); + } + + private getHostedRuntimeBadgeType() { + const status = this.hostedRuntime.upgradeState?.status; + if (!this.hostedRuntime.isHosted) return 'info'; + if (status === 'failed') return 'error'; + if (status === 'upToDate' || status === 'success') return 'success'; + return 'info'; + } + + private getHostedRuntimeStatusText() { + if (!this.hostedRuntime.isHosted) return 'Not hosted'; + const status = this.hostedRuntime.upgradeState?.status || 'unknown'; + switch (status) { + case 'upToDate': return 'Up to date'; + case 'available': return 'Update available'; + case 'running': return 'Upgrade running'; + case 'success': return 'Upgrade complete'; + case 'failed': return 'Upgrade failed'; + default: return 'Unknown'; + } + } + + private getHostedRuntimeMessage() { + if (!this.hostedRuntime.isHosted) { + return this.hostedRuntime.unavailableReason || 'This Cloudly instance is not running as a managed hosted app.'; + } + if (this.hostedRuntime.unavailableReason) { + return this.hostedRuntime.unavailableReason; + } + const upgradeState = this.hostedRuntime.upgradeState; + if (upgradeState?.status === 'available') { + return `Parent host can upgrade Cloudly from ${upgradeState.currentVersion || 'current'} to ${upgradeState.targetVersion || upgradeState.latestVersion}.`; + } + if (upgradeState?.status === 'running') { + return 'The parent host is upgrading this Cloudly service. Status refreshes automatically.'; + } + if (upgradeState?.status === 'failed') { + return upgradeState.error || 'The last parent-hosted upgrade failed.'; + } + return 'Parent hosted runtime status is available. No upgrade action is currently required.'; + } + + private async startHostedRuntimeUpgrade() { + const upgradeState = this.hostedRuntime.upgradeState; + const targetVersion = upgradeState?.targetVersion || upgradeState?.latestVersion; + if (!targetVersion) { + plugins.deesCatalog.DeesToast.createAndShow({ message: 'No hosted runtime upgrade target is available.', type: 'error' }); + return; + } + + let upgradeStarting = false; + await plugins.deesCatalog.DeesModal.createAndShow({ + heading: 'Upgrade Hosted Cloudly', + content: html` +
+ The parent host will upgrade this Cloudly app from ${upgradeState?.currentVersion || 'current'} to ${targetVersion} using its hosted app lifecycle controls. +
+ `, + menuOptions: [ + { + name: 'Start Upgrade', + action: async (modalArg: any) => { + if (upgradeStarting) return; + upgradeStarting = true; + try { + await appstate.hostedRuntimeStatePart.dispatchAction(appstate.startHostedRuntimeParentUpgradeAction, { targetVersion }); + plugins.deesCatalog.DeesToast.createAndShow({ message: 'Hosted runtime upgrade started.', type: 'success' }); + 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() }, + ], + }); + } + + private renderHostedRuntimePanel() { + const upgradeState = this.hostedRuntime.upgradeState; + const canStartUpgrade = this.hostedRuntime.isHosted && upgradeState?.status === 'available' && !this.hostedRuntime.loading; + return html` + +
+
+
+
Runtime
+
${this.hostedRuntime.isHosted ? 'Managed hosted app' : 'Standalone'}
+
+
+
Upgrade Status
+
+
+
+
Version
+
${upgradeState?.currentVersion || '-'}${upgradeState?.latestVersion ? ` / ${upgradeState.latestVersion}` : ''}
+
+
+
+ ${this.getHostedRuntimeMessage()} +
+ ${upgradeState?.warnings?.length ? html`
${upgradeState.warnings.join(' | ')}
` : ''} +
+ this.refreshHostedRuntimeStatus()}> + this.startHostedRuntimeUpgrade()}> +
+
+
+ `; + } + public render() { if (this.isLoading && Object.keys(this.settings).length === 0) { return html`
`; @@ -109,6 +262,7 @@ export class CloudlyViewSettings extends DeesElement { return html` Settings
+ ${this.renderHostedRuntimePanel()} { this.saveSettings((e.detail as any).data); }}>