backend-defaults: add backend.health.headers config

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-12-05 09:35:34 +01:00
parent 45b5f3440f
commit fd5d337df4
8 changed files with 242 additions and 31 deletions
+7
View File
@@ -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.
@@ -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
```
+17
View File
@@ -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.
@@ -27,6 +27,7 @@ import { ServiceFactory } from '@backstage/backend-plugin-api';
// @public (undocumented)
export function createHealthRouter(options: {
health: RootHealthService;
config: RootConfigService;
}): Router;
// @public
@@ -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);
},
);
@@ -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',
);
});
});
@@ -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')),
@@ -251,7 +251,7 @@ export async function startTestBackend<TExtensionPoints extends any[]>(
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());