Skip to content

Commit 7f96de0

Browse files
jportnerkibanamachine
authored andcommitted
Allow optional OSS to X-Pack dependencies (elastic#107432)
1 parent cda9966 commit 7f96de0

File tree

5 files changed

+179
-26
lines changed

5 files changed

+179
-26
lines changed

.eslintrc.js

-5
Original file line numberDiff line numberDiff line change
@@ -474,11 +474,6 @@ module.exports = {
474474
errorMessage:
475475
'Server modules cannot be imported into client modules or shared modules.',
476476
},
477-
{
478-
target: ['src/**/*'],
479-
from: ['x-pack/**/*'],
480-
errorMessage: 'OSS cannot import x-pack files.',
481-
},
482477
{
483478
target: ['src/core/**/*'],
484479
from: ['plugins/**/*', 'src/plugins/**/*'],

src/core/server/plugins/plugin.test.ts

+44
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ jest.doMock(join('plugin-with-wrong-initializer-path', 'server'), () => ({ plugi
3939
virtual: true,
4040
});
4141

42+
const OSS_PLUGIN_PATH_POSIX = '/kibana/src/plugins/ossPlugin';
43+
const OSS_PLUGIN_PATH_WINDOWS = 'C:\\kibana\\src\\plugins\\ossPlugin';
44+
const XPACK_PLUGIN_PATH_POSIX = '/kibana/x-pack/plugins/xPackPlugin';
45+
const XPACK_PLUGIN_PATH_WINDOWS = 'C:\\kibana\\x-pack\\plugins\\xPackPlugin';
46+
4247
function createPluginManifest(manifestProps: Partial<PluginManifest> = {}): PluginManifest {
4348
return {
4449
id: 'some-plugin-id',
@@ -97,10 +102,49 @@ test('`constructor` correctly initializes plugin instance', () => {
97102
expect(plugin.name).toBe('some-plugin-id');
98103
expect(plugin.configPath).toBe('path');
99104
expect(plugin.path).toBe('some-plugin-path');
105+
expect(plugin.source).toBe('external'); // see below for test cases for non-external sources (OSS and X-Pack)
100106
expect(plugin.requiredPlugins).toEqual(['some-required-dep']);
101107
expect(plugin.optionalPlugins).toEqual(['some-optional-dep']);
102108
});
103109

110+
describe('`constructor` correctly sets non-external source', () => {
111+
function createPlugin(path: string) {
112+
const manifest = createPluginManifest();
113+
const opaqueId = Symbol();
114+
return new PluginWrapper({
115+
path,
116+
manifest,
117+
opaqueId,
118+
initializerContext: createPluginInitializerContext(
119+
coreContext,
120+
opaqueId,
121+
manifest,
122+
instanceInfo
123+
),
124+
});
125+
}
126+
127+
test('for OSS plugin in POSIX', () => {
128+
const plugin = createPlugin(OSS_PLUGIN_PATH_POSIX);
129+
expect(plugin.source).toBe('oss');
130+
});
131+
132+
test('for OSS plugin in Windows', () => {
133+
const plugin = createPlugin(OSS_PLUGIN_PATH_WINDOWS);
134+
expect(plugin.source).toBe('oss');
135+
});
136+
137+
test('for X-Pack plugin in POSIX', () => {
138+
const plugin = createPlugin(XPACK_PLUGIN_PATH_POSIX);
139+
expect(plugin.source).toBe('x-pack');
140+
});
141+
142+
test('for X-Pack plugin in Windows', () => {
143+
const plugin = createPlugin(XPACK_PLUGIN_PATH_WINDOWS);
144+
expect(plugin.source).toBe('x-pack');
145+
});
146+
});
147+
104148
test('`setup` fails if `plugin` initializer is not exported', () => {
105149
const manifest = createPluginManifest();
106150
const opaqueId = Symbol();

src/core/server/plugins/plugin.ts

+14
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import {
2727
} from './types';
2828
import { CorePreboot, CoreSetup, CoreStart } from '..';
2929

30+
const OSS_PATH_REGEX = /[\/|\\]src[\/|\\]plugins[\/|\\]/; // Matches src/plugins directory on POSIX and Windows
31+
const XPACK_PATH_REGEX = /[\/|\\]x-pack[\/|\\]plugins[\/|\\]/; // Matches x-pack/plugins directory on POSIX and Windows
32+
3033
/**
3134
* Lightweight wrapper around discovered plugin that is responsible for instantiating
3235
* plugin and dispatching proper context and dependencies into plugin's lifecycle hooks.
@@ -40,6 +43,7 @@ export class PluginWrapper<
4043
TPluginsStart extends object = object
4144
> {
4245
public readonly path: string;
46+
public readonly source: 'oss' | 'x-pack' | 'external';
4347
public readonly manifest: PluginManifest;
4448
public readonly opaqueId: PluginOpaqueId;
4549
public readonly name: PluginManifest['id'];
@@ -70,6 +74,7 @@ export class PluginWrapper<
7074
}
7175
) {
7276
this.path = params.path;
77+
this.source = getPluginSource(params.path);
7378
this.manifest = params.manifest;
7479
this.opaqueId = params.opaqueId;
7580
this.initializerContext = params.initializerContext;
@@ -204,3 +209,12 @@ export class PluginWrapper<
204209
return this.manifest.type === PluginType.preboot;
205210
}
206211
}
212+
213+
function getPluginSource(path: string): 'oss' | 'x-pack' | 'external' {
214+
if (OSS_PATH_REGEX.test(path)) {
215+
return 'oss';
216+
} else if (XPACK_PATH_REGEX.test(path)) {
217+
return 'x-pack';
218+
}
219+
return 'external';
220+
}

src/core/server/plugins/plugins_service.test.ts

+78
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ expect.addSnapshotSerializer(createAbsolutePathSerializer());
5252
});
5353
});
5454

55+
const OSS_PLUGIN_PATH = '/kibana/src/plugins/ossPlugin';
56+
const XPACK_PLUGIN_PATH = '/kibana/x-pack/plugins/xPackPlugin';
57+
const EXTERNAL_PLUGIN_PATH = '/kibana/plugins/externalPlugin';
58+
[OSS_PLUGIN_PATH, XPACK_PLUGIN_PATH, EXTERNAL_PLUGIN_PATH].forEach((path) => {
59+
jest.doMock(join(path, 'server'), () => ({}), {
60+
virtual: true,
61+
});
62+
});
63+
5564
const createPlugin = (
5665
id: string,
5766
{
@@ -247,6 +256,75 @@ describe('PluginsService', () => {
247256
expect(standardMockPluginSystem.setupPlugins).not.toHaveBeenCalled();
248257
});
249258

259+
describe('X-Pack dependencies', () => {
260+
function mockDiscoveryResults(params: { sourcePluginPath: string; dependencyType: string }) {
261+
const { sourcePluginPath, dependencyType } = params;
262+
// Each plugin's source is derived from its path; the PluginWrapper test suite contains more detailed test cases around the paths (for both POSIX and Windows)
263+
mockDiscover.mockReturnValue({
264+
error$: from([]),
265+
plugin$: from([
266+
createPlugin('sourcePlugin', {
267+
path: sourcePluginPath,
268+
version: 'some-version',
269+
configPath: 'path',
270+
requiredPlugins: dependencyType === 'requiredPlugin' ? ['xPackPlugin'] : [],
271+
requiredBundles: dependencyType === 'requiredBundle' ? ['xPackPlugin'] : undefined,
272+
optionalPlugins: dependencyType === 'optionalPlugin' ? ['xPackPlugin'] : [],
273+
}),
274+
createPlugin('xPackPlugin', {
275+
path: XPACK_PLUGIN_PATH,
276+
version: 'some-version',
277+
configPath: 'path',
278+
requiredPlugins: [],
279+
optionalPlugins: [],
280+
}),
281+
]),
282+
});
283+
}
284+
285+
async function expectError() {
286+
await expect(pluginsService.discover({ environment: environmentPreboot })).rejects.toThrow(
287+
`X-Pack plugin or bundle with id "xPackPlugin" is required by OSS plugin "sourcePlugin", which is prohibited. Consider making this an optional dependency instead.`
288+
);
289+
expect(standardMockPluginSystem.addPlugin).not.toHaveBeenCalled();
290+
}
291+
async function expectSuccess() {
292+
await expect(pluginsService.discover({ environment: environmentPreboot })).resolves.toEqual(
293+
expect.anything()
294+
);
295+
expect(standardMockPluginSystem.addPlugin).toHaveBeenCalled();
296+
}
297+
298+
it('throws if an OSS plugin requires an X-Pack plugin or bundle', async () => {
299+
for (const dependencyType of ['requiredPlugin', 'requiredBundle']) {
300+
mockDiscoveryResults({ sourcePluginPath: OSS_PLUGIN_PATH, dependencyType });
301+
await expectError();
302+
}
303+
});
304+
305+
it('does not throw if an OSS plugin has an optional dependency on an X-Pack plugin', async () => {
306+
mockDiscoveryResults({
307+
sourcePluginPath: OSS_PLUGIN_PATH,
308+
dependencyType: 'optionalPlugin',
309+
});
310+
await expectSuccess();
311+
});
312+
313+
it('does not throw if an X-Pack plugin depends on an X-Pack plugin or bundle', async () => {
314+
for (const dependencyType of ['requiredPlugin', 'requiredBundle', 'optionalPlugin']) {
315+
mockDiscoveryResults({ sourcePluginPath: XPACK_PLUGIN_PATH, dependencyType });
316+
await expectSuccess();
317+
}
318+
});
319+
320+
it('does not throw if an external plugin depends on an X-Pack plugin or bundle', async () => {
321+
for (const dependencyType of ['requiredPlugin', 'requiredBundle', 'optionalPlugin']) {
322+
mockDiscoveryResults({ sourcePluginPath: EXTERNAL_PLUGIN_PATH, dependencyType });
323+
await expectSuccess();
324+
}
325+
});
326+
});
327+
250328
it('properly detects plugins that should be disabled.', async () => {
251329
jest
252330
.spyOn(configService, 'isEnabledAtPath')

src/core/server/plugins/plugins_service.ts

+43-21
Original file line numberDiff line numberDiff line change
@@ -314,27 +314,7 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
314314
.toPromise();
315315

316316
for (const [pluginName, { plugin, isEnabled }] of pluginEnableStatuses) {
317-
// validate that `requiredBundles` ids point to a discovered plugin which `includesUiPlugin`
318-
for (const requiredBundleId of plugin.requiredBundles) {
319-
if (!pluginEnableStatuses.has(requiredBundleId)) {
320-
throw new Error(
321-
`Plugin bundle with id "${requiredBundleId}" is required by plugin "${pluginName}" but it is missing.`
322-
);
323-
}
324-
325-
const requiredPlugin = pluginEnableStatuses.get(requiredBundleId)!.plugin;
326-
if (!requiredPlugin.includesUiPlugin) {
327-
throw new Error(
328-
`Plugin bundle with id "${requiredBundleId}" is required by plugin "${pluginName}" but it doesn't have a UI bundle.`
329-
);
330-
}
331-
332-
if (requiredPlugin.manifest.type !== plugin.manifest.type) {
333-
throw new Error(
334-
`Plugin bundle with id "${requiredBundleId}" is required by plugin "${pluginName}" and expected to have "${plugin.manifest.type}" type, but its type is "${requiredPlugin.manifest.type}".`
335-
);
336-
}
337-
}
317+
this.validatePluginDependencies(plugin, pluginEnableStatuses);
338318

339319
const pluginEnablement = this.shouldEnablePlugin(pluginName, pluginEnableStatuses);
340320

@@ -358,6 +338,48 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
358338
this.log.debug(`Discovered ${pluginEnableStatuses.size} plugins.`);
359339
}
360340

341+
/** Throws an error if the plugin's dependencies are invalid. */
342+
private validatePluginDependencies(
343+
plugin: PluginWrapper,
344+
pluginEnableStatuses: Map<PluginName, { plugin: PluginWrapper; isEnabled: boolean }>
345+
) {
346+
const { name, manifest, requiredBundles, requiredPlugins } = plugin;
347+
348+
// validate that `requiredBundles` ids point to a discovered plugin which `includesUiPlugin`
349+
for (const requiredBundleId of requiredBundles) {
350+
if (!pluginEnableStatuses.has(requiredBundleId)) {
351+
throw new Error(
352+
`Plugin bundle with id "${requiredBundleId}" is required by plugin "${name}" but it is missing.`
353+
);
354+
}
355+
356+
const requiredPlugin = pluginEnableStatuses.get(requiredBundleId)!.plugin;
357+
if (!requiredPlugin.includesUiPlugin) {
358+
throw new Error(
359+
`Plugin bundle with id "${requiredBundleId}" is required by plugin "${name}" but it doesn't have a UI bundle.`
360+
);
361+
}
362+
363+
if (requiredPlugin.manifest.type !== plugin.manifest.type) {
364+
throw new Error(
365+
`Plugin bundle with id "${requiredBundleId}" is required by plugin "${name}" and expected to have "${manifest.type}" type, but its type is "${requiredPlugin.manifest.type}".`
366+
);
367+
}
368+
}
369+
370+
// validate that OSS plugins do not have required dependencies on X-Pack plugins
371+
if (plugin.source === 'oss') {
372+
for (const id of [...requiredPlugins, ...requiredBundles]) {
373+
const requiredPlugin = pluginEnableStatuses.get(id);
374+
if (requiredPlugin && requiredPlugin.plugin.source === 'x-pack') {
375+
throw new Error(
376+
`X-Pack plugin or bundle with id "${id}" is required by OSS plugin "${name}", which is prohibited. Consider making this an optional dependency instead.`
377+
);
378+
}
379+
}
380+
}
381+
}
382+
361383
private shouldEnablePlugin(
362384
pluginName: PluginName,
363385
pluginEnableStatuses: Map<PluginName, { plugin: PluginWrapper; isEnabled: boolean }>,

0 commit comments

Comments
 (0)