/api/status - always return a consistent status code (#159768)

## Summary

Fix https://github.com/elastic/kibana/issues/158910

Changes the behavior of the `/api/status` endpoint to always returns a
consistent http status code, and in particular:
- during the preboot stage 
- when accessed by unauthenticated users and `status.allowAnonymous` is
`false`.

That way, `/api/status` can properly be used for readiness checks. 

Please refer to https://github.com/elastic/kibana/issues/158910 for more
details.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pierre Gayvallet 2023-06-20 05:06:40 -04:00 committed by GitHub
parent cc04704cb5
commit 9e0c9a7ad5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 660 additions and 420 deletions

View file

@ -196,6 +196,7 @@ enabled:
- x-pack/test/api_integration/apis/security/config.ts
- x-pack/test/api_integration/apis/security_solution/config.ts
- x-pack/test/api_integration/apis/spaces/config.ts
- x-pack/test/api_integration/apis/status/config.ts
- x-pack/test/api_integration/apis/synthetics/config.ts
- x-pack/test/api_integration/apis/telemetry/config.ts
- x-pack/test/api_integration/apis/transform/config.ts

View file

@ -348,42 +348,6 @@ test('register preboot route handler on preboot', async () => {
await service.stop();
});
test('register preboot route handler on setup', async () => {
const registerRouterMock = jest.fn();
mockHttpServer
.mockImplementationOnce(() => ({
setup: () => ({
server: { start: jest.fn(), stop: jest.fn(), route: jest.fn() },
registerStaticDir: jest.fn(),
registerRouterAfterListening: registerRouterMock,
}),
start: noop,
stop: noop,
isListening: jest.fn(),
}))
.mockImplementationOnce(() => ({
setup: () => ({ server: {} }),
start: noop,
stop: noop,
isListening: jest.fn(),
}));
const service = new HttpService({ coreId, configService: createConfigService(), env, logger });
await service.preboot(prebootDeps);
const registerRoutesMock = jest.fn();
const { registerPrebootRoutes } = await service.setup(setupDeps);
registerPrebootRoutes('some-path', registerRoutesMock);
expect(registerRoutesMock).toHaveBeenCalledTimes(1);
expect(registerRoutesMock).toHaveBeenCalledWith(expect.any(Router));
const [[router]] = registerRoutesMock.mock.calls;
expect(registerRouterMock).toHaveBeenCalledTimes(1);
expect(registerRouterMock).toHaveBeenCalledWith(router);
await service.stop();
});
test('returns `preboot` http server contract on preboot', async () => {
const configService = createConfigService();
const httpServer = {
@ -442,7 +406,6 @@ test('returns http server contract on setup', async () => {
expect(setupContract).toMatchObject(httpServer);
expect(setupContract).toMatchObject({
createRouter: expect.any(Function),
registerPrebootRoutes: expect.any(Function),
});
await service.stop();
});

View file

@ -189,8 +189,6 @@ export class HttpService
contextName: ContextName,
provider: IContextProvider<Context, ContextName>
) => this.requestHandlerContext!.registerContext(pluginOpaqueId, contextName, provider),
registerPrebootRoutes: this.internalPreboot!.registerRoutes,
};
return this.internalSetup;

View file

@ -61,8 +61,6 @@ export interface InternalHttpServiceSetup
contextName: ContextName,
provider: IContextProvider<Context, ContextName>
) => IContextContainer;
registerPrebootRoutes(path: string, callback: (router: IRouter) => void): void;
}
/** @internal */

View file

@ -153,7 +153,6 @@ const createInternalSetupContractMock = () => {
auth: createAuthMock(),
authRequestHeaders: createAuthHeaderStorageMock(),
getServerInfo: jest.fn(),
registerPrebootRoutes: jest.fn(),
registerRouterAfterListening: jest.fn(),
};
mock.createCookieSessionStorageFactory.mockResolvedValue(sessionStorageMock.createFactory());

View file

