From 814de0857e2d1811aed9a0ff632e06e1af93f24c Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 26 Apr 2021 14:45:25 -0400 Subject: [PATCH] Add support for /api/status before Kibana completes startup (#79012) (#98329) Co-authored-by: Larry Gregory Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Josh Dover <1813008+joshdover@users.noreply.github.com> Co-authored-by: Larry Gregory --- ...omhttpresponseoptions.bypasserrorformat.md | 13 +++ ...n-core-server.customhttpresponseoptions.md | 1 + ...r.httpresponseoptions.bypasserrorformat.md | 13 +++ ...-plugin-core-server.httpresponseoptions.md | 1 + .../functional_tests/lib/run_kibana_server.js | 2 +- .../src/kbn_client/kbn_client_requester.ts | 9 ++ .../src/kbn_client/kbn_client_status.ts | 2 + src/core/server/http/http_server.test.ts | 34 ++++++ src/core/server/http/http_server.ts | 102 +++++++++++------- src/core/server/http/http_service.test.ts | 31 +++++- src/core/server/http/http_service.ts | 63 ++++++++--- .../http/integration_tests/router.test.ts | 56 ++++++++++ src/core/server/http/router/index.ts | 8 +- src/core/server/http/router/response.ts | 6 +- .../server/http/router/response_adapter.ts | 3 + src/core/server/http/router/router.ts | 3 +- src/core/server/http/types.ts | 7 ++ .../migrations/core/index_migrator.test.ts | 1 + .../migrations/core/index_migrator.ts | 2 + .../migrations/core/migration_context.ts | 6 +- .../core/migration_coordinator.test.ts | 6 ++ .../migrations/core/migration_coordinator.ts | 18 +++- .../migrations/kibana/kibana_migrator.ts | 4 +- src/core/server/saved_objects/status.ts | 11 +- src/core/server/server.api.md | 4 +- src/core/server/status/legacy_status.ts | 2 +- src/core/server/status/routes/status.ts | 5 +- src/core/server/status/status_service.ts | 23 +++- .../apis/saved_objects/migrations.ts | 1 + 29 files changed, 363 insertions(+), 74 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md new file mode 100644 index 000000000000..bbd97ab517d2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [CustomHttpResponseOptions](./kibana-plugin-core-server.customhttpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) + +## CustomHttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md index 67242bbd4e2e..82089c831d71 100644 --- a/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.customhttpresponseoptions.md @@ -17,6 +17,7 @@ export interface CustomHttpResponseOptionsT | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.customhttpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | | [statusCode](./kibana-plugin-core-server.customhttpresponseoptions.statuscode.md) | number | | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md new file mode 100644 index 000000000000..98792c47d564 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpResponseOptions](./kibana-plugin-core-server.httpresponseoptions.md) > [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) + +## HttpResponseOptions.bypassErrorFormat property + +Bypass the default error formatting + +Signature: + +```typescript +bypassErrorFormat?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md index 9f31e86175f7..497adc6a5ec5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpresponseoptions.md @@ -17,5 +17,6 @@ export interface HttpResponseOptions | Property | Type | Description | | --- | --- | --- | | [body](./kibana-plugin-core-server.httpresponseoptions.body.md) | HttpResponsePayload | HTTP message to send to the client | +| [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) | boolean | Bypass the default error formatting | | [headers](./kibana-plugin-core-server.httpresponseoptions.headers.md) | ResponseHeaders | HTTP Headers with additional information about response | diff --git a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js index a43d3a09c7d7..f92d01d6454d 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js +++ b/packages/kbn-test/src/functional_tests/lib/run_kibana_server.js @@ -38,7 +38,7 @@ export async function runKibanaServer({ procs, config, options }) { ...extendNodeOptions(installDir), }, cwd: installDir || KIBANA_ROOT, - wait: /http server running/, + wait: /\[Kibana\]\[http\] http server running/, }); } diff --git a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts index 31cd3a689956..af75137d148e 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_requester.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_requester.ts @@ -19,6 +19,10 @@ const isConcliftOnGetError = (error: any) => { ); }; +const isIgnorableError = (error: any, ignorableErrors: number[] = []) => { + return isAxiosResponseError(error) && ignorableErrors.includes(error.response.status); +}; + export const uriencode = ( strings: TemplateStringsArray, ...values: Array @@ -53,6 +57,7 @@ export interface ReqOptions { body?: any; retries?: number; headers?: Record; + ignoreErrors?: number[]; responseType?: ResponseType; } @@ -125,6 +130,10 @@ export class KbnClientRequester { const requestedRetries = options.retries !== undefined; const failedToGetResponse = isAxiosRequestError(error); + if (isIgnorableError(error, options.ignoreErrors)) { + return error.response; + } + let errorMessage; if (conflictOnGet) { errorMessage = `Conflict on GET (path=${options.path}, attempt=${attempt}/${maxAttempts})`; diff --git a/packages/kbn-test/src/kbn_client/kbn_client_status.ts b/packages/kbn-test/src/kbn_client/kbn_client_status.ts index 7e14e58309fa..26c46917ae8d 100644 --- a/packages/kbn-test/src/kbn_client/kbn_client_status.ts +++ b/packages/kbn-test/src/kbn_client/kbn_client_status.ts @@ -44,6 +44,8 @@ export class KbnClientStatus { const { data } = await this.requester.request({ method: 'GET', path: 'api/status', + // Status endpoint returns 503 if any services are in an unavailable state + ignoreErrors: [503], }); return data; } diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index bc35352af6a1..8e01127d1c3b 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -138,6 +138,40 @@ test('log listening address after started when configured with BasePath and rewr `); }); +test('does not allow router registration after server is listening', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouter } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouter(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouter(router2)).toThrowErrorMatchingInlineSnapshot( + `"Routers can be registered only when HTTP server is stopped."` + ); +}); + +test('allows router registration after server is listening via `registerRouterAfterListening`', async () => { + expect(server.isListening()).toBe(false); + + const { registerRouterAfterListening } = await server.setup(config); + + const router1 = new Router('/foo', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router1)).not.toThrowError(); + + await server.start(); + + expect(server.isListening()).toBe(true); + + const router2 = new Router('/bar', logger, enhanceWithContext); + expect(() => registerRouterAfterListening(router2)).not.toThrowError(); +}); + test('valid params', async () => { const router = new Router('/foo', logger, enhanceWithContext); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index d845ac1b639b..8b4c3b941615 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -33,6 +33,7 @@ import { KibanaRouteOptions, KibanaRequestState, isSafeMethod, + RouterRoute, } from './router'; import { SessionStorageCookieOptions, @@ -52,6 +53,13 @@ export interface HttpServerSetup { * @param router {@link IRouter} - a router with registered route handlers. */ registerRouter: (router: IRouter) => void; + /** + * Add all the routes registered with `router` to HTTP server request listeners. + * Unlike `registerRouter`, this function allows routes to be registered even after the server + * has started listening for requests. + * @param router {@link IRouter} - a router with registered route handlers. + */ + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; @@ -114,6 +122,17 @@ export class HttpServer { this.registeredRouters.add(router); } + private registerRouterAfterListening(router: IRouter) { + if (this.isListening()) { + for (const route of router.getRoutes()) { + this.configureRoute(route); + } + } else { + // Not listening yet, add to set of registeredRouters so that it can be added after listening has started. + this.registeredRouters.add(router); + } + } + public async setup(config: HttpConfig): Promise { const serverOptions = getServerOptions(config); const listenerOptions = getListenerOptions(config); @@ -130,6 +149,7 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), + registerRouterAfterListening: this.registerRouterAfterListening.bind(this), registerStaticDir: this.registerStaticDir.bind(this), registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), @@ -170,45 +190,7 @@ export class HttpServer { for (const router of this.registeredRouters) { for (const route of router.getRoutes()) { - this.log.debug(`registering route handler for [${route.path}]`); - // Hapi does not allow payload validation to be specified for 'head' or 'get' requests - const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {}, timeout } = route.options; - const { accepts: allow, maxBytes, output, parse } = body; - - const kibanaRouteOptions: KibanaRouteOptions = { - xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), - }; - - this.server.route({ - handler: route.handler, - method: route.method, - path: route.path, - options: { - auth: this.getAuthOption(authRequired), - app: kibanaRouteOptions, - tags: tags ? Array.from(tags) : undefined, - // TODO: This 'validate' section can be removed once the legacy platform is completely removed. - // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default - // validation applied in ./http_tools#getServerOptions - // (All NP routes are already required to specify their own validation in order to access the payload) - validate, - // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` - payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) - ? { - allow, - maxBytes, - output, - parse, - timeout: timeout?.payload, - multipart: true, - } - : undefined, - timeout: { - socket: timeout?.idleSocket ?? this.config!.socketTimeout, - }, - }, - }); + this.configureRoute(route); } } @@ -486,4 +468,46 @@ export class HttpServer { options: { auth: false }, }); } + + private configureRoute(route: RouterRoute) { + this.log.debug(`registering route handler for [${route.path}]`); + // Hapi does not allow payload validation to be specified for 'head' or 'get' requests + const validate = isSafeMethod(route.method) ? undefined : { payload: true }; + const { authRequired, tags, body = {}, timeout } = route.options; + const { accepts: allow, maxBytes, output, parse } = body; + + const kibanaRouteOptions: KibanaRouteOptions = { + xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), + }; + + this.server!.route({ + handler: route.handler, + method: route.method, + path: route.path, + options: { + auth: this.getAuthOption(authRequired), + app: kibanaRouteOptions, + tags: tags ? Array.from(tags) : undefined, + // TODO: This 'validate' section can be removed once the legacy platform is completely removed. + // We are telling Hapi that NP routes can accept any payload, so that it can bypass the default + // validation applied in ./http_tools#getServerOptions + // (All NP routes are already required to specify their own validation in order to access the payload) + validate, + // @ts-expect-error Types are outdated and doesn't allow `payload.multipart` to be `true` + payload: [allow, maxBytes, output, parse, timeout?.payload].some((x) => x !== undefined) + ? { + allow, + maxBytes, + output, + parse, + timeout: timeout?.payload, + multipart: true, + } + : undefined, + timeout: { + socket: timeout?.idleSocket ?? this.config!.socketTimeout, + }, + }, + }); + } } diff --git a/src/core/server/http/http_service.test.ts b/src/core/server/http/http_service.test.ts index 83279e99bc47..ebb9ad971b84 100644 --- a/src/core/server/http/http_service.test.ts +++ b/src/core/server/http/http_service.test.ts @@ -68,20 +68,32 @@ test('creates and sets up http server', async () => { start: jest.fn(), stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: jest.fn(), + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); expect(mockHttpServer.mock.instances.length).toBe(1); expect(httpServer.setup).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).not.toHaveBeenCalled(); await service.setup(setupDeps); expect(httpServer.setup).toHaveBeenCalled(); expect(httpServer.start).not.toHaveBeenCalled(); + expect(notReadyHttpServer.setup).toHaveBeenCalled(); + expect(notReadyHttpServer.start).toHaveBeenCalled(); + await service.start(); expect(httpServer.start).toHaveBeenCalled(); + expect(notReadyHttpServer.stop).toHaveBeenCalled(); }); test('spins up notReady server until started if configured with `autoListen:true`', async () => { @@ -102,6 +114,8 @@ test('spins up notReady server until started if configured with `autoListen:true .mockImplementationOnce(() => httpServer) .mockImplementationOnce(() => ({ setup: () => ({ server: notReadyHapiServer }), + start: jest.fn(), + stop: jest.fn().mockImplementation(() => notReadyHapiServer.stop()), })); const service = new HttpService({ @@ -163,7 +177,14 @@ test('stops http server', async () => { start: noop, stop: jest.fn(), }; - mockHttpServer.mockImplementation(() => httpServer); + const notReadyHttpServer = { + isListening: () => false, + setup: jest.fn().mockReturnValue({ server: fakeHapiServer }), + start: noop, + stop: jest.fn(), + }; + mockHttpServer.mockImplementationOnce(() => httpServer); + mockHttpServer.mockImplementationOnce(() => notReadyHttpServer); const service = new HttpService({ coreId, configService, env, logger }); @@ -171,6 +192,7 @@ test('stops http server', async () => { await service.start(); expect(httpServer.stop).toHaveBeenCalledTimes(0); + expect(notReadyHttpServer.stop).toHaveBeenCalledTimes(1); await service.stop(); @@ -188,7 +210,7 @@ test('stops not ready server if it is running', async () => { isListening: () => false, setup: jest.fn().mockReturnValue({ server: mockHapiServer }), start: noop, - stop: jest.fn(), + stop: jest.fn().mockImplementation(() => mockHapiServer.stop()), }; mockHttpServer.mockImplementation(() => httpServer); @@ -198,7 +220,7 @@ test('stops not ready server if it is running', async () => { await service.stop(); - expect(mockHapiServer.stop).toHaveBeenCalledTimes(1); + expect(mockHapiServer.stop).toHaveBeenCalledTimes(2); }); test('register route handler', async () => { @@ -231,6 +253,7 @@ test('returns http server contract on setup', async () => { mockHttpServer.mockImplementation(() => ({ isListening: () => false, setup: jest.fn().mockReturnValue(httpServer), + start: noop, stop: noop, })); diff --git a/src/core/server/http/http_service.ts b/src/core/server/http/http_service.ts index fdf9b738a983..0d2850660768 100644 --- a/src/core/server/http/http_service.ts +++ b/src/core/server/http/http_service.ts @@ -8,7 +8,6 @@ import { Observable, Subscription, combineLatest, of } from 'rxjs'; import { first, map } from 'rxjs/operators'; -import { Server } from '@hapi/hapi'; import { pick } from '@kbn/std'; import type { RequestHandlerContext } from 'src/core/server'; @@ -20,7 +19,7 @@ import { CoreContext } from '../core_context'; import { PluginOpaqueId } from '../plugins'; import { CspConfigType, config as cspConfig } from '../csp'; -import { Router } from './router'; +import { IRouter, Router } from './router'; import { HttpConfig, HttpConfigType, config as httpConfig } from './http_config'; import { HttpServer } from './http_server'; import { HttpsRedirectServer } from './https_redirect_server'; @@ -30,6 +29,7 @@ import { RequestHandlerContextProvider, InternalHttpServiceSetup, InternalHttpServiceStart, + InternalNotReadyHttpServiceSetup, } from './types'; import { registerCoreHandlers } from './lifecycle_handlers'; @@ -54,7 +54,7 @@ export class HttpService private readonly logger: LoggerFactory; private readonly log: Logger; private readonly env: Env; - private notReadyServer?: Server; + private notReadyServer?: HttpServer; private internalSetup?: InternalHttpServiceSetup; private requestHandlerContext?: RequestHandlerContextContainer; @@ -88,9 +88,7 @@ export class HttpService const config = await this.config$.pipe(first()).toPromise(); - if (this.shouldListen(config)) { - await this.runNotReadyServer(config); - } + const notReadyServer = await this.setupNotReadyService({ config, context: deps.context }); const { registerRouter, ...serverContract } = await this.httpServer.setup(config); @@ -99,6 +97,8 @@ export class HttpService this.internalSetup = { ...serverContract, + notReadyServer, + externalUrl: new ExternalUrlConfig(config.externalUrl), createRouter: ( @@ -178,14 +178,51 @@ export class HttpService await this.httpsRedirectServer.stop(); } + private async setupNotReadyService({ + config, + context, + }: { + config: HttpConfig; + context: ContextSetup; + }): Promise { + if (!this.shouldListen(config)) { + return; + } + + const notReadySetup = await this.runNotReadyServer(config); + + // We cannot use the real context container since the core services may not yet be ready + const fakeContext: RequestHandlerContextContainer = new Proxy( + context.createContextContainer(), + { + get: (target, property, receiver) => { + if (property === 'createHandler') { + return Reflect.get(target, property, receiver); + } + throw new Error(`Unexpected access from fake context: ${String(property)}`); + }, + } + ); + + return { + registerRoutes: (path: string, registerCallback: (router: IRouter) => void) => { + const router = new Router( + path, + this.log, + fakeContext.createHandler.bind(null, this.coreContext.coreId) + ); + + registerCallback(router); + notReadySetup.registerRouterAfterListening(router); + }, + }; + } + private async runNotReadyServer(config: HttpConfig) { this.log.debug('starting NotReady server'); - const httpServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); - const { server } = await httpServer.setup(config); - this.notReadyServer = server; - // use hapi server while KibanaResponseFactory doesn't allow specifying custom headers - // https://github.com/elastic/kibana/issues/33779 - this.notReadyServer.route({ + this.notReadyServer = new HttpServer(this.logger, 'NotReady', of(config.shutdownTimeout)); + const notReadySetup = await this.notReadyServer.setup(config); + notReadySetup.server.route({ path: '/{p*}', method: '*', handler: (req, responseToolkit) => { @@ -201,5 +238,7 @@ export class HttpService }, }); await this.notReadyServer.start(); + + return notReadySetup; } } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index 5b297ab44f8b..354ab1c65d56 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -15,6 +15,8 @@ import { contextServiceMock } from '../../context/context_service.mock'; import { loggingSystemMock } from '../../logging/logging_system.mock'; import { createHttpServer } from '../test_utils'; import { HttpService } from '../http_service'; +import { Router } from '../router'; +import { loggerMock } from '@kbn/logging/target/mocks'; let server: HttpService; let logger: ReturnType; @@ -1836,3 +1838,57 @@ describe('ETag', () => { .expect(304, ''); }); }); + +describe('registerRouterAfterListening', () => { + it('allows a router to be registered before server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); + + it('allows a router to be registered after server has started listening', async () => { + const { server: innerServer, createRouter, registerRouterAfterListening } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello' }); + }); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + await supertest(innerServer.listener).get('/test/afterListening').expect(404); + + const enhanceWithContext = (fn: (...args: any[]) => any) => fn.bind(null, {}); + + const otherRouter = new Router('/test', loggerMock.create(), enhanceWithContext); + otherRouter.get({ path: '/afterListening', validate: false }, (context, req, res) => { + return res.ok({ body: 'hello from other router' }); + }); + + registerRouterAfterListening(otherRouter); + + await supertest(innerServer.listener).get('/test/afterListening').expect(200); + }); +}); diff --git a/src/core/server/http/router/index.ts b/src/core/server/http/router/index.ts index a958d330bf24..5ba814393656 100644 --- a/src/core/server/http/router/index.ts +++ b/src/core/server/http/router/index.ts @@ -9,7 +9,13 @@ export { filterHeaders } from './headers'; export type { Headers, ResponseHeaders, KnownHeaders } from './headers'; export { Router } from './router'; -export type { RequestHandler, RequestHandlerWrapper, IRouter, RouteRegistrar } from './router'; +export type { + RequestHandler, + RequestHandlerWrapper, + IRouter, + RouteRegistrar, + RouterRoute, +} from './router'; export { isKibanaRequest, isRealRequest, ensureRawRequest, KibanaRequest } from './request'; export type { KibanaRequestEvents, diff --git a/src/core/server/http/router/response.ts b/src/core/server/http/router/response.ts index e2babf719f67..6cea7fcf4c94 100644 --- a/src/core/server/http/router/response.ts +++ b/src/core/server/http/router/response.ts @@ -62,6 +62,8 @@ export interface HttpResponseOptions { body?: HttpResponsePayload; /** HTTP Headers with additional information about response */ headers?: ResponseHeaders; + /** Bypass the default error formatting */ + bypassErrorFormat?: boolean; } /** @@ -79,6 +81,8 @@ export interface CustomHttpResponseOptions; diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index f007a77a2a21..bbd296d6b183 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -277,6 +277,11 @@ export interface HttpServiceSetup { getServerInfo: () => HttpServerInfo; } +/** @internal */ +export interface InternalNotReadyHttpServiceSetup { + registerRoutes(path: string, callback: (router: IRouter) => void): void; +} + /** @internal */ export interface InternalHttpServiceSetup extends Omit { @@ -287,6 +292,7 @@ export interface InternalHttpServiceSetup path: string, plugin?: PluginOpaqueId ) => IRouter; + registerRouterAfterListening: (router: IRouter) => void; registerStaticDir: (path: string, dirPath: string) => void; getAuthHeaders: GetAuthHeaders; registerRouteHandlerContext: < @@ -297,6 +303,7 @@ export interface InternalHttpServiceSetup contextName: ContextName, provider: RequestHandlerContextProvider ) => RequestHandlerContextContainer; + notReadyServer?: InternalNotReadyHttpServiceSetup; } /** @public */ diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index dd295efacf6b..fcc03f363139 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -27,6 +27,7 @@ describe('IndexMigrator', () => { index: '.kibana', kibanaVersion: '7.10.0', log: loggingSystemMock.create().get(), + setStatus: jest.fn(), mappingProperties: {}, pollInterval: 1, scrollDuration: '1m', diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index 472fb4f8d1a3..14dba1db9b62 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -41,6 +41,8 @@ export class IndexMigrator { pollInterval: context.pollInterval, + setStatus: context.setStatus, + async isMigrated() { return !(await requiresMigration(context)); }, diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index 441c7efed049..d7f7aff45a47 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -25,6 +25,7 @@ import { buildActiveMappings } from './build_active_mappings'; import { VersionedTransformer } from './document_migrator'; import * as Index from './elastic_index'; import { SavedObjectsMigrationLogger, MigrationLogger } from './migration_logger'; +import { KibanaMigratorStatus } from '../kibana'; export interface MigrationOpts { batchSize: number; @@ -34,6 +35,7 @@ export interface MigrationOpts { index: string; kibanaVersion: string; log: Logger; + setStatus: (status: KibanaMigratorStatus) => void; mappingProperties: SavedObjectsTypeMappingDefinitions; documentMigrator: VersionedTransformer; serializer: SavedObjectsSerializer; @@ -57,6 +59,7 @@ export interface Context { documentMigrator: VersionedTransformer; kibanaVersion: string; log: SavedObjectsMigrationLogger; + setStatus: (status: KibanaMigratorStatus) => void; batchSize: number; pollInterval: number; scrollDuration: string; @@ -70,7 +73,7 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, client } = opts; + const { log, client, setStatus } = opts; const alias = opts.index; const source = createSourceContext(await Index.fetchInfo(client, alias), alias); const dest = createDestContext(source, alias, opts.mappingProperties); @@ -82,6 +85,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { dest, kibanaVersion: opts.kibanaVersion, log: new MigrationLogger(log), + setStatus, batchSize: opts.batchSize, documentMigrator: opts.documentMigrator, pollInterval: opts.pollInterval, diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts index 9a045d0fbf7f..63476a15d77c 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.test.ts @@ -19,6 +19,7 @@ describe('coordinateMigration', () => { throw { body: { error: { index: '.foo', type: 'resource_already_exists_exception' } } }; }); const isMigrated = jest.fn(); + const setStatus = jest.fn(); isMigrated.mockResolvedValueOnce(false).mockResolvedValueOnce(true); @@ -27,6 +28,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }); expect(runMigration).toHaveBeenCalledTimes(1); @@ -39,12 +41,14 @@ describe('coordinateMigration', () => { const pollInterval = 1; const runMigration = jest.fn(() => Promise.resolve()); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await coordinateMigration({ log, runMigration, pollInterval, isMigrated, + setStatus, }); expect(isMigrated).not.toHaveBeenCalled(); }); @@ -55,6 +59,7 @@ describe('coordinateMigration', () => { throw new Error('Doh'); }); const isMigrated = jest.fn(() => Promise.resolve(true)); + const setStatus = jest.fn(); await expect( coordinateMigration({ @@ -62,6 +67,7 @@ describe('coordinateMigration', () => { runMigration, pollInterval, isMigrated, + setStatus, }) ).rejects.toThrow(/Doh/); expect(isMigrated).not.toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts index 3e66d37ce696..5b99f050b0ec 100644 --- a/src/core/server/saved_objects/migrations/core/migration_coordinator.ts +++ b/src/core/server/saved_objects/migrations/core/migration_coordinator.ts @@ -24,11 +24,16 @@ */ import _ from 'lodash'; +import { KibanaMigratorStatus } from '../kibana'; import { SavedObjectsMigrationLogger } from './migration_logger'; const DEFAULT_POLL_INTERVAL = 15000; -export type MigrationStatus = 'waiting' | 'running' | 'completed'; +export type MigrationStatus = + | 'waiting_to_start' + | 'waiting_for_other_nodes' + | 'running' + | 'completed'; export type MigrationResult = | { status: 'skipped' } @@ -43,6 +48,7 @@ export type MigrationResult = interface Opts { runMigration: () => Promise; isMigrated: () => Promise; + setStatus: (status: KibanaMigratorStatus) => void; log: SavedObjectsMigrationLogger; pollInterval?: number; } @@ -64,7 +70,9 @@ export async function coordinateMigration(opts: Opts): Promise try { return await opts.runMigration(); } catch (error) { - if (handleIndexExists(error, opts.log)) { + const waitingIndex = handleIndexExists(error, opts.log); + if (waitingIndex) { + opts.setStatus({ status: 'waiting_for_other_nodes', waitingIndex }); await waitForMigration(opts.isMigrated, opts.pollInterval); return { status: 'skipped' }; } @@ -77,11 +85,11 @@ export async function coordinateMigration(opts: Opts): Promise * and is the cue for us to fall into a polling loop, waiting for some * other Kibana instance to complete the migration. */ -function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { +function handleIndexExists(error: any, log: SavedObjectsMigrationLogger): string | undefined { const isIndexExistsError = _.get(error, 'body.error.type') === 'resource_already_exists_exception'; if (!isIndexExistsError) { - return false; + return undefined; } const index = _.get(error, 'body.error.index'); @@ -93,7 +101,7 @@ function handleIndexExists(error: any, log: SavedObjectsMigrationLogger) { `restarting Kibana.` ); - return true; + return index; } /** diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 58dcae7309ee..e09284b49c86 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -52,6 +52,7 @@ export type IKibanaMigrator = Pick; export interface KibanaMigratorStatus { status: MigrationStatus; result?: MigrationResult[]; + waitingIndex?: string; } /** @@ -67,7 +68,7 @@ export class KibanaMigrator { private readonly serializer: SavedObjectsSerializer; private migrationResult?: Promise; private readonly status$ = new BehaviorSubject({ - status: 'waiting', + status: 'waiting_to_start', }); private readonly activeMappings: IndexMapping; private migrationsRetryDelay?: number; @@ -200,6 +201,7 @@ export class KibanaMigrator { kibanaVersion: this.kibanaVersion, log: this.log, mappingProperties: indexMap[index].typeMappings, + setStatus: (status) => this.status$.next(status), pollInterval: this.soMigrationsConfig.pollInterval, scrollDuration: this.soMigrationsConfig.scrollDuration, serializer: this.serializer, diff --git a/src/core/server/saved_objects/status.ts b/src/core/server/saved_objects/status.ts index 24e87d292454..95bf6ddd9ff5 100644 --- a/src/core/server/saved_objects/status.ts +++ b/src/core/server/saved_objects/status.ts @@ -18,11 +18,20 @@ export const calculateStatus$ = ( ): Observable> => { const migratorStatus$: Observable> = rawMigratorStatus$.pipe( map((migrationStatus) => { - if (migrationStatus.status === 'waiting') { + if (migrationStatus.status === 'waiting_to_start') { return { level: ServiceStatusLevels.unavailable, summary: `SavedObjects service is waiting to start migrations`, }; + } else if (migrationStatus.status === 'waiting_for_other_nodes') { + return { + level: ServiceStatusLevels.unavailable, + summary: `SavedObjects service is waiting for other nodes to complete the migration`, + detail: + `If no other Kibana instance is attempting ` + + `migrations, you can get past this message by deleting index ${migrationStatus.waitingIndex} and ` + + `restarting Kibana.`, + }; } else if (migrationStatus.status === 'running') { return { level: ServiceStatusLevels.unavailable, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index b4c6ee323cba..327aee1a9dfc 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -788,6 +788,7 @@ export class CspConfig implements ICspConfig { // @public export interface CustomHttpResponseOptions { body?: T; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; // (undocumented) statusCode: number; @@ -1078,6 +1079,7 @@ export interface HttpResourcesServiceToolkit { // @public export interface HttpResponseOptions { body?: HttpResponsePayload; + bypassErrorFormat?: boolean; headers?: ResponseHeaders; } @@ -3261,7 +3263,7 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/elasticsearch/client/types.ts:94:7 - (ae-forgotten-export) The symbol "Explanation" needs to be exported by the entry point index.d.ts -// src/core/server/http/router/response.ts:297:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts +// src/core/server/http/router/response.ts:301:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:326:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:329:3 - (ae-forgotten-export) The symbol "SavedObjectsConfigType" needs to be exported by the entry point index.d.ts diff --git a/src/core/server/status/legacy_status.ts b/src/core/server/status/legacy_status.ts index b7d0965e31f6..1b3d139b1345 100644 --- a/src/core/server/status/legacy_status.ts +++ b/src/core/server/status/legacy_status.ts @@ -95,7 +95,7 @@ const serviceStatusToHttpComponent = ( since: string ): StatusComponentHttp => ({ id: serviceName, - message: status.summary, + message: [status.summary, status.detail].filter(Boolean).join(' '), since, ...serviceStatusAttrs(status), }); diff --git a/src/core/server/status/routes/status.ts b/src/core/server/status/routes/status.ts index c1782570ecfa..72f639231996 100644 --- a/src/core/server/status/routes/status.ts +++ b/src/core/server/status/routes/status.ts @@ -12,7 +12,7 @@ import { schema } from '@kbn/config-schema'; import { IRouter } from '../../http'; import { MetricsServiceSetup } from '../../metrics'; -import { ServiceStatus, CoreStatus } from '../types'; +import { ServiceStatus, CoreStatus, ServiceStatusLevels } from '../types'; import { PluginName } from '../../plugins'; import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status'; import { PackageInfo } from '../../config'; @@ -160,7 +160,8 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) = }, }; - return res.ok({ body }); + const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200; + return res.custom({ body, statusCode, bypassErrorFormat: true }); } ); }; diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index 7724e7a5e44b..cfd4d92d91d3 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -88,9 +88,7 @@ export class StatusService implements CoreService { // Create an unused subscription to ensure all underlying lazy observables are started. this.overallSubscription = overall$.subscribe(); - const router = http.createRouter(''); - registerStatusRoute({ - router, + const commonRouteDeps = { config: { allowAnonymous: statusConfig.allowAnonymous, packageInfo: this.coreContext.env.packageInfo, @@ -103,8 +101,27 @@ export class StatusService implements CoreService { plugins$: this.pluginsStatus.getAll$(), core$, }, + }; + + const router = http.createRouter(''); + registerStatusRoute({ + router, + ...commonRouteDeps, }); + if (http.notReadyServer && commonRouteDeps.config.allowAnonymous) { + http.notReadyServer.registerRoutes('', (notReadyRouter) => { + registerStatusRoute({ + router: notReadyRouter, + ...commonRouteDeps, + config: { + ...commonRouteDeps.config, + allowAnonymous: true, + }, + }); + }); + } + return { core$, overall$, diff --git a/test/api_integration/apis/saved_objects/migrations.ts b/test/api_integration/apis/saved_objects/migrations.ts index 87997ab4231a..dcd34c604dc3 100644 --- a/test/api_integration/apis/saved_objects/migrations.ts +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -735,6 +735,7 @@ async function migrateIndex({ mappingProperties, batchSize: 10, log: getLogMock(), + setStatus: () => {}, pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry),