backend-defaults: add backend.health.headers config
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
+154
-27
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user