Change the health gateway to use the status API (#160125)

## Summary

Follow-up of https://github.com/elastic/kibana/pull/159768
Related to https://github.com/elastic/kibana/issues/158910

Change the health-gateway behavior to hit the `/api/status` endpoint
instead of just the root `/` path. This was made possible by
https://github.com/elastic/kibana/pull/159768, as we now always return
the correct http code from the status endpoint even for unauthenticated
requests.
This commit is contained in:
Pierre Gayvallet 2023-06-26 11:34:00 +02:00 committed by GitHub
parent 968c09a695
commit c10ab82521
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 195 additions and 95 deletions

View file

@ -6,4 +6,4 @@
* Side Public License, v 1.
*/
export { RootRoute } from './root';
export { StatusHandler } from './status';

View file

@ -8,12 +8,13 @@
import { Server } from '@hapi/hapi';
import { duration } from 'moment';
import { URL } from 'url';
import fetch, { Response } from 'node-fetch';
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
import type { KibanaConfig } from '../kibana_config';
import { RootRoute } from './root';
import { StatusHandler } from './status';
describe('RootRoute', () => {
describe('StatusHandler', () => {
let kibanaConfig: KibanaConfig;
let logger: MockedLogger;
let server: Server;
@ -30,7 +31,11 @@ describe('RootRoute', () => {
logger = loggerMock.create();
server = new Server();
server.route(new RootRoute(kibanaConfig, logger));
server.route({
method: 'GET',
path: '/',
handler: new StatusHandler(kibanaConfig, logger).handler,
});
await server.initialize();
});
@ -248,5 +253,49 @@ describe('RootRoute', () => {
})
);
});
it('should call the host with the correct path', async () => {
kibanaConfig.hosts.splice(0, kibanaConfig.hosts.length);
kibanaConfig.hosts.push('http://localhost:5601', 'http://localhost:5602');
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(new Response('', ok));
await server.inject({
method: 'get',
url: '/',
});
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(
new URL('http://localhost:5601/api/status'),
expect.any(Object)
);
expect(fetch).toHaveBeenCalledWith(
new URL('http://localhost:5602/api/status'),
expect.any(Object)
);
});
it('should append the status path when path already present on the host', async () => {
kibanaConfig.hosts.splice(0, kibanaConfig.hosts.length);
kibanaConfig.hosts.push('http://localhost:5601/prefix', 'http://localhost:5602/other/path');
(fetch as jest.MockedFunction<typeof fetch>).mockResolvedValue(new Response('', ok));
await server.inject({
method: 'get',
url: '/',
});
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(
new URL('http://localhost:5601/prefix/api/status'),
expect.any(Object)
);
expect(fetch).toHaveBeenCalledWith(
new URL('http://localhost:5602/other/path/api/status'),
expect.any(Object)
);
});
});
});

View file

