[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:
Jean-Louis Leysens 2023-04-27 15:42:31 +02:00 committed by GitHub
parent a0cd72436c
commit e9197ad359
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 3598 additions and 4 deletions

View file

@ -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

View 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
View file

@ -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

View file

@ -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",

View 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.
*/
/**
* @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;

View file

@ -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);
});
});

View file

@ -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);
}
}

View file

@ -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' },

View file

@ -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!`);

View 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.

File diff suppressed because it is too large Load diff

View 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'],
};

View file

@ -0,0 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/check-mappings-update-cli",
"owner": "@elastic/kibana-core",
"devOnly": true
}

View 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"
}

View file

@ -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.`);
}

View file

@ -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.'
);
});
});

View file

@ -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."`
);
});
});

View file

@ -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.`
);
}
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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);
});

View 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(),
};
}

View file

@ -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.
`,
},
}
);

View 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",
]
}

View 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');

View file

@ -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"],

View file

@ -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 ""