From fd5d337df4622b0c70026de5effcf1c99131b7f0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Dec 2024 09:35:34 +0100 Subject: [PATCH] backend-defaults: add backend.health.headers config Signed-off-by: Patrik Oldsberg --- .changeset/chilly-games-trade.md | 7 + .../core-services/root-health.md | 26 +++ packages/backend-defaults/config.d.ts | 17 ++ .../report-rootHttpRouter.api.md | 1 + .../rootHttpRouter/createHealthRouter.ts | 37 +++- .../rootHttpRouterServiceFactory.test.ts | 181 +++++++++++++++--- .../rootHttpRouterServiceFactory.ts | 2 +- .../src/next/wiring/TestBackend.ts | 2 +- 8 files changed, 242 insertions(+), 31 deletions(-) create mode 100644 .changeset/chilly-games-trade.md diff --git a/.changeset/chilly-games-trade.md b/.changeset/chilly-games-trade.md new file mode 100644 index 0000000000..259063776f --- /dev/null +++ b/.changeset/chilly-games-trade.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-defaults': minor +--- + +Added a new `backend.health.headers` configuration that can be used to set additional headers to include in health check responses. + +**BREAKING CONSUMERS**: As part of this change the `createHealthRouter` function exported from `@backstage/backend-defaults/rootHttpRouter` now requires the root config service to be passed through the `config` option. diff --git a/docs/backend-system/core-services/root-health.md b/docs/backend-system/core-services/root-health.md index 663dd699ae..fa147499a6 100644 --- a/docs/backend-system/core-services/root-health.md +++ b/docs/backend-system/core-services/root-health.md @@ -38,3 +38,29 @@ backend.add( }), ); ``` + +### Custom headers in health check responses + +While not implemented directly in the root health service, the default implementation of the [RootHttpRouter](./root-http-router.md) service includes a configuration option to set additional headers to include in health check responses. For example, you can add a `service-name` header using the following configuration: + +```yaml +backend: + health: + headers: + service-name: my-service +``` + +It can be a good idea to set a header for your health check responses that +uniquely identifies your service in a multi-service environment. This ensures +that the health check that is configured for your service is actually hitting +your service and not another. + +For example, if using Envoy you can use the [`service_name_matcher`](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/health_checking#health-check-identity) configuration and +set the `x-envoy-upstream-healthchecked-cluster` header to a matching value. For example: + +```yaml +backend: + health: + headers: + x-envoy-upstream-healthchecked-cluster: my-service +``` diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 8272bc5ca8..0f7f9906f8 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -552,6 +552,23 @@ export interface Config { */ csp?: { [policyId: string]: string[] | false }; + /** + * Options for the health check service and endpoint. + */ + health?: { + /** + * Additional headers to always include in the health check response. + * + * It can be a good idea to set a header that uniquely identifies your service + * in a multi-service environment. This ensures that the health check that is + * configured for your service is actually hitting your service and not another. + * + * For example, if using Envoy you can use the `service_name_matcher` configuration + * and set the `x-envoy-upstream-healthchecked-cluster` header to a matching value. + */ + headers?: { [name: string]: string }; + }; + /** * Configuration related to URL reading, used for example for reading catalog info * files, scaffolder templates, and techdocs content. diff --git a/packages/backend-defaults/report-rootHttpRouter.api.md b/packages/backend-defaults/report-rootHttpRouter.api.md index 3686f25a12..41830ebf17 100644 --- a/packages/backend-defaults/report-rootHttpRouter.api.md +++ b/packages/backend-defaults/report-rootHttpRouter.api.md @@ -27,6 +27,7 @@ import { ServiceFactory } from '@backstage/backend-plugin-api'; // @public (undocumented) export function createHealthRouter(options: { health: RootHealthService; + config: RootConfigService; }): Router; // @public diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts index 3772ab0fab..a22f4f32a0 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/createHealthRouter.ts @@ -14,20 +14,50 @@ * limitations under the License. */ -import { RootHealthService } from '@backstage/backend-plugin-api'; +import { + RootConfigService, + RootHealthService, +} from '@backstage/backend-plugin-api'; import Router from 'express-promise-router'; import { Request, Response } from 'express'; +const HEADER_CONFIG_KEY = 'backend.health.headers'; + /** * @public */ -export function createHealthRouter(options: { health: RootHealthService }) { +export function createHealthRouter(options: { + health: RootHealthService; + config: RootConfigService; +}) { + const headersConfig = options.config + .getOptionalConfig(HEADER_CONFIG_KEY) + ?.get(); + if (headersConfig) { + for (const [key, value] of Object.entries(headersConfig)) { + if (!key || typeof key !== 'string') { + throw new Error( + `Invalid header name in at ${HEADER_CONFIG_KEY}, must be a non-empty string`, + ); + } + if (!value || typeof value !== 'string') { + throw new Error( + `Invalid header value in at ${HEADER_CONFIG_KEY}, must be a non-empty string`, + ); + } + } + } + const headers = headersConfig && new Headers(headersConfig as HeadersInit); + const router = Router(); router.get( '/.backstage/health/v1/readiness', async (_request: Request, response: Response) => { const { status, payload } = await options.health.getReadiness(); + if (headers) { + response.setHeaders(headers); + } response.status(status).json(payload); }, ); @@ -36,6 +66,9 @@ export function createHealthRouter(options: { health: RootHealthService }) { '/.backstage/health/v1/liveness', async (_request: Request, response: Response) => { const { status, payload } = await options.health.getLiveness(); + if (headers) { + response.setHeaders(headers); + } response.status(status).json(payload); }, ); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts index dea0159dff..f058c87900 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.test.ts @@ -21,42 +21,50 @@ import { import { Express } from 'express'; import request from 'supertest'; import { rootHttpRouterServiceFactory } from './rootHttpRouterServiceFactory'; -import { coreServices } from '@backstage/backend-plugin-api'; +import { ServiceFactory, coreServices } from '@backstage/backend-plugin-api'; + +async function createExpressApp(...dependencies: ServiceFactory[]) { + let app: Express | undefined = undefined; + + const tester = ServiceFactoryTester.from( + rootHttpRouterServiceFactory({ + configure(options) { + options.applyDefaults(); + app = options.app; + }, + }), + { + dependencies, + }, + ); + + // Trigger creation of the http service, accessing the app instance through the configure callback + await tester.getSubject(); + + if (!app) { + throw new Error('App not yet created'); + } + + return { app, tester }; +} describe('rootHttpRouterServiceFactory', () => { it('should make the health endpoints available', async () => { - let app: Express | undefined = undefined; - - const tester = ServiceFactoryTester.from( - rootHttpRouterServiceFactory({ - configure(options) { - options.applyDefaults(); - app = options.app; + const { app, tester } = await createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + }, }, }), - { - dependencies: [ - mockServices.rootConfig.factory({ - data: { - app: { baseUrl: 'http://localhost' }, - backend: { - baseUrl: 'http://localhost', - listen: { host: '', port: 0 }, - }, - }, - }), - ], - }, ); - // Trigger creation of the http service, accessing the app instance through the configure callback - await tester.getSubject(); - - await request(app!) + await request(app) .get('/.backstage/health/v1/liveness') .expect(200, { status: 'ok' }); - await request(app!).get('/.backstage/health/v1/readiness').expect(503, { + await request(app).get('/.backstage/health/v1/readiness').expect(503, { message: 'Backend has not started yet', status: 'error', }); @@ -65,10 +73,129 @@ describe('rootHttpRouterServiceFactory', () => { await (lifecycle as any).startup(); // Trigger startup by calling the private startup method - await request(app!).get('/.backstage/health/v1/readiness').expect(200, { + await request(app).get('/.backstage/health/v1/readiness').expect(200, { status: 'ok', }); expect('test').toBe('test'); }); + + it('should include custom headers for health endpoint', async () => { + const { app, tester } = await createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + health: { + headers: { + 'x-test-header': 'test', + }, + }, + }, + }, + }), + ); + + const lRes = await request(app).get('/.backstage/health/v1/liveness'); + + expect(lRes.status).toBe(200); + expect(lRes.get('x-test-header')).toBe('test'); + + const r1Res = await request(app).get('/.backstage/health/v1/readiness'); + + expect(r1Res.status).toBe(503); + expect(r1Res.get('x-test-header')).toBe('test'); + + await request(app) + .get('/.backstage/health/v1/liveness') + .expect(200, { status: 'ok' }); + + await request(app).get('/.backstage/health/v1/readiness').expect(503, { + message: 'Backend has not started yet', + status: 'error', + }); + + const lifecycle = await tester.getService(coreServices.rootLifecycle); + + await (lifecycle as any).startup(); // Trigger startup by calling the private startup method + + const r2Res = await request(app).get('/.backstage/health/v1/readiness'); + + expect(r2Res.status).toBe(200); + expect(r2Res.get('x-test-header')).toBe('test'); + }); + + it('should reject invalid health headers config', async () => { + await expect( + createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + health: { + headers: 'not-an-object', + }, + }, + }, + }), + ), + ).rejects.toThrow( + "Invalid type in config for key 'backend.health.headers' in 'mock-config', got string, wanted object", + ); + + await expect( + createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + health: { + headers: [], + }, + }, + }, + }), + ), + ).rejects.toThrow( + "Invalid type in config for key 'backend.health.headers' in 'mock-config', got array, wanted object", + ); + + await expect( + createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + health: { + headers: { + 'invalid-header': {}, + }, + }, + }, + }, + }), + ), + ).rejects.toThrow( + 'Invalid header value in at backend.health.headers, must be a non-empty string', + ); + + await expect( + createExpressApp( + mockServices.rootConfig.factory({ + data: { + backend: { + listen: { port: 0 }, + health: { + headers: { + 'invalid-header': '', + }, + }, + }, + }, + }), + ), + ).rejects.toThrow( + 'Invalid header value in at backend.health.headers, must be a non-empty string', + ); + }); }); diff --git a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts index 59811c15d4..4e3483d67e 100644 --- a/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/rootHttpRouter/rootHttpRouterServiceFactory.ts @@ -89,7 +89,7 @@ const rootHttpRouterServiceFactoryWithOptions = ( const middleware = MiddlewareFactory.create({ config, logger }); const routes = router.handler(); - const healthRouter = createHealthRouter({ health }); + const healthRouter = createHealthRouter({ config, health }); const server = await createHttpServer( app, readHttpServerOptions(config.getOptionalConfig('backend')), diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 96bf0f7dc1..42b46f4de7 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -251,7 +251,7 @@ export async function startTestBackend( const app = express(); const middleware = MiddlewareFactory.create({ config, logger }); - const healthRouter = createHealthRouter({ health }); + const healthRouter = createHealthRouter({ config, health }); app.use(healthRouter); app.use(router.handler());