@ -6,17 +6,19 @@
* Side Public License, v 1.
*/
import { capitalize, chain, memoize, pick } from 'lodash';
import { capitalize, chain, memoize } from 'lodash';
import { Agent, AgentOptions } from 'https';
import { URL } from 'url';
import type { Request, ResponseObject, ResponseToolkit, ServerRoute } from '@hapi/hapi';
import type { Request, ResponseObject, ResponseToolkit } from '@hapi/hapi';
import nodeFetch, { Response } from 'node-fetch';
import type { Logger } from '@kbn/logging';
import type { KibanaConfig } from '../kibana_config';
type Status = 'healthy' | 'unhealthy' | 'failure' | 'timeout';
interface RootRouteResponse {
const statusApiPath = '/api/status';
interface StatusRouteResponse {
status: Status;
hosts?: HostStatus[];
}
@ -27,9 +29,9 @@ interface HostStatus {
code?: number;
}
export class RootRoute implements ServerRoute {
export class StatusHandler {
private static isHealthy(response: Response) {
return RootRoute.isSuccess(response) || RootRoute.isUnauthorized(response);
return StatusHandler.isSuccess(response) || StatusHandler.isUnauthorized(response);
}
private static isUnauthorized({ status, headers }: Response): boolean {
@ -47,25 +49,20 @@ export class RootRoute implements ServerRoute {
timeout: 504,
};
readonly method = 'GET';
readonly path = '/';
constructor(private kibanaConfig: KibanaConfig, private logger: Logger) {
this.handler = this.handler.bind(this);
return pick(this, ['method', 'path', 'handler']) as RootRoute;
}
async handler(request: Request, toolkit: ResponseToolkit): Promise<ResponseObject> {
const body = await this.poll();
const code = RootRoute.STATUS_CODE[body.status];
const code = StatusHandler.STATUS_CODE[body.status];
this.logger.debug(`Returning ${code} response with body: ${JSON.stringify(body)}`);
return toolkit.response(body).type('application/json').code(code);
}
private async poll(): Promise<RootRouteResponse> {
private async poll(): Promise<StatusRouteResponse> {
const hosts = await Promise.all(this.kibanaConfig.hosts.map(this.pollHost.bind(this)));
const statuses = chain(hosts).map('status').uniq().value();
const status = statuses.length <= 1 ? statuses[0] ?? 'healthy' : 'unhealthy';
@ -81,7 +78,7 @@ export class RootRoute implements ServerRoute {
try {
const response = await this.fetch(host);
const status = RootRoute.isHealthy(response) ? 'healthy' : 'unhealthy';
const status = StatusHandler.isHealthy(response) ? 'healthy' : 'unhealthy';
this.logger.debug(
`${capitalize(status)} response from '${host}' with code ${response.status}`
);
@ -112,8 +109,9 @@ export class RootRoute implements ServerRoute {
}
}
private async fetch(url: string) {
const { protocol } = new URL(url);
private async fetch(host: string) {
const url = new URL(host);
appendStatusApiPath(url);
const controller = new AbortController();
const timeoutId = setTimeout(
@ -123,7 +121,7 @@ export class RootRoute implements ServerRoute {
try {
return await nodeFetch(url, {
agent: protocol === 'https:' ? this.getAgent() : undefined,
agent: url.protocol === 'https:' ? this.getAgent() : undefined,
signal: controller.signal,
redirect: 'manual',
});
@ -161,3 +159,7 @@ export class RootRoute implements ServerRoute {
return options;
}
}
const appendStatusApiPath = (url: URL) => {
url.pathname = `${url.pathname}/${statusApiPath}`.replace(/\/{2,}/g, '/');
};

View file

@ -50,6 +50,19 @@ describe('KibanaService', () => {
expect.objectContaining({
method: 'GET',
path: '/',
handler: expect.any(Function),
})
);
});
test('registers /api/status route with the server', async () => {
const kibanaService = new KibanaService({ config, logger });
await kibanaService.start({ server });
expect(server.addRoute).toHaveBeenCalledWith(
expect.objectContaining({
method: 'GET',
path: '/api/status',
handler: expect.any(Function),
})
);
});

View file

@ -10,7 +10,7 @@ import type { IConfigService } from '@kbn/config';
import type { Logger, LoggerFactory } from '@kbn/logging';
import { ServerStart } from '../server';
import { KibanaConfig } from './kibana_config';
import { RootRoute } from './routes';
import { StatusHandler } from './handlers';
interface KibanaServiceStartDependencies {
server: ServerStart;
@ -34,7 +34,17 @@ export class KibanaService {
}
async start({ server }: KibanaServiceStartDependencies) {
server.addRoute(new RootRoute(this.kibanaConfig, this.logger));
const statusHandler = new StatusHandler(this.kibanaConfig, this.logger);
server.addRoute({
method: 'GET',
path: '/',
handler: statusHandler.handler,
});
server.addRoute({
method: 'GET',
path: '/api/status',
handler: statusHandler.handler,
});
this.logger.info('Server is ready');
}

View file

@ -4,8 +4,8 @@ server:
kibana:
hosts:
- ${KIBANA_URL}/api/status/ok
- ${KIBANA_URL}/api/status/flaky?session=${SESSION}
- ${KIBANA_URL}/health/ok
- ${KIBANA_URL}/health/flaky?session=${SESSION}
logging:
root:

View file

@ -4,9 +4,9 @@ server:
kibana:
hosts:
- ${KIBANA_URL}/api/status/ok
- ${KIBANA_URL}/api/status/redirect
- ${KIBANA_URL}/api/status/unauthorized
- ${KIBANA_URL}/health/ok
- ${KIBANA_URL}/health/redirect
- ${KIBANA_URL}/health/unauthorized
logging:
root:

View file

@ -4,7 +4,7 @@ server:
kibana:
hosts:
- http://localhost:65537/api/status/ok
- http://localhost:65537/health/ok
requestTimeout: 2s
logging:

View file

@ -4,8 +4,8 @@ server:
kibana:
hosts:
- ${KIBANA_URL}/api/status/ok
- ${KIBANA_URL}/api/status/not-found
- ${KIBANA_URL}/health/ok
- ${KIBANA_URL}/health/not-found
logging:
root:

View file

@ -4,7 +4,7 @@ server:
kibana:
hosts:
- ${KIBANA_URL}/api/status/slow
- ${KIBANA_URL}/health/slow
requestTimeout: 2s
logging:

View file

@ -4,7 +4,7 @@ server:
kibana:
hosts:
- ${KIBANA_URL}/api/status/not-found
- ${KIBANA_URL}/health/not-found
logging:
root:

View file

@ -13,23 +13,27 @@ export class HealthGatewayStatusPlugin implements Plugin<void, void> {
public setup(core: CoreSetup) {
const router = core.http.createRouter();
router.get({ path: '/api/status/ok', validate: {} }, async (context, req, res) => res.ok());
router.get({ path: '/api/status/redirect', validate: {} }, async (context, req, res) =>
res.redirected({ headers: { location: '/api/status/ok' } })
router.get({ path: '/health/ok/api/status', validate: {} }, async (context, req, res) =>
res.ok()
);
router.get({ path: '/api/status/unauthorized', validate: {} }, async (context, req, res) =>
res.unauthorized({
headers: { 'www-authenticate': 'Basic' },
})
router.get({ path: '/health/redirect/api/status', validate: {} }, async (context, req, res) =>
res.redirected({ headers: { location: '/health/ok/api/status' } })
);
router.get({ path: '/api/status/not-found', validate: {} }, async (context, req, res) =>
router.get(
{ path: '/health/unauthorized/api/status', validate: {} },
async (context, req, res) =>
res.unauthorized({
headers: { 'www-authenticate': 'Basic' },
})
);
router.get({ path: '/health/not-found/api/status', validate: {} }, async (context, req, res) =>
res.notFound()
);
router.get({ path: '/api/status/slow', validate: {} }, async (context, req, res) => {
router.get({ path: '/health/slow/api/status', validate: {} }, async (context, req, res) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
return res.ok();
@ -38,7 +42,7 @@ export class HealthGatewayStatusPlugin implements Plugin<void, void> {
const sessions = new Set<string>();
router.get(
{
path: '/api/status/flaky',
path: '/health/flaky/api/status',
validate: {
query: schema.object({ session: schema.string() }),
},
@ -58,5 +62,6 @@ export class HealthGatewayStatusPlugin implements Plugin<void, void> {
}
public start() {}
public stop() {}
}

View file

@ -66,9 +66,9 @@ export class HealthGatewayService extends FtrService {
this.port = undefined;
}
poll() {
poll(path: string) {
this.assertRunning();
return supertest(`http://${this.host}:${this.port}`).get('/').send();
return supertest(`http://${this.host}:${this.port}`).get(path).send();
}
}

View file

@ -6,58 +6,10 @@
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from './ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const healthGateway = getService('healthGateway');
export default function ({ loadTestFile }: FtrProviderContext) {
describe('health gateway', () => {
it('returns 200 on healthy hosts', async () => {
await healthGateway.start('fixtures/healthy.yaml');
const { body } = await healthGateway.poll().expect(200);
expect(body).to.have.property('status', 'healthy');
});
it('returns 503 on unhealthy host', async () => {
await healthGateway.start('fixtures/unhealthy.yaml');
const { body } = await healthGateway.poll().expect(503);
expect(body).to.have.property('status', 'unhealthy');
});
it('returns 503 on mixed responses', async () => {
await healthGateway.start('fixtures/mixed.yaml');
const { body } = await healthGateway.poll().expect(503);
expect(body).to.have.property('status', 'unhealthy');
});
it('returns 504 on timeout', async () => {
await healthGateway.start('fixtures/timeout.yaml');
const { body } = await healthGateway.poll().expect(504);
expect(body).to.have.property('status', 'timeout');
});
it('returns 502 on exception', async () => {
await healthGateway.start('fixtures/invalid.yaml');
const { body } = await healthGateway.poll().expect(502);
expect(body).to.have.property('status', 'failure');
});
it('returns different status codes on state changes', async () => {
await healthGateway.start('fixtures/flaky.yaml', { env: { SESSION: `${Math.random()}` } });
await healthGateway.poll().expect(200);
await healthGateway.poll().expect(503);
await healthGateway.poll().expect(200);
});
afterEach(async () => {
await healthGateway.stop();
});
loadTestFile(require.resolve('./status'));
});
}

View file

@ -0,0 +1,69 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from './ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const healthGateway = getService('healthGateway');
describe('status API', () => {
afterEach(async () => {
await healthGateway.stop();
});
['/', '/api/status'].forEach((path) => {
describe(`${path}`, () => {
it('returns 200 on healthy hosts', async () => {
await healthGateway.start('fixtures/healthy.yaml');
const { body } = await healthGateway.poll(path).expect(200);
expect(body).to.have.property('status', 'healthy');
});
it('returns 503 on unhealthy host', async () => {
await healthGateway.start('fixtures/unhealthy.yaml');
const { body } = await healthGateway.poll(path).expect(503);
expect(body).to.have.property('status', 'unhealthy');
});
it('returns 503 on mixed responses', async () => {
await healthGateway.start('fixtures/mixed.yaml');
const { body } = await healthGateway.poll(path).expect(503);
expect(body).to.have.property('status', 'unhealthy');
});
it('returns 504 on timeout', async () => {
await healthGateway.start('fixtures/timeout.yaml');
const { body } = await healthGateway.poll(path).expect(504);
expect(body).to.have.property('status', 'timeout');
});
it('returns 502 on exception', async () => {
await healthGateway.start('fixtures/invalid.yaml');
const { body } = await healthGateway.poll(path).expect(502);
expect(body).to.have.property('status', 'failure');
});
it('returns different status codes on state changes', async () => {
await healthGateway.start('fixtures/flaky.yaml', {
env: { SESSION: `${Math.random()}` },
});
await healthGateway.poll(path).expect(200);
await healthGateway.poll(path).expect(503);
await healthGateway.poll(path).expect(200);
});
});
});
});
}