@ -79,6 +79,7 @@ test('preboot services on "preboot"', async () => {
expect(mockLoggingService.preboot).not.toHaveBeenCalled();
expect(mockPluginsService.preboot).not.toHaveBeenCalled();
expect(mockPrebootService.preboot).not.toHaveBeenCalled();
expect(mockStatusService.preboot).not.toHaveBeenCalled();
await server.preboot();
@ -93,6 +94,7 @@ test('preboot services on "preboot"', async () => {
expect(mockLoggingService.preboot).toHaveBeenCalledTimes(1);
expect(mockPluginsService.preboot).toHaveBeenCalledTimes(1);
expect(mockPrebootService.preboot).toHaveBeenCalledTimes(1);
expect(mockStatusService.preboot).toHaveBeenCalledTimes(1);
});
test('sets up services on "setup"', async () => {

View file

@ -190,6 +190,7 @@ export class Server {
this.capabilities.preboot({ http: httpPreboot });
const elasticsearchServicePreboot = await this.elasticsearch.preboot();
const uiSettingsPreboot = await this.uiSettings.preboot();
await this.status.preboot({ http: httpPreboot });
const renderingPreboot = await this.rendering.preboot({ http: httpPreboot, uiPlugins });
const httpResourcesPreboot = this.httpResources.preboot({

View file

@ -7,3 +7,4 @@
*/
export { registerStatusRoute } from './status';
export { registerPrebootStatusRoute } from './status_preboot';

View file

@ -14,7 +14,12 @@ import type { IRouter } from '@kbn/core-http-server';
import type { MetricsServiceSetup } from '@kbn/core-metrics-server';
import type { CoreIncrementUsageCounter } from '@kbn/core-usage-data-server';
import type { StatusResponse } from '@kbn/core-status-common-internal';
import { ServiceStatus, CoreStatus, ServiceStatusLevels } from '@kbn/core-status-common';
import {
ServiceStatus,
ServiceStatusLevel,
CoreStatus,
ServiceStatusLevels,
} from '@kbn/core-status-common';
import { calculateLegacyStatus, LegacyStatusInfo } from '../legacy_status';
const SNAPSHOT_POSTFIX = /-SNAPSHOT$/;
@ -48,6 +53,14 @@ interface StatusHttpBody extends Omit<StatusResponse, 'status'> {
status: StatusInfo | LegacyStatusInfo;
}
export interface RedactedStatusHttpBody {
status: {
overall: {
level: ServiceStatusLevel;
};
};
}
export const registerStatusRoute = ({
router,
config,
@ -68,7 +81,7 @@ export const registerStatusRoute = ({
{
path: '/api/status',
options: {
authRequired: !config.allowAnonymous,
authRequired: 'optional',
tags: ['api'], // ensures that unauthenticated calls receive a 401 rather than a 302 redirect to login page
},
validate: {
@ -88,60 +101,107 @@ export const registerStatusRoute = ({
},
},
async (context, req, res) => {
const { version, buildSha, buildNum, buildDate } = config.packageInfo;
const versionWithoutSnapshot = version.replace(SNAPSHOT_POSTFIX, '');
const authRequired = !config.allowAnonymous;
const isAuthenticated = req.auth.isAuthenticated;
const redactedStatus = authRequired && !isAuthenticated;
const [overall, coreOverall, core, plugins] = await firstValueFrom(combinedStatus$);
const { v8format = true, v7format = false } = req.query ?? {};
let statusInfo: StatusInfo | LegacyStatusInfo;
if (!v7format && v8format) {
statusInfo = {
overall,
core,
plugins,
};
} else {
incrementUsageCounter({ counterName: 'status_v7format' });
statusInfo = calculateLegacyStatus({
overall,
core,
plugins,
versionWithoutSnapshot,
});
}
const lastMetrics = await firstValueFrom(metrics.getOpsMetrics$());
const body: StatusHttpBody = {
name: config.serverName,
uuid: config.uuid,
version: {
number: versionWithoutSnapshot,
build_hash: buildSha,
build_number: buildNum,
build_snapshot: SNAPSHOT_POSTFIX.test(version),
build_date: buildDate.toISOString(),
},
status: statusInfo,
metrics: {
last_updated: lastMetrics.collected_at.toISOString(),
collection_interval_in_millis: metrics.collectionInterval,
os: lastMetrics.os,
process: lastMetrics.process,
processes: lastMetrics.processes,
response_times: lastMetrics.response_times,
concurrent_connections: lastMetrics.concurrent_connections,
requests: {
...lastMetrics.requests,
status_codes: lastMetrics.requests.statusCodes,
},
elasticsearch_client: lastMetrics.elasticsearch_client,
},
};
const responseBody = redactedStatus
? getRedactedStatusResponse({ coreOverall })
: await getFullStatusResponse({
incrementUsageCounter,
config,
query: req.query,
metrics,
statuses: { overall, core, plugins },
});
const statusCode = coreOverall.level >= ServiceStatusLevels.unavailable ? 503 : 200;
return res.custom({ body, statusCode, bypassErrorFormat: true });
return res.custom({ body: responseBody, statusCode, bypassErrorFormat: true });
}
);
};
const getFullStatusResponse = async ({
config,
incrementUsageCounter,
metrics,
statuses: { plugins, overall, core },
query: { v7format = false, v8format = true },
}: {
config: Deps['config'];
incrementUsageCounter: CoreIncrementUsageCounter;
metrics: MetricsServiceSetup;
statuses: {
overall: ServiceStatus<unknown>;
core: CoreStatus;
plugins: Record<string, ServiceStatus<unknown>>;
};
query: { v8format?: boolean; v7format?: boolean };
}): Promise<StatusHttpBody> => {
const { version, buildSha, buildNum, buildDate } = config.packageInfo;
const versionWithoutSnapshot = version.replace(SNAPSHOT_POSTFIX, '');
let statusInfo: StatusInfo | LegacyStatusInfo;
if (!v7format && v8format) {
statusInfo = {
overall,
core,
plugins,
};
} else {
incrementUsageCounter({ counterName: 'status_v7format' });
statusInfo = calculateLegacyStatus({
overall,
core,
plugins,
versionWithoutSnapshot,
});
}
const lastMetrics = await firstValueFrom(metrics.getOpsMetrics$());
const body: StatusHttpBody = {
name: config.serverName,
uuid: config.uuid,
version: {
number: versionWithoutSnapshot,
build_hash: buildSha,
build_number: buildNum,
build_snapshot: SNAPSHOT_POSTFIX.test(version),
build_date: buildDate.toISOString(),
},
status: statusInfo,
metrics: {
last_updated: lastMetrics.collected_at.toISOString(),
collection_interval_in_millis: metrics.collectionInterval,
os: lastMetrics.os,
process: lastMetrics.process,
processes: lastMetrics.processes,
response_times: lastMetrics.response_times,
concurrent_connections: lastMetrics.concurrent_connections,
requests: {
...lastMetrics.requests,
status_codes: lastMetrics.requests.statusCodes,
},
elasticsearch_client: lastMetrics.elasticsearch_client,
},
};
return body;
};
const getRedactedStatusResponse = ({
coreOverall,
}: {
coreOverall: ServiceStatus;
}): RedactedStatusHttpBody => {
const body: RedactedStatusHttpBody = {
status: {
overall: {
level: coreOverall.level,
},
},
};
return body;
};

View file

@ -0,0 +1,38 @@
/*
* 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 type { IRouter } from '@kbn/core-http-server';
import { ServiceStatusLevels } from '@kbn/core-status-common';
import type { RedactedStatusHttpBody } from './status';
export const registerPrebootStatusRoute = ({ router }: { router: IRouter }) => {
router.get(
{
path: '/api/status',
options: {
authRequired: false,
tags: ['api'],
},
validate: false,
},
async (context, req, res) => {
const body: RedactedStatusHttpBody = {
status: {
overall: {
level: ServiceStatusLevels.unavailable,
},
},
};
return res.custom({
body,
statusCode: 503,
bypassErrorFormat: true,
});
}
);
};

View file

@ -65,7 +65,32 @@ describe('StatusService', () => {
};
};
describe('setup', () => {
describe('#preboot', () => {
it('registers `status` route', async () => {
const configService = configServiceMock.create();
configService.atPath.mockReturnValue(new BehaviorSubject({ allowAnonymous: true }));
service = new StatusService(mockCoreContext.create({ configService }));
const prebootRouterMock: RouterMock = mockRouter.create();
const httpPreboot = httpServiceMock.createInternalPrebootContract();
httpPreboot.registerRoutes.mockImplementation((path, callback) =>
callback(prebootRouterMock)
);
await service.preboot({ http: httpPreboot });
expect(prebootRouterMock.get).toHaveBeenCalledTimes(1);
expect(prebootRouterMock.get).toHaveBeenCalledWith(
{
path: '/api/status',
options: { authRequired: false, tags: ['api'] },
validate: false,
},
expect.any(Function)
);
});
});
describe('#setup', () => {
describe('core$', () => {
it('rolls up core status observables into single observable', async () => {
const setup = await service.setup(
@ -500,45 +525,6 @@ describe('StatusService', () => {
});
});
describe('preboot status routes', () => {
let prebootRouterMock: RouterMock;
beforeEach(async () => {
prebootRouterMock = mockRouter.create();
});
it('does not register `status` route if anonymous access is not allowed', async () => {
const httpSetup = httpServiceMock.createInternalSetupContract();
httpSetup.registerPrebootRoutes.mockImplementation((path, callback) =>
callback(prebootRouterMock)
);
await service.setup(setupDeps({ http: httpSetup }));
expect(prebootRouterMock.get).not.toHaveBeenCalled();
});
it('registers `status` route if anonymous access is allowed', async () => {
const configService = configServiceMock.create();
configService.atPath.mockReturnValue(new BehaviorSubject({ allowAnonymous: true }));
service = new StatusService(mockCoreContext.create({ configService }));
const httpSetup = httpServiceMock.createInternalSetupContract();
httpSetup.registerPrebootRoutes.mockImplementation((path, callback) =>
callback(prebootRouterMock)
);
await service.setup(setupDeps({ http: httpSetup }));
expect(prebootRouterMock.get).toHaveBeenCalledTimes(1);
expect(prebootRouterMock.get).toHaveBeenCalledWith(
{
path: '/api/status',
options: { authRequired: false, tags: ['api'] },
validate: expect.anything(),
},
expect.any(Function)
);
});
});
describe('analytics', () => {
let analyticsMock: jest.Mocked<AnalyticsServiceSetup>;
let setup: InternalStatusServiceSetup;

View file

@ -24,13 +24,16 @@ import type { CoreContext, CoreService } from '@kbn/core-base-server-internal';
import type { PluginName } from '@kbn/core-base-common';
import type { AnalyticsServiceSetup } from '@kbn/core-analytics-server';
import type { InternalEnvironmentServiceSetup } from '@kbn/core-environment-server-internal';
import type { InternalHttpServiceSetup } from '@kbn/core-http-server-internal';
import type {
InternalHttpServiceSetup,
InternalHttpServicePreboot,
} from '@kbn/core-http-server-internal';
import type { InternalElasticsearchServiceSetup } from '@kbn/core-elasticsearch-server-internal';
import type { InternalMetricsServiceSetup } from '@kbn/core-metrics-server-internal';
import type { InternalSavedObjectsServiceSetup } from '@kbn/core-saved-objects-server-internal';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { ServiceStatus, CoreStatus } from '@kbn/core-status-common';
import { registerStatusRoute } from './routes';
import { registerStatusRoute, registerPrebootStatusRoute } from './routes';
import { statusConfig as config, StatusConfigType } from './status_config';
import type { InternalStatusServiceSetup } from './types';
@ -47,6 +50,10 @@ interface StatusAnalyticsPayload {
overall_status_summary: string;
}
export interface StatusServicePrebootDeps {
http: InternalHttpServicePreboot;
}
export interface StatusServiceSetupDeps {
analytics: AnalyticsServiceSetup;
elasticsearch: Pick<InternalElasticsearchServiceSetup, 'status$'>;
@ -72,6 +79,12 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
this.config$ = coreContext.configService.atPath<StatusConfigType>(config.path);
}
public async preboot({ http }: StatusServicePrebootDeps) {
http.registerRoutes('', (router) => {
registerPrebootStatusRoute({ router });
});
}
public async setup({
analytics,
elasticsearch,
@ -149,19 +162,6 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
...commonRouteDeps,
});
if (commonRouteDeps.config.allowAnonymous) {
http.registerPrebootRoutes('', (prebootRouter) => {
registerStatusRoute({
router: prebootRouter,
...commonRouteDeps,
config: {
...commonRouteDeps.config,
allowAnonymous: true,
},
});
});
}
return {
core$,
coreOverall$,

View file

@ -54,6 +54,7 @@ type StatusServiceContract = PublicMethodsOf<StatusService>;
const createMock = () => {
const mocked: jest.Mocked<StatusServiceContract> = {
preboot: jest.fn(),
setup: jest.fn().mockReturnValue(createInternalSetupContractMock()),
start: jest.fn(),
stop: jest.fn(),

View file

@ -8,7 +8,7 @@
import { esTestConfig } from '@kbn/test';
import * as http from 'http';
import supertest from 'supertest';
import { firstValueFrom, ReplaySubject } from 'rxjs';
import { Root } from '@kbn/core-root-server-internal';
import {
@ -17,6 +17,8 @@ import {
type TestElasticsearchUtils,
type TestKibanaUtils,
} from '@kbn/core-test-helpers-kbn-server';
import { ServiceStatus } from '@kbn/core-status-common';
import { ElasticsearchStatusMeta } from '@kbn/core-elasticsearch-server-internal';
describe('elasticsearch clients', () => {
let esServer: TestElasticsearchUtils;
@ -71,15 +73,17 @@ function createFakeElasticsearchServer() {
describe('fake elasticsearch', () => {
let esServer: http.Server;
let kibanaServer: Root;
let kibanaHttpServer: http.Server;
let esStatus$: ReplaySubject<ServiceStatus<ElasticsearchStatusMeta>>;
beforeAll(async () => {
kibanaServer = createRootWithCorePlugins({ status: { allowAnonymous: true } });
esServer = createFakeElasticsearchServer();
const kibanaPreboot = await kibanaServer.preboot();
kibanaHttpServer = kibanaPreboot.http.server.listener; // Mind that we are using the prebootServer at this point because the migration gets hanging, while waiting for ES to be correct
await kibanaServer.setup();
await kibanaServer.preboot();
const { elasticsearch } = await kibanaServer.setup();
esStatus$ = new ReplaySubject(1);
elasticsearch.status$.subscribe(esStatus$);
// give kibanaServer's status Observables enough time to bootstrap
// and emit a status after the initial "unavailable: Waiting for Elasticsearch"
// see https://github.com/elastic/kibana/issues/129754
@ -94,9 +98,9 @@ describe('fake elasticsearch', () => {
});
test('should return unknown product when it cannot perform the Product check (503 response)', async () => {
const resp = await supertest(kibanaHttpServer).get('/api/status').expect(503);
expect(resp.body.status.overall.level).toBe('critical');
expect(resp.body.status.core.elasticsearch.summary).toBe(
const esStatus = await firstValueFrom(esStatus$);
expect(esStatus.level.toString()).toBe('critical');
expect(esStatus.summary).toBe(
'Unable to retrieve version information from Elasticsearch nodes. The client noticed that the server is not Elasticsearch and we do not support this unknown product.'
);
});

View file

@ -0,0 +1,44 @@
/*
* 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 supertest from 'supertest';
import { createCoreContext, createHttpServer } from '@kbn/core-http-server-mocks';
import type { HttpService, InternalHttpServicePreboot } from '@kbn/core-http-server-internal';
import { contextServiceMock } from '@kbn/core-http-context-server-mocks';
import { registerPrebootStatusRoute } from '@kbn/core-status-server-internal/src/routes';
const coreId = Symbol('core');
describe('GET /api/status', () => {
let server: HttpService;
let httpPreboot: InternalHttpServicePreboot;
const setupServer = async () => {
const coreContext = createCoreContext({ coreId });
server = createHttpServer(coreContext);
httpPreboot = await server.preboot({
context: contextServiceMock.createPrebootContract(),
});
httpPreboot.registerRoutes('', (router) => {
registerPrebootStatusRoute({ router });
});
};
afterEach(async () => {
await server.stop();
});
it('respond with a 503 and with redacted status', async () => {
await setupServer();
const response = await supertest(httpPreboot.server.listener).get('/api/status').expect(503);
expect(response.body).toEqual({ status: { overall: { level: 'unavailable' } } });
});
});

View file

@ -19,7 +19,6 @@ import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from '@kbn/cor
import { statusServiceMock } from '@kbn/core-status-server-mocks';
import { executionContextServiceMock } from '@kbn/core-execution-context-server-mocks';
import { contextServiceMock } from '@kbn/core-http-context-server-mocks';
import { registerStatusRoute } from '@kbn/core-status-server-internal';
const coreId = Symbol('core');
@ -40,7 +39,12 @@ describe('GET /api/status', () => {
const setupServer = async ({
allowAnonymous = true,
coreOverall,
}: { allowAnonymous?: boolean; coreOverall?: ServiceStatus } = {}) => {
overall,
}: {
allowAnonymous?: boolean;
coreOverall?: ServiceStatus;
overall?: ServiceStatus;
} = {}) => {
const coreContext = createCoreContext({ coreId });
const contextService = new ContextService(coreContext);
@ -57,6 +61,9 @@ describe('GET /api/status', () => {
if (coreOverall) {
status.coreOverall$ = new BehaviorSubject(coreOverall);
}
if (overall) {
status.overall$ = new BehaviorSubject(overall);
}
const pluginsStatus$ = new BehaviorSubject<Record<string, ServiceStatus>>({
a: { level: ServiceStatusLevels.available, summary: 'a is available' },
@ -110,284 +117,342 @@ describe('GET /api/status', () => {
});
describe('allowAnonymous: false', () => {
it('rejects requests with no credentials', async () => {
await setupServer({ allowAnonymous: false });
await supertest(httpSetup.server.listener).get('/api/status').expect(401);
describe('http response code', () => {
it('respond with a 200 when core.overall.status is available', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
await supertest(httpSetup.server.listener).get('/api/status').expect(200);
});
it('respond with a 200 when core.overall.status is degraded', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.degraded),
});
await supertest(httpSetup.server.listener).get('/api/status').expect(200);
});
it('respond with a 503 when core.overall.status is unavailable', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.unavailable),
});
await supertest(httpSetup.server.listener).get('/api/status').expect(503);
});
it('respond with a 503 when core.overall.status is critical', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.critical),
});
await supertest(httpSetup.server.listener).get('/api/status').expect(503);
});
it('does not depend on the overall status', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.available),
overall: createServiceStatus(ServiceStatusLevels.critical),
});
await supertest(httpSetup.server.listener).get('/api/status').expect(200);
});
});
it('rejects requests with bad credentials', async () => {
await setupServer({ allowAnonymous: false });
await supertest(httpSetup.server.listener)
.get('/api/status')
.set('Authorization', 'fake creds')
.expect(401);
});
describe('response payload', () => {
it('returns redacted body for requests with no credentials', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
const response = await supertest(httpSetup.server.listener).get('/api/status').expect(200);
expect(response.body).toEqual({ status: { overall: { level: 'available' } } });
});
it('accepts authenticated requests', async () => {
await setupServer({ allowAnonymous: false });
await supertest(httpSetup.server.listener)
.get('/api/status')
.set('Authorization', 'let me in')
.expect(200);
it('returns redacted body for requests with bad credentials', async () => {
await setupServer({
allowAnonymous: false,
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
const response = await supertest(httpSetup.server.listener)
.get('/api/status')
.set('Authorization', 'fake creds')
.expect(200);
expect(response.body).toEqual({ status: { overall: { level: 'available' } } });
});
it('returns full body for authenticated requests', async () => {
await setupServer({ allowAnonymous: false });
const response = await supertest(httpSetup.server.listener)
.get('/api/status')
.set('Authorization', 'let me in')
.expect(200);
expect(response.body).toEqual(
expect.objectContaining({
name: expect.any(String),
status: expect.any(Object),
metrics: expect.any(Object),
})
);
});
});
});
it('returns basic server info & metrics', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200);
expect(result.body.name).toEqual('xkibana');
expect(result.body.uuid).toEqual('xxxx-xxxxx');
expect(result.body.version).toEqual({
number: '9.9.9',
build_hash: 'xsha',
build_number: 1234,
build_snapshot: true,
build_date: new Date('2023-05-15T23:12:09.000Z').toISOString(),
});
const metricsMockValue = await firstValueFrom(metrics.getOpsMetrics$());
expect(result.body.metrics).toEqual({
last_updated: expect.any(String),
collection_interval_in_millis: metrics.collectionInterval,
...omit(metricsMockValue, ['collected_at']),
requests: {
...metricsMockValue.requests,
status_codes: metricsMockValue.requests.statusCodes,
},
});
});
describe('legacy status format', () => {
const legacyFormat = {
overall: {
icon: 'success',
nickname: 'Looking good',
since: expect.any(String),
state: 'green',
title: 'Green',
uiColor: 'success',
},
statuses: [
{
icon: 'success',
id: 'core:elasticsearch@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'success',
},
{
icon: 'success',
id: 'core:savedObjects@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'success',
},
{
icon: 'success',
id: 'plugin:a@9.9.9',
message: 'a is available',
since: expect.any(String),
state: 'green',
uiColor: 'success',
},
{
icon: 'warning',
id: 'plugin:b@9.9.9',
message: 'b is degraded',
since: expect.any(String),
state: 'yellow',
uiColor: 'warning',
},
{
icon: 'danger',
id: 'plugin:c@9.9.9',
message: 'c is unavailable',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
{
icon: 'danger',
id: 'plugin:d@9.9.9',
message: 'd is critical',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
],
};
it('returns legacy status format when v7format=true is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v7format=true')
.expect(200);
expect(result.body.status).toEqual(legacyFormat);
expect(incrementUsageCounter).toHaveBeenCalledTimes(1);
expect(incrementUsageCounter).toHaveBeenCalledWith({ counterName: 'status_v7format' });
});
it('returns legacy status format when v8format=false is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false')
.expect(200);
expect(result.body.status).toEqual(legacyFormat);
expect(incrementUsageCounter).toHaveBeenCalledTimes(1);
expect(incrementUsageCounter).toHaveBeenCalledWith({ counterName: 'status_v7format' });
});
});
describe('v8format', () => {
const newFormat = {
core: {
elasticsearch: {
level: 'available',
summary: 'Service is working',
},
savedObjects: {
level: 'available',
summary: 'Service is working',
},
},
overall: {
level: 'available',
summary: 'Service is working',
},
plugins: {
a: {
level: 'available',
summary: 'a is available',
},
b: {
level: 'degraded',
summary: 'b is degraded',
},
c: {
level: 'unavailable',
summary: 'c is unavailable',
},
d: {
level: 'critical',
summary: 'd is critical',
},
},
};
it('returns new status format when no query params are provided', async () => {
describe('allowAnonymous: true', () => {
it('returns basic server info & metrics', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200);
expect(result.body.status).toEqual(newFormat);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('returns new status format when v8format=true is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true')
.expect(200);
expect(result.body.status).toEqual(newFormat);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('returns new status format when v7format=false is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v7format=false')
.expect(200);
expect(result.body.status).toEqual(newFormat);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
});
describe('invalid query parameters', () => {
it('v8format=true and v7format=true', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true&v7format=true')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('v8format=true and v7format=false', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true&v7format=false')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('v8format=false and v7format=false', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false&v7format=false')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('v8format=false and v7format=true', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false&v7format=true')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
});
describe('status level and http response code', () => {
describe('using standard format', () => {
it('respond with a 200 when core.overall.status is available', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(200);
expect(result.body.name).toEqual('xkibana');
expect(result.body.uuid).toEqual('xxxx-xxxxx');
expect(result.body.version).toEqual({
number: '9.9.9',
build_hash: 'xsha',
build_number: 1234,
build_snapshot: true,
build_date: new Date('2023-05-15T23:12:09.000Z').toISOString(),
});
it('respond with a 200 when core.overall.status is degraded', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.degraded),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(200);
});
it('respond with a 503 when core.overall.status is unavailable', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.unavailable),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(503);
});
it('respond with a 503 when core.overall.status is critical', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.critical),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(503);
const metricsMockValue = await firstValueFrom(metrics.getOpsMetrics$());
expect(result.body.metrics).toEqual({
last_updated: expect.any(String),
collection_interval_in_millis: metrics.collectionInterval,
...omit(metricsMockValue, ['collected_at']),
requests: {
...metricsMockValue.requests,
status_codes: metricsMockValue.requests.statusCodes,
},
});
});
describe('using legacy format', () => {
it('respond with a 200 when core.overall.status is available', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(200);
describe('legacy status format', () => {
const legacyFormat = {
overall: {
icon: 'success',
nickname: 'Looking good',
since: expect.any(String),
state: 'green',
title: 'Green',
uiColor: 'success',
},
statuses: [
{
icon: 'success',
id: 'core:elasticsearch@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'success',
},
{
icon: 'success',
id: 'core:savedObjects@9.9.9',
message: 'Service is working',
since: expect.any(String),
state: 'green',
uiColor: 'success',
},
{
icon: 'success',
id: 'plugin:a@9.9.9',
message: 'a is available',
since: expect.any(String),
state: 'green',
uiColor: 'success',
},
{
icon: 'warning',
id: 'plugin:b@9.9.9',
message: 'b is degraded',
since: expect.any(String),
state: 'yellow',
uiColor: 'warning',
},
{
icon: 'danger',
id: 'plugin:c@9.9.9',
message: 'c is unavailable',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
{
icon: 'danger',
id: 'plugin:d@9.9.9',
message: 'd is critical',
since: expect.any(String),
state: 'red',
uiColor: 'danger',
},
],
};
it('returns legacy status format when v7format=true is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v7format=true')
.expect(200);
expect(result.body.status).toEqual(legacyFormat);
expect(incrementUsageCounter).toHaveBeenCalledTimes(1);
expect(incrementUsageCounter).toHaveBeenCalledWith({ counterName: 'status_v7format' });
});
it('respond with a 200 when core.overall.status is degraded', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.degraded),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(200);
it('returns legacy status format when v8format=false is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false')
.expect(200);
expect(result.body.status).toEqual(legacyFormat);
expect(incrementUsageCounter).toHaveBeenCalledTimes(1);
expect(incrementUsageCounter).toHaveBeenCalledWith({ counterName: 'status_v7format' });
});
it('respond with a 503 when core.overall.status is unavailable', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.unavailable),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(503);
});
describe('v8format', () => {
const newFormat = {
core: {
elasticsearch: {
level: 'available',
summary: 'Service is working',
},
savedObjects: {
level: 'available',
summary: 'Service is working',
},
},
overall: {
level: 'available',
summary: 'Service is working',
},
plugins: {
a: {
level: 'available',
summary: 'a is available',
},
b: {
level: 'degraded',
summary: 'b is degraded',
},
c: {
level: 'unavailable',
summary: 'c is unavailable',
},
d: {
level: 'critical',
summary: 'd is critical',
},
},
};
it('returns new status format when no query params are provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener).get('/api/status').expect(200);
expect(result.body.status).toEqual(newFormat);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('respond with a 503 when core.overall.status is critical', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.critical),
it('returns new status format when v8format=true is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true')
.expect(200);
expect(result.body.status).toEqual(newFormat);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('returns new status format when v7format=false is provided', async () => {
await setupServer();
const result = await supertest(httpSetup.server.listener)
.get('/api/status?v7format=false')
.expect(200);
expect(result.body.status).toEqual(newFormat);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
});
describe('invalid query parameters', () => {
it('v8format=true and v7format=true', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true&v7format=true')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('v8format=true and v7format=false', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=true&v7format=false')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('v8format=false and v7format=false', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false&v7format=false')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
it('v8format=false and v7format=true', async () => {
await setupServer();
await supertest(httpSetup.server.listener)
.get('/api/status?v8format=false&v7format=true')
.expect(400);
expect(incrementUsageCounter).not.toHaveBeenCalled();
});
});
describe('status level and http response code', () => {
describe('using standard format', () => {
it('respond with a 200 when core.overall.status is available', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(200);
});
it('respond with a 200 when core.overall.status is degraded', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.degraded),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(200);
});
it('respond with a 503 when core.overall.status is unavailable', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.unavailable),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(503);
});
it('respond with a 503 when core.overall.status is critical', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.critical),
});
await supertest(httpSetup.server.listener).get('/api/status?v8format=true').expect(503);
});
});
describe('using legacy format', () => {
it('respond with a 200 when core.overall.status is available', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.available),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(200);
});
it('respond with a 200 when core.overall.status is degraded', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.degraded),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(200);
});
it('respond with a 503 when core.overall.status is unavailable', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.unavailable),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(503);
});
it('respond with a 503 when core.overall.status is critical', async () => {
await setupServer({
coreOverall: createServiceStatus(ServiceStatusLevels.critical),
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(503);
});
await supertest(httpSetup.server.listener).get('/api/status?v7format=true').expect(503);
});
});
});

View file

@ -0,0 +1,24 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrConfigProviderContext } from '@kbn/test';
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const baseIntegrationTestsConfig = await readConfigFile(require.resolve('../../config.ts'));
return {
...baseIntegrationTestsConfig.getAll(),
kbnTestServer: {
...baseIntegrationTestsConfig.get('kbnTestServer'),
serverArgs: [
...baseIntegrationTestsConfig.get('kbnTestServer.serverArgs'),
'--status.allowAnonymous=false',
],
},
testFiles: [require.resolve('.')],
};
}

View file

@ -0,0 +1,14 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('Status API', () => {
loadTestFile(require.resolve('./status'));
});
}

View file

@ -0,0 +1,41 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('GET /api/status', () => {
describe('When status.allowAnonymous is false', () => {
it('returns full status payload for authenticated requests', async () => {
const { body } = await supertest.get('/api/status').set('kbn-xsrf', 'kibana');
expect(body.name).to.be.a('string');
expect(body.uuid).to.be.a('string');
expect(body.version.number).to.be.a('string');
expect(body.status.overall).to.be.an('object');
expect(body.status.core).to.be.an('object');
expect(body.status.plugins).to.be.an('object');
});
it('returns redacted payload for unauthenticated requests', async () => {
const { body } = await supertestWithoutAuth.get('/api/status').set('kbn-xsrf', 'kibana');
expect(Object.keys(body)).to.eql(['status']);
expect(body.status).to.be.an('object');
expect(Object.keys(body.status)).to.eql(['overall']);
expect(body.status.overall).to.be.an('object');
expect(Object.keys(body.status.overall)).to.eql(['level']);
expect(body.status.overall.level).to.be.a('string');
});
});
});
}