Merge pull request #24817 from backstage/healthcheck-router

Healthcheck service
This commit is contained in:
Fredrik Adelöw
2024-06-24 13:39:52 +02:00
committed by GitHub
19 changed files with 369 additions and 1 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/backend-plugin-api': patch
'@backstage/backend-defaults': patch
---
Added a new Root Health Service which adds new endpoints for health checks.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Added mock for the Root Health Service in `mockServices`.
@@ -28,6 +28,7 @@ import { coreServices } from '@backstage/backend-plugin-api';
- [Permissions Service](./permissions.md) - Permission system integration for authorization of user actions.
- [Plugin Metadata Service](./plugin-metadata.md) - Built-in service for accessing metadata about the current plugin.
- [Root Config Service](./root-config.md) - Access to static configuration.
- [Root Health Service](./root-health.md) - Health check endpoints for the backend.
- [Root Http Router Service](./root-http-router.md) - HTTP route registration for root services.
- [Root Lifecycle Service](./root-lifecycle.md) - Registration of backend startup and shutdown lifecycle hooks.
- [Root Logger Service](./root-logger.md) - Root-level logging.
@@ -0,0 +1,40 @@
---
id: root-health
title: Root Health Service
sidebar_label: Health
description: Documentation for the Health service
---
The Root Health service provides some health check endpoints for the backend. By default, the `rootHttpRouter` exposes a `/.backstage/health/v1/readiness` and `/.backstage/health/v1/liveness` endpoints, which return a JSON object with the status of the backend services according the implementation of the Root Health Service.
## Configuring the service
The following example shows how you can override the root health service implementation.
```ts
import { RootHealthService, coreServices } from '@backstage/backend-plugin-api';
const backend = createBackend();
class MyRootHealthService implements RootHealthService {
async getLiveness() {
// provide your own implementation
return { status: 200, payload: { status: 'ok' } };
}
async getReadiness() {
// provide your own implementation
return { status: 200, payload: { status: 'ok' } };
}
}
backend.add(
createServiceFactory({
service: coreServices.rootHealth,
deps: {},
async factory({}) {
return new MyRootHealthService();
},
}),
);
```
@@ -0,0 +1,16 @@
## API Report File for "@backstage/backend-defaults"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { RootHealthService } from '@backstage/backend-plugin-api';
import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
export const rootHealthServiceFactory: () => ServiceFactory<
RootHealthService,
'root'
>;
// (No @packageDocumentation comment for this package)
```
@@ -121,6 +121,8 @@ export interface RootHttpRouterConfigureContext {
// (undocumented)
config: RootConfigService;
// (undocumented)
healthRouter: RequestHandler;
// (undocumented)
lifecycle: LifecycleService;
// (undocumented)
logger: LoggerService;
+4
View File
@@ -30,6 +30,7 @@
"./logger": "./src/entrypoints/logger/index.ts",
"./permissions": "./src/entrypoints/permissions/index.ts",
"./rootConfig": "./src/entrypoints/rootConfig/index.ts",
"./rootHealth": "./src/entrypoints/rootHealth/index.ts",
"./rootHttpRouter": "./src/entrypoints/rootHttpRouter/index.ts",
"./rootLifecycle": "./src/entrypoints/rootLifecycle/index.ts",
"./rootLogger": "./src/entrypoints/rootLogger/index.ts",
@@ -72,6 +73,9 @@
"rootConfig": [
"src/entrypoints/rootConfig/index.ts"
],
"rootHealth": [
"src/entrypoints/rootHealth/index.ts"
],
"rootHttpRouter": [
"src/entrypoints/rootHttpRouter/index.ts"
],
@@ -30,6 +30,7 @@ import { lifecycleServiceFactory } from '@backstage/backend-defaults/lifecycle';
import { loggerServiceFactory } from '@backstage/backend-defaults/logger';
import { permissionsServiceFactory } from '@backstage/backend-defaults/permissions';
import { rootConfigServiceFactory } from '@backstage/backend-defaults/rootConfig';
import { rootHealthServiceFactory } from '@backstage/backend-defaults/rootHealth';
import { rootHttpRouterServiceFactory } from '@backstage/backend-defaults/rootHttpRouter';
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
import { rootLoggerServiceFactory } from '@backstage/backend-defaults/rootLogger';
@@ -50,6 +51,7 @@ export const defaultServiceFactories = [
lifecycleServiceFactory(),
loggerServiceFactory(),
permissionsServiceFactory(),
rootHealthServiceFactory(),
rootHttpRouterServiceFactory(),
rootLifecycleServiceFactory(),
rootLoggerServiceFactory(),
@@ -0,0 +1,17 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { rootHealthServiceFactory } from './rootHealthServiceFactory';
@@ -0,0 +1,91 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockServices } from '@backstage/backend-test-utils';
import { DefaultRootHealthService } from './rootHealthServiceFactory';
describe('DefaultRootHealthService', () => {
describe('readiness', () => {
it(`should return a 500 response if the server hasn't started yet`, async () => {
const service = new DefaultRootHealthService({
lifecycle: mockServices.rootLifecycle.mock(),
});
await expect(service.getReadiness()).resolves.toEqual({
status: 503,
payload: {
message: 'Backend has not started yet',
status: 'error',
},
});
});
it('should return 200 if the server has started', async () => {
let mockServerStartedFn = () => {};
const lifecycle = mockServices.rootLifecycle.mock({
addStartupHook: jest.fn(fn => (mockServerStartedFn = fn)),
});
const service = new DefaultRootHealthService({
lifecycle,
});
mockServerStartedFn();
await expect(service.getReadiness()).resolves.toEqual({
status: 200,
payload: { status: 'ok' },
});
});
it(`should return a 500 response if the server has stopped`, async () => {
let mockServerStartedFn = () => {};
let mockServerStoppedFn = () => {};
const lifecycle = mockServices.rootLifecycle.mock({
addStartupHook: jest.fn(fn => (mockServerStartedFn = fn)),
addShutdownHook: jest.fn(fn => (mockServerStoppedFn = fn)),
});
const service = new DefaultRootHealthService({
lifecycle,
});
mockServerStartedFn();
mockServerStoppedFn();
await expect(service.getReadiness()).resolves.toEqual({
status: 503,
payload: {
message: 'Backend has not started yet',
status: 'error',
},
});
});
});
describe('liveness', () => {
it('should return 200 if the server has started', async () => {
const service = new DefaultRootHealthService({
lifecycle: mockServices.rootLifecycle.mock(),
});
await expect(service.getLiveness()).resolves.toEqual({
status: 200,
payload: { status: 'ok' },
});
});
});
});
@@ -0,0 +1,64 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RootHealthService,
RootLifecycleService,
coreServices,
createServiceFactory,
} from '@backstage/backend-plugin-api';
/** @internal */
export class DefaultRootHealthService implements RootHealthService {
#isRunning = false;
constructor(readonly options: { lifecycle: RootLifecycleService }) {
options.lifecycle.addStartupHook(() => {
this.#isRunning = true;
});
options.lifecycle.addShutdownHook(() => {
this.#isRunning = false;
});
}
async getLiveness(): Promise<{ status: number; payload?: any }> {
return { status: 200, payload: { status: 'ok' } };
}
async getReadiness(): Promise<{ status: number; payload?: any }> {
if (!this.#isRunning) {
return {
status: 503,
payload: { message: 'Backend has not started yet', status: 'error' },
};
}
return { status: 200, payload: { status: 'ok' } };
}
}
/**
* @public
*/
export const rootHealthServiceFactory = createServiceFactory({
service: coreServices.rootHealth,
deps: {
lifecycle: coreServices.rootLifecycle,
},
async factory({ lifecycle }) {
return new DefaultRootHealthService({ lifecycle });
},
});
@@ -0,0 +1,42 @@
import { RootHealthService } from '@backstage/backend-plugin-api';
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Router from 'express-promise-router';
import { Request, Response } from 'express';
export function createHealthRouter(options: { health: RootHealthService }) {
const router = Router();
router.get(
'.backstage/health/v1/readiness',
async (_request: Request, response: Response) => {
const { status, payload } = await options.health.getReadiness();
response.status(status).json(payload);
},
);
router.get(
'.backstage/health/v1/liveness',
async (_request: Request, response: Response) => {
const { status, payload } = await options.health.getLiveness();
response.status(status).json(payload);
},
);
return router;
}
@@ -29,6 +29,7 @@ import {
readHttpServerOptions,
} from './http';
import { DefaultRootHttpRouter } from './DefaultRootHttpRouter';
import { createHealthRouter } from './createHealthRouter';
/**
* @public
@@ -41,6 +42,7 @@ export interface RootHttpRouterConfigureContext {
config: RootConfigService;
logger: LoggerService;
lifecycle: LifecycleService;
healthRouter: RequestHandler;
applyDefaults: () => void;
}
@@ -75,8 +77,9 @@ export const rootHttpRouterServiceFactory = createServiceFactory(
config: coreServices.rootConfig,
rootLogger: coreServices.rootLogger,
lifecycle: coreServices.rootLifecycle,
health: coreServices.rootHealth,
},
async factory({ config, rootLogger, lifecycle }) {
async factory({ config, rootLogger, lifecycle, health }) {
const { indexPath, configure = defaultConfigure } = options ?? {};
const logger = rootLogger.child({ service: 'rootHttpRouter' });
const app = express();
@@ -84,6 +87,8 @@ export const rootHttpRouterServiceFactory = createServiceFactory(
const router = DefaultRootHttpRouter.create({ indexPath });
const middleware = MiddlewareFactory.create({ config, logger });
const routes = router.handler();
const healthRouter = createHealthRouter({ health });
const server = await createHttpServer(
app,
readHttpServerOptions(config.getOptionalConfig('backend')),
@@ -98,11 +103,13 @@ export const rootHttpRouterServiceFactory = createServiceFactory(
config,
logger,
lifecycle,
healthRouter,
applyDefaults() {
app.use(middleware.helmet());
app.use(middleware.cors());
app.use(middleware.compression());
app.use(middleware.logging());
app.use(healthRouter);
app.use(routes);
app.use(middleware.notFound());
app.use(middleware.error());
+13
View File
@@ -194,6 +194,7 @@ export namespace coreServices {
const rootConfig: ServiceRef<RootConfigService, 'root'>;
const database: ServiceRef<DatabaseService, 'plugin'>;
const discovery: ServiceRef<DiscoveryService, 'plugin'>;
const rootHealth: ServiceRef<RootHealthService, 'root'>;
const httpAuth: ServiceRef<HttpAuthService, 'plugin'>;
const httpRouter: ServiceRef<HttpRouterService, 'plugin'>;
const lifecycle: ServiceRef<LifecycleService, 'plugin'>;
@@ -500,6 +501,18 @@ export function resolveSafeChildPath(base: string, path: string): string;
// @public
export interface RootConfigService extends Config {}
// @public (undocumented)
export interface RootHealthService {
getLiveness(): Promise<{
status: number;
payload?: JsonValue;
}>;
getReadiness(): Promise<{
status: number;
payload?: JsonValue;
}>;
}
// @public
export interface RootHttpRouterService {
use(path: string, handler: Handler): void;
@@ -0,0 +1,31 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JsonValue } from '@backstage/types';
/**
* @public
*/
export interface RootHealthService {
/**
* Get the liveness status of the backend.
*/
getLiveness(): Promise<{ status: number; payload?: JsonValue }>;
/**
* Get the readiness status of the backend.
*/
getReadiness(): Promise<{ status: number; payload?: JsonValue }>;
}
@@ -102,6 +102,13 @@ export namespace coreServices {
import('./DiscoveryService').DiscoveryService
>({ id: 'core.discovery' });
/**
* The service reference for the plugin scoped {@link RootHealthService}.
*/
export const rootHealth = createServiceRef<
import('./RootHealthService').RootHealthService
>({ id: 'core.rootHealth', scope: 'root' });
/**
* Authentication of HTTP requests.
*
@@ -32,6 +32,7 @@ export type {
export type { RootConfigService } from './RootConfigService';
export type { DatabaseService } from './DatabaseService';
export type { DiscoveryService } from './DiscoveryService';
export type { RootHealthService } from './RootHealthService';
export type {
HttpRouterService,
HttpRouterServiceAuthPolicy,
+10
View File
@@ -32,6 +32,7 @@ import { LifecycleService } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { PermissionsService } from '@backstage/backend-plugin-api';
import { RootConfigService } from '@backstage/backend-plugin-api';
import { RootHealthService } from '@backstage/backend-plugin-api';
import { RootHttpRouterFactoryOptions } from '@backstage/backend-defaults/rootHttpRouter';
import { RootHttpRouterService } from '@backstage/backend-plugin-api';
import { RootLifecycleService } from '@backstage/backend-plugin-api';
@@ -280,6 +281,15 @@ export namespace mockServices {
) => ServiceFactory<RootConfigService, 'root'>;
}
// (undocumented)
export namespace rootHealth {
const // (undocumented)
factory: () => ServiceFactory<RootHealthService, 'root'>;
const // (undocumented)
mock: (
partialImpl?: Partial<RootHealthService> | undefined,
) => ServiceMock<RootHealthService>;
}
// (undocumented)
export namespace rootHttpRouter {
const // (undocumented)
factory: (
@@ -24,6 +24,7 @@ import { httpRouterServiceFactory } from '@backstage/backend-defaults/httpRouter
import { lifecycleServiceFactory } from '@backstage/backend-defaults/lifecycle';
import { loggerServiceFactory } from '@backstage/backend-defaults/logger';
import { permissionsServiceFactory } from '@backstage/backend-defaults/permissions';
import { rootHealthServiceFactory } from '@backstage/backend-defaults/rootHealth';
import { rootHttpRouterServiceFactory } from '@backstage/backend-defaults/rootHttpRouter';
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler';
@@ -344,6 +345,14 @@ export namespace mockServices {
}));
}
export namespace rootHealth {
export const factory = rootHealthServiceFactory;
export const mock = simpleMock(coreServices.rootHealth, () => ({
getLiveness: jest.fn(),
getReadiness: jest.fn(),
}));
}
export namespace httpRouter {
export const factory = httpRouterServiceFactory;
export const mock = simpleMock(coreServices.httpRouter, () => ({