diff --git a/.changeset/nine-paws-sit.md b/.changeset/nine-paws-sit.md new file mode 100644 index 0000000000000..04b0112a8b8ad --- /dev/null +++ b/.changeset/nine-paws-sit.md @@ -0,0 +1,16 @@ +--- +'@rocket.chat/network-broker': minor +'@rocket.chat/mock-providers': minor +'@rocket.chat/pdf-worker': minor +'@rocket.chat/core-services': minor +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/ui-contexts': minor +'@rocket.chat/ui-voip': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Enhances the `/api/apps/installed` and `/api/apps/:id/status` endpoints so they get apps' status across the cluster in High-Availability and Microservices deployments diff --git a/apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts b/apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts new file mode 100644 index 0000000000000..830971dceb3cd --- /dev/null +++ b/apps/meteor/ee/lib/misc/fetchAppsStatusFromCluster.ts @@ -0,0 +1,12 @@ +import { Apps } from '@rocket.chat/core-services'; + +import { isRunningMs } from '../../../server/lib/isRunningMs'; +import { Instance } from '../../server/sdk'; + +export async function fetchAppsStatusFromCluster() { + if (isRunningMs()) { + return Apps.getAppsStatusInNodes(); + } + + return Instance.getAppsStatusInInstances(); +} diff --git a/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts b/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts index bf096122c50fe..7819404525d5f 100644 --- a/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts +++ b/apps/meteor/ee/lib/misc/formatAppInstanceForRest.ts @@ -3,6 +3,8 @@ import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import type { ProxiedApp } from '@rocket.chat/apps-engine/server/ProxiedApp'; import type { AppLicenseValidationResult } from '@rocket.chat/apps-engine/server/marketplace/license'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; +import type { AppStatusReport } from '@rocket.chat/core-services'; +import type { App } from '@rocket.chat/core-typings'; import { getInstallationSourceFromAppStorageItem } from '../../../lib/apps/getInstallationSourceFromAppStorageItem'; @@ -12,9 +14,10 @@ interface IAppInfoRest extends IAppInfo { licenseValidation?: AppLicenseValidationResult; private: boolean; migrated: boolean; + clusterStatus?: App['clusterStatus']; } -export async function formatAppInstanceForRest(app: ProxiedApp): Promise { +export async function formatAppInstanceForRest(app: ProxiedApp, clusterStatus?: AppStatusReport): Promise { const appRest: IAppInfoRest = { ...app.getInfo(), status: await app.getStatus(), @@ -23,6 +26,10 @@ export async function formatAppInstanceForRest(app: ProxiedApp): Promise formatAppInstanceForRest(app, clusterStatus))); + return API.v1.success({ apps: formatted }); }, }, @@ -298,7 +307,7 @@ export class AppsRestApi { apiDeprecationLogger.endpoint(this.request.route, '7.0.0', this.response, 'Use /apps/installed to get the installed apps list.'); const proxiedApps = await manager.get(); - const apps = await Promise.all(proxiedApps.map(formatAppInstanceForRest)); + const apps = await Promise.all(proxiedApps.map((app) => formatAppInstanceForRest(app))); return API.v1.success({ apps }); }, @@ -1268,12 +1277,25 @@ export class AppsRestApi { { authRequired: true, permissionsRequired: ['manage-apps'] }, { async get() { - const prl = manager.getOneById(this.urlParams.id); + const app = manager.getOneById(this.urlParams.id); - if (prl) { - return API.v1.success({ status: await prl.getStatus() }); + if (!app) { + return API.v1.notFound(`No App found by the id of: ${this.urlParams.id}`); } - return API.v1.notFound(`No App found by the id of: ${this.urlParams.id}`); + + const response: { status: AppStatus; clusterStatus?: AppStatusReport[string] } = { status: await app.getStatus() }; + + try { + const clusterStatus = await fetchAppsStatusFromCluster(); + + if (clusterStatus?.[app.getID()]) { + response.clusterStatus = clusterStatus[app.getID()]; + } + } catch (e) { + orchestrator.getRocketChatLogger().warn('App status endpoint: could not fetch status across cluster', e); + } + + return API.v1.success(response); }, async post() { const { id: appId } = this.urlParams; diff --git a/apps/meteor/ee/server/local-services/instance/service.ts b/apps/meteor/ee/server/local-services/instance/service.ts index 93d6d0c45e980..0c6dfa8f1695d 100644 --- a/apps/meteor/ee/server/local-services/instance/service.ts +++ b/apps/meteor/ee/server/local-services/instance/service.ts @@ -1,6 +1,7 @@ import os from 'os'; -import { License, ServiceClassInternal } from '@rocket.chat/core-services'; +import type { AppStatusReport } from '@rocket.chat/core-services'; +import { Apps, License, ServiceClassInternal } from '@rocket.chat/core-services'; import { InstanceStatus, defaultPingInterval, indexExpire } from '@rocket.chat/instance-status'; import { InstanceStatus as InstanceStatusRaw } from '@rocket.chat/models'; import EJSON from 'ejson'; @@ -117,6 +118,11 @@ export class InstanceService extends ServiceClassInternal implements IInstanceSe } }, }, + actions: { + getAppsStatus(_ctx) { + return Apps.getAppsStatusLocal(); + }, + }, }); } @@ -176,4 +182,45 @@ export class InstanceService extends ServiceClassInternal implements IInstanceSe async getInstances(): Promise { return this.broker.call('$node.list', { onlyAvailable: true }); } + + async getAppsStatusInInstances(): Promise { + const instances = await this.getInstances(); + + const control: Promise[] = []; + const statusByApp: AppStatusReport = {}; + + instances.forEach((instance) => { + if (instance.local) { + return; + } + + const { id: instanceId } = instance; + + control.push( + (async () => { + const appsStatus = await this.broker.call>, null>( + 'matrix.getAppsStatus', + null, + { nodeID: instanceId }, + ); + + if (!appsStatus) { + throw new Error(`Failed to get apps status from instance ${instanceId}`); + } + + appsStatus.forEach(({ status, appId }) => { + if (!statusByApp[appId]) { + statusByApp[appId] = []; + } + + statusByApp[appId].push({ instanceId, status }); + }); + })(), + ); + }); + + await Promise.all(control); + + return statusByApp; + } } diff --git a/apps/meteor/ee/server/sdk/types/IInstanceService.ts b/apps/meteor/ee/server/sdk/types/IInstanceService.ts index b5c54349dfa16..7ce7b4be285a3 100644 --- a/apps/meteor/ee/server/sdk/types/IInstanceService.ts +++ b/apps/meteor/ee/server/sdk/types/IInstanceService.ts @@ -1,5 +1,7 @@ +import type { AppStatusReport } from '@rocket.chat/core-services'; import type { BrokerNode } from 'moleculer'; export interface IInstanceService { getInstances(): Promise; + getAppsStatusInInstances(): Promise; } diff --git a/apps/meteor/server/services/apps-engine/service.ts b/apps/meteor/server/services/apps-engine/service.ts index 486a788563946..a858eead4c430 100644 --- a/apps/meteor/server/services/apps-engine/service.ts +++ b/apps/meteor/server/services/apps-engine/service.ts @@ -4,9 +4,10 @@ import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; -import type { IAppsEngineService } from '@rocket.chat/core-services'; +import type { AppStatusReport, IAppsEngineService } from '@rocket.chat/core-services'; import { ServiceClassInternal } from '@rocket.chat/core-services'; +import { isRunningMs } from '../../lib/isRunningMs'; import { SystemLogger } from '../../lib/logger/system'; export class AppsEngineService extends ServiceClassInternal implements IAppsEngineService { @@ -133,4 +134,68 @@ export class AppsEngineService extends ServiceClassInternal implements IAppsEngi return app.getStorageItem(); } + + async getAppsStatusLocal(): Promise<{ status: AppStatus; appId: string }[]> { + const apps = await Apps.self?.getManager()?.get(); + + if (!apps) { + return []; + } + + return Promise.all( + apps.map(async (app) => ({ + status: await app.getStatus(), + appId: app.getID(), + })), + ); + } + + async getAppsStatusInNodes(): Promise { + if (!isRunningMs()) { + throw new Error('Getting apps status in cluster is only available in microservices mode'); + } + + if (!this.api) { + throw new Error('AppsEngineService is not initialized'); + } + + // If we are running MS AND this.api is defined, we KNOW there is a local node + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + const { id: localNodeId } = (await this.api.nodeList()).find((node) => node.local)!; + + const services: { name: string; nodes: string[] }[] = await this.api?.call('$node.services', { onlyActive: true }); + + // We can filter out the local node because we already know its status + const availableNodes = services?.find((service) => service.name === 'apps-engine')?.nodes.filter((node) => node !== localNodeId); + + if (!availableNodes || availableNodes.length < 1) { + throw new Error('Not enough Apps-Engine nodes in deployment'); + } + + const statusByApp: AppStatusReport = {}; + + const apps: Promise[] = availableNodes.map(async (nodeID) => { + const appsStatus: Awaited> | undefined = await this.api?.call( + 'apps-engine.getAppsStatusLocal', + [], + { nodeID }, + ); + + if (!appsStatus) { + throw new Error(`Failed to get apps status from node ${nodeID}`); + } + + appsStatus.forEach(({ status, appId }) => { + if (!statusByApp[appId]) { + statusByApp[appId] = []; + } + + statusByApp[appId].push({ instanceId: nodeID, status }); + }); + }); + + await Promise.all(apps); + + return statusByApp; + } } diff --git a/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts b/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts new file mode 100644 index 0000000000000..277b3011eddfe --- /dev/null +++ b/apps/meteor/tests/unit/server/services/apps-engine/service.tests.ts @@ -0,0 +1,231 @@ +import type { IAppsEngineService } from '@rocket.chat/core-services'; +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const AppsMock = { + self: { + isInitialized: sinon.stub(), + getManager: sinon.stub(), + getStorage: sinon.stub(), + getAppSourceStorage: sinon.stub(), + getRocketChatLogger: sinon.stub(), + triggerEvent: sinon.stub(), + }, +}; + +const apiMock = { + call: sinon.stub(), + nodeList: sinon.stub(), +}; + +const isRunningMsMock = sinon.stub(); + +const serviceMocks = { + '@rocket.chat/apps': { Apps: AppsMock }, + '@rocket.chat/core-services': { + api: apiMock, + ServiceClassInternal: class { + onEvent = sinon.stub(); + }, + }, + '../../lib/isRunningMs': { isRunningMs: isRunningMsMock }, + '../../lib/logger/system': { SystemLogger: { error: sinon.stub() } }, +}; + +const { AppsEngineService } = proxyquire.noCallThru().load('../../../../../server/services/apps-engine/service', serviceMocks); + +describe('AppsEngineService', () => { + let service: IAppsEngineService; + + it('should instantiate properly', () => { + expect(new AppsEngineService()).to.be.instanceOf(AppsEngineService); + }); + + describe('#getAppsStatusInNode - part 1', () => { + it('should error if api is not available', async () => { + isRunningMsMock.returns(true); + + const service = new AppsEngineService(); + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('AppsEngineService is not initialized'); + }); + }); + + beforeEach(() => { + service = new AppsEngineService(); + (service as any).api = apiMock; + }); + + afterEach(() => { + apiMock.call.reset(); + apiMock.nodeList.reset(); + AppsMock.self.isInitialized.reset(); + AppsMock.self.getManager.reset(); + AppsMock.self.getStorage.reset(); + AppsMock.self.getAppSourceStorage.reset(); + AppsMock.self.getRocketChatLogger.reset(); + AppsMock.self.triggerEvent.reset(); + isRunningMsMock.reset(); + }); + + describe('#isInitialized', () => { + it('should return true when Apps is initialized', () => { + AppsMock.self.isInitialized.returns(true); + expect(service.isInitialized()).to.be.true; + }); + + it('should return false when Apps is not initialized', () => { + AppsMock.self.isInitialized.returns(false); + expect(service.isInitialized()).to.be.false; + }); + }); + + describe('#getApps', () => { + it('should return app info from manager', async () => { + const mockApps = [{ getInfo: () => ({ id: 'app1' }) }]; + const mockManager = { get: sinon.stub().resolves(mockApps) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getApps({}); + expect(result).to.deep.equal([{ id: 'app1' }]); + }); + + it('should return undefined when manager is not available', async () => { + AppsMock.self.getManager.returns(undefined); + const result = await service.getApps({}); + expect(result).to.be.undefined; + }); + }); + + describe('#getAppsStatusLocal', () => { + it('should return app status reports', async () => { + const mockApps = [ + { + getStatus: sinon.stub().resolves('enabled'), + getID: sinon.stub().returns('app1'), + }, + ]; + const mockManager = { get: sinon.stub().resolves(mockApps) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getAppsStatusLocal(); + expect(result).to.deep.equal([ + { + status: 'enabled', + appId: 'app1', + }, + ]); + }); + + it('should return empty array when manager is not available', async () => { + AppsMock.self.getManager.returns(undefined); + const result = await service.getAppsStatusLocal(); + expect(result).to.deep.equal([]); + }); + }); + + describe('#getAppStorageItemById', () => { + it('should return storage item for existing app', async () => { + const mockStorageItem = { id: 'app1' }; + const mockApp = { + getStorageItem: sinon.stub().returns(mockStorageItem), + }; + const mockManager = { getOneById: sinon.stub().returns(mockApp) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getAppStorageItemById('app1'); + expect(result).to.equal(mockStorageItem); + }); + + it('should return undefined for non-existent app', async () => { + const mockManager = { getOneById: sinon.stub().returns(undefined) }; + AppsMock.self.getManager.returns(mockManager); + + const result = await service.getAppStorageItemById('non-existent'); + expect(result).to.be.undefined; + }); + }); + + describe('#getAppsStatusInNode - part 2', () => { + it('should throw error when not in microservices mode', async () => { + isRunningMsMock.returns(false); + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith( + 'Getting apps status in cluster is only available in microservices mode', + ); + }); + + it('should throw error when not enough apps-engine nodes are available', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call.resolves([{ name: 'apps-engine', nodes: ['node1'] }]); + + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('Not enough Apps-Engine nodes in deployment'); + }); + + it('should not call the service for the local node', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call + .onFirstCall() + .resolves([{ name: 'apps-engine', nodes: ['node1', 'node2'] }]) + .onSecondCall() + .resolves([ + { status: 'enabled', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]) + .onThirdCall() + .rejects(new Error('Should not be called')); + + const result = await service.getAppsStatusInNodes(); + + expect(result).to.deep.equal({ + app1: [{ instanceId: 'node2', status: 'enabled' }], + app2: [{ instanceId: 'node2', status: 'enabled' }], + }); + }); + + it('should return status from all nodes', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call + .onFirstCall() + .resolves([{ name: 'apps-engine', nodes: ['node1', 'node2', 'node3'] }]) + .onSecondCall() + .resolves([ + { status: 'enabled', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]) + .onThirdCall() + .resolves([ + { status: 'initialized', appId: 'app1' }, + { status: 'enabled', appId: 'app2' }, + ]); + + const result = await service.getAppsStatusInNodes(); + + expect(result).to.deep.equal({ + app1: [ + { instanceId: 'node2', status: 'enabled' }, + { instanceId: 'node3', status: 'initialized' }, + ], + app2: [ + { instanceId: 'node2', status: 'enabled' }, + { instanceId: 'node3', status: 'enabled' }, + ], + }); + }); + + it('should throw error when failed to get status from a node', async () => { + isRunningMsMock.returns(true); + apiMock.nodeList.resolves([{ id: 'node1', local: true }]); + apiMock.call + .onFirstCall() + .resolves([{ name: 'apps-engine', nodes: ['node1', 'node2'] }]) + .onSecondCall() + .resolves(undefined); + + await expect(service.getAppsStatusInNodes()).to.be.rejectedWith('Failed to get apps status from node node2'); + }); + }); +}); diff --git a/apps/meteor/tests/unit/server/services/instance/service.tests.ts b/apps/meteor/tests/unit/server/services/instance/service.tests.ts new file mode 100644 index 0000000000000..dac15385514a5 --- /dev/null +++ b/apps/meteor/tests/unit/server/services/instance/service.tests.ts @@ -0,0 +1,122 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +import type { IInstanceService } from '../../../../../ee/server/sdk/types/IInstanceService'; + +const ServiceBrokerMock = { + call: sinon.stub(), +}; + +const AppsMock = { + getAppsStatusLocal: sinon.stub(), +}; + +const serviceMocks = { + '@rocket.chat/core-services': { + ServiceClassInternal: class { + onEvent = sinon.stub(); + }, + Apps: AppsMock, + }, + 'moleculer': { + ServiceBroker: sinon.stub().returns(ServiceBrokerMock), + Serializers: { + Base: class {}, + }, + }, +}; + +const { InstanceService } = proxyquire + .noPreserveCache() + .noCallThru() + .load('../../../../../ee/server/local-services/instance/service', serviceMocks); + +describe('InstanceService', () => { + let service: IInstanceService; + + beforeEach(() => { + service = new InstanceService(); + (service as any).broker = ServiceBrokerMock; + }); + + afterEach(() => { + ServiceBrokerMock.call.reset(); + AppsMock.getAppsStatusLocal.reset(); + }); + + describe('#getInstances', () => { + it('should return list of instances', async () => { + const mockInstances = [{ id: 'node1' }]; + ServiceBrokerMock.call.resolves(mockInstances); + + const instances = await service.getInstances(); + + expect(instances).to.deep.equal(mockInstances); + expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true; + }); + + it('should handle empty instance list', async () => { + ServiceBrokerMock.call.resolves([]); + + const instances = await service.getInstances(); + + expect(instances).to.deep.equal([]); + expect(ServiceBrokerMock.call.calledWith('$node.list', { onlyAvailable: true })).to.be.true; + }); + }); + + describe('#getAppsStatusInInstances', () => { + it('should return app status from all non-local instances', async () => { + const mockInstances = [ + { id: 'node1', local: true }, + { id: 'node2', local: false }, + { id: 'node3', local: false }, + ]; + + ServiceBrokerMock.call + .onFirstCall() + .resolves(mockInstances) + .onSecondCall() + .resolves([{ status: 'enabled', appId: 'app1' }]) + .onThirdCall() + .resolves([{ status: 'disabled', appId: 'app2' }]); + + const result = await service.getAppsStatusInInstances(); + + expect(result).to.deep.equal({ + app1: [{ instanceId: 'node2', status: 'enabled' }], + app2: [{ instanceId: 'node3', status: 'disabled' }], + }); + + expect(ServiceBrokerMock.call.calledThrice).to.be.true; + }); + + it('should handle empty app status response', async () => { + const mockInstances = [ + { id: 'node1', local: true }, + { id: 'node2', local: false }, + ]; + + ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves([]); + + const result = await service.getAppsStatusInInstances(); + + expect(result).to.deep.equal({}); + expect(ServiceBrokerMock.call.calledTwice).to.be.true; + }); + + it('should handle undefined app status response', async () => { + const mockInstances = [ + { id: 'node1', local: true }, + { id: 'node2', local: false }, + ]; + + ServiceBrokerMock.call.onFirstCall().resolves(mockInstances).onSecondCall().resolves(undefined); + + await expect(service.getAppsStatusInInstances()).to.be.rejectedWith(`Failed to get apps status from instance node2`); + expect(ServiceBrokerMock.call.calledTwice).to.be.true; + }); + }); +}); diff --git a/ee/packages/network-broker/src/NetworkBroker.ts b/ee/packages/network-broker/src/NetworkBroker.ts index c38650e9ad7de..99d8716222413 100644 --- a/ee/packages/network-broker/src/NetworkBroker.ts +++ b/ee/packages/network-broker/src/NetworkBroker.ts @@ -1,5 +1,5 @@ import { asyncLocalStorage } from '@rocket.chat/core-services'; -import type { IBroker, IBrokerNode, IServiceMetrics, IServiceClass, EventSignatures } from '@rocket.chat/core-services'; +import type { CallingOptions, IBroker, IBrokerNode, IServiceMetrics, IServiceClass, EventSignatures } from '@rocket.chat/core-services'; import { injectCurrentContext, tracerSpan } from '@rocket.chat/tracing'; import type { ServiceBroker, Context, ServiceSchema } from 'moleculer'; @@ -32,7 +32,7 @@ export class NetworkBroker implements IBroker { this.metrics = broker.metrics; } - async call(method: string, data: any): Promise { + async call(method: string, data: any, options?: CallingOptions): Promise { if (!(await this.started)) { return; } @@ -40,17 +40,19 @@ export class NetworkBroker implements IBroker { const context = asyncLocalStorage.getStore(); if (context?.ctx?.call) { - return context.ctx.call(method, data); + return context.ctx.call(method, data, options); } const services: { name: string }[] = await this.broker.call('$node.services', { onlyAvailable: true, }); + if (!services.find((service) => service.name === method.split('.')[0])) { return new Error('method-not-available'); } return this.broker.call(method, data, { + ...options, meta: { optl: injectCurrentContext(), }, diff --git a/packages/core-services/src/LocalBroker.ts b/packages/core-services/src/LocalBroker.ts index 9e319c4cdbdb0..be19791097658 100644 --- a/packages/core-services/src/LocalBroker.ts +++ b/packages/core-services/src/LocalBroker.ts @@ -6,7 +6,7 @@ import { injectCurrentContext, tracerActiveSpan } from '@rocket.chat/tracing'; import { asyncLocalStorage } from '.'; import type { EventSignatures } from './events/Events'; -import type { IBroker, IBrokerNode } from './types/IBroker'; +import type { CallingOptions, IBroker, IBrokerNode } from './types/IBroker'; import type { ServiceClass, IServiceClass } from './types/ServiceClass'; type ExtendedServiceClass = { instance: IServiceClass; dependencies: string[]; isStarted: boolean }; @@ -29,7 +29,11 @@ export class LocalBroker implements IBroker { private defaultDependencies = ['settings']; - async call(method: string, data: any): Promise { + async call(method: string, data: any, options?: CallingOptions): Promise { + if (options) { + logger.warn('Options are not supported in LocalBroker'); + } + return tracerActiveSpan( `action ${method}`, {}, diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index dedbe3571aaac..c59df5ee55e66 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -50,13 +50,14 @@ import type { IVideoConfService, VideoConferenceJoinOptions } from './types/IVid import type { IVoipFreeSwitchService } from './types/IVoipFreeSwitchService'; import type { IVoipService } from './types/IVoipService'; +export { AppStatusReport } from './types/IAppsEngineService'; export { asyncLocalStorage } from './lib/asyncLocalStorage'; export { MeteorError, isMeteorError } from './MeteorError'; export { api } from './api'; export { EventSignatures } from './events/Events'; export { LocalBroker } from './LocalBroker'; -export { IBroker, IBrokerNode, BaseMetricOptions, IServiceMetrics } from './types/IBroker'; +export { IBroker, IBrokerNode, BaseMetricOptions, CallingOptions, IServiceMetrics } from './types/IBroker'; export { IServiceContext, ServiceClass, IServiceClass, ServiceClassInternal } from './types/ServiceClass'; diff --git a/packages/core-services/src/lib/Api.ts b/packages/core-services/src/lib/Api.ts index de028dcafd1d5..86516e27b31e1 100644 --- a/packages/core-services/src/lib/Api.ts +++ b/packages/core-services/src/lib/Api.ts @@ -1,6 +1,6 @@ import type { EventSignatures } from '../events/Events'; import type { IApiService } from '../types/IApiService'; -import type { IBroker, IBrokerNode } from '../types/IBroker'; +import type { CallingOptions, IBroker, IBrokerNode } from '../types/IBroker'; import type { IServiceClass } from '../types/ServiceClass'; export class Api implements IApiService { @@ -37,8 +37,8 @@ export class Api implements IApiService { } } - async call(method: string, data?: unknown): Promise { - return this.broker?.call(method, data); + async call(method: string, data?: unknown, options?: CallingOptions): Promise { + return this.broker?.call(method, data, options); } async broadcast(event: T, ...args: Parameters): Promise { diff --git a/packages/core-services/src/types/IApiService.ts b/packages/core-services/src/types/IApiService.ts index bff4bc3a2d82a..ab01517f9a0eb 100644 --- a/packages/core-services/src/types/IApiService.ts +++ b/packages/core-services/src/types/IApiService.ts @@ -1,4 +1,4 @@ -import type { IBroker, IBrokerNode } from './IBroker'; +import type { CallingOptions, IBroker, IBrokerNode } from './IBroker'; import type { IServiceClass } from './ServiceClass'; import type { EventSignatures } from '../events/Events'; @@ -9,7 +9,7 @@ export interface IApiService { registerService(instance: IServiceClass): void; - call(method: string, data?: unknown): Promise; + call(method: string, data?: unknown, options?: CallingOptions): Promise; broadcast(event: T, ...args: Parameters): Promise; diff --git a/packages/core-services/src/types/IAppsEngineService.ts b/packages/core-services/src/types/IAppsEngineService.ts index 9158d2fe3b299..e3abbb350e934 100644 --- a/packages/core-services/src/types/IAppsEngineService.ts +++ b/packages/core-services/src/types/IAppsEngineService.ts @@ -1,9 +1,16 @@ +import type { AppStatus } from '@rocket.chat/apps-engine/definition/AppStatus'; import type { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import type { IGetAppsFilter } from '@rocket.chat/apps-engine/server/IGetAppsFilter'; import type { IAppStorageItem } from '@rocket.chat/apps-engine/server/storage'; +export type AppStatusReport = { + [appId: string]: { instanceId: string; status: AppStatus }[]; +}; + export interface IAppsEngineService { isInitialized(): boolean; getApps(query: IGetAppsFilter): Promise; getAppStorageItemById(appId: string): Promise; + getAppsStatusLocal(): Promise<{ appId: string; status: AppStatus }[]>; + getAppsStatusInNodes(): Promise; } diff --git a/packages/core-services/src/types/IBroker.ts b/packages/core-services/src/types/IBroker.ts index d72221a09cabe..9ce5b693eaa41 100644 --- a/packages/core-services/src/types/IBroker.ts +++ b/packages/core-services/src/types/IBroker.ts @@ -30,6 +30,20 @@ export type BaseMetricOptions = { [key: string]: unknown; }; +export type CallingOptions = { + nodeID?: string; + // timeout?: number; + // retries?: number; + // fallbackResponse?: FallbackResponse | FallbackResponse[] | FallbackResponseHandler; + // meta?: GenericObject; + // parentSpan?: ContextParentSpan; + // parentCtx?: Context; + // requestID?: string; + // tracking?: boolean; + // paramsCloning?: boolean; + // caller?: string; +}; + export interface IServiceMetrics { register(opts: BaseMetricOptions): void; @@ -50,7 +64,7 @@ export interface IBroker { metrics?: IServiceMetrics; destroyService(service: IServiceClass): Promise; createService(service: IServiceClass, serviceDependencies?: string[]): void; - call(method: string, data: any): Promise; + call(method: string, data: any, options?: CallingOptions): Promise; broadcastToServices( services: string[], event: T, diff --git a/packages/core-typings/src/Apps.ts b/packages/core-typings/src/Apps.ts index 456aa987bb19c..06a18581cd442 100644 --- a/packages/core-typings/src/Apps.ts +++ b/packages/core-typings/src/Apps.ts @@ -129,6 +129,11 @@ export type App = { private: boolean; documentationUrl: string; migrated: boolean; + // Status of the app across the cluster (when deployment includes multiple instances) + clusterStatus?: { + instanceId: string; + status: AppStatus; + }[]; }; export type AppCategory = { diff --git a/packages/rest-typings/src/apps/index.ts b/packages/rest-typings/src/apps/index.ts index 528a1cc224078..d5f9de98d0ca8 100644 --- a/packages/rest-typings/src/apps/index.ts +++ b/packages/rest-typings/src/apps/index.ts @@ -123,6 +123,7 @@ export type AppsEndpoints = { '/apps/:id/status': { GET: () => { status: string; + clusterStatus: App['clusterStatus']; }; POST: (params: { status: AppStatus }) => { status: AppStatus; @@ -173,7 +174,7 @@ export type AppsEndpoints = { }; '/apps/installed': { - GET: () => { apps: App[] }; + GET: (params: { includeClusterStatus?: 'true' | 'false' }) => { success: true; apps: App[] } | { success: false; error: string }; }; '/apps/buildExternalAppRequest': {