Skip to content

Commit 960b0aa

Browse files
authored
smokeTest.koaMiddleware: Add function (#33)
1 parent 2b4ef20 commit 960b0aa

File tree

10 files changed

+332
-14
lines changed

10 files changed

+332
-14
lines changed

.changeset/warm-keys-fold.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@seek/aws-codedeploy-hooks': minor
3+
---
4+
5+
smokeTest.koaMiddleware: Add function

packages/hooks/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,32 @@ Checks for:
6464

6565
- An empty event object
6666
- A custom `user-agent` in context that starts with `aws-codedeploy-hook-`
67+
68+
### `smokeTest.koaMiddleware`
69+
70+
A Koa middleware that executes a smoke test function to check whether the application is broadly operational and ready to serve requests.
71+
72+
The `skipHook` option skips synchronous validation of the smoke test function during pre-deployment checks from an AWS CodeDeploy hook.
73+
This may be used when a build needs to be expedited in a disaster recovery scenario or when a dependency is known to be unhealthy.
74+
75+
```typescript
76+
import { Env } from 'skuba-dive';
77+
import Router from '@koa/router';
78+
import logger from '@seek/logger';
79+
80+
const config = {
81+
skipHook: Env.boolean('SKIP_SMOKE', { default: false }),
82+
};
83+
84+
const logger = createLogger();
85+
86+
export const router = new Router().get(
87+
'/smoke',
88+
smokeTest.koaMiddleware({ logger, skipHook: config.skipHook }, async () => {
89+
// Run dependency checks.
90+
await checkDependencies();
91+
}),
92+
);
93+
```
94+
95+
Uses [`isHttpHook`](#ishttphook) under the hood.

packages/hooks/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dependencies": {},
2929
"devDependencies": {
3030
"@aws-sdk/client-lambda": "3.485.0",
31+
"@seek/logger": "^6.2.0",
3132
"@types/aws-lambda": "^8.10.130",
3233
"@types/koa": "^2.13.6",
3334
"@types/supertest": "^6.0.2",

packages/hooks/src/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ type HeadersClass = { get: (name: string) => string | null };
44

55
type HeadersRecord = Record<string, null | string | string[] | undefined>;
66

7-
type HttpRequest = {
7+
export type HttpRequest = {
88
headers: HeadersClass | HeadersRecord;
99
};
1010

packages/hooks/src/index.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ describe('rootModule', () => {
66
{
77
"isHttpHook": [Function],
88
"isLambdaHook": [Function],
9+
"smokeTest": {
10+
"koaMiddleware": [Function],
11+
},
912
}
1013
`));
1114
});

packages/hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { isHttpHook } from './http';
22
export { isLambdaHook } from './lambda';
3+
export { smokeTest } from './smokeTest';

packages/hooks/src/smokeTest/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { koaMiddleware } from './koa';
2+
3+
export const smokeTest = {
4+
koaMiddleware,
5+
};
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import createLogger from '@seek/logger';
2+
import Koa from 'koa';
3+
import { agent } from 'supertest';
4+
5+
import { koaMiddleware } from './koa';
6+
7+
const onError = jest.fn();
8+
const write = jest.fn();
9+
10+
const stdout = () => write.mock.calls.flat().join('').trim();
11+
12+
afterEach(jest.clearAllMocks);
13+
14+
const logger = createLogger(
15+
{ serializers: { err: (err) => String(err) }, timestamp: false },
16+
{ write: (msg) => write(msg) },
17+
);
18+
19+
type Options = {
20+
skipHook: Parameters<typeof koaMiddleware>[0]['skipHook'];
21+
smokeTest: Parameters<typeof koaMiddleware>[1];
22+
userAgent: string;
23+
};
24+
25+
const run = ({ skipHook, smokeTest, userAgent }: Options) => {
26+
const app = new Koa()
27+
.use(koaMiddleware({ logger, skipHook }, smokeTest))
28+
.on('error', onError);
29+
30+
return agent(
31+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
32+
app.callback(),
33+
)
34+
.get('/')
35+
.set('User-Agent', userAgent);
36+
};
37+
38+
it.each`
39+
skipHook | userAgent
40+
${true} | ${'Mozilla/5.0'}
41+
${false} | ${'aws-codedeploy-hook-BeforeAllowTraffic/1.2.3'}
42+
${false} | ${'gantry-codedeploy-hook-BeforeAllowTraffic-dev/1.2.3'}
43+
${false} | ${'Mozilla/5.0'}
44+
`(
45+
'handles a foreground smoke test with skipHook: $skipHook and userAgent: $userAgent',
46+
async ({ skipHook, userAgent }) => {
47+
await run({
48+
skipHook,
49+
smokeTest: () => undefined,
50+
userAgent,
51+
}).expect(200, 'Smoke test succeeded');
52+
53+
expect(onError).not.toHaveBeenCalled();
54+
expect(stdout()).toBeFalsy();
55+
},
56+
);
57+
58+
it.each`
59+
skipHook | userAgent
60+
${true} | ${'aws-codedeploy-hook-BeforeAllowTraffic/1.2.3'}
61+
${true} | ${'gantry-codedeploy-hook-BeforeAllowTraffic-dev/1.2.3'}
62+
`(
63+
'handles a background smoke test with skipHook: $skipHook and userAgent: $userAgent',
64+
async ({ skipHook, userAgent }) => {
65+
await run({
66+
skipHook,
67+
smokeTest: () => undefined,
68+
userAgent,
69+
}).expect(200, 'Smoke test skipped');
70+
71+
expect(onError).not.toHaveBeenCalled();
72+
expect(stdout()).toBe(
73+
'{"level":30,"msg":"Smoke test succeeded in background"}',
74+
);
75+
},
76+
);
77+
78+
it.each`
79+
userAgent
80+
${'aws-codedeploy-hook-BeforeAllowTraffic/123'}
81+
${'gantry-codedeploy-hook-BeforeAllowTraffic-dev/1.2.3'}
82+
`(
83+
'skips a synchronous error with skipHook: true and userAgent: $userAgent',
84+
async ({ userAgent }) => {
85+
await run({
86+
skipHook: true,
87+
smokeTest: () => {
88+
throw new Error('Badness!');
89+
},
90+
userAgent,
91+
}).expect(200, 'Smoke test skipped');
92+
93+
expect(onError).not.toHaveBeenCalled();
94+
expect(stdout()).toBe(
95+
'{"level":40,"err":"Error: Badness!","msg":"Smoke test failed in background"}',
96+
);
97+
},
98+
);
99+
100+
it.each`
101+
userAgent
102+
${'aws-codedeploy-hook-BeforeAllowTraffic/123'}
103+
${'gantry-codedeploy-hook-BeforeAllowTraffic-dev/1.2.3'}
104+
`(
105+
'skips an asynchronous error with skipHook: true and userAgent: $userAgent',
106+
async ({ userAgent }) => {
107+
await run({
108+
skipHook: true,
109+
smokeTest: () => Promise.reject(new Error('Badness!')),
110+
userAgent,
111+
}).expect(200, 'Smoke test skipped');
112+
113+
expect(onError).not.toHaveBeenCalled();
114+
expect(stdout()).toBe(
115+
'{"level":40,"err":"Error: Badness!","msg":"Smoke test failed in background"}',
116+
);
117+
},
118+
);
119+
120+
it.each`
121+
skipHook | userAgent
122+
${true} | ${'Mozilla/5.0'}
123+
${false} | ${'aws-codedeploy-hook-BeforeAllowTraffic/123'}
124+
${false} | ${'gantry-codedeploy-hook-BeforeAllowTraffic-dev/1.2.3'}
125+
${false} | ${'Mozilla/5.0'}
126+
`(
127+
'throws a synchronous error with skipHook: $skipHook and userAgent: $userAgent',
128+
async ({ skipHook, userAgent }) => {
129+
await run({
130+
skipHook,
131+
smokeTest: () => {
132+
throw new Error('Badness!');
133+
},
134+
userAgent,
135+
}).expect(500, 'Internal Server Error');
136+
137+
expect(onError).toHaveBeenCalledTimes(1);
138+
expect(stdout()).toBeFalsy();
139+
},
140+
);
141+
142+
it.each`
143+
skipHook | userAgent
144+
${true} | ${'Mozilla/5.0'}
145+
${false} | ${'aws-codedeploy-hook-BeforeAllowTraffic/123'}
146+
${false} | ${'gantry-codedeploy-hook-BeforeAllowTraffic-dev/1.2.3'}
147+
${false} | ${'Mozilla/5.0'}
148+
`(
149+
'throws an asynchronous error with skipHook: $skipHook and userAgent: $userAgent',
150+
async ({ skipHook, userAgent }) => {
151+
await run({
152+
skipHook,
153+
smokeTest: () => Promise.reject(new Error('Badness!')),
154+
userAgent,
155+
}).expect(500, 'Internal Server Error');
156+
157+
expect(onError).toHaveBeenCalledTimes(1);
158+
expect(stdout()).toBeFalsy();
159+
},
160+
);
161+
162+
it('passes no arguments to the smoke test function asynchronously', async () => {
163+
const smokeTest = jest.fn();
164+
165+
await run({
166+
skipHook: true,
167+
smokeTest,
168+
userAgent: 'aws-codedeploy-hook-BeforeAllowTraffic/123',
169+
}).expect(200, 'Smoke test skipped');
170+
171+
expect(onError).not.toHaveBeenCalled();
172+
expect(stdout()).toBe(
173+
'{"level":30,"msg":"Smoke test succeeded in background"}',
174+
);
175+
176+
expect(smokeTest).toHaveBeenCalledTimes(1);
177+
expect(smokeTest).toHaveBeenLastCalledWith();
178+
});
179+
180+
it('passes no arguments to the smoke test function synchronously', async () => {
181+
const smokeTest = jest.fn();
182+
183+
await run({
184+
skipHook: false,
185+
smokeTest,
186+
userAgent: 'Mozilla/5.0',
187+
}).expect(200, 'Smoke test succeeded');
188+
189+
expect(onError).not.toHaveBeenCalled();
190+
expect(stdout()).toBe('');
191+
192+
expect(smokeTest).toHaveBeenCalledTimes(1);
193+
expect(smokeTest).toHaveBeenLastCalledWith();
194+
});

packages/hooks/src/smokeTest/koa.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { type HttpRequest, isHttpHook } from '../http';
2+
3+
type Context = { body: unknown; req: Readonly<HttpRequest>; status: number };
4+
5+
type Logger = {
6+
info: (message: string) => unknown;
7+
warn: (props: { err: unknown }, message: string) => unknown;
8+
};
9+
10+
type Options = {
11+
/**
12+
* An instance of the application logger.
13+
*
14+
* When `skipHook` takes effect, the smoke test function is not entirely
15+
* skipped but is rather invoked in the background. Its execution does not
16+
* hold up or otherwise affect the HTTP response to the client, but its result
17+
* is still logged as a data point for future reference.
18+
*/
19+
logger: Logger;
20+
21+
/**
22+
* Whether to skip synchronous validation of the smoke test function during
23+
* pre-deployment checks from an AWS CodeDeploy hook.
24+
*
25+
* - Set to `false` in general operation to exercise the smoke test before
26+
* accepting production traffic.
27+
* - Set to `true` when a build needs to be expedited in a disaster recovery
28+
* scenario or when a dependency is known to be unhealthy.
29+
*
30+
* This option is ignored and synchronous validation of the `smokeTest`
31+
* function proceeds if the request does not originate from an AWS CodeDeploy
32+
* or Gantry hook user agent. For example, a regular health check poll from a
33+
* CloudWatch or Datadog synthetic will continue to exercise the smoke test.
34+
*/
35+
skipHook: boolean;
36+
};
37+
38+
/**
39+
* A Koa middleware that executes a smoke test function to check whether the
40+
* application is broadly operational and ready to serve requests.
41+
*
42+
* The `skipHook` option skips synchronous validation of the smoke test function
43+
* during pre-deployment checks from an AWS CodeDeploy hook. This may be used
44+
* when a build needs to be expedited in a disaster recovery scenario or when a
45+
* dependency is known to be unhealthy.
46+
*/
47+
export const koaMiddleware =
48+
(
49+
{ logger, skipHook }: Options,
50+
51+
/**
52+
* A function that checks whether the application is broadly operational and
53+
* ready to serve requests.
54+
*
55+
* A typical smoke test execution may validate that the application container
56+
* has started, its server process is accepting requests, its core application
57+
* logic is intact, and its dependencies are reachable.
58+
*
59+
* This function should `throw` an error upon failure; all other return values
60+
* will be treated as a success.
61+
*/
62+
smokeTest: () => unknown,
63+
) =>
64+
async (ctx: Context) => {
65+
if (isHttpHook(ctx.req) && skipHook) {
66+
// Run in the background. This avoids holding up the deployment while the
67+
// result is still logged as a data point for future reference.
68+
Promise.resolve()
69+
.then(() => smokeTest())
70+
.then(() => logger.info('Smoke test succeeded in background'))
71+
.catch((err: unknown) =>
72+
logger.warn({ err }, 'Smoke test failed in background'),
73+
);
74+
75+
ctx.status = 200;
76+
ctx.body = 'Smoke test skipped';
77+
78+
return;
79+
}
80+
81+
await smokeTest();
82+
83+
ctx.status = 200;
84+
ctx.body = 'Smoke test succeeded';
85+
86+
return;
87+
};

0 commit comments

Comments
 (0)