[ZDT] Kibana as migration runner (#152813)

## Summary

This implements the behaviour for the `migrator` role introduced in
https://github.com/elastic/kibana/pull/151978.

The approach taken is kept simple:

1. Start Kibana as per usual, letting the `Root` and `Server` be
instantiated by the bootstrap logic
2. Inside the `Server`, just after the SO migrations are completed we
throw a special `CriticalError` (named `CriticalException` with code
`MigrationOnlyNode`)
3. The `Root` then handles this exception as it would handle any other
start up exception (like invalid config)

## To reviewers

* We can make this more sophisticated. I suspect this can quickly become
a tricky flow to implement/maintain since there are lots of moving parts
during "normal" Kibana startup. Using the same flow means our "migrator"
node will always behave the same way up until SOs have been migrated.
* Are we missing anything? Specifically, we want this "migrator" node to
exit with `0` since shutting down is expected. But is throwing during
`start` phase going to cover all cases we can think of?

Closes https://github.com/elastic/kibana/issues/150295

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2023-03-08 18:51:06 +01:00 committed by GitHub
parent 91ef7654fd
commit 5b9206fba2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 89 additions and 7 deletions

View file

@ -41,6 +41,7 @@ export const rolesConfig = schema.arrayOf(
]),
{
defaultValue: [NODE_WILDCARD_CHAR],
minSize: 1,
validate: (value) => {
if (value.length > 1) {
if (value.includes(NODE_WILDCARD_CHAR)) {
@ -51,7 +52,6 @@ export const rolesConfig = schema.arrayOf(
}
}
},
minSize: 1,
}
);

View file

