Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for /api/status before Kibana completes startup #79012

Merged
merged 29 commits into from
Apr 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
06dd043
Add support for /api/status before Kibana completes startup
joshdover Sep 30, 2020
70ca428
Serve 503s when unavailable on /api/status
joshdover Sep 30, 2020
a5addbe
Expose detailed information when SO migrations are waiting for other …
joshdover Sep 30, 2020
069213f
Fix 503 handling
joshdover Oct 2, 2020
567da20
fx ci
joshdover Oct 2, 2020
2cba1e3
Merge branch 'master' of github.com:elastic/kibana into update-not-re…
legrego Apr 2, 2021
56e1f5f
fix merge
legrego Apr 2, 2021
c328b35
Type & test fixes
legrego Apr 5, 2021
78cd007
Change custom response handling to optionally bypass the error format
legrego Apr 5, 2021
9b1d174
fixing http service tests
legrego Apr 6, 2021
5742dd9
Merge branch 'master' of github.com:elastic/kibana into update-not-re…
legrego Apr 6, 2021
e7f355b
allow KbnClient to ignore errors
legrego Apr 6, 2021
11aa452
Merge branch 'master' into not-ready-status
kibanamachine Apr 7, 2021
8791a24
wait for the kibana http server to start, not the NotReady server
legrego Apr 8, 2021
8a6c767
Merge branch 'not-ready-status' of github.com:joshdover/kibana into u…
legrego Apr 8, 2021
efbbc7c
Avoid mutating internalSetup object
legrego Apr 8, 2021
fcc1853
Create dedicated method to support router registration after server h…
legrego Apr 8, 2021
2e0ac39
Throw error if fakeContext is used unexpectedly
legrego Apr 8, 2021
ade858b
Declare return type for handleIndexExists
legrego Apr 12, 2021
9e04194
Change legacy_status to .filter(Boolean)
legrego Apr 12, 2021
bbfda7b
Extract notReadyServer setup into its own function
legrego Apr 12, 2021
4bac4c8
Skip adding to registeredRouters if server is already listening
legrego Apr 12, 2021
d7ad6d0
Adds integration tests for registerRouterAfterListening
legrego Apr 12, 2021
11f0a0a
Merge branch 'master' of github.com:elastic/kibana into update-not-re…
legrego Apr 12, 2021
c0aebe1
Remove status retry, let's see what happens
legrego Apr 13, 2021
ecd4cbf
Merge branch 'master' of github.com:elastic/kibana into update-not-re…
legrego Apr 22, 2021
4a4b772
only register status route on NotReady server when anonymous access i…
legrego Apr 22, 2021
e9ce811
Merge branch 'master' into not-ready-status
kibanamachine Apr 23, 2021
efa4809
Merge branch 'master' into not-ready-status
kibanamachine Apr 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [CustomHttpResponseOptions](./kibana-plugin-core-server.customhttpresponseoptions.md) &gt; [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md)

## CustomHttpResponseOptions.bypassErrorFormat property

Bypass the default error formatting

<b>Signature:</b>

```typescript
bypassErrorFormat?: boolean;
```
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CustomHttpResponseOptions<T extends HttpResponsePayload | Respo
| Property | Type | Description |
| --- | --- | --- |
| [body](./kibana-plugin-core-server.customhttpresponseoptions.body.md) | <code>T</code> | HTTP message to send to the client |
| [bypassErrorFormat](./kibana-plugin-core-server.customhttpresponseoptions.bypasserrorformat.md) | <code>boolean</code> | Bypass the default error formatting |
| [headers](./kibana-plugin-core-server.customhttpresponseoptions.headers.md) | <code>ResponseHeaders</code> | HTTP Headers with additional information about response |
| [statusCode](./kibana-plugin-core-server.customhttpresponseoptions.statuscode.md) | <code>number</code> | |

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [HttpResponseOptions](./kibana-plugin-core-server.httpresponseoptions.md) &gt; [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md)

## HttpResponseOptions.bypassErrorFormat property

Bypass the default error formatting

<b>Signature:</b>

```typescript
bypassErrorFormat?: boolean;
```
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export interface HttpResponseOptions
| Property | Type | Description |
| --- | --- | --- |
| [body](./kibana-plugin-core-server.httpresponseoptions.body.md) | <code>HttpResponsePayload</code> | HTTP message to send to the client |
| [bypassErrorFormat](./kibana-plugin-core-server.httpresponseoptions.bypasserrorformat.md) | <code>boolean</code> | Bypass the default error formatting |
| [headers](./kibana-plugin-core-server.httpresponseoptions.headers.md) | <code>ResponseHeaders</code> | HTTP Headers with additional information about response |

Original file line number Diff line number Diff line change
Expand Up @@ -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/,
});
}

Expand Down
9 changes: 9 additions & 0 deletions packages/kbn-test/src/kbn_client/kbn_client_requester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | number | boolean>
Expand Down Expand Up @@ -53,6 +57,7 @@ export interface ReqOptions {
body?: any;
retries?: number;
headers?: Record<string, string>;
ignoreErrors?: number[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine to add it as long as it doesn't leak outside of the @kbn/test package, but would it make sense to name it ignore for consistency with ES API?

Copy link
Member

@legrego legrego Apr 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had that same exact thought, but I thought that ignoreErrors was slightly more descriptive. I was personally confused the first time I saw the ignore property on the ES API. Happy to adjust this though if you'd prefer the consistency

legrego marked this conversation as resolved.
Show resolved Hide resolved
responseType?: ResponseType;
}

Expand Down Expand Up @@ -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})`;
Expand Down
2 changes: 2 additions & 0 deletions packages/kbn-test/src/kbn_client/kbn_client_status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export class KbnClientStatus {
const { data } = await this.requester.request<ApiResponseStatus>({
method: 'GET',
path: 'api/status',
// Status endpoint returns 503 if any services are in an unavailable state
ignoreErrors: [503],
});
return data;
}
Expand Down
34 changes: 34 additions & 0 deletions src/core/server/http/http_server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
legrego marked this conversation as resolved.
Show resolved Hide resolved
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);

Expand Down
102 changes: 63 additions & 39 deletions src/core/server/http/http_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
KibanaRouteOptions,
KibanaRequestState,
isSafeMethod,
RouterRoute,
} from './router';
import {
SessionStorageCookieOptions,
Expand All @@ -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'];
Expand Down Expand Up @@ -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<HttpServerSetup> {
const serverOptions = getServerOptions(config);
const listenerOptions = getListenerOptions(config);
Expand All @@ -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),
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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,
},
},
});
}
}
31 changes: 27 additions & 4 deletions src/core/server/http/http_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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({
Expand Down Expand Up @@ -163,14 +177,22 @@ 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 });

await service.setup(setupDeps);
await service.start();

expect(httpServer.stop).toHaveBeenCalledTimes(0);
expect(notReadyHttpServer.stop).toHaveBeenCalledTimes(1);

await service.stop();

Expand All @@ -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);

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
}));

Expand Down
Loading