mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Saved Objects] Compatible mappings PR check (#148656)
## Summary This PR adds a technical control to prevent incompatible mappings changes. These include: 1. Removing mapped fields. For the foreseeable future we require that teams only introduce new fields - in short: this avoids the "reindex" step in our migrations. 2. Changing the type of a field. We leverage ES to determine whether a given set of mappings can be applied "on top" of another. Similarly, this avoids the "reindex" step in migrations. The above checks depend on a snapshot of the mappings from `main`, these are the "current" mappings and are extracted from plugin code. This PR will bootstrap `main` with an initial set of mappings extracted from plugins (bulk of new lines added). ## The new CLI See the added `README.md` for details on how the CLI works. ## How will it work? Any new PR that introduces compatible mappings changes will result in a new snapshot being captured, then merged to main for other PRs to merge and run the same checks against (currently committing new snapshots happens in the CI check so there is no manual step of maintaining the snapshot). ## Additional We should consider combining this CI check with the existing check in `src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts`. Hopefully we can automate the check such that no manual review is needed from Core, not sure how we might cover the hash of the non-mappings related fields. We could consider narrowing the Jest test to exclude mappings. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: spalger <spencer@elastic.co>
This commit is contained in:
parent
a0cd72436c
commit
e9197ad359
27 changed files with 3598 additions and 4 deletions
|
@ -22,3 +22,4 @@ export DISABLE_BOOTSTRAP_VALIDATION=false
|
|||
.buildkite/scripts/steps/checks/test_projects.sh
|
||||
.buildkite/scripts/steps/checks/test_hardening.sh
|
||||
.buildkite/scripts/steps/checks/ftr_configs.sh
|
||||
.buildkite/scripts/steps/checks/saved_objects_compat_changes.sh
|
14
.buildkite/scripts/steps/checks/saved_objects_compat_changes.sh
Executable file
14
.buildkite/scripts/steps/checks/saved_objects_compat_changes.sh
Executable file
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source .buildkite/scripts/common/util.sh
|
||||
|
||||
echo --- Check Mappings Update
|
||||
cmd="node scripts/check_mappings_update"
|
||||
if is_pr && ! is_auto_commit_disabled; then
|
||||
cmd="$cmd --fix"
|
||||
fi
|
||||
|
||||
eval "$cmd"
|
||||
check_for_changed_files "$cmd" true
|
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -61,6 +61,7 @@ packages/kbn-cell-actions @elastic/security-threat-hunting-explore
|
|||
src/plugins/chart_expressions/common @elastic/kibana-visualizations
|
||||
packages/kbn-chart-icons @elastic/kibana-visualizations
|
||||
src/plugins/charts @elastic/kibana-visualizations
|
||||
packages/kbn-check-mappings-update-cli @elastic/kibana-core
|
||||
packages/kbn-ci-stats-core @elastic/kibana-operations
|
||||
packages/kbn-ci-stats-performance-metrics @elastic/kibana-operations
|
||||
packages/kbn-ci-stats-reporter @elastic/kibana-operations
|
||||
|
|
|
@ -1013,6 +1013,7 @@
|
|||
"@kbn/babel-register": "link:packages/kbn-babel-register",
|
||||
"@kbn/babel-transform": "link:packages/kbn-babel-transform",
|
||||
"@kbn/bazel-runner": "link:packages/kbn-bazel-runner",
|
||||
"@kbn/check-mappings-update-cli": "link:packages/kbn-check-mappings-update-cli",
|
||||
"@kbn/ci-stats-core": "link:packages/kbn-ci-stats-core",
|
||||
"@kbn/ci-stats-performance-metrics": "link:packages/kbn-ci-stats-performance-metrics",
|
||||
"@kbn/ci-stats-reporter": "link:packages/kbn-ci-stats-reporter",
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const ENABLE_ALL_PLUGINS_CONFIG_PATH = 'forceEnableAllPlugins' as const;
|
||||
|
||||
/**
|
||||
* Set this to true in the raw configuration passed to {@link Root} to force
|
||||
* enable all plugins.
|
||||
* @internal
|
||||
*/
|
||||
export const PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH =
|
||||
`plugins.${ENABLE_ALL_PLUGINS_CONFIG_PATH}` as const;
|
|
@ -31,4 +31,15 @@ describe('PluginsConfig', () => {
|
|||
const config = new PluginsConfig(rawConfig, env);
|
||||
expect(config.additionalPluginPaths).toEqual(['some-path', 'another-path']);
|
||||
});
|
||||
|
||||
it('retrieves shouldEnableAllPlugins', () => {
|
||||
const env = Env.createDefault(REPO_ROOT, getEnvOptions({ cliArgs: { dev: true } }));
|
||||
const rawConfig: any = {
|
||||
initialize: true,
|
||||
paths: ['some-path', 'another-path'],
|
||||
forceEnableAllPlugins: true,
|
||||
};
|
||||
const config = new PluginsConfig(rawConfig, env);
|
||||
expect(config.shouldEnableAllPlugins).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,9 +7,12 @@
|
|||
*/
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { get } from 'lodash';
|
||||
import { Env } from '@kbn/config';
|
||||
import type { ServiceConfigDescriptor } from '@kbn/core-base-server-internal';
|
||||
|
||||
import { ENABLE_ALL_PLUGINS_CONFIG_PATH } from './constants';
|
||||
|
||||
const configSchema = schema.object({
|
||||
initialize: schema.boolean({ defaultValue: true }),
|
||||
|
||||
|
@ -17,9 +20,16 @@ const configSchema = schema.object({
|
|||
* Defines an array of directories where another plugin should be loaded from.
|
||||
*/
|
||||
paths: schema.arrayOf(schema.string(), { defaultValue: [] }),
|
||||
/**
|
||||
* Internal config, not intended to be used by end users. Only for specific
|
||||
* internal purposes.
|
||||
*/
|
||||
forceEnableAllPlugins: schema.maybe(schema.boolean({ defaultValue: false })),
|
||||
});
|
||||
|
||||
export type PluginsConfigType = TypeOf<typeof configSchema>;
|
||||
type InternalPluginsConfigType = TypeOf<typeof configSchema>;
|
||||
|
||||
export type PluginsConfigType = Omit<InternalPluginsConfigType, '__internal__'>;
|
||||
|
||||
export const config: ServiceConfigDescriptor<PluginsConfigType> = {
|
||||
path: 'plugins',
|
||||
|
@ -43,9 +53,17 @@ export class PluginsConfig {
|
|||
*/
|
||||
public readonly additionalPluginPaths: readonly string[];
|
||||
|
||||
/**
|
||||
* Whether to enable all plugins.
|
||||
*
|
||||
* @note this is intended to be an undocumented setting.
|
||||
*/
|
||||
public readonly shouldEnableAllPlugins: boolean;
|
||||
|
||||
constructor(rawConfig: PluginsConfigType, env: Env) {
|
||||
this.initialize = rawConfig.initialize;
|
||||
this.pluginSearchPaths = env.pluginSearchPaths;
|
||||
this.additionalPluginPaths = rawConfig.paths;
|
||||
this.shouldEnableAllPlugins = get(rawConfig, ENABLE_ALL_PLUGINS_CONFIG_PATH, false);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ import { PluginDiscoveryError } from './discovery';
|
|||
import { PluginWrapper } from './plugin';
|
||||
import { PluginsService } from './plugins_service';
|
||||
import { PluginsSystem } from './plugins_system';
|
||||
import { config } from './plugins_config';
|
||||
import { config, PluginsConfigType } from './plugins_config';
|
||||
import { take } from 'rxjs/operators';
|
||||
import type { PluginConfigDescriptor } from '@kbn/core-plugins-server';
|
||||
import { DiscoveredPlugin, PluginType } from '@kbn/core-base-common';
|
||||
|
@ -32,6 +32,7 @@ import { DiscoveredPlugin, PluginType } from '@kbn/core-base-common';
|
|||
const MockPluginsSystem: jest.Mock<PluginsSystem<PluginType>> = PluginsSystem as any;
|
||||
|
||||
let pluginsService: PluginsService;
|
||||
let pluginsConfig: PluginsConfigType;
|
||||
let config$: BehaviorSubject<Record<string, any>>;
|
||||
let configService: ConfigService;
|
||||
let coreId: symbol;
|
||||
|
@ -130,7 +131,8 @@ async function testSetup() {
|
|||
coreId = Symbol('core');
|
||||
env = Env.createDefault(REPO_ROOT, getEnvOptions());
|
||||
|
||||
config$ = new BehaviorSubject<Record<string, any>>({ plugins: { initialize: true } });
|
||||
pluginsConfig = { initialize: true, paths: [] };
|
||||
config$ = new BehaviorSubject<Record<string, any>>({ plugins: pluginsConfig });
|
||||
const rawConfigService = rawConfigServiceMock.create({ rawConfig$: config$ });
|
||||
configService = new ConfigService(rawConfigService, env, logger);
|
||||
await configService.setSchema(config.path, config.schema);
|
||||
|
@ -496,6 +498,52 @@ describe('PluginsService', () => {
|
|||
`);
|
||||
});
|
||||
|
||||
describe('forceEnableAllPlugins', () => {
|
||||
it('enables all plugins when "true"', async () => {
|
||||
(pluginsConfig as any).forceEnableAllPlugins = true;
|
||||
jest
|
||||
.spyOn(configService, 'isEnabledAtPath')
|
||||
.mockImplementation((path) => Promise.resolve(!path.includes('disabled')));
|
||||
prebootMockPluginSystem.setupPlugins.mockResolvedValue(new Map());
|
||||
standardMockPluginSystem.setupPlugins.mockResolvedValue(new Map());
|
||||
await pluginsService.setup(setupDeps);
|
||||
|
||||
mockDiscover.mockReturnValue({
|
||||
error$: from([]),
|
||||
plugin$: from([
|
||||
createPlugin('explicitly-disabled-plugin-preboot', {
|
||||
type: PluginType.preboot,
|
||||
disabled: true,
|
||||
path: 'path-1-preboot',
|
||||
configPath: 'path-1-preboot',
|
||||
}),
|
||||
createPlugin('explicitly-disabled-plugin-standard', {
|
||||
disabled: true,
|
||||
path: 'path-1-standard',
|
||||
configPath: 'path-1-standard',
|
||||
}),
|
||||
createPlugin('plugin-with-missing-required-deps-preboot', {
|
||||
type: PluginType.preboot,
|
||||
path: 'path-2-preboot',
|
||||
configPath: 'path-2-preboot',
|
||||
requiredPlugins: ['missing-plugin-preboot'],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
await pluginsService.discover({ environment: environmentPreboot, node: nodePreboot });
|
||||
await pluginsService.preboot(prebootDeps);
|
||||
|
||||
expect(loggingSystemMock.collect(logger).info).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
"Plugin \\"plugin-with-missing-required-deps-preboot\\" has been disabled since the following direct or transitive dependencies are missing, disabled, or have incompatible types: [missing-plugin-preboot]",
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw in case of mutual plugin dependencies', async () => {
|
||||
const prebootPlugins = [
|
||||
createPlugin('first-plugin-preboot', {
|
||||
|
@ -725,6 +773,7 @@ describe('PluginsService', () => {
|
|||
resolve(REPO_ROOT, '..', 'kibana-extra'),
|
||||
resolve(REPO_ROOT, 'plugins'),
|
||||
],
|
||||
shouldEnableAllPlugins: false,
|
||||
},
|
||||
coreContext: { coreId, env, logger, configService },
|
||||
instanceInfo: { uuid: 'uuid' },
|
||||
|
|
|
@ -282,11 +282,19 @@ export class PluginsService implements CoreService<PluginsServiceSetup, PluginsS
|
|||
}
|
||||
}
|
||||
|
||||
const config = await firstValueFrom(this.config$);
|
||||
const enableAllPlugins = config.shouldEnableAllPlugins;
|
||||
if (enableAllPlugins) {
|
||||
this.log.warn('Detected override configuration; will enable all plugins');
|
||||
}
|
||||
|
||||
// Validate config and handle enabled statuses.
|
||||
// NOTE: We can't do both in the same previous loop because some plugins' deprecations may affect others.
|
||||
// Hence, we need all the deprecations to be registered before accessing any config parameter.
|
||||
for (const plugin of plugins) {
|
||||
const isEnabled = await this.coreContext.configService.isEnabledAtPath(plugin.configPath);
|
||||
const isEnabled =
|
||||
enableAllPlugins ||
|
||||
(await this.coreContext.configService.isEnabledAtPath(plugin.configPath));
|
||||
|
||||
if (pluginEnableStatuses.has(plugin.name)) {
|
||||
throw new Error(`Plugin with id "${plugin.name}" is already registered!`);
|
||||
|
|
7
packages/kbn-check-mappings-update-cli/README.md
Normal file
7
packages/kbn-check-mappings-update-cli/README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# @kbn/check-mappings-update-cli
|
||||
|
||||
Saved Objects CLI tool that can be used to check whether a snapshot of current
|
||||
mappings (i.e., mappings on main) is compatible with mappings we can extract
|
||||
from the current code.
|
||||
|
||||
See `node scripts/check_mappings_update --help` for more info.
|
2823
packages/kbn-check-mappings-update-cli/current_mappings.json
Normal file
2823
packages/kbn-check-mappings-update-cli/current_mappings.json
Normal file
File diff suppressed because it is too large
Load diff
13
packages/kbn-check-mappings-update-cli/jest.config.js
Normal file
13
packages/kbn-check-mappings-update-cli/jest.config.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../..',
|
||||
roots: ['<rootDir>/packages/kbn-check-mappings-update-cli'],
|
||||
};
|
6
packages/kbn-check-mappings-update-cli/kibana.jsonc
Normal file
6
packages/kbn-check-mappings-update-cli/kibana.jsonc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/check-mappings-update-cli",
|
||||
"owner": "@elastic/kibana-core",
|
||||
"devOnly": true
|
||||
}
|
7
packages/kbn-check-mappings-update-cli/package.json
Normal file
7
packages/kbn-check-mappings-update-cli/package.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@kbn/check-mappings-update-cli",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0",
|
||||
"main": "./src/run_check_mappings_update_cli"
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { get } from 'lodash';
|
||||
import { SomeDevLog } from '@kbn/some-dev-log';
|
||||
import { createFailError } from '@kbn/dev-cli-errors';
|
||||
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
|
||||
|
||||
function isObject(v: unknown): v is object {
|
||||
return Object.prototype.toString.call(v) === '[object Object]';
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Walk the current mappings
|
||||
* 2. For each mapped field (i.e., for each field under a "properties" object)
|
||||
* in current, check that the same field exists in next
|
||||
* 3. If we see any missing fields in next, throw an appropriate error
|
||||
*/
|
||||
export function checkAdditiveOnlyChange(
|
||||
log: SomeDevLog,
|
||||
current: SavedObjectsTypeMappingDefinitions,
|
||||
next: SavedObjectsTypeMappingDefinitions
|
||||
) {
|
||||
let checkedCount = 0;
|
||||
const Os: Array<[path: string[], value: unknown]> = [[[], current]];
|
||||
const missingProps: string[] = [];
|
||||
|
||||
while (Os.length) {
|
||||
const [path, value] = Os.shift()!;
|
||||
// "Recurse" into the value if it's an object
|
||||
if (isObject(value)) {
|
||||
Object.entries(value).forEach(([k, v]) => Os.push([[...path, k], v]));
|
||||
}
|
||||
// If we're at a "properties" object, check that the next mappings have the same field
|
||||
if (path.length > 1 && path[path.length - 2] === 'properties') {
|
||||
const prop = path.join('.');
|
||||
if (!get(next, prop)) missingProps.push(prop);
|
||||
checkedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (missingProps.length > 0) {
|
||||
const props = JSON.stringify(missingProps, null, 2);
|
||||
throw createFailError(
|
||||
`Removing mapped properties is disallowed. Properties found in current mappings but not in next mappings:\n${props}`
|
||||
);
|
||||
}
|
||||
|
||||
log.success(`Checked ${checkedCount} existing properties. All present in extracted mappings.`);
|
||||
}
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
import type { SomeDevLog } from '@kbn/some-dev-log';
|
||||
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { checkAdditiveOnlyChange } from './check_additive_only_change';
|
||||
import { createSomeDevLogMock } from './mocks';
|
||||
|
||||
describe('#checkAdditiveOnlyChange', () => {
|
||||
let log: SomeDevLog;
|
||||
beforeEach(() => {
|
||||
log = createSomeDevLogMock();
|
||||
});
|
||||
test('detect removed mapping', () => {
|
||||
const current: SavedObjectsTypeMappingDefinitions = {
|
||||
foo: {
|
||||
properties: {
|
||||
text: { type: 'text' },
|
||||
number: { type: 'long' },
|
||||
object: { type: 'object', properties: { nestedText: { type: 'text' } } },
|
||||
},
|
||||
},
|
||||
bar: {
|
||||
properties: {
|
||||
text: { type: 'text' },
|
||||
number: { type: 'long' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const next = cloneDeep(current);
|
||||
delete next.foo.properties.text;
|
||||
delete next.foo.properties.object.properties!.nestedText;
|
||||
delete next.bar.properties.number;
|
||||
|
||||
expect(() => checkAdditiveOnlyChange(log, current, next)).toThrowErrorMatchingInlineSnapshot(`
|
||||
"Removing mapped properties is disallowed. Properties found in current mappings but not in next mappings:
|
||||
[
|
||||
\\"foo.properties.text\\",
|
||||
\\"bar.properties.number\\",
|
||||
\\"foo.properties.object.properties.nestedText\\"
|
||||
]"
|
||||
`);
|
||||
});
|
||||
|
||||
test('detects when no mappings are removed', () => {
|
||||
const current: SavedObjectsTypeMappingDefinitions = {
|
||||
foo: {
|
||||
properties: {
|
||||
text: { type: 'text' },
|
||||
number: { type: 'long' },
|
||||
object: { type: 'object', properties: { nestedText: { type: 'text' } } },
|
||||
},
|
||||
},
|
||||
bar: {
|
||||
properties: {
|
||||
text: { type: 'text' },
|
||||
number: { type: 'long' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => checkAdditiveOnlyChange(log, current, cloneDeep(current))).not.toThrow();
|
||||
expect(log.success).toHaveBeenCalledTimes(1);
|
||||
expect(log.success).toHaveBeenCalledWith(
|
||||
'Checked 6 existing properties. All present in extracted mappings.'
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores new field', () => {
|
||||
const current: SavedObjectsTypeMappingDefinitions = {
|
||||
foo: {
|
||||
properties: {
|
||||
text: { type: 'text' },
|
||||
number: { type: 'long' },
|
||||
object: { type: 'object', properties: { nestedText: { type: 'text' } } },
|
||||
},
|
||||
},
|
||||
bar: {
|
||||
properties: {
|
||||
text: { type: 'text' },
|
||||
number: { type: 'long' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const next = cloneDeep(current);
|
||||
next.foo.properties.newField = { type: 'text' };
|
||||
|
||||
expect(() => checkAdditiveOnlyChange(log, current, next)).not.toThrow();
|
||||
expect(log.success).toHaveBeenCalledTimes(1);
|
||||
expect(log.success).toHaveBeenCalledWith(
|
||||
'Checked 6 existing properties. All present in extracted mappings.'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles empty current', () => {
|
||||
const current: SavedObjectsTypeMappingDefinitions = {};
|
||||
const next: SavedObjectsTypeMappingDefinitions = {
|
||||
foo: { properties: { text: { type: 'text' } } },
|
||||
};
|
||||
|
||||
expect(() => checkAdditiveOnlyChange(log, current, next)).not.toThrow();
|
||||
expect(log.success).toHaveBeenCalledTimes(1);
|
||||
expect(log.success).toHaveBeenCalledWith(
|
||||
'Checked 0 existing properties. All present in extracted mappings.'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { Client } from '@elastic/elasticsearch';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import type { SomeDevLog } from '@kbn/some-dev-log';
|
||||
import { checkIncompatibleMappings } from './check_incompatible_mappings';
|
||||
import { createSomeDevLogMock } from './mocks';
|
||||
|
||||
describe('#checkIncompatibleMappings', () => {
|
||||
let log: SomeDevLog;
|
||||
let esClient: Client;
|
||||
beforeEach(() => {
|
||||
log = createSomeDevLogMock();
|
||||
esClient = elasticsearchClientMock.createClusterClient().asInternalUser as unknown as Client;
|
||||
});
|
||||
|
||||
test('calls ES with the expected inputs', async () => {
|
||||
await checkIncompatibleMappings({ log, esClient, currentMappings: {}, nextMappings: {} });
|
||||
expect(esClient.indices.create).toHaveBeenCalledTimes(1);
|
||||
expect(esClient.indices.create).toHaveBeenCalledWith({
|
||||
index: '.kibana_mappings_check',
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: {},
|
||||
},
|
||||
settings: {
|
||||
mapping: {
|
||||
total_fields: {
|
||||
limit: 1500,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(esClient.indices.putMapping).toHaveBeenCalledTimes(1);
|
||||
expect(esClient.indices.putMapping).toHaveBeenCalledWith({
|
||||
index: '.kibana_mappings_check',
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('throws expected error when cannot put mappings', async () => {
|
||||
(esClient.indices.putMapping as jest.Mock).mockRejectedValueOnce(new Error('foo'));
|
||||
expect(() =>
|
||||
checkIncompatibleMappings({ log, esClient, currentMappings: {}, nextMappings: {} })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Only mappings changes that are compatible with current mappings are allowed. Consider reaching out to the Kibana core team if you are stuck."`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SomeDevLog } from '@kbn/some-dev-log';
|
||||
import { Client } from '@elastic/elasticsearch';
|
||||
import { createFailError } from '@kbn/dev-cli-errors';
|
||||
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
|
||||
|
||||
const TEST_INDEX_NAME = '.kibana_mappings_check';
|
||||
|
||||
export async function checkIncompatibleMappings({
|
||||
log,
|
||||
esClient,
|
||||
currentMappings,
|
||||
nextMappings,
|
||||
}: {
|
||||
log: SomeDevLog;
|
||||
esClient: Client;
|
||||
currentMappings: SavedObjectsTypeMappingDefinitions;
|
||||
nextMappings: SavedObjectsTypeMappingDefinitions;
|
||||
}) {
|
||||
try {
|
||||
log.debug('creating index using current mappings...');
|
||||
await esClient.indices.create({
|
||||
index: TEST_INDEX_NAME,
|
||||
mappings: {
|
||||
dynamic: false,
|
||||
properties: currentMappings,
|
||||
},
|
||||
settings: {
|
||||
mapping: {
|
||||
total_fields: { limit: 1500 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
log.debug('attempting to update to new mappings...');
|
||||
const resp = await esClient.indices.putMapping({
|
||||
index: TEST_INDEX_NAME,
|
||||
properties: nextMappings,
|
||||
});
|
||||
|
||||
log.success('Extracted mappings are compatible with existing mappings.');
|
||||
log.debug(`Got response:`, resp);
|
||||
} catch (error) {
|
||||
log.error('There was an issue trying to apply the extracted mappings to the existing index.');
|
||||
log.error(error);
|
||||
throw createFailError(
|
||||
`Only mappings changes that are compatible with current mappings are allowed. Consider reaching out to the Kibana core team if you are stuck.`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import Fsp from 'fs/promises';
|
||||
import Path from 'path';
|
||||
|
||||
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
|
||||
|
||||
export const CURRENT_MAPPINGS_FILE = Path.resolve(__dirname, '../current_mappings.json');
|
||||
|
||||
export async function readCurrentMappings(): Promise<SavedObjectsTypeMappingDefinitions> {
|
||||
let currentMappingsJson;
|
||||
try {
|
||||
currentMappingsJson = await Fsp.readFile(CURRENT_MAPPINGS_FILE, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return JSON.parse(currentMappingsJson);
|
||||
}
|
||||
|
||||
export async function updateCurrentMappings(newMappings: SavedObjectsTypeMappingDefinitions) {
|
||||
await Fsp.writeFile(CURRENT_MAPPINGS_FILE, JSON.stringify(newMappings, null, 2) + '\n', 'utf8');
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import ChildProcess from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import * as Rx from 'rxjs';
|
||||
|
||||
import { REPO_ROOT } from '@kbn/repo-info';
|
||||
import { SomeDevLog } from '@kbn/some-dev-log';
|
||||
import { observeLines } from '@kbn/stdio-dev-helpers';
|
||||
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
|
||||
|
||||
import type { Result } from './extract_mappings_from_plugins_worker';
|
||||
|
||||
function routeToLog(readable: Readable, log: SomeDevLog, level: 'debug' | 'error') {
|
||||
return observeLines(readable).pipe(
|
||||
Rx.tap((line) => {
|
||||
log[level](line);
|
||||
}),
|
||||
Rx.ignoreElements()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a worker process that starts the core with all plugins enabled and sends back the
|
||||
* saved object mappings for all plugins. We run this in a child process so that we can
|
||||
* harvest logs and feed them into the logger when debugging.
|
||||
*/
|
||||
export async function extractMappingsFromPlugins(
|
||||
log: SomeDevLog
|
||||
): Promise<SavedObjectsTypeMappingDefinitions> {
|
||||
log.info('Loading core with all plugins enabled so that we can get all savedObject mappings...');
|
||||
|
||||
const fork = ChildProcess.fork(require.resolve('./extract_mappings_from_plugins_worker.ts'), {
|
||||
execArgv: ['--require=@kbn/babel-register/install'],
|
||||
cwd: REPO_ROOT,
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
|
||||
const mappings = await Rx.firstValueFrom(
|
||||
Rx.merge(
|
||||
// the actual value we are interested in
|
||||
Rx.fromEvent(fork, 'message'),
|
||||
|
||||
// worker logs are written to the logger, but dropped from the stream
|
||||
routeToLog(fork.stdout!, log, 'debug'),
|
||||
routeToLog(fork.stderr!, log, 'error'),
|
||||
|
||||
// if an error occurs running the worker throw it into the stream
|
||||
Rx.fromEvent(fork, 'error').pipe(
|
||||
Rx.map((err) => {
|
||||
throw err;
|
||||
})
|
||||
)
|
||||
).pipe(
|
||||
Rx.takeUntil(Rx.fromEvent(fork, 'exit')),
|
||||
Rx.map((results) => {
|
||||
const [result] = results as [Result];
|
||||
log.debug('message received from worker', result);
|
||||
fork.kill('SIGILL');
|
||||
return result.mappings;
|
||||
}),
|
||||
Rx.defaultIfEmpty(undefined)
|
||||
)
|
||||
);
|
||||
|
||||
if (!mappings) {
|
||||
throw new Error('worker exitted without sending mappings');
|
||||
}
|
||||
|
||||
log.info(`Got mappings for ${Object.keys(mappings).length} types from plugins.`);
|
||||
|
||||
return mappings;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { createRootWithCorePlugins } from '@kbn/core-test-helpers-kbn-server';
|
||||
import { set } from '@kbn/safer-lodash-set';
|
||||
import { buildTypesMappings } from '@kbn/core-saved-objects-migration-server-internal';
|
||||
import type { SavedObjectsTypeMappingDefinitions } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH } from '@kbn/core-plugins-server-internal/src/constants';
|
||||
|
||||
export interface Result {
|
||||
mappings: SavedObjectsTypeMappingDefinitions;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (!process.send) {
|
||||
throw new Error('worker must be run in a node.js fork');
|
||||
}
|
||||
|
||||
const settings = {
|
||||
logging: {
|
||||
loggers: [{ name: 'root', level: 'info', appenders: ['console'] }],
|
||||
},
|
||||
};
|
||||
|
||||
set(settings, PLUGIN_SYSTEM_ENABLE_ALL_PLUGINS_CONFIG_PATH, true);
|
||||
|
||||
const root = createRootWithCorePlugins(settings, {
|
||||
basePath: false,
|
||||
cache: false,
|
||||
dev: true,
|
||||
disableOptimizer: true,
|
||||
silent: false,
|
||||
dist: false,
|
||||
oss: false,
|
||||
runExamples: false,
|
||||
watch: false,
|
||||
});
|
||||
|
||||
await root.preboot();
|
||||
const { savedObjects } = await root.setup();
|
||||
const result: Result = {
|
||||
mappings: buildTypesMappings(savedObjects.getTypeRegistry().getAllTypes()),
|
||||
};
|
||||
process.send(result);
|
||||
})().catch((error) => {
|
||||
process.stderr.write(`UNHANDLED ERROR: ${error.stack}`);
|
||||
process.exit(1);
|
||||
});
|
20
packages/kbn-check-mappings-update-cli/src/mocks.ts
Normal file
20
packages/kbn-check-mappings-update-cli/src/mocks.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { SomeDevLog } from '@kbn/some-dev-log';
|
||||
|
||||
export function createSomeDevLogMock(): SomeDevLog {
|
||||
return {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
success: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
warning: jest.fn(),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
import { run } from '@kbn/dev-cli-runner';
|
||||
import { createTestEsCluster } from '@kbn/test';
|
||||
|
||||
import { extractMappingsFromPlugins } from './extract_mappings_from_plugins';
|
||||
|
||||
import { checkAdditiveOnlyChange } from './check_additive_only_change';
|
||||
import { checkIncompatibleMappings } from './check_incompatible_mappings';
|
||||
import { readCurrentMappings, updateCurrentMappings } from './current_mappings';
|
||||
|
||||
run(
|
||||
async ({ log, flagsReader, addCleanupTask }) => {
|
||||
const fix = flagsReader.boolean('fix');
|
||||
const verify = flagsReader.boolean('verify');
|
||||
|
||||
/**
|
||||
* Algorithm for checking compatible mappings. Should work in CI or local
|
||||
* dev environment.
|
||||
* 1. Extract mappings from code as JSON object
|
||||
* 2. Check if extracted mappings is different from current_mappings.json, current_mappings.json stores
|
||||
* the mappings from upstream and is commited to each branch
|
||||
* 3. Start a fresh ES node
|
||||
* 4. Upload current_mappings.json to ES node
|
||||
* 5. Upload extracted mappings.json to ES node
|
||||
* 6. Check result of response to step 5, if bad response the mappings are incompatible
|
||||
* 7. If good response, write extracted mappings to current_mappings.json
|
||||
*/
|
||||
|
||||
log.info('Extracting mappings from plugins');
|
||||
const extractedMappings = await log.indent(4, async () => {
|
||||
return await extractMappingsFromPlugins(log);
|
||||
});
|
||||
|
||||
const currentMappings = await readCurrentMappings();
|
||||
const isMappingChanged = !deepEqual(currentMappings, extractedMappings);
|
||||
|
||||
if (!isMappingChanged) {
|
||||
log.success('Mappings are unchanged.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (verify) {
|
||||
log.info('Checking if any mappings have been removed');
|
||||
await log.indent(4, async () => {
|
||||
return checkAdditiveOnlyChange(log, currentMappings, extractedMappings);
|
||||
});
|
||||
|
||||
log.info('Starting es...');
|
||||
const esClient = await log.indent(4, async () => {
|
||||
const cluster = createTestEsCluster({ log });
|
||||
await cluster.start();
|
||||
addCleanupTask(() => cluster.cleanup());
|
||||
return cluster.getClient();
|
||||
});
|
||||
|
||||
log.info(`Checking if mappings are compatible`);
|
||||
await log.indent(4, async () => {
|
||||
await checkIncompatibleMappings({
|
||||
log,
|
||||
esClient,
|
||||
currentMappings,
|
||||
nextMappings: extractedMappings,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (fix) {
|
||||
await updateCurrentMappings(extractedMappings);
|
||||
log.warning(
|
||||
`Updated extracted mappings in current_mappings.json file, please commit the changes if desired.`
|
||||
);
|
||||
} else {
|
||||
log.warning(
|
||||
`The extracted mappings do not match the current_mappings.json file, run with --fix to update.`
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
description: `
|
||||
Determine if the current SavedObject mappings in the source code can be applied to the current mappings from upstream.
|
||||
`,
|
||||
flags: {
|
||||
boolean: ['fix', 'verify'],
|
||||
default: {
|
||||
verify: true,
|
||||
},
|
||||
help: `
|
||||
--fix If the current mappings differ from the mappings in the file, update the current_mappings.json file
|
||||
--no-verify Don't run any validation, just update the current_mappings.json file.
|
||||
`,
|
||||
},
|
||||
}
|
||||
);
|
30
packages/kbn-check-mappings-update-cli/tsconfig.json
Normal file
30
packages/kbn-check-mappings-update-cli/tsconfig.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/some-dev-log",
|
||||
"@kbn/dev-cli-errors",
|
||||
"@kbn/core-saved-objects-base-server-internal",
|
||||
"@kbn/repo-info",
|
||||
"@kbn/stdio-dev-helpers",
|
||||
"@kbn/core-test-helpers-kbn-server",
|
||||
"@kbn/core-plugins-server-internal",
|
||||
"@kbn/core-saved-objects-migration-server-internal",
|
||||
"@kbn/dev-cli-runner",
|
||||
"@kbn/test",
|
||||
"@kbn/core-elasticsearch-client-server-mocks",
|
||||
"@kbn/safer-lodash-set",
|
||||
]
|
||||
}
|
10
scripts/check_mappings_update.js
Normal file
10
scripts/check_mappings_update.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
require('../src/setup_node_env');
|
||||
require('@kbn/check-mappings-update-cli');
|
|
@ -116,6 +116,8 @@
|
|||
"@kbn/chart-icons/*": ["packages/kbn-chart-icons/*"],
|
||||
"@kbn/charts-plugin": ["src/plugins/charts"],
|
||||
"@kbn/charts-plugin/*": ["src/plugins/charts/*"],
|
||||
"@kbn/check-mappings-update-cli": ["packages/kbn-check-mappings-update-cli"],
|
||||
"@kbn/check-mappings-update-cli/*": ["packages/kbn-check-mappings-update-cli/*"],
|
||||
"@kbn/ci-stats-core": ["packages/kbn-ci-stats-core"],
|
||||
"@kbn/ci-stats-core/*": ["packages/kbn-ci-stats-core/*"],
|
||||
"@kbn/ci-stats-performance-metrics": ["packages/kbn-ci-stats-performance-metrics"],
|
||||
|
|
|
@ -2969,6 +2969,10 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/check-mappings-update-cli@link:packages/kbn-check-mappings-update-cli":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/ci-stats-core@link:packages/kbn-ci-stats-core":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue