Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 75 additions & 3 deletions src/core/server/status/routes/integration_tests/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,29 @@ import { MetricsServiceSetup } from '../../../metrics';
import { HttpService, InternalHttpServiceSetup } from '../../../http';

import { registerStatusRoute } from '../status';
import { ServiceStatus, ServiceStatusLevels } from '../../types';
import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from '../../types';
import { statusServiceMock } from '../../status_service.mock';
import { executionContextServiceMock } from '../../../execution_context/execution_context_service.mock';
import { contextServiceMock } from '../../../context/context_service.mock';

const coreId = Symbol('core');

const createServiceStatus = (
level: ServiceStatusLevel = ServiceStatusLevels.available
): ServiceStatus => ({
level,
summary: 'status summary',
});

describe('GET /api/status', () => {
let server: HttpService;
let httpSetup: InternalHttpServiceSetup;
let metrics: jest.Mocked<MetricsServiceSetup>;

const setupServer = async ({ allowAnonymous = true }: { allowAnonymous?: boolean } = {}) => {
const setupServer = async ({
allowAnonymous = true,
coreOverall,
}: { allowAnonymous?: boolean; coreOverall?: ServiceStatus } = {}) => {
const coreContext = createCoreContext({ coreId });
const contextService = new ContextService(coreContext);

Expand All @@ -42,7 +52,12 @@ describe('GET /api/status', () => {
});

metrics = metricsServiceMock.createSetupContract();
const status = statusServiceMock.createSetupContract();

const status = statusServiceMock.createInternalSetupContract();
if (coreOverall) {
status.coreOverall$ = new BehaviorSubject(coreOverall);
}

const pluginsStatus$ = new BehaviorSubject<Record<string, ServiceStatus>>({
a: { level: ServiceStatusLevels.available, summary: 'a is available' },
b: { level: ServiceStatusLevels.degraded, summary: 'b is degraded' },
Expand All @@ -68,6 +83,7 @@ describe('GET /api/status', () => {
metrics,
status: {
overall$: status.overall$,
coreOverall$: status.coreOverall$,
core$: status.core$,
plugins$: pluginsStatus$,
},
Expand Down Expand Up @@ -312,4 +328,60 @@ describe('GET /api/status', () => {
});
});
});

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').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').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').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').expect(503);
});
});
});
});
11 changes: 7 additions & 4 deletions src/core/server/status/routes/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface Deps {
};
metrics: MetricsServiceSetup;
status: {
coreOverall$: Observable<ServiceStatus>;
overall$: Observable<ServiceStatus>;
core$: Observable<CoreStatus>;
plugins$: Observable<Record<PluginName, ServiceStatus>>;
Expand All @@ -51,9 +52,11 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) =
// Since the status.plugins$ observable is not subscribed to elsewhere, we need to subscribe it here to eagerly load
// the plugins status when Kibana starts up so this endpoint responds quickly on first boot.
const combinedStatus$ = new ReplaySubject<
[ServiceStatus<unknown>, CoreStatus, Record<string, ServiceStatus<unknown>>]
[ServiceStatus<unknown>, ServiceStatus, CoreStatus, Record<string, ServiceStatus<unknown>>]
>(1);
combineLatest([status.overall$, status.core$, status.plugins$]).subscribe(combinedStatus$);
combineLatest([status.overall$, status.coreOverall$, status.core$, status.plugins$]).subscribe(
combinedStatus$
);

