add coreOverall$ to internal status contract (#113729)

* add coreOverall$ to internal status contract

* add unit tests

* re-patch flaky tests

* add and improve tests
This commit is contained in:
Pierre Gayvallet 2021-10-06 13:49:46 +02:00 committed by GitHub
parent dc07c06fa7
commit 0e406d167e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 294 additions and 14 deletions

View file

@ -18,20 +18,30 @@ 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>;
let incrementUsageCounter: jest.Mock;
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);
@ -43,7 +53,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' },
@ -71,6 +86,7 @@ describe('GET /api/status', () => {
metrics,
status: {
overall$: status.overall$,
coreOverall$: status.coreOverall$,
core$: status.core$,
plugins$: pluginsStatus$,
},
@ -318,4 +334,60 @@ describe('GET /api/status', () => {
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);
});
});
});
});

View file

@ -31,6 +31,7 @@ interface Deps {
};
metrics: MetricsServiceSetup;
status: {
coreOverall$: Observable<ServiceStatus>;
overall$: Observable<ServiceStatus>;
core$: Observable<CoreStatus>;
plugins$: Observable<Record<PluginName, ServiceStatus>>;
@ -59,9 +60,11 @@ export const registerStatusRoute = ({
// 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(
{
@ -89,7 +92,7 @@ export const registerStatusRoute = ({
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();
const { v8format = true, v7format = false } = req.query ?? {};
@ -137,7 +140,7 @@ export const registerStatusRoute = ({
},
};
const statusCode = overall.level >= ServiceStatusLevels.unavailable ? 503 : 200;
const statusCode = coreOverall.level >= ServiceStatusLevels.unavailable ? 503 : 200;
return res.custom({ body, statusCode, bypassErrorFormat: true });
}
);

View file

@ -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: {

View file

@ -30,6 +30,7 @@ describe('StatusService', () => {
});
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const available: ServiceStatus<any> = {
level: ServiceStatusLevels.available,
summary: 'Available',
@ -38,6 +39,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 => {
@ -321,6 +326,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 () => {

View file

@ -49,7 +49,7 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
private overall$?: Observable<ServiceStatus>;
private pluginsStatus?: PluginsStatusService;
private overallSubscription?: Subscription;
private subscriptions: Subscription[] = [];
constructor(private readonly coreContext: CoreContext) {
this.logger = coreContext.logger.get('status');
@ -88,8 +88,24 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
shareReplay(1)
);
// Create an unused subscription to ensure all underlying lazy observables are started.
this.overallSubscription = this.overall$.subscribe();
const coreOverall$ = core$.pipe(
// Prevent many emissions at once from dependency status resolution from making this too noisy
debounceTime(25),
map((coreStatus) => {
const coreOverall = getSummaryStatus([...Object.entries(coreStatus)]);
this.logger.debug<StatusLogMeta>(`Recalculated core overall status`, {
kibana: {
status: coreOverall,
},
});
return coreOverall;
}),
distinctUntilChanged(isDeepStrictEqual),
shareReplay(1)
);
// Create unused subscriptions to ensure all underlying lazy observables are started.
this.subscriptions.push(this.overall$.subscribe(), coreOverall$.subscribe());
const commonRouteDeps = {
config: {
@ -103,6 +119,7 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
overall$: this.overall$,
plugins$: this.pluginsStatus.getAll$(),
core$,
coreOverall$,
},
incrementUsageCounter: coreUsageData.incrementUsageCounter,
};
@ -128,6 +145,7 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
return {
core$,
coreOverall$,
overall$: this.overall$,
plugins: {
set: this.pluginsStatus.set.bind(this.pluginsStatus),
@ -153,10 +171,10 @@ export class StatusService implements CoreService<InternalStatusServiceSetup> {
this.stop$.next();
this.stop$.complete();
if (this.overallSubscription) {
this.overallSubscription.unsubscribe();
this.overallSubscription = undefined;
}
this.subscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
this.subscriptions = [];
}
private setupCoreStatus({

View file

@ -232,6 +232,11 @@ export interface StatusServiceSetup {
/** @internal */
export interface InternalStatusServiceSetup
extends Pick<StatusServiceSetup, 'core$' | 'overall$' | 'isStatusPageAnonymous'> {
/**
* Overall status of core's service.
*/
coreOverall$: Observable<ServiceStatus>;
// Namespaced under `plugins` key to improve clarity that these are APIs for plugins specifically.
plugins: {
set(plugin: PluginName, status$: Observable<ServiceStatus>): void;

View file

@ -23,6 +23,9 @@ export default function ({ getService }: FtrProviderContext) {
return resp.body.status.plugins[pluginName];
};
// max debounce of the status observable + 1
const statusPropagation = () => new Promise((resolve) => setTimeout(resolve, 501));
const setStatus = async <T extends keyof typeof ServiceStatusLevels>(level: T) =>
supertest
.post(`/internal/status_plugin_a/status/set?level=${level}`)
@ -53,6 +56,7 @@ export default function ({ getService }: FtrProviderContext) {
5_000,
async () => (await getStatus('statusPluginA')).level === 'degraded'
);
await statusPropagation();
expect((await getStatus('statusPluginA')).level).to.eql('degraded');
expect((await getStatus('statusPluginB')).level).to.eql('degraded');
@ -62,6 +66,7 @@ export default function ({ getService }: FtrProviderContext) {
5_000,
async () => (await getStatus('statusPluginA')).level === 'available'
);
await statusPropagation();
expect((await getStatus('statusPluginA')).level).to.eql('available');
expect((await getStatus('statusPluginB')).level).to.eql('available');
});