@ -11,6 +11,7 @@ import { getPackages } from '@kbn/repo-packages';
import { CliArgs, Env, RawConfigService } from '@kbn/config';
import { CriticalError } from '@kbn/core-base-server-internal';
import { Root } from './root';
import { MIGRATION_EXCEPTION_CODE } from './constants';
interface BootstrapArgs {
configs: string[];
@ -114,11 +115,13 @@ export async function bootstrap({ configs, cliArgs, applyConfigOverrides }: Boot
function onRootShutdown(reason?: any) {
if (reason !== undefined) {
// There is a chance that logger wasn't configured properly and error that
// that forced root to shut down could go unnoticed. To prevent this we always
// mirror such fatal errors in standard output with `console.error`.
// eslint-disable-next-line no-console
console.error(`\n${chalk.white.bgRed(' FATAL ')} ${reason}\n`);
if (reason.code !== MIGRATION_EXCEPTION_CODE) {
// There is a chance that logger wasn't configured properly and error that
// that forced root to shut down could go unnoticed. To prevent this we always
// mirror such fatal errors in standard output with `console.error`.
// eslint-disable-next-line no-console
console.error(`\n${chalk.white.bgRed(' FATAL ')} ${reason}\n`);
}
process.exit(reason instanceof CriticalError ? reason.processExitCode : 1);
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export const MIGRATION_EXCEPTION_CODE = 'MigrationOnlyNode';

View file

@ -27,6 +27,7 @@ export const mockServer = {
setupCoreConfig: jest.fn(),
preboot: jest.fn(),
setup: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
configService,
};

View file

@ -10,6 +10,7 @@ import { rawConfigService, configService, logger, mockServer } from './index.tes
import { BehaviorSubject } from 'rxjs';
import { filter, first } from 'rxjs/operators';
import { CriticalError } from '@kbn/core-base-server-internal';
import { REPO_ROOT } from '@kbn/repo-info';
import { Env } from '@kbn/config';
import { getEnvOptions } from '@kbn/config-mocks';
@ -239,3 +240,16 @@ test('stops services if consequent logger upgrade fails', async () => {
expect(mockConsoleError.mock.calls).toMatchSnapshot();
});
test('handles migrator-only node exception', async () => {
const mockOnShutdown = jest.fn();
const root = new Root(rawConfigService, env, mockOnShutdown);
mockServer.start.mockImplementation(() => {
throw new CriticalError('Test', 'MigratioOnlyNode', 0);
});
await root.preboot();
await root.setup();
await expect(() => root.start()).rejects.toBeInstanceOf(CriticalError);
expect(mockServer.stop).toHaveBeenCalledTimes(1);
expect(mockOnShutdown).toHaveBeenCalledTimes(1);
});

View file

@ -22,6 +22,7 @@ import apm from 'elastic-apm-node';
import { isEqual } from 'lodash';
import type { ElasticConfigType } from './elastic_config';
import { Server } from '../server';
import { MIGRATION_EXCEPTION_CODE } from '../constants';
/**
* Top-level entry point to kick off the app and start the Kibana server.
@ -89,7 +90,9 @@ export class Root {
);
}
this.log.fatal(reason);
if (reason.code !== MIGRATION_EXCEPTION_CODE) {
this.log.fatal(reason);
}
}
await this.server.stop();

View file

@ -33,8 +33,11 @@ import { REPO_ROOT } from '@kbn/repo-info';
import { Env } from '@kbn/config';
import { rawConfigServiceMock, getEnvOptions } from '@kbn/config-mocks';
import { Server } from './server';
import { MIGRATION_EXCEPTION_CODE } from './constants';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import type { InternalNodeServicePreboot } from '@kbn/core-node-server-internal';
import { CriticalError } from '@kbn/core-base-server-internal';
const env = Env.createDefault(REPO_ROOT, getEnvOptions());
const logger = loggingSystemMock.create();
@ -58,6 +61,7 @@ beforeEach(() => {
afterEach(() => {
jest.clearAllMocks();
mockEnsureValidConfiguration.mockReset();
});
test('preboot services on "preboot"', async () => {
@ -252,3 +256,33 @@ test(`doesn't preboot core services if config validation fails`, async () => {
expect(mockPluginsService.preboot).not.toHaveBeenCalled();
expect(mockPrebootService.preboot).not.toHaveBeenCalled();
});
test('migrator-only node throws exception during start', async () => {
rawConfigService.getConfig$.mockReturnValue(
new BehaviorSubject({ node: { roles: ['migrator'] } })
);
const nodeServiceContract: InternalNodeServicePreboot = {
roles: { migrator: true, ui: false, backgroundTasks: false },
};
mockNodeService.preboot.mockResolvedValue(nodeServiceContract);
mockNodeService.start.mockReturnValue(nodeServiceContract);
const server = new Server(rawConfigService, env, logger);
await server.preboot();
await server.setup();
let migrationException: undefined | CriticalError;
expect(mockSavedObjectsService.start).not.toHaveBeenCalled();
await server.start().catch((e) => (migrationException = e));
expect(mockSavedObjectsService.start).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsService.start).toHaveNthReturnedWith(1, expect.anything());
expect(migrationException).not.toBeUndefined();
expect(migrationException).toBeInstanceOf(CriticalError);
expect(migrationException!.message).toBe('Migrations completed, shutting down Kibana');
expect(migrationException!.code).toBe(MIGRATION_EXCEPTION_CODE);
expect(migrationException!.processExitCode).toBe(0);
expect(migrationException!.cause).toBeUndefined();
});

View file

@ -9,6 +9,8 @@
import apm from 'elastic-apm-node';
import { reportPerformanceMetricEvent } from '@kbn/ebt-tools';
import type { Logger, LoggerFactory } from '@kbn/logging';
import type { NodeRoles } from '@kbn/core-node-server';
import { CriticalError } from '@kbn/core-base-server-internal';
import { ConfigService, Env, RawConfigurationProvider } from '@kbn/config';
import { DocLinksService } from '@kbn/core-doc-links-server-internal';
import { LoggingService, ILoggingSystem } from '@kbn/core-logging-server-internal';
@ -51,6 +53,7 @@ import type {
import { DiscoveredPlugins, PluginsService } from '@kbn/core-plugins-server-internal';
import { CoreAppsService } from '@kbn/core-apps-server-internal';
import { registerServiceConfig } from './register_service_config';
import { MIGRATION_EXCEPTION_CODE } from './constants';
const coreId = Symbol('core');
const KIBANA_STARTED_EVENT = 'kibana_started';
@ -103,6 +106,7 @@ export class Server {
private coreStart?: InternalCoreStart;
private discoveredPlugins?: DiscoveredPlugins;
private readonly logger: LoggerFactory;
private nodeRoles?: NodeRoles;
private readonly uptimePerStep: Partial<UptimeSteps> = {};
@ -159,6 +163,8 @@ export class Server {
const environmentPreboot = await this.environment.preboot({ analytics: analyticsPreboot });
const nodePreboot = await this.node.preboot({ loggingSystem: this.loggingSystem });
this.nodeRoles = nodePreboot.roles;
// Discover any plugins before continuing. This allows other systems to utilize the plugin dependency graph.
this.discoveredPlugins = await this.plugins.discover({
environment: environmentPreboot,
@ -364,6 +370,17 @@ export class Server {
await this.resolveSavedObjectsStartPromise!(savedObjectsStart);
soStartSpan?.end();
if (this.nodeRoles?.migrator === true) {
startTransaction?.end();
this.log.info('Detected migrator node role; shutting down Kibana...');
throw new CriticalError(
'Migrations completed, shutting down Kibana',
MIGRATION_EXCEPTION_CODE,
0
);
}
const capabilitiesStart = this.capabilities.start();
const uiSettingsStart = await this.uiSettings.start();
const customBrandingStart = this.customBranding.start();

View file

@ -68,6 +68,7 @@
"@kbn/core-custom-branding-server-internal",
"@kbn/core-custom-branding-server-mocks",
"@kbn/repo-packages",
"@kbn/core-node-server",
],
"exclude": [
"target/**/*",