diff --git a/.changeset/bright-panthers-leave.md b/.changeset/bright-panthers-leave.md new file mode 100644 index 0000000000..35d9c0f869 --- /dev/null +++ b/.changeset/bright-panthers-leave.md @@ -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. diff --git a/.changeset/serious-kings-trade.md b/.changeset/serious-kings-trade.md new file mode 100644 index 0000000000..826392bf98 --- /dev/null +++ b/.changeset/serious-kings-trade.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Added mock for the Root Health Service in `mockServices`. diff --git a/docs/backend-system/core-services/01-index.md b/docs/backend-system/core-services/01-index.md index 04d2e57ae8..3be12b2e26 100644 --- a/docs/backend-system/core-services/01-index.md +++ b/docs/backend-system/core-services/01-index.md @@ -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. diff --git a/docs/backend-system/core-services/root-health.md b/docs/backend-system/core-services/root-health.md new file mode 100644 index 0000000000..fc73ba9546 --- /dev/null +++ b/docs/backend-system/core-services/root-health.md @@ -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(); + }, + }), +); +``` diff --git a/packages/backend-defaults/api-report-rootHealth.md b/packages/backend-defaults/api-report-rootHealth.md new file mode 100644 index 0000000000..ffa067605a --- /dev/null +++ b/packages/backend-defaults/api-report-rootHealth.md @@ -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) +``` diff --git a/packages/backend-defaults/api-report-rootHttpRouter.md b/packages/backend-defaults/api-report-rootHttpRouter.md index b753406202..d9727fd36d 100644 --- a/packages/backend-defaults/api-report-rootHttpRouter.md +++ b/packages/backend-defaults/api-report-rootHttpRouter.md @@ -121,6 +121,8 @@ export interface RootHttpRouterConfigureContext { // (undocumented) config: RootConfigService; // (undocumented) + healthRouter: RequestHandler; + // (undocumented) lifecycle: LifecycleService; // (undocumented) logger: LoggerService; diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 4c34c7f317..2d84bae308 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -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" ], diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 9b3fa5d649..e3195748a6 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.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(), diff --git a/packages/backend-defaults/src/entrypoints/rootHealth/index.ts b/packages/backend-defaults/src/entrypoints/rootHealth/index.ts new file mode 100644 index 0000000000..35225b39c4 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHealth/index.ts @@ -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'; diff --git a/packages/backend-defaults/src/entrypoints/rootHealth/rootHealthServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHealth/rootHealthServiceFactory.test.ts new file mode 100644 index 0000000000..0eee5c18c6 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHealth/rootHealthServiceFactory.test.ts @@ -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' }, + }); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/rootHealth/rootHealthServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHealth/rootHealthServiceFactory.ts new file mode 100644 index 0000000000..c8b4ad5394 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHealth/rootHealthServiceFactory.ts @@ -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 }); + }, +}); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts new file mode 100644 index 0000000000..13691ede28 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts @@ -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; +} diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts index ea3dc42eb7..22f5c2f310 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts @@ -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()); diff --git a/packages/backend-plugin-api/api-report.md b/packages/backend-plugin-api/api-report.md index 258cdefe1b..e9762f652e 100644 --- a/packages/backend-plugin-api/api-report.md +++ b/packages/backend-plugin-api/api-report.md @@ -194,6 +194,7 @@ export namespace coreServices { const rootConfig: ServiceRef; const database: ServiceRef; const discovery: ServiceRef; + const rootHealth: ServiceRef; const httpAuth: ServiceRef; const httpRouter: ServiceRef; const lifecycle: ServiceRef; @@ -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; diff --git a/packages/backend-plugin-api/src/services/definitions/RootHealthService.ts b/packages/backend-plugin-api/src/services/definitions/RootHealthService.ts new file mode 100644 index 0000000000..e6c56e8654 --- /dev/null +++ b/packages/backend-plugin-api/src/services/definitions/RootHealthService.ts @@ -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 }>; +} diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index b0518fb9d4..33d1da2903 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -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. * diff --git a/packages/backend-plugin-api/src/services/definitions/index.ts b/packages/backend-plugin-api/src/services/definitions/index.ts index 6f30c5f980..5e30d444fd 100644 --- a/packages/backend-plugin-api/src/services/definitions/index.ts +++ b/packages/backend-plugin-api/src/services/definitions/index.ts @@ -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, diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 019d398ea6..a2fb4b7674 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -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; } // (undocumented) + export namespace rootHealth { + const // (undocumented) + factory: () => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) export namespace rootHttpRouter { const // (undocumented) factory: ( diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 24571b634c..d56179d958 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -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, () => ({