Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Decouple] Add new core service to expose functionality to verify plugin compatibility with OpenSearch plugins #4710

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- Add support for read-only mode through tenants ([#4498](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4498))
- [Workspace] Add core workspace service module to enable the implementation of workspace features within OSD plugins ([#5092](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5092))
- [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075))
- [Decouple] Add new cross compatibility check core service which export functionality for plugins to verify if their OpenSearch plugin counterpart is installed on the cluster or has incompatible version to configure the plugin behavior([#4710](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4710))

### 🐛 Bug Fixes

Expand Down
1 change: 1 addition & 0 deletions src/core/public/plugins/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function createManifest(
requiredPlugins: required,
optionalPlugins: optional,
requiredBundles: [],
requiredEnginePlugins: {},
} as DiscoveredPlugin;
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/public/plugins/plugins_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function createManifest(
version: 'some-version',
configPath: ['path'],
requiredPlugins: required,
requiredEnginePlugins: optional,
requiredEnginePlugins: {},
optionalPlugins: optional,
requiredBundles: [],
};
Expand Down
78 changes: 78 additions & 0 deletions src/core/server/cross_compatibility/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
## Cross Compatibility Service

The cross compatibility service provides a way for OpenSearch Dashboards plugins to check if they are compatible with the installed OpenSearch plugins. This allows plugins to gracefully degrade their functionality or disable themselves if they are not compatible with the current OpenSearch plugin version.

### Overview

OpenSearch Dashboards plugins depend on specific versions of OpenSearch plugins. When a plugin is installed, OpenSearch Dashboards checks to make sure that the required OpenSearch plugins are installed and compatible. If a required plugin is not installed or is not compatible, OpenSearch Dashboards will log a warning but will still allow the plugin to start.

The cross compatibility service provides a way for plugins to check for compatibility with their OpenSearch counterparts. This allows plugins to make informed decisions about how to behave when they are not compatible. For example, a plugin could disable itself, limit its functionality, or notify the user that they are using an incompatible plugin.

### Usage

To use the Cross Compatibility service, plugins can call the `verifyOpenSearchPluginsState()` API. This API checks the compatibility of the plugin with the installed OpenSearch plugins. The API returns a list of `CrossCompatibilityResult` objects, which contain information about the compatibility of each plugin.

The `CrossCompatibilityResult` object has the following properties:

`pluginName`: The OpenSearch Plugin name.
`isCompatible`: A boolean indicating whether the plugin is compatible.
`incompatibilityReason`: The reason the OpenSearch Plugin version is not compatible with the plugin.
`installedVersions`: The version of the plugin that is installed.

Plugins can use the information in the `CrossCompatibilityResult` object to decide how to behave. For example, a plugin could disable itself if the `isCompatible` property is false.

The `verifyOpenSearchPluginsState()` API should be called from the `start()` lifecycle method. This allows plugins to check for compatibility before they start.

### Example usage inside DashboardsSample Plugin

```
export class DashboardsSamplePlugin implements Plugin<DashboardsSamplePluginSetup, DashboardsSamplePluginStart> {

public setup(core: CoreSetup) {
this.logger.debug('Dashboard sample plugin setup');
this.capabilitiesService = core.capabilities;
return {};
}
public start(core: CoreStart) {
this.logger.debug('Dashboard sample plugin: Started');
exampleCompatibilityCheck(core);
return {};
}
......

// Example capability provider
export const capabilitiesProvider = () => ({
exampleDashboardsPlugin: {
show: true,
createShortUrl: true,
},
});

function exampleCompatibilityCheck(core: CoreStart) {
const pluginName = 'exampleDashboardsPlugin';
const result = await core.versionCompatibility.verifyOpenSearchPluginsState(pluginName);
result.forEach((mustHavePlugin) => {
if (!mustHavePlugin.isCompatible) {
// use capabilities provider API to register plugin's capability to enable/disbale plugin
this.capabilitiesService.registerProvider(capabilitiesProvider);
}
else { // feature to enable when plugin has compatible version installed }
});
......
}
.....
}

```
The `exampleCompatibilityCheck()` function uses the `verifyOpenSearchPluginsState()` API to check for compatibility with the `DashboardsSample` plugin. If the plugin is compatible, the function enables the plugin's features. If the plugin is not compatible, the function gracefully degrades the plugin's functionality.

### Use cases:

The cross compatibility service can be used by plugins to:

* Disable themselves if they are not compatible with the installed OpenSearch plugins.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the above example, can you please add a condition saying if mustHavePlug is not compatble and show how I can disable my plugin?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! In current world plugins can use core/capabilities API to configure whether to enable/disable plugin. I've included the example.

* Limit their functionality if they are not fully compatible with the installed OpenSearch plugins.
* Notify users if they are using incompatible plugins.
* Provide information to users about how to upgrade their plugins.

The cross compatibility service is a valuable tool for developers who are building plugins for OpenSearch Dashboards. It allows plugins to be more resilient to changes in the OpenSearch ecosystem.
17 changes: 17 additions & 0 deletions src/core/server/cross_compatibility/cross_compatibility.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CrossCompatibilityServiceStart } from './types';

const createStartContractMock = () => {
const startContract: jest.Mocked<CrossCompatibilityServiceStart> = {
verifyOpenSearchPluginsState: jest.fn().mockReturnValue(Promise.resolve({})),
};
return startContract;
};

export const crossCompatibilityServiceMock = {
createStartContract: createStartContractMock,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CrossCompatibilityService } from './cross_compatibility_service';
import { CompatibleEnginePluginVersions } from '../plugins/types';
import { mockCoreContext } from '../core_context.mock';
import { opensearchServiceMock } from '../opensearch/opensearch_service.mock';

describe('CrossCompatibilityService', () => {
AMoo-Miki marked this conversation as resolved.
Show resolved Hide resolved
let service: CrossCompatibilityService;
let opensearch: any;
const plugins = new Map<string, CompatibleEnginePluginVersions>();

beforeEach(() => {
opensearch = opensearchServiceMock.createStart();
opensearch.client.asInternalUser.cat.plugins.mockResolvedValue({
body: [
{
name: 'node1',
component: 'os-plugin',
version: '1.1.0.0',
},
],
} as any);

plugins?.set('foo', { 'os-plugin': '1.0.0 - 2.0.0' });
plugins?.set('incompatiblePlugin', { 'os-plugin': '^3.0.0' });
plugins?.set('test', {});
service = new CrossCompatibilityService(mockCoreContext.create());
});

it('should start the cross compatibility service', async () => {
const startDeps = { opensearch, plugins };
const startResult = await service.start(startDeps);
expect(startResult).toEqual({
verifyOpenSearchPluginsState: expect.any(Function),
});
});

it('should return an array of CrossCompatibilityResult objects if plugin dependencies are specified', async () => {
const pluginName = 'foo';
const startDeps = { opensearch, plugins };
const startResult = await service.start(startDeps);
const results = await startResult.verifyOpenSearchPluginsState(pluginName);
expect(results).not.toBeUndefined();
expect(results.length).toEqual(1);
expect(results[0].pluginName).toEqual('os-plugin');
expect(results[0].isCompatible).toEqual(true);
expect(results[0].incompatibilityReason).toEqual('');
expect(results[0].installedVersions).toEqual(['1.1.0.0']);
expect(opensearch.client.asInternalUser.cat.plugins).toHaveBeenCalledTimes(1);
});

it('should return an empty array if no plugin dependencies are specified', async () => {
const pluginName = 'test';
const startDeps = { opensearch, plugins };
const startResult = await service.start(startDeps);
const results = await startResult.verifyOpenSearchPluginsState(pluginName);
expect(results).not.toBeUndefined();
expect(results.length).toEqual(0);
expect(opensearch.client.asInternalUser.cat.plugins).toHaveBeenCalledTimes(1);
});

it('should return an array of CrossCompatibilityResult objects with the incompatible reason if the plugin is not installed', async () => {
const pluginName = 'incompatiblePlugin';
const startDeps = { opensearch, plugins };
const startResult = await service.start(startDeps);
const results = await startResult.verifyOpenSearchPluginsState(pluginName);
expect(results).not.toBeUndefined();
expect(results.length).toEqual(1);
expect(results[0].pluginName).toEqual('os-plugin');
expect(results[0].isCompatible).toEqual(false);
expect(results[0].incompatibilityReason).toEqual(
'OpenSearch plugin "os-plugin" in the version range "^3.0.0" is not installed on the OpenSearch for the OpenSearch Dashboards plugin to function as expected.'
);
expect(results[0].installedVersions).toEqual(['1.1.0.0']);
expect(opensearch.client.asInternalUser.cat.plugins).toHaveBeenCalledTimes(1);
});
});
115 changes: 115 additions & 0 deletions src/core/server/cross_compatibility/cross_compatibility_service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CatPluginsResponse } from '@opensearch-project/opensearch/api/types';
import semver from 'semver';
import { CrossCompatibilityResult, CrossCompatibilityServiceStart } from './types';
import { CoreContext } from '../core_context';
import { Logger } from '../logging';
import { OpenSearchServiceStart } from '../opensearch';
import { CompatibleEnginePluginVersions, PluginName } from '../plugins/types';

export interface StartDeps {
opensearch: OpenSearchServiceStart;
plugins: Map<PluginName, CompatibleEnginePluginVersions>;
}

export class CrossCompatibilityService {
private readonly log: Logger;

constructor(coreContext: CoreContext) {
this.log = coreContext.logger.get('cross-compatibility-service');
}

start({ opensearch, plugins }: StartDeps): CrossCompatibilityServiceStart {
this.log.warn('Starting cross compatibility service');
return {
verifyOpenSearchPluginsState: (pluginName: string) => {
const pluginOpenSearchDeps = plugins.get(pluginName) || {};
return this.verifyOpenSearchPluginsState(opensearch, pluginOpenSearchDeps, pluginName);
},
};
}

public async getOpenSearchPlugins(opensearch: OpenSearchServiceStart) {
// Makes cat.plugin api call to fetch list of OpenSearch plugins installed on the cluster
try {
const { body } = await opensearch.client.asInternalUser.cat.plugins<any[]>({
format: 'JSON',
});
return body;
} catch (error) {
this.log.warn(

Check warning on line 44 in src/core/server/cross_compatibility/cross_compatibility_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/cross_compatibility/cross_compatibility_service.ts#L44

Added line #L44 was not covered by tests
`Cat API call to OpenSearch to get list of plugins installed on the cluster has failed: ${error}`
);
return [];

Check warning on line 47 in src/core/server/cross_compatibility/cross_compatibility_service.ts

View check run for this annotation

Codecov / codecov/patch

src/core/server/cross_compatibility/cross_compatibility_service.ts#L47

Added line #L47 was not covered by tests
}
}

public checkPluginVersionCompatibility(
pluginOpenSearchDeps: CompatibleEnginePluginVersions,
opensearchInstalledPlugins: CatPluginsResponse,
dashboardsPluginName: string
) {
const results: CrossCompatibilityResult[] = [];
for (const [pluginName, versionRange] of Object.entries(pluginOpenSearchDeps)) {
// add check to see if the Dashboards plugin version is compatible with installed OpenSearch plugin
const { isCompatible, installedPluginVersions } = this.isVersionCompatibleOSPluginInstalled(
opensearchInstalledPlugins,
pluginName,
versionRange
);
results.push({
pluginName,
isCompatible: !isCompatible ? false : true,
incompatibilityReason: !isCompatible
? `OpenSearch plugin "${pluginName}" in the version range "${versionRange}" is not installed on the OpenSearch for the OpenSearch Dashboards plugin to function as expected.`
: '',
installedVersions: installedPluginVersions,
});

if (!isCompatible) {
this.log.warn(
`OpenSearch plugin "${pluginName}" is not installed on the cluster for the OpenSearch Dashboards plugin "${dashboardsPluginName}" to function as expected.`
);
}
}
return results;
}

private async verifyOpenSearchPluginsState(
opensearch: OpenSearchServiceStart,
pluginOpenSearchDeps: CompatibleEnginePluginVersions,
pluginName: string
): Promise<CrossCompatibilityResult[]> {
this.log.info('Checking OpenSearch Plugin version compatibility');
// make _cat/plugins?format=json call to the OpenSearch instance
const opensearchInstalledPlugins = await this.getOpenSearchPlugins(opensearch);
const results = this.checkPluginVersionCompatibility(
pluginOpenSearchDeps,
opensearchInstalledPlugins,
pluginName
);
return results;
}

private isVersionCompatibleOSPluginInstalled(
opensearchInstalledPlugins: CatPluginsResponse,
depPluginName: string,
depPluginVersionRange: string
) {
let isCompatible = false;
const installedPluginVersions = new Set<string>();
opensearchInstalledPlugins.forEach((obj) => {
if (obj.component === depPluginName && obj.version) {
installedPluginVersions.add(obj.version);
if (semver.satisfies(semver.coerce(obj.version)!.version, depPluginVersionRange)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, I was going to release a PEP 440 alternate to semver but I found @renovatebot/pep440 on NPM which I have not used. If that library works, it would be better solution to semver.coerce and if it doesn't, I would release my alternative and we can use it here. If you have the time, can you check if @renovatebot/pep440 works?

isCompatible = true;
}
}
});
return { isCompatible, installedPluginVersions: [...installedPluginVersions] };
}
}
7 changes: 7 additions & 0 deletions src/core/server/cross_compatibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

export { CrossCompatibilityService } from './cross_compatibility_service';
export { CrossCompatibilityResult, CrossCompatibilityServiceStart } from './types';
22 changes: 22 additions & 0 deletions src/core/server/cross_compatibility/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CrossCompatibilityResult } from '../../types/cross_compatibility';

/**
* API to check if the OpenSearch Dashboards plugin version is compatible with the installed OpenSearch plugin.
*
* @public
*/
export interface CrossCompatibilityServiceStart {
/**
* Checks if the OpenSearch Dashboards plugin version is compatible with the installed OpenSearch plugin.
*
* @returns {Promise<CrossCompatibilityResult[]>}
*/
verifyOpenSearchPluginsState: (pluginName: string) => Promise<CrossCompatibilityResult[]>;
}

export { CrossCompatibilityResult };
4 changes: 4 additions & 0 deletions src/core/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { Auditor, AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { AppenderConfigType, appendersSchema, LoggingServiceSetup } from './logging';
import { CoreUsageDataStart } from './core_usage_data';
import { SecurityServiceSetup } from './security/types';
import { CrossCompatibilityServiceStart } from './cross_compatibility/types';

// Because of #79265 we need to explicity import, then export these types for
// scripts/telemetry_check.js to work as expected
Expand Down Expand Up @@ -485,6 +486,8 @@ export interface CoreStart {
auditTrail: AuditTrailStart;
/** @internal {@link CoreUsageDataStart} */
coreUsageData: CoreUsageDataStart;
/** {@link CrossCompatibilityServiceStart} */
crossCompatibility: CrossCompatibilityServiceStart;
}

export {
Expand All @@ -496,6 +499,7 @@ export {
PluginsServiceStart,
PluginOpaqueId,
AuditTrailStart,
CrossCompatibilityServiceStart,
};

/**
Expand Down
2 changes: 2 additions & 0 deletions src/core/server/internal_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { AuditTrailSetup, AuditTrailStart } from './audit_trail';
import { InternalLoggingServiceSetup } from './logging';
import { CoreUsageDataStart } from './core_usage_data';
import { InternalSecurityServiceSetup } from './security/types';
import { CrossCompatibilityServiceStart } from './cross_compatibility';

/** @internal */
export interface InternalCoreSetup {
Expand Down Expand Up @@ -80,6 +81,7 @@ export interface InternalCoreStart {
uiSettings: InternalUiSettingsServiceStart;
auditTrail: AuditTrailStart;
coreUsageData: CoreUsageDataStart;
crossCompatibility: CrossCompatibilityServiceStart;
}

/**
Expand Down
Loading
Loading