router.get(
{
Expand All @@ -71,7 +74,7 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) =
async (context, req, res) => {
const { version, buildSha, buildNum } = config.packageInfo;
const versionWithoutSnapshot = version.replace(SNAPSHOT_POSTFIX, '');
const [overall, core, plugins] = await combinedStatus$.pipe(first()).toPromise();
const [overall, coreOverall, core, plugins] = await combinedStatus$.pipe(first()).toPromise();

let statusInfo: StatusInfo | LegacyStatusInfo;
if (req.query?.v8format) {
Expand Down Expand Up @@ -116,7 +119,7 @@ export const registerStatusRoute = ({ router, config, metrics, status }: Deps) =
},
};

const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200;
const statusCode = coreOverall.level >= ServiceStatusLevels.unavailable ? 503 : 200;
return res.custom({ body, statusCode, bypassErrorFormat: true });
}
);
Expand Down
1 change: 1 addition & 0 deletions src/core/server/status/status_service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const createSetupContractMock = () => {
const createInternalSetupContractMock = () => {
const setupContract: jest.Mocked<InternalStatusServiceSetup> = {
core$: new BehaviorSubject(availableCoreStatus),
coreOverall$: new BehaviorSubject(available),
overall$: new BehaviorSubject(available),
isStatusPageAnonymous: jest.fn().mockReturnValue(false),
plugins: {
Expand Down
176 changes: 176 additions & 0 deletions src/core/server/status/status_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('StatusService', () => {
});

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const available: ServiceStatus<any> = {
level: ServiceStatusLevels.available,
summary: 'Available',
Expand All @@ -37,6 +38,10 @@ describe('StatusService', () => {
level: ServiceStatusLevels.degraded,
summary: 'This is degraded!',
};
const critical: ServiceStatus<any> = {
level: ServiceStatusLevels.critical,
summary: 'This is critical!',
};

type SetupDeps = Parameters<StatusService['setup']>[0];
const setupDeps = (overrides: Partial<SetupDeps>): SetupDeps => {
Expand Down Expand Up @@ -319,6 +324,177 @@ describe('StatusService', () => {
});
});

describe('coreOverall$', () => {
it('exposes an overall summary of core services', async () => {
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(degraded),
},
})
);
expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
});
});

it('computes the summary depending on the services status', async () => {
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(critical),
},
})
);
expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({
level: ServiceStatusLevels.critical,
summary: '[savedObjects]: This is critical!',
});
});

it('replays last event', async () => {
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: of(degraded),
},
savedObjects: {
status$: of(degraded),
},
})
);

const subResult1 = await setup.coreOverall$.pipe(first()).toPromise();
const subResult2 = await setup.coreOverall$.pipe(first()).toPromise();
const subResult3 = await setup.coreOverall$.pipe(first()).toPromise();

expect(subResult1).toMatchObject({
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
});
expect(subResult2).toMatchObject({
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
});
expect(subResult3).toMatchObject({
level: ServiceStatusLevels.degraded,
summary: '[2] services are degraded',
});
});

it('does not emit duplicate events', async () => {
const elasticsearch$ = new BehaviorSubject(available);
const savedObjects$ = new BehaviorSubject(degraded);
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: elasticsearch$,
},
savedObjects: {
status$: savedObjects$,
},
})
);

const statusUpdates: ServiceStatus[] = [];
const subscription = setup.coreOverall$.subscribe((status) => statusUpdates.push(status));

// Wait for timers to ensure that duplicate events are still filtered out regardless of debouncing.
elasticsearch$.next(available);
await delay(500);
elasticsearch$.next(available);
await delay(500);
elasticsearch$.next({
level: ServiceStatusLevels.available,
summary: `Wow another summary`,
});
await delay(500);
savedObjects$.next(degraded);
await delay(500);
savedObjects$.next(available);
await delay(500);
savedObjects$.next(available);
await delay(500);
subscription.unsubscribe();

expect(statusUpdates).toMatchInlineSnapshot(`
Array [
Object {
"detail": "See the status page for more information",
"level": degraded,
"meta": Object {
"affectedServices": Array [
"savedObjects",
],
},
"summary": "[savedObjects]: This is degraded!",
},
Object {
"level": available,
"summary": "All services are available",
},
]
`);
});

it('debounces events in quick succession', async () => {
const savedObjects$ = new BehaviorSubject(available);
const setup = await service.setup(
setupDeps({
elasticsearch: {
status$: new BehaviorSubject(available),
},
savedObjects: {
status$: savedObjects$,
},
})
);

const statusUpdates: ServiceStatus[] = [];
const subscription = setup.coreOverall$.subscribe((status) => statusUpdates.push(status));

// All of these should debounced into a single `available` status
savedObjects$.next(degraded);
savedObjects$.next(available);
savedObjects$.next(degraded);
savedObjects$.next(available);
savedObjects$.next(degraded);
savedObjects$.next(available);
savedObjects$.next(degraded);
// Waiting for the debounce timeout should cut a new update
await delay(500);
savedObjects$.next(available);
await delay(500);
subscription.unsubscribe();

expect(statusUpdates).toMatchInlineSnapshot(`
Array [
Object {
"detail": "See the status page for more information",
"level": degraded,
"meta": Object {
"affectedServices": Array [
"savedObjects",
],
},
"summary": "[savedObjects]: This is degraded!",
},
Object {
"level": available,
"summary": "All services are available",
},
]
`);
});
});

describe('preboot status routes', () => {
let prebootRouterMock: RouterMock;
beforeEach(async () => {
Expand Down
Loading