[8.x] Add ESLINT constraints to detect inter-group dependencies (#194810) (#197670)

# Backport

This will backport the following commits from `main` to `8.x`:
- [Add ESLINT constraints to detect inter-group dependencies
(#194810)](https://github.com/elastic/kibana/pull/194810)

<!--- Backport version: 8.9.8 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Gerard
Soldevila","email":"gerard.soldevila@elastic.co"},"sourceCommit":{"committedDate":"2024-10-22T11:34:19Z","message":"Add
ESLINT constraints to detect inter-group dependencies (#194810)\n\n##
Summary\r\n\r\nAddresses
https://github.com/elastic/kibana-team/issues/1175\r\n\r\nAs part of the
**Sustainable Kibana Architecture** initiative, this PR\r\nsets the
foundation to start classifying plugins in isolated groups,\r\nmatching
our current solutions / project types:\r\n\r\n* It adds support for the
following fields in the packages' manifests\r\n(kibana.jsonc):\r\n*
`group?: 'search' | 'security' | 'observability' | 'platform'
|\r\n'common'`\r\n * `visibility?: 'private' | 'shared'`\r\n\r\n* It
proposes a folder structure to automatically infer
groups:\r\n```javascript\r\n 'src/platform/plugins/shared': {\r\n group:
'platform',\r\n visibility: 'shared',\r\n },\r\n
'src/platform/plugins/internal': {\r\n group: 'platform',\r\n
visibility: 'private',\r\n },\r\n 'x-pack/platform/plugins/shared':
{\r\n group: 'platform',\r\n visibility: 'shared',\r\n },\r\n
'x-pack/platform/plugins/internal': {\r\n group: 'platform',\r\n
visibility: 'private',\r\n },\r\n
'x-pack/solutions/observability/plugins': {\r\n group:
'observability',\r\n visibility: 'private',\r\n },\r\n
'x-pack/solutions/security/plugins': {\r\n group: 'security',\r\n
visibility: 'private',\r\n },\r\n 'x-pack/solutions/search/plugins':
{\r\n group: 'search',\r\n visibility: 'private',\r\n },\r\n```\r\n\r\n*
If a plugin is moved to one of the specific locations above, the
group\r\nand visibility in the manifest (if specified) must match those
inferred\r\nfrom the path.\r\n* Plugins that are not relocated are
considered: `group: 'common',\r\nvisibility: 'shared'` by default. As
soon as we specify a custom\r\n`group`, the ESLINT rules will check
violations against dependencies /\r\ndependants.\r\n\r\nThe ESLINT rules
are pretty simple:\r\n* Plugins can only depend on:\r\n * Plugins in the
same group\r\n * OR plugins with `'shared'` visibility\r\n* Plugins in
`'observability', 'security', 'search'` groups are\r\nmandatorily
`'private'`.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"2a085e103afe8c7bdfb626d0dc683fc8be0e6c05","branchLabelMapping":{"^v9.0.0$":"main","^v8.17.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Core","backport
missing","v9.0.0","release_note:feature","backport:prev-minor"],"number":194810,"url":"https://github.com/elastic/kibana/pull/194810","mergeCommit":{"message":"Add
ESLINT constraints to detect inter-group dependencies (#194810)\n\n##
Summary\r\n\r\nAddresses
https://github.com/elastic/kibana-team/issues/1175\r\n\r\nAs part of the
**Sustainable Kibana Architecture** initiative, this PR\r\nsets the
foundation to start classifying plugins in isolated groups,\r\nmatching
our current solutions / project types:\r\n\r\n* It adds support for the
following fields in the packages' manifests\r\n(kibana.jsonc):\r\n*
`group?: 'search' | 'security' | 'observability' | 'platform'
|\r\n'common'`\r\n * `visibility?: 'private' | 'shared'`\r\n\r\n* It
proposes a folder structure to automatically infer
groups:\r\n```javascript\r\n 'src/platform/plugins/shared': {\r\n group:
'platform',\r\n visibility: 'shared',\r\n },\r\n
'src/platform/plugins/internal': {\r\n group: 'platform',\r\n
visibility: 'private',\r\n },\r\n 'x-pack/platform/plugins/shared':
{\r\n group: 'platform',\r\n visibility: 'shared',\r\n },\r\n
'x-pack/platform/plugins/internal': {\r\n group: 'platform',\r\n
visibility: 'private',\r\n },\r\n
'x-pack/solutions/observability/plugins': {\r\n group:
'observability',\r\n visibility: 'private',\r\n },\r\n
'x-pack/solutions/security/plugins': {\r\n group: 'security',\r\n
visibility: 'private',\r\n },\r\n 'x-pack/solutions/search/plugins':
{\r\n group: 'search',\r\n visibility: 'private',\r\n },\r\n```\r\n\r\n*
If a plugin is moved to one of the specific locations above, the
group\r\nand visibility in the manifest (if specified) must match those
inferred\r\nfrom the path.\r\n* Plugins that are not relocated are
considered: `group: 'common',\r\nvisibility: 'shared'` by default. As
soon as we specify a custom\r\n`group`, the ESLINT rules will check
violations against dependencies /\r\ndependants.\r\n\r\nThe ESLINT rules
are pretty simple:\r\n* Plugins can only depend on:\r\n * Plugins in the
same group\r\n * OR plugins with `'shared'` visibility\r\n* Plugins in
`'observability', 'security', 'search'` groups are\r\nmandatorily
`'private'`.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"2a085e103afe8c7bdfb626d0dc683fc8be0e6c05"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","labelRegex":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194810","number":194810,"mergeCommit":{"message":"Add
ESLINT constraints to detect inter-group dependencies (#194810)\n\n##
Summary\r\n\r\nAddresses
https://github.com/elastic/kibana-team/issues/1175\r\n\r\nAs part of the
**Sustainable Kibana Architecture** initiative, this PR\r\nsets the
foundation to start classifying plugins in isolated groups,\r\nmatching
our current solutions / project types:\r\n\r\n* It adds support for the
following fields in the packages' manifests\r\n(kibana.jsonc):\r\n*
`group?: 'search' | 'security' | 'observability' | 'platform'
|\r\n'common'`\r\n * `visibility?: 'private' | 'shared'`\r\n\r\n* It
proposes a folder structure to automatically infer
groups:\r\n```javascript\r\n 'src/platform/plugins/shared': {\r\n group:
'platform',\r\n visibility: 'shared',\r\n },\r\n
'src/platform/plugins/internal': {\r\n group: 'platform',\r\n
visibility: 'private',\r\n },\r\n 'x-pack/platform/plugins/shared':
{\r\n group: 'platform',\r\n visibility: 'shared',\r\n },\r\n
'x-pack/platform/plugins/internal': {\r\n group: 'platform',\r\n
visibility: 'private',\r\n },\r\n
'x-pack/solutions/observability/plugins': {\r\n group:
'observability',\r\n visibility: 'private',\r\n },\r\n
'x-pack/solutions/security/plugins': {\r\n group: 'security',\r\n
visibility: 'private',\r\n },\r\n 'x-pack/solutions/search/plugins':
{\r\n group: 'search',\r\n visibility: 'private',\r\n },\r\n```\r\n\r\n*
If a plugin is moved to one of the specific locations above, the
group\r\nand visibility in the manifest (if specified) must match those
inferred\r\nfrom the path.\r\n* Plugins that are not relocated are
considered: `group: 'common',\r\nvisibility: 'shared'` by default. As
soon as we specify a custom\r\n`group`, the ESLINT rules will check
violations against dependencies /\r\ndependants.\r\n\r\nThe ESLINT rules
are pretty simple:\r\n* Plugins can only depend on:\r\n * Plugins in the
same group\r\n * OR plugins with `'shared'` visibility\r\n* Plugins in
`'observability', 'security', 'search'` groups are\r\nmandatorily
`'private'`.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"2a085e103afe8c7bdfb626d0dc683fc8be0e6c05"}}]}]
BACKPORT-->
This commit is contained in:
Gerard Soldevila 2024-10-24 22:20:30 +02:00 committed by GitHub
parent 9d54a62014
commit 7b820130ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1277 additions and 48 deletions

7
.github/CODEOWNERS vendored
View file

@ -596,6 +596,7 @@ packages/kbn-management/settings/types @elastic/kibana-management
packages/kbn-management/settings/utilities @elastic/kibana-management
packages/kbn-management/storybook/config @elastic/kibana-management
test/plugin_functional/plugins/management_test_plugin @elastic/kibana-management
packages/kbn-manifest @elastic/kibana-core
packages/kbn-mapbox-gl @elastic/kibana-gis
x-pack/examples/third_party_maps_source_example @elastic/kibana-gis
src/plugins/maps_ems @elastic/kibana-gis
@ -930,9 +931,9 @@ packages/kbn-test-eui-helpers @elastic/kibana-visualizations
x-pack/test/licensing_plugin/plugins/test_feature_usage @elastic/kibana-security
packages/kbn-test-jest-helpers @elastic/kibana-operations @elastic/appex-qa
packages/kbn-test-subj-selector @elastic/kibana-operations @elastic/appex-qa
x-pack/test_serverless
test
x-pack/test
x-pack/test_serverless
test
x-pack/test
x-pack/performance @elastic/appex-qa
x-pack/examples/testing_embedded_lens @elastic/kibana-visualizations
x-pack/examples/third_party_lens_navigation_prompt @elastic/kibana-visualizations

View file

@ -633,6 +633,7 @@
"@kbn/management-settings-types": "link:packages/kbn-management/settings/types",
"@kbn/management-settings-utilities": "link:packages/kbn-management/settings/utilities",
"@kbn/management-test-plugin": "link:test/plugin_functional/plugins/management_test_plugin",
"@kbn/manifest": "link:packages/kbn-manifest",
"@kbn/mapbox-gl": "link:packages/kbn-mapbox-gl",
"@kbn/maps-custom-raster-source-plugin": "link:x-pack/examples/third_party_maps_source_example",
"@kbn/maps-ems-plugin": "link:src/plugins/maps_ems",

View file

@ -20,14 +20,39 @@ const OUTPUT_PATH = Path.resolve(REPO_ROOT, 'docs/developer/plugin-list.asciidoc
export function runPluginListCli() {
run(async ({ log }) => {
log.info('looking for oss plugins');
const ossPlugins = discoverPlugins('src/plugins');
log.success(`found ${ossPlugins.length} plugins`);
const ossLegacyPlugins = discoverPlugins('src/plugins');
const ossPlatformPlugins = discoverPlugins('src/platform/plugins');
log.success(`found ${ossLegacyPlugins.length + ossPlatformPlugins.length} plugins`);
log.info('looking for x-pack plugins');
const xpackPlugins = discoverPlugins('x-pack/plugins');
log.success(`found ${xpackPlugins.length} plugins`);
const xpackLegacyPlugins = discoverPlugins('x-pack/plugins');
const xpackPlatformPlugins = discoverPlugins('x-pack/platform/plugins');
const xpackSearchPlugins = discoverPlugins('x-pack/solutions/search/plugins');
const xpackSecurityPlugins = discoverPlugins('x-pack/solutions/security/plugins');
const xpackObservabilityPlugins = discoverPlugins('x-pack/solutions/observability/plugins');
log.success(
`found ${
xpackLegacyPlugins.length +
xpackPlatformPlugins.length +
xpackSearchPlugins.length +
xpackSecurityPlugins.length +
xpackObservabilityPlugins.length
} plugins`
);
log.info('writing plugin list to', OUTPUT_PATH);
Fs.writeFileSync(OUTPUT_PATH, generatePluginList(ossPlugins, xpackPlugins));
Fs.writeFileSync(
OUTPUT_PATH,
generatePluginList(
[...ossLegacyPlugins, ...ossPlatformPlugins],
[
...xpackLegacyPlugins,
...xpackPlatformPlugins,
...xpackSearchPlugins,
...xpackSecurityPlugins,
...xpackObservabilityPlugins,
]
)
);
});
}

View file

@ -326,7 +326,8 @@ module.exports = {
'@kbn/imports/uniform_imports': 'error',
'@kbn/imports/no_unused_imports': 'error',
'@kbn/imports/no_boundary_crossing': 'error',
'@kbn/imports/no_group_crossing_manifests': 'error',
'@kbn/imports/no_group_crossing_imports': 'error',
'no-new-func': 'error',
'no-implied-eval': 'error',
'no-prototype-builtins': 'error',

View file

@ -12,4 +12,6 @@ export const PROTECTED_RULES = new Set([
'@kbn/disable/no_protected_eslint_disable',
'@kbn/disable/no_naked_eslint_disable',
'@kbn/imports/no_unused_imports',
'@kbn/imports/no_group_crossing_imports',
'@kbn/imports/no_group_crossing_manifests',
]);

View file

@ -13,6 +13,8 @@ import { UniformImportsRule } from './src/rules/uniform_imports';
import { ExportsMovedPackagesRule } from './src/rules/exports_moved_packages';
import { NoUnusedImportsRule } from './src/rules/no_unused_imports';
import { NoBoundaryCrossingRule } from './src/rules/no_boundary_crossing';
import { NoGroupCrossingImportsRule } from './src/rules/no_group_crossing_imports';
import { NoGroupCrossingManifestsRule } from './src/rules/no_group_crossing_manifests';
import { RequireImportRule } from './src/rules/require_import';
/**
@ -25,5 +27,7 @@ export const rules = {
exports_moved_packages: ExportsMovedPackagesRule,
no_unused_imports: NoUnusedImportsRule,
no_boundary_crossing: NoBoundaryCrossingRule,
no_group_crossing_imports: NoGroupCrossingImportsRule,
no_group_crossing_manifests: NoGroupCrossingManifestsRule,
require_import: RequireImportRule,
};

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
/**
* Checks whether a given ModuleGroup can import from another one
* @param importerGroup The group of the module that we are checking
* @param importedGroup The group of the imported module
* @param importedVisibility The visibility of the imported module
* @returns true if importerGroup is allowed to import from importedGroup/Visibiliy
*/
export function isImportableFrom(
importerGroup: ModuleGroup,
importedGroup: ModuleGroup,
importedVisibility: ModuleVisibility
): boolean {
return importerGroup === importedGroup || importedVisibility === 'shared';
}

View file

@ -30,3 +30,19 @@ export function report(context: Rule.RuleContext, options: ReportOptions) {
: null,
});
}
export const toList = (strings: string[]) => {
const items = strings.map((s) => `"${s}"`);
const list = items.slice(0, -1).join(', ');
const last = items.at(-1);
return !list.length ? last ?? '' : `${list} or ${last}`;
};
export const formatSuggestions = (suggestions: string[]) => {
const s = suggestions.map((l) => l.trim()).filter(Boolean);
if (!s.length) {
return '';
}
return ` \nSuggestions:\n - ${s.join('\n - ')}\n\n`;
};

View file

@ -9,8 +9,9 @@
import { RuleTester } from 'eslint';
import { NoBoundaryCrossingRule } from './no_boundary_crossing';
import { ModuleType } from '@kbn/repo-source-classifier';
import type { ModuleType } from '@kbn/repo-source-classifier';
import dedent from 'dedent';
import { formatSuggestions } from '../helpers/report';
const make = (from: ModuleType, to: ModuleType, imp = 'import') => ({
filename: `${from}.ts`,
@ -107,13 +108,12 @@ for (const [name, tester] of [tsTester, babelTester]) {
data: {
importedType: 'server package',
ownType: 'common package',
suggestion: ` ${dedent`
Suggestions:
- Remove the import statement.
- Limit your imports to "common package" or "static" code.
- Covert to a type-only import.
- Reach out to #kibana-operations for help.
`}`,
suggestion: formatSuggestions([
'Remove the import statement.',
'Limit your imports to "common package" or "static" code.',
'Covert to a type-only import.',
'Reach out to #kibana-operations for help.',
]),
},
},
],

View file

@ -12,13 +12,14 @@ import Path from 'path';
import { TSESTree } from '@typescript-eslint/typescript-estree';
import * as Bt from '@babel/types';
import type { Rule } from 'eslint';
import ESTree from 'estree';
import { ModuleType } from '@kbn/repo-source-classifier';
import type { Node } from 'estree';
import type { ModuleType } from '@kbn/repo-source-classifier';
import { visitAllImportStatements, Importer } from '../helpers/visit_all_import_statements';
import { getSourcePath } from '../helpers/source';
import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
import { getImportResolver } from '../get_import_resolver';
import { formatSuggestions, toList } from '../helpers/report';
const ANY = Symbol();
@ -33,22 +34,6 @@ const IMPORTABLE_FROM: Record<ModuleType, ModuleType[] | typeof ANY> = {
tooling: ANY,
};
const toList = (strings: string[]) => {
const items = strings.map((s) => `"${s}"`);
const list = items.slice(0, -1).join(', ');
const last = items.at(-1);
return !list.length ? last ?? '' : `${list} or ${last}`;
};
const formatSuggestions = (suggestions: string[]) => {
const s = suggestions.map((l) => l.trim()).filter(Boolean);
if (!s.length) {
return '';
}
return ` Suggestions:\n - ${s.join('\n - ')}`;
};
const isTypeOnlyImport = (importer: Importer) => {
// handle babel nodes
if (Bt.isImportDeclaration(importer)) {
@ -125,7 +110,7 @@ export const NoBoundaryCrossingRule: Rule.RuleModule = {
if (!importable.includes(imported.type)) {
context.report({
node: node as ESTree.Node,
node: node as Node,
messageId: 'TYPE_MISMATCH',
data: {
ownType: self.type,

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RuleTester } from 'eslint';
import dedent from 'dedent';
import { NoGroupCrossingImportsRule } from './no_group_crossing_imports';
import { formatSuggestions } from '../helpers/report';
import { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
const make = (
fromGroup: ModuleGroup,
fromVisibility: ModuleVisibility,
toGroup: ModuleGroup,
toVisibility: ModuleVisibility,
imp = 'import'
) => ({
filename: `${fromGroup}.${fromVisibility}.ts`,
code: dedent`
${imp} '${toGroup}.${toVisibility}'
`,
});
jest.mock('../get_import_resolver', () => {
return {
getImportResolver() {
return {
resolve(req: string) {
return {
type: 'file',
absolute: req.split('.'),
};
},
};
},
};
});
jest.mock('../helpers/repo_source_classifier', () => {
return {
getRepoSourceClassifier() {
return {
classify(r: string | [string, string]) {
const [group, visibility] =
typeof r === 'string' ? (r.endsWith('.ts') ? r.slice(0, -3) : r).split('.') : r;
return {
pkgInfo: {
pkgId: 'aPackage',
},
group,
visibility,
};
},
};
},
};
});
const tsTester = [
'@typescript-eslint/parser',
new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
}),
] as const;
const babelTester = [
'@babel/eslint-parser',
new RuleTester({
parser: require.resolve('@babel/eslint-parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
requireConfigFile: false,
babelOptions: {
presets: ['@kbn/babel-preset/node_preset'],
},
},
}),
] as const;
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run('@kbn/imports/no_group_crossing_imports', NoGroupCrossingImportsRule, {
valid: [
make('observability', 'private', 'observability', 'private'),
make('security', 'private', 'security', 'private'),
make('search', 'private', 'search', 'private'),
make('observability', 'private', 'platform', 'shared'),
make('security', 'private', 'common', 'shared'),
make('platform', 'shared', 'platform', 'shared'),
make('platform', 'shared', 'platform', 'private'),
make('common', 'shared', 'common', 'shared'),
],
invalid: [
{
...make('observability', 'private', 'security', 'private'),
errors: [
{
line: 1,
messageId: 'ILLEGAL_IMPORT',
data: {
importerPackage: 'aPackage',
importerGroup: 'observability',
importedPackage: 'aPackage',
importedGroup: 'security',
importedVisibility: 'private',
sourcePath: 'observability.private.ts',
suggestion: formatSuggestions([
`Please review the dependencies in your module's manifest (kibana.jsonc).`,
`Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
`Address the conflicting dependencies by refactoring the code`,
]),
},
},
],
},
{
...make('security', 'private', 'platform', 'private'),
errors: [
{
line: 1,
messageId: 'ILLEGAL_IMPORT',
data: {
importerPackage: 'aPackage',
importerGroup: 'security',
importedPackage: 'aPackage',
importedGroup: 'platform',
importedVisibility: 'private',
sourcePath: 'security.private.ts',
suggestion: formatSuggestions([
`Please review the dependencies in your module's manifest (kibana.jsonc).`,
`Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
`Address the conflicting dependencies by refactoring the code`,
]),
},
},
],
},
],
});
});
}

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { dirname } from 'path';
import type { Rule } from 'eslint';
import type { Node } from 'estree';
import { REPO_ROOT } from '@kbn/repo-info';
import { visitAllImportStatements } from '../helpers/visit_all_import_statements';
import { getSourcePath } from '../helpers/source';
import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
import { getImportResolver } from '../get_import_resolver';
import { formatSuggestions } from '../helpers/report';
import { isImportableFrom } from '../helpers/groups';
export const NoGroupCrossingImportsRule: Rule.RuleModule = {
meta: {
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unused_imports',
},
messages: {
ILLEGAL_IMPORT: `⚠ Illegal import statement: "{{importerPackage}}" ({{importerGroup}}) is importing "{{importedPackage}}" ({{importedGroup}}/{{importedVisibility}}). File: {{sourcePath}}\n{{suggestion}}\n`,
},
},
create(context) {
const resolver = getImportResolver(context);
const classifier = getRepoSourceClassifier(resolver);
const sourcePath = getSourcePath(context);
const ownDirname = dirname(sourcePath);
const self = classifier.classify(sourcePath);
const relativePath = sourcePath.replace(REPO_ROOT, '').replace(/^\//, '');
return visitAllImportStatements((req, { node }) => {
if (
req === null ||
// we can ignore imports using the raw-loader, they will need to be resolved but can be managed on a case by case basis
req.startsWith('!!raw-loader')
) {
return;
}
const result = resolver.resolve(req, ownDirname);
if (result?.type !== 'file' || result.nodeModule) {
return;
}
const imported = classifier.classify(result.absolute);
if (!isImportableFrom(self.group, imported.group, imported.visibility)) {
context.report({
node: node as Node,
messageId: 'ILLEGAL_IMPORT',
data: {
importerPackage: self.pkgInfo?.pkgId ?? 'unknown',
importerGroup: self.group,
importedPackage: imported.pkgInfo?.pkgId ?? 'unknown',
importedGroup: imported.group,
importedVisibility: imported.visibility,
sourcePath: relativePath,
suggestion: formatSuggestions([
`Please review the dependencies in your module's manifest (kibana.jsonc).`,
`Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
`Address the conflicting dependencies by refactoring the code`,
]),
},
});
return;
}
});
},
};

View file

@ -0,0 +1,280 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { RuleTester } from 'eslint';
import dedent from 'dedent';
import { NoGroupCrossingManifestsRule } from './no_group_crossing_manifests';
import { formatSuggestions } from '../helpers/report';
import { ModuleId } from '@kbn/repo-source-classifier/src/module_id';
import { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
const makePlugin = (filename: string) => ({
filename,
code: dedent`
export function plugin() {
return new MyPlugin();
}
`,
});
const makePluginClass = (filename: string) => ({
filename,
code: dedent`
class MyPlugin implements Plugin {
setup() {
console.log('foo');
}
start() {
console.log('foo');
}
}
`,
});
const makeModuleByPath = (
path: string,
group: ModuleGroup,
visibility: ModuleVisibility,
pluginOverrides: any = {}
): Record<string, ModuleId> => {
const pluginId = path.split('/')[4];
const packageId = `@kbn/${pluginId}-plugin`;
return {
[path]: {
type: 'server package',
dirs: [],
repoRel: 'some/relative/path',
pkgInfo: {
pkgId: packageId,
pkgDir: path.split('/').slice(0, -2).join('/'),
rel: 'some/relative/path',
},
group,
visibility,
manifest: {
type: 'plugin',
id: packageId,
owner: ['@kbn/kibana-operations'],
plugin: {
id: pluginId,
browser: true,
server: true,
...pluginOverrides,
},
},
},
};
};
const makeError = (line: number, ...violations: string[]) => ({
line,
messageId: 'ILLEGAL_MANIFEST_DEPENDENCY',
data: {
violations: violations.join('\n'),
suggestion: formatSuggestions([
`Please review the dependencies in your plugin's manifest (kibana.jsonc).`,
`Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
`Address the conflicting dependencies by refactoring the code`,
]),
},
});
jest.mock('../helpers/repo_source_classifier', () => {
const MODULES_BY_PATH: Record<string, ModuleId> = {
...makeModuleByPath(
'path/to/search/plugins/searchPlugin1/server/index.ts',
'search',
'private',
{
requiredPlugins: ['searchPlugin2'], // allowed, same group
}
),
...makeModuleByPath(
'path/to/search/plugins/searchPlugin2/server/index.ts',
'search',
'private',
{
requiredPlugins: ['securityPlugin1'], // invalid, dependency belongs to another group
}
),
...makeModuleByPath(
'path/to/security/plugins/securityPlugin1/server/index.ts',
'security',
'private',
{
requiredPlugins: ['securityPlugin2'], // allowed, same group
}
),
...makeModuleByPath(
'path/to/security/plugins/securityPlugin2/server/index.ts',
'security',
'private',
{
requiredPlugins: ['platformPlugin1', 'platformPlugin2', 'platformPlugin3'], // 3rd one is private!
}
),
...makeModuleByPath(
'path/to/platform/shared/platformPlugin1/server/index.ts',
'platform',
'shared',
{
requiredPlugins: ['platformPlugin2', 'platformPlugin3', 'platformPlugin4'],
}
),
...makeModuleByPath(
'path/to/platform/shared/platformPlugin2/server/index.ts',
'platform',
'shared'
),
...makeModuleByPath(
'path/to/platform/private/platformPlugin3/server/index.ts',
'platform',
'private'
),
...makeModuleByPath(
'path/to/platform/private/platformPlugin4/server/index.ts',
'platform',
'private'
),
};
return {
getRepoSourceClassifier() {
return {
classify(path: string) {
return MODULES_BY_PATH[path];
},
};
},
};
});
jest.mock('@kbn/repo-packages', () => {
const original = jest.requireActual('@kbn/repo-packages');
return {
...original,
getPluginPackagesFilter: () => () => true,
getPackages() {
return [
'path/to/search/plugins/searchPlugin1/server/index.ts',
'path/to/search/plugins/searchPlugin2/server/index.ts',
'path/to/security/plugins/securityPlugin1/server/index.ts',
'path/to/security/plugins/securityPlugin2/server/index.ts',
'path/to/platform/shared/platformPlugin1/server/index.ts',
'path/to/platform/shared/platformPlugin2/server/index.ts',
'path/to/platform/private/platformPlugin3/server/index.ts',
'path/to/platform/private/platformPlugin4/server/index.ts',
].map((path) => {
const [, , group, , id] = path.split('/');
return {
id: `@kbn/${id}-plugin`,
group,
visibility: path.includes('platform/shared') ? 'shared' : 'private',
manifest: {
plugin: {
id,
},
},
};
});
},
};
});
const tsTester = [
'@typescript-eslint/parser',
new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
}),
] as const;
const babelTester = [
'@babel/eslint-parser',
new RuleTester({
parser: require.resolve('@babel/eslint-parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
requireConfigFile: false,
babelOptions: {
presets: ['@kbn/babel-preset/node_preset'],
},
},
}),
] as const;
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run('@kbn/imports/no_group_crossing_manifests', NoGroupCrossingManifestsRule, {
valid: [
makePlugin('path/to/search/plugins/searchPlugin1/server/index.ts'),
makePlugin('path/to/security/plugins/securityPlugin1/server/index.ts'),
makePlugin('path/to/platform/shared/platformPlugin1/server/index.ts'),
makePluginClass('path/to/search/plugins/searchPlugin1/server/index.ts'),
makePluginClass('path/to/security/plugins/securityPlugin1/server/index.ts'),
makePluginClass('path/to/platform/shared/platformPlugin1/server/index.ts'),
],
invalid: [
{
...makePlugin('path/to/search/plugins/searchPlugin2/server/index.ts'),
errors: [
makeError(
1,
`⚠ Illegal dependency on manifest: Plugin "searchPlugin2" (package: "@kbn/searchPlugin2-plugin"; group: "search") depends on "securityPlugin1" (package: "@kbn/securityPlugin1-plugin"; group: security/private). File: path/to/search/plugins/searchPlugin2/kibana.jsonc`
),
],
},
{
...makePlugin('path/to/security/plugins/securityPlugin2/server/index.ts'),
errors: [
makeError(
1,
`⚠ Illegal dependency on manifest: Plugin "securityPlugin2" (package: "@kbn/securityPlugin2-plugin"; group: "security") depends on "platformPlugin3" (package: "@kbn/platformPlugin3-plugin"; group: platform/private). File: path/to/security/plugins/securityPlugin2/kibana.jsonc`
),
],
},
{
...makePluginClass('path/to/search/plugins/searchPlugin2/server/index.ts'),
errors: [
makeError(
2,
`⚠ Illegal dependency on manifest: Plugin "searchPlugin2" (package: "@kbn/searchPlugin2-plugin"; group: "search") depends on "securityPlugin1" (package: "@kbn/securityPlugin1-plugin"; group: security/private). File: path/to/search/plugins/searchPlugin2/kibana.jsonc`
),
makeError(
5,
`⚠ Illegal dependency on manifest: Plugin "searchPlugin2" (package: "@kbn/searchPlugin2-plugin"; group: "search") depends on "securityPlugin1" (package: "@kbn/securityPlugin1-plugin"; group: security/private). File: path/to/search/plugins/searchPlugin2/kibana.jsonc`
),
],
},
{
...makePluginClass('path/to/security/plugins/securityPlugin2/server/index.ts'),
errors: [
makeError(
2,
`⚠ Illegal dependency on manifest: Plugin "securityPlugin2" (package: "@kbn/securityPlugin2-plugin"; group: "security") depends on "platformPlugin3" (package: "@kbn/platformPlugin3-plugin"; group: platform/private). File: path/to/security/plugins/securityPlugin2/kibana.jsonc`
),
makeError(
5,
`⚠ Illegal dependency on manifest: Plugin "securityPlugin2" (package: "@kbn/securityPlugin2-plugin"; group: "security") depends on "platformPlugin3" (package: "@kbn/platformPlugin3-plugin"; group: platform/private). File: path/to/security/plugins/securityPlugin2/kibana.jsonc`
),
],
},
],
});
});
}

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { join } from 'path';
import { TSESTree } from '@typescript-eslint/typescript-estree';
import type { Rule } from 'eslint';
import type { Node } from 'estree';
import { getPackages, getPluginPackagesFilter } from '@kbn/repo-packages';
import { REPO_ROOT } from '@kbn/repo-info';
import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
import { getSourcePath } from '../helpers/source';
import { getImportResolver } from '../get_import_resolver';
import { getRepoSourceClassifier } from '../helpers/repo_source_classifier';
import { isImportableFrom } from '../helpers/groups';
import { formatSuggestions } from '../helpers/report';
const NODE_TYPES = TSESTree.AST_NODE_TYPES;
interface PluginInfo {
id: string;
pluginId: string;
group: ModuleGroup;
visibility: ModuleVisibility;
}
export const NoGroupCrossingManifestsRule: Rule.RuleModule = {
meta: {
docs: {
url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.mdx#kbnimportsno_unused_imports',
},
messages: {
ILLEGAL_MANIFEST_DEPENDENCY: `{{violations}}\n{{suggestion}}`,
},
},
create(context) {
const sourcePath = getSourcePath(context);
let manifestPath: string;
const resolver = getImportResolver(context);
const classifier = getRepoSourceClassifier(resolver);
const moduleId = classifier.classify(sourcePath);
const offendingDependencies: PluginInfo[] = [];
let currentPlugin: PluginInfo;
if (moduleId.manifest?.type === 'plugin') {
manifestPath = join(moduleId.pkgInfo!.pkgDir, 'kibana.jsonc')
.replace(REPO_ROOT, '')
.replace(/^\//, '');
currentPlugin = {
id: moduleId.pkgInfo!.pkgId,
pluginId: moduleId.manifest.plugin.id,
group: moduleId.group,
visibility: moduleId.visibility,
};
const allPlugins = getPackages(REPO_ROOT).filter(getPluginPackagesFilter());
const currentPluginInfo = moduleId.manifest!.plugin;
// check all the dependencies in the manifest, looking for plugin violations
[
...(currentPluginInfo.requiredPlugins ?? []),
...(currentPluginInfo.requiredBundles ?? []),
...(currentPluginInfo.optionalPlugins ?? []),
...(currentPluginInfo.runtimePluginDependencies ?? []),
].forEach((pluginId) => {
const dependency = allPlugins.find(({ manifest }) => manifest.plugin.id === pluginId);
if (dependency) {
// at this point, we know the dependency is a plugin
const { id, group, visibility } = dependency;
if (!isImportableFrom(moduleId.group, group, visibility)) {
offendingDependencies.push({ id, pluginId, group, visibility });
}
}
});
}
return {
FunctionDeclaration(node) {
// complain in exported plugin() function
if (
currentPlugin &&
offendingDependencies.length &&
node.id?.name === 'plugin' &&
node.parent.type === NODE_TYPES.ExportNamedDeclaration
) {
reportViolation({
context,
node,
currentPlugin,
manifestPath,
offendingDependencies,
});
}
},
MethodDefinition(node) {
// complain in setup() and start() hooks
if (
offendingDependencies.length &&
node.key.type === NODE_TYPES.Identifier &&
(node.key.name === 'setup' || node.key.name === 'start') &&
node.kind === 'method' &&
node.parent.parent.type === NODE_TYPES.ClassDeclaration &&
(node.parent.parent.id?.name.includes('Plugin') ||
(node.parent.parent as TSESTree.ClassDeclaration).implements?.find(
(value) =>
value.expression.type === NODE_TYPES.Identifier &&
value.expression.name === 'Plugin'
))
) {
reportViolation({
context,
node,
currentPlugin,
manifestPath,
offendingDependencies,
});
}
},
};
},
};
interface ReportViolationParams {
context: Rule.RuleContext;
node: Node;
currentPlugin: PluginInfo;
offendingDependencies: PluginInfo[];
manifestPath: string;
}
const reportViolation = ({
context,
node,
currentPlugin,
offendingDependencies,
manifestPath,
}: ReportViolationParams) =>
context.report({
node,
messageId: 'ILLEGAL_MANIFEST_DEPENDENCY',
data: {
violations: [
...offendingDependencies.map(
({ id, pluginId, group, visibility }) =>
`⚠ Illegal dependency on manifest: Plugin "${currentPlugin.pluginId}" (package: "${currentPlugin.id}"; group: "${currentPlugin.group}") depends on "${pluginId}" (package: "${id}"; group: ${group}/${visibility}). File: ${manifestPath}`
),
].join('\n'),
suggestion: formatSuggestions([
`Please review the dependencies in your plugin's manifest (kibana.jsonc).`,
`Relocate this module to a different group, and/or make sure it has the right 'visibility'.`,
`Address the conflicting dependencies by refactoring the code`,
]),
},
});

View file

@ -14,6 +14,7 @@
"@kbn/import-resolver",
"@kbn/repo-source-classifier",
"@kbn/repo-info",
"@kbn/repo-packages",
],
"exclude": [
"target/**/*",

View file

@ -63,7 +63,11 @@ export const CodeownersCommand: GenerateCommand = {
}
const newCodeowners = `${GENERATED_START}${pkgs
.map((pkg) => `${pkg.normalizedRepoRelativeDir} ${pkg.manifest.owner.join(' ')}`)
.map(
(pkg) =>
pkg.normalizedRepoRelativeDir +
(pkg.manifest.owner.length ? ' ' + pkg.manifest.owner.join(' ') : '')
)
.join('\n')}${GENERATED_END}${content}${ULTIMATE_PRIORITY_RULES}`;
if (newCodeowners === oldCodeowners) {

View file

@ -48,6 +48,20 @@ export const MANIFEST_V2: JSONSchema = {
For additional codeowners, the value can be an array of user/team names.
`,
},
group: {
enum: ['common', 'platform', 'observability', 'security', 'search'],
description: desc`
Specifies the group to which this module pertains.
`,
default: 'common',
},
visibility: {
enum: ['private', 'shared'],
description: desc`
Specifies the visibility of this module, i.e. whether it can be accessed by everybody or only modules in the same group
`,
default: 'shared',
},
devOnly: {
type: 'boolean',
description: desc`

View file

@ -0,0 +1,30 @@
# @kbn/manifest
This package contains a CLI to list `kibana.jsonc` manifests and also to mass update their properties.
## Usage
To list all `kibana.jsonc` manifests, run the following command from the root of the Kibana repo:
```sh
node scripts/manifest --list all
```
To print a manifest by packageId or by pluginId, run the following command from the root of the Kibana repo:
```sh
node scripts/manifest --package @kbn/package_name
node scripts/manifest --plugin pluginId
```
To update properties in one or more manifest files, run the following command from the root of the Kibana repo:
```sh
node scripts/manifest \
--package @kbn/package_1 \
--package @kbn/package_2 \
# ...
--package @kbn/package_N \
--set path.to.property1=value \
--set property2=value
```

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { run } from '@kbn/dev-cli-runner';
import { listManifestFiles, printManifest, updateManifest } from './manifest';
/**
* A CLI to manipulate Kibana package manifest files
*/
export const runKbnManifestCli = () => {
run(
async ({ log, flags }) => {
if (flags.list === 'all') {
listManifestFiles(flags, log);
} else {
if (!flags.package && !flags.plugin) {
throw new Error('You must specify the identifer of the --package or --plugin to update.');
}
await updateManifest(flags, log);
await printManifest(flags, log);
}
},
{
log: {
defaultLevel: 'info',
},
flags: {
string: ['list', 'package', 'plugin', 'set', 'unset'],
help: `
Usage: node scripts/manifest --package <packageId> --set group=platform --set visibility=private
--list all List all the manifests
--package [packageId] Select a package to update.
--plugin [pluginId] Select a plugin to update.
--set [property] [value] Set the desired "[property]": "[value]"
--unset [property] Removes the desired "[property]: value" from the manifest
`,
},
}
);
};

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-manifest'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-server",
"id": "@kbn/manifest",
"owner": "@elastic/kibana-core"
}

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { join } from 'path';
import { writeFile } from 'fs/promises';
import { flatMap, unset } from 'lodash';
import { set } from '@kbn/safer-lodash-set';
import type { ToolingLog } from '@kbn/tooling-log';
import type { Flags } from '@kbn/dev-cli-runner';
import { type Package, getPackages } from '@kbn/repo-packages';
import { REPO_ROOT } from '@kbn/repo-info';
const MANIFEST_FILE = 'kibana.jsonc';
const getKibanaJsonc = (flags: Flags, log: ToolingLog): Package[] => {
const modules = getPackages(REPO_ROOT);
let packageIds: string[] = [];
let pluginIds: string[] = [];
if (typeof flags.package === 'string') {
packageIds = [flags.package].filter(Boolean);
} else if (Array.isArray(flags.package)) {
packageIds = [...flags.package].filter(Boolean);
}
if (typeof flags.plugin === 'string') {
pluginIds = [flags.plugin].filter(Boolean);
} else if (Array.isArray(flags.plugin)) {
pluginIds = [...flags.plugin].filter(Boolean);
}
return modules.filter(
(pkg) =>
packageIds.includes(pkg.id) || (pkg.isPlugin() && pluginIds.includes(pkg.manifest.plugin.id))
);
};
export const listManifestFiles = (flags: Flags, log: ToolingLog) => {
const modules = getPackages(REPO_ROOT);
modules
.filter((module) => module.manifest.type === 'plugin')
.forEach((module) => {
log.info(join(module.directory, MANIFEST_FILE), module.id);
});
};
export const printManifest = (flags: Flags, log: ToolingLog) => {
const kibanaJsoncs = getKibanaJsonc(flags, log);
kibanaJsoncs.forEach((kibanaJsonc) => {
const manifestPath = join(kibanaJsonc.directory, MANIFEST_FILE);
log.info('\n\nShowing manifest: ', manifestPath);
log.info(JSON.stringify(kibanaJsonc, null, 2));
});
};
export const updateManifest = async (flags: Flags, log: ToolingLog) => {
let toSet: string[] = [];
let toUnset: string[] = [];
if (typeof flags.set === 'string') {
toSet = [flags.set].filter(Boolean);
} else if (Array.isArray(flags.set)) {
toSet = [...flags.set].filter(Boolean);
}
if (typeof flags.unset === 'string') {
toUnset = [flags.unset].filter(Boolean);
} else if (Array.isArray(flags.unset)) {
toUnset = [...flags.unset].filter(Boolean);
}
if (!toSet.length && !toUnset.length) {
// no need to update anything
return;
}
const kibanaJsoncs = getKibanaJsonc(flags, log);
for (let i = 0; i < kibanaJsoncs.length; ++i) {
const kibanaJsonc = kibanaJsoncs[i];
if (kibanaJsonc?.manifest) {
const manifestPath = join(kibanaJsonc.directory, MANIFEST_FILE);
log.info('Updating manifest: ', manifestPath);
toSet.forEach((propValue) => {
const [prop, value] = propValue.split('=');
log.info(`Setting "${prop}": "${value}"`);
set(kibanaJsonc.manifest, prop, value);
});
toUnset.forEach((prop) => {
log.info(`Removing "${prop}"`);
unset(kibanaJsonc.manifest, prop);
});
sanitiseManifest(kibanaJsonc);
await writeFile(manifestPath, JSON.stringify(kibanaJsonc.manifest, null, 2));
log.info('DONE');
}
}
};
const sanitiseManifest = (kibanaJsonc: Package) => {
kibanaJsonc.manifest.owner = flatMap(kibanaJsonc.manifest.owner.map((owner) => owner.split(' ')));
};

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/manifest",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0"
}

View file

@ -0,0 +1,23 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/dev-cli-runner",
"@kbn/repo-info",
"@kbn/repo-packages",
"@kbn/safer-lodash-set",
"@kbn/tooling-log",
]
}

View file

@ -7,6 +7,9 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export type ModuleGroup = 'platform' | 'observability' | 'search' | 'security' | 'common';
export type ModuleVisibility = 'private' | 'shared';
export interface KibanaPackageJson {
name: string;
version: string;
@ -27,4 +30,6 @@ export interface KibanaPackageJson {
[name: string]: string | undefined;
};
[key: string]: unknown;
group?: ModuleGroup;
visibility?: ModuleVisibility;
}

View file

@ -116,6 +116,22 @@ class Package {
* @readonly
*/
this.id = manifest.id;
const { group, visibility } = this.determineGroupAndVisibility();
/**
* the group to which this package belongs
* @type {import('@kbn/repo-info/types').ModuleGroup}
* @readonly
*/
this.group = group;
/**
* the visibility of this package, i.e. whether it can be accessed by everybody or only modules in the same group
* @type {import('@kbn/repo-info/types').ModuleVisibility}
* @readonly
*/
this.visibility = visibility;
}
/**
@ -140,6 +156,24 @@ class Package {
return this.manifest.type === 'plugin';
}
/**
* Returns the group to which this package belongs
* @readonly
* @returns {import('@kbn/repo-info/types').ModuleGroup}
*/
getGroup() {
return this.group;
}
/**
* Returns the package visibility, i.e. whether it can be accessed by everybody or only packages in the same group
* @readonly
* @returns {import('@kbn/repo-info/types').ModuleVisibility}
*/
getVisibility() {
return this.visibility;
}
/**
* Returns true if the package represents some type of plugin
* @returns {import('./types').PluginCategoryInfo}
@ -158,6 +192,7 @@ class Package {
const oss = !dir.startsWith('x-pack/');
const example = dir.startsWith('examples/') || dir.startsWith('x-pack/examples/');
const testPlugin = dir.startsWith('test/') || dir.startsWith('x-pack/test/');
return {
oss,
example,
@ -165,6 +200,40 @@ class Package {
};
}
determineGroupAndVisibility() {
const dir = this.normalizedRepoRelativeDir;
/** @type {import('@kbn/repo-info/types').ModuleGroup} */
let group = 'common';
/** @type {import('@kbn/repo-info/types').ModuleVisibility} */
let visibility = 'shared';
if (dir.startsWith('src/platform/') || dir.startsWith('x-pack/platform/')) {
group = 'platform';
visibility =
/src\/platform\/[^\/]+\/shared/.test(dir) || /x-pack\/platform\/[^\/]+\/shared/.test(dir)
? 'shared'
: 'private';
} else if (dir.startsWith('x-pack/solutions/search/')) {
group = 'search';
visibility = 'private';
} else if (dir.startsWith('x-pack/solutions/security/')) {
group = 'security';
visibility = 'private';
} else if (dir.startsWith('x-pack/solutions/observability/')) {
group = 'observability';
visibility = 'private';
} else {
group = this.manifest.group ?? 'common';
// if the group is 'private-only', enforce it
visibility = ['search', 'security', 'observability'].includes(group)
? 'private'
: this.manifest.visibility ?? 'shared';
}
return { group, visibility };
}
/**
* Custom inspect handler so that logging variables in scripts/generate doesn't
* print all the BUILD.bazel files

View file

@ -225,16 +225,20 @@ function validatePackageManifest(parsed, repoRoot, path) {
type,
id,
owner,
group,
visibility,
devOnly,
plugin,
sharedBrowserBundle,
build,
description,
serviceFolders,
...extra
} = parsed;
} = /** @type {import('./types').PackageManifestBaseFields} */ (/** @type {unknown} */ (parsed));
const extraKeys = Object.keys(extra);
const { plugin, sharedBrowserBundle } = parsed;
const extraKeys = Object.keys(extra).filter(
(key) => !['plugin', 'sharedBrowserBundle'].includes(key)
);
if (extraKeys.length) {
throw new Error(`unexpected keys in package manifest [${extraKeys.join(', ')}]`);
}
@ -258,6 +262,25 @@ function validatePackageManifest(parsed, repoRoot, path) {
);
}
if (
group !== undefined &&
(!isSomeString(group) ||
!['platform', 'search', 'security', 'observability', 'common'].includes(group))
) {
throw err(
`plugin.group`,
group,
`must have a valid value ("platform" | "search" | "security" | "observability" | "common")`
);
}
if (
visibility !== undefined &&
(!isSomeString(visibility) || !['private', 'shared'].includes(visibility))
) {
throw err(`plugin.visibility`, visibility, `must have a valid value ("private" | "shared")`);
}
if (devOnly !== undefined && typeof devOnly !== 'boolean') {
throw err(`devOnly`, devOnly, `must be a boolean when defined`);
}
@ -273,6 +296,8 @@ function validatePackageManifest(parsed, repoRoot, path) {
const base = {
id,
owner: Array.isArray(owner) ? owner : [owner],
group,
visibility,
devOnly,
build: validatePackageManifestBuild(build),
description,

View file

@ -7,6 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
import type { Package } from './package';
import type { PLUGIN_CATEGORY } from './plugin_category_info';
@ -44,7 +45,7 @@ export type KibanaPackageType =
| 'functional-tests'
| 'test-helper';
interface PackageManifestBaseFields {
export interface PackageManifestBaseFields {
/**
* The type of this package. Package types define how a package can and should
* be used/built. Some package types also change the way that packages are
@ -91,6 +92,14 @@ interface PackageManifestBaseFields {
* @deprecated
*/
serviceFolders?: string[];
/**
* Specifies the group to which this package belongs
*/
group?: ModuleGroup;
/**
* Specifies the package visibility, i.e. whether it can be accessed by everybody or only packages in the same group
*/
visibility?: ModuleVisibility;
}
export interface PluginPackageManifest extends PackageManifestBaseFields {

View file

@ -14,5 +14,8 @@
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/repo-info",
]
}

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
interface ModuleAttrs {
group: ModuleGroup;
visibility: ModuleVisibility;
}
const DEFAULT_MODULE_ATTRS: ModuleAttrs = {
group: 'common',
visibility: 'shared',
};
const MODULE_GROUPING_BY_PATH: Record<string, ModuleAttrs> = {
'src/platform/plugins/shared': {
group: 'platform',
visibility: 'shared',
},
'src/platform/plugins/internal': {
group: 'platform',
visibility: 'private',
},
'x-pack/platform/plugins/shared': {
group: 'platform',
visibility: 'shared',
},
'x-pack/platform/plugins/internal': {
group: 'platform',
visibility: 'private',
},
'x-pack/solutions/observability/plugins': {
group: 'observability',
visibility: 'private',
},
'x-pack/solutions/security/plugins': {
group: 'security',
visibility: 'private',
},
'x-pack/solutions/search/plugins': {
group: 'search',
visibility: 'private',
},
};
/**
* Determine a plugin's grouping information based on the path where it is defined
* @param packageRelativePath the path in the repo where the package is located
* @returns The grouping information that corresponds to the given path
*/
export function inferGroupAttrsFromPath(packageRelativePath: string): ModuleAttrs {
const grouping = Object.entries(MODULE_GROUPING_BY_PATH).find(([chunk]) =>
packageRelativePath.startsWith(chunk)
)?.[1];
return grouping ?? DEFAULT_MODULE_ATTRS;
}

View file

@ -7,16 +7,24 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ModuleType } from './module_type';
import { PkgInfo } from './pkg_info';
import type { KibanaPackageManifest } from '@kbn/repo-packages';
import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
import type { ModuleType } from './module_type';
import type { PkgInfo } from './pkg_info';
export interface ModuleId {
/** Type of the module */
type: ModuleType;
/** Specifies the group to which this module belongs */
group: ModuleGroup;
/** Specifies the module visibility, i.e. whether it can be accessed by everybody or only modules in the same group */
visibility: ModuleVisibility;
/** repo relative path to the module's source file */
repoRel: string;
/** info about the package the source file is within, in the case the file is found within a package */
pkgInfo?: PkgInfo;
/** The type of package, as described in the manifest */
manifest?: KibanaPackageManifest;
/** path segments of the dirname of this */
dirs: string[];
}

View file

@ -7,11 +7,14 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ImportResolver } from '@kbn/import-resolver';
import { ModuleId } from './module_id';
import { ModuleType } from './module_type';
import type { ImportResolver } from '@kbn/import-resolver';
import type { ModuleGroup, ModuleVisibility } from '@kbn/repo-info/types';
import type { KibanaPackageManifest } from '@kbn/repo-packages/modern/types';
import type { ModuleId } from './module_id';
import type { ModuleType } from './module_type';
import { RANDOM_TEST_FILE_NAMES, TEST_DIR, TEST_TAG } from './config';
import { RepoPath } from './repo_path';
import { inferGroupAttrsFromPath } from './group';
const STATIC_EXTS = new Set(
'json|woff|woff2|ttf|eot|svg|ico|png|jpg|gif|jpeg|html|md|txt|tmpl|xml'
@ -231,7 +234,43 @@ export class RepoSourceClassifier {
return 'common package';
}
classify(absolute: string) {
private getManifest(path: RepoPath): KibanaPackageManifest | undefined {
const pkgInfo = path.getPkgInfo();
return pkgInfo?.pkgId ? this.resolver.getPkgManifest(pkgInfo!.pkgId) : undefined;
}
/**
* Determine the "group" of a file
*/
private getGroup(path: RepoPath): ModuleGroup {
const attrs = inferGroupAttrsFromPath(path.getRepoRel());
const manifest = this.getManifest(path);
if (attrs.group !== 'common') {
// this package has been moved to a 'group-specific' folder, the group is determined by its location
return attrs.group;
} else {
// the package is still in its original location, allow Manifest to dictate its group
return manifest?.group ?? 'common';
}
}
/**
* Determine the "visibility" of a file
*/
private getVisibility(path: RepoPath): ModuleVisibility {
const attrs = inferGroupAttrsFromPath(path.getRepoRel());
const manifest = this.getManifest(path);
if (attrs.group !== 'common') {
// this package has been moved to a 'group-specific' folder, the visibility is determined by its location
return attrs.visibility;
} else {
// the package is still in its original location, allow Manifest to dictate its visibility
return manifest?.visibility ?? 'shared';
}
}
classify(absolute: string): ModuleId {
const path = this.getRepoPath(absolute);
const cached = this.ids.get(path);
@ -241,8 +280,12 @@ export class RepoSourceClassifier {
const id: ModuleId = {
type: this.getType(path),
group: this.getGroup(path),
visibility: this.getVisibility(path),
repoRel: path.getRepoRel(),
pkgInfo: path.getPkgInfo() ?? undefined,
manifest:
(path.getPkgInfo() && this.resolver.getPkgManifest(path.getPkgInfo()!.pkgId)) ?? undefined,
dirs: path.getSegs(),
};
this.ids.set(path, id);

View file

@ -13,6 +13,7 @@
"kbn_references": [
"@kbn/import-resolver",
"@kbn/repo-info",
"@kbn/repo-packages",
],
"exclude": [
"target/**/*",

11
scripts/manifest.js Normal file
View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
require('../src/setup_node_env');
require('@kbn/manifest').runKbnManifestCli();

View file

@ -1186,6 +1186,8 @@
"@kbn/management-storybook-config/*": ["packages/kbn-management/storybook/config/*"],
"@kbn/management-test-plugin": ["test/plugin_functional/plugins/management_test_plugin"],
"@kbn/management-test-plugin/*": ["test/plugin_functional/plugins/management_test_plugin/*"],
"@kbn/manifest": ["packages/kbn-manifest"],
"@kbn/manifest/*": ["packages/kbn-manifest/*"],
"@kbn/mapbox-gl": ["packages/kbn-mapbox-gl"],
"@kbn/mapbox-gl/*": ["packages/kbn-mapbox-gl/*"],
"@kbn/maps-custom-raster-source-plugin": ["x-pack/examples/third_party_maps_source_example"],

View file

@ -5637,6 +5637,10 @@
version "0.0.0"
uid ""
"@kbn/manifest@link:packages/kbn-manifest":
version "0.0.0"
uid ""
"@kbn/mapbox-gl@link:packages/kbn-mapbox-gl":
version "0.0.0"
uid ""