diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a8b982984126b..87b36355bd90e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/package.json b/package.json index 7dea94cc02e44..71a094af859c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts b/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts index a201cfcd0e262..65f6735e22ca6 100644 --- a/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts +++ b/packages/kbn-dev-utils/src/plugin_list/run_plugin_list_cli.ts @@ -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, + ] + ) + ); }); } diff --git a/packages/kbn-eslint-config/.eslintrc.js b/packages/kbn-eslint-config/.eslintrc.js index fe068900915af..14ede0f4db45f 100644 --- a/packages/kbn-eslint-config/.eslintrc.js +++ b/packages/kbn-eslint-config/.eslintrc.js @@ -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', diff --git a/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts b/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts index 0eabafc48ab69..6e555f1d9527c 100644 --- a/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts +++ b/packages/kbn-eslint-plugin-disable/src/helpers/protected_rules.ts @@ -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', ]); diff --git a/packages/kbn-eslint-plugin-imports/index.ts b/packages/kbn-eslint-plugin-imports/index.ts index 9c57d66f60225..31e3483ea6139 100644 --- a/packages/kbn-eslint-plugin-imports/index.ts +++ b/packages/kbn-eslint-plugin-imports/index.ts @@ -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, }; diff --git a/packages/kbn-eslint-plugin-imports/src/helpers/groups.ts b/packages/kbn-eslint-plugin-imports/src/helpers/groups.ts new file mode 100644 index 0000000000000..a76251f028389 --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/helpers/groups.ts @@ -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'; +} diff --git a/packages/kbn-eslint-plugin-imports/src/helpers/report.ts b/packages/kbn-eslint-plugin-imports/src/helpers/report.ts index 9ac0171507efd..11fc09fbecab3 100644 --- a/packages/kbn-eslint-plugin-imports/src/helpers/report.ts +++ b/packages/kbn-eslint-plugin-imports/src/helpers/report.ts @@ -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`; +}; diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts index be9e60978fa88..f44c0571b2c94 100644 --- a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.test.ts @@ -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.', + ]), }, }, ], diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts index 59c73c1d0336c..3f426e13a6215 100644 --- a/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_boundary_crossing.ts @@ -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 = { 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, diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.test.ts new file mode 100644 index 0000000000000..dc4828603f73f --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.test.ts @@ -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`, + ]), + }, + }, + ], + }, + ], + }); + }); +} diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.ts new file mode 100644 index 0000000000000..255973ab7460a --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_imports.ts @@ -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; + } + }); + }, +}; diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.test.ts new file mode 100644 index 0000000000000..bf75a01b222bb --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.test.ts @@ -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 => { + 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 = { + ...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` + ), + ], + }, + ], + }); + }); +} diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.ts new file mode 100644 index 0000000000000..e68f7217905a5 --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_group_crossing_manifests.ts @@ -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`, + ]), + }, + }); diff --git a/packages/kbn-eslint-plugin-imports/tsconfig.json b/packages/kbn-eslint-plugin-imports/tsconfig.json index 087d77fbfe437..b0ab9182171c3 100644 --- a/packages/kbn-eslint-plugin-imports/tsconfig.json +++ b/packages/kbn-eslint-plugin-imports/tsconfig.json @@ -14,6 +14,7 @@ "@kbn/import-resolver", "@kbn/repo-source-classifier", "@kbn/repo-info", + "@kbn/repo-packages", ], "exclude": [ "target/**/*", diff --git a/packages/kbn-generate/src/commands/codeowners_command.ts b/packages/kbn-generate/src/commands/codeowners_command.ts index 79f7025b99a02..a86b4250d6850 100644 --- a/packages/kbn-generate/src/commands/codeowners_command.ts +++ b/packages/kbn-generate/src/commands/codeowners_command.ts @@ -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) { diff --git a/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts b/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts index 441df3948632b..30682d763e0b0 100644 --- a/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts +++ b/packages/kbn-kibana-manifest-schema/src/kibana_json_v2_schema.ts @@ -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` diff --git a/packages/kbn-manifest/README.md b/packages/kbn-manifest/README.md new file mode 100644 index 0000000000000..a7dc2054252dc --- /dev/null +++ b/packages/kbn-manifest/README.md @@ -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 +``` diff --git a/packages/kbn-manifest/index.ts b/packages/kbn-manifest/index.ts new file mode 100644 index 0000000000000..5fc4727a1a72d --- /dev/null +++ b/packages/kbn-manifest/index.ts @@ -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 --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 + `, + }, + } + ); +}; diff --git a/packages/kbn-manifest/jest.config.js b/packages/kbn-manifest/jest.config.js new file mode 100644 index 0000000000000..ed8288d9fb712 --- /dev/null +++ b/packages/kbn-manifest/jest.config.js @@ -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: ['/packages/kbn-manifest'], +}; diff --git a/packages/kbn-manifest/kibana.jsonc b/packages/kbn-manifest/kibana.jsonc new file mode 100644 index 0000000000000..27f2d95e65501 --- /dev/null +++ b/packages/kbn-manifest/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/manifest", + "owner": "@elastic/kibana-core" +} diff --git a/packages/kbn-manifest/manifest.ts b/packages/kbn-manifest/manifest.ts new file mode 100644 index 0000000000000..a839dba7b4077 --- /dev/null +++ b/packages/kbn-manifest/manifest.ts @@ -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(' '))); +}; diff --git a/packages/kbn-manifest/package.json b/packages/kbn-manifest/package.json new file mode 100644 index 0000000000000..52304cc4c1e21 --- /dev/null +++ b/packages/kbn-manifest/package.json @@ -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" +} diff --git a/packages/kbn-manifest/tsconfig.json b/packages/kbn-manifest/tsconfig.json new file mode 100644 index 0000000000000..1ee41aafca1ee --- /dev/null +++ b/packages/kbn-manifest/tsconfig.json @@ -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", + ] +} diff --git a/packages/kbn-repo-info/types.ts b/packages/kbn-repo-info/types.ts index a4776c28760a2..338881e878fdc 100644 --- a/packages/kbn-repo-info/types.ts +++ b/packages/kbn-repo-info/types.ts @@ -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; } diff --git a/packages/kbn-repo-packages/modern/package.js b/packages/kbn-repo-packages/modern/package.js index 1c44cd0cf86d9..3ec33a69e841a 100644 --- a/packages/kbn-repo-packages/modern/package.js +++ b/packages/kbn-repo-packages/modern/package.js @@ -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 diff --git a/packages/kbn-repo-packages/modern/parse_package_manifest.js b/packages/kbn-repo-packages/modern/parse_package_manifest.js index 40a6f7bf1059b..46004983848bb 100644 --- a/packages/kbn-repo-packages/modern/parse_package_manifest.js +++ b/packages/kbn-repo-packages/modern/parse_package_manifest.js @@ -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, diff --git a/packages/kbn-repo-packages/modern/types.ts b/packages/kbn-repo-packages/modern/types.ts index 41250de7c6346..c883e33d82497 100644 --- a/packages/kbn-repo-packages/modern/types.ts +++ b/packages/kbn-repo-packages/modern/types.ts @@ -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 { diff --git a/packages/kbn-repo-packages/tsconfig.json b/packages/kbn-repo-packages/tsconfig.json index 19c7e8d59f651..be62cc1a4c90b 100644 --- a/packages/kbn-repo-packages/tsconfig.json +++ b/packages/kbn-repo-packages/tsconfig.json @@ -14,5 +14,8 @@ ], "exclude": [ "target/**/*", + ], + "kbn_references": [ + "@kbn/repo-info", ] } diff --git a/packages/kbn-repo-source-classifier/src/group.ts b/packages/kbn-repo-source-classifier/src/group.ts new file mode 100644 index 0000000000000..8103d5c82c590 --- /dev/null +++ b/packages/kbn-repo-source-classifier/src/group.ts @@ -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 = { + '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; +} diff --git a/packages/kbn-repo-source-classifier/src/module_id.ts b/packages/kbn-repo-source-classifier/src/module_id.ts index 6af8ece2438fa..284ffe26de0db 100644 --- a/packages/kbn-repo-source-classifier/src/module_id.ts +++ b/packages/kbn-repo-source-classifier/src/module_id.ts @@ -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[]; } diff --git a/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts b/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts index 470dd3c424421..c0ab29f659ebd 100644 --- a/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts +++ b/packages/kbn-repo-source-classifier/src/repo_source_classifier.ts @@ -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); diff --git a/packages/kbn-repo-source-classifier/tsconfig.json b/packages/kbn-repo-source-classifier/tsconfig.json index f41dffcd32f06..418b114eebafa 100644 --- a/packages/kbn-repo-source-classifier/tsconfig.json +++ b/packages/kbn-repo-source-classifier/tsconfig.json @@ -13,6 +13,7 @@ "kbn_references": [ "@kbn/import-resolver", "@kbn/repo-info", + "@kbn/repo-packages", ], "exclude": [ "target/**/*", diff --git a/scripts/manifest.js b/scripts/manifest.js new file mode 100644 index 0000000000000..f9da9c3d174bd --- /dev/null +++ b/scripts/manifest.js @@ -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(); diff --git a/tsconfig.base.json b/tsconfig.base.json index 1bb261fd1748f..df033f9cfdb9f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/yarn.lock b/yarn.lock index 2f089f8372e2b..fb3ed52f06521 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""