Skip to content

Commit d3ea1e9

Browse files
authored
HookStack: Implement version prune in AfterAllowTraffic (#57)
1 parent 577455f commit d3ea1e9

File tree

12 files changed

+549
-48
lines changed

12 files changed

+549
-48
lines changed

.changeset/wet-numbers-pull.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@seek/aws-codedeploy-infra': minor
3+
---
4+
5+
HookStack: Implement version prune in AfterAllowTraffic

packages/infra/cli/codegen/bundleAssets.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const bundleAssets = async () => {
2929
],
3030
format: 'esm',
3131
logLevel: 'debug',
32+
outExtension: { '.js': '.mjs' },
3233
outdir: assetDir,
3334
platform: 'node',
3435
sourcemap: true,

packages/infra/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@types/aws-lambda": "^8.10.130",
3232
"aws-cdk-lib": "^2.115.0",
3333
"constructs": "^10.3.0",
34+
"skuba-dive": "^2.0.0",
3435
"zod": "^3.22.4"
3536
},
3637
"devDependencies": {

packages/infra/src/constructs/__snapshots__/stack.test.ts.snap

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,138 @@ exports[`returns expected CloudFormation stack 1`] = `
1111
},
1212
},
1313
"Resources": {
14+
"AfterAllowTrafficHookE4DBA370": {
15+
"DependsOn": [
16+
"AfterAllowTrafficHookServiceRoleDefaultPolicyB04250EE",
17+
"AfterAllowTrafficHookServiceRoleE545FEA8",
18+
],
19+
"Properties": {
20+
"Architectures": [
21+
"arm64",
22+
],
23+
"Code": {
24+
"S3Bucket": {
25+
"Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}",
26+
},
27+
"S3Key": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.zip",
28+
},
29+
"Description": "AfterAllowTraffic hook deployed outside of a VPC",
30+
"Environment": {
31+
"Variables": {
32+
"NODE_ENV": "production",
33+
"NODE_OPTIONS": "--enable-source-maps",
34+
"VERSIONS_TO_KEEP": "3",
35+
},
36+
},
37+
"FunctionName": "aws-codedeploy-hook-AfterAllowTraffic",
38+
"Handler": "index.handler",
39+
"Role": {
40+
"Fn::GetAtt": [
41+
"AfterAllowTrafficHookServiceRoleE545FEA8",
42+
"Arn",
43+
],
44+
},
45+
"Runtime": "nodejs20.x",
46+
"Timeout": 300,
47+
},
48+
"Type": "AWS::Lambda::Function",
49+
},
50+
"AfterAllowTrafficHookServiceRoleDefaultPolicyB04250EE": {
51+
"Properties": {
52+
"PolicyDocument": {
53+
"Statement": [
54+
{
55+
"Action": [
56+
"codedeploy:GetApplicationRevision",
57+
"codedeploy:GetDeployment",
58+
"codedeploy:PutLifecycleEventHookExecutionStatus",
59+
"lambda:ListAliases",
60+
"lambda:ListVersionsByFunction",
61+
"lambda:DeleteFunction",
62+
],
63+
"Effect": "Allow",
64+
"Resource": "*",
65+
},
66+
{
67+
"Action": [
68+
"codedeploy:GetApplicationRevision",
69+
"codedeploy:GetDeployment",
70+
"codedeploy:PutLifecycleEventHookExecutionStatus",
71+
"lambda:ListAliases",
72+
"lambda:ListVersionsByFunction",
73+
"lambda:DeleteFunction",
74+
],
75+
"Condition": {
76+
"Null": {
77+
"aws:ResourceTag/aws-codedeploy-hooks": "true",
78+
},
79+
},
80+
"Effect": "Deny",
81+
"Resource": "*",
82+
},
83+
{
84+
"Action": [
85+
"codedeploy:GetApplicationRevision",
86+
"codedeploy:GetDeployment",
87+
"codedeploy:PutLifecycleEventHookExecutionStatus",
88+
"lambda:ListAliases",
89+
"lambda:ListVersionsByFunction",
90+
"lambda:DeleteFunction",
91+
],
92+
"Condition": {
93+
"StringEquals": {
94+
"aws:ResourceTag/aws-codedeploy-hooks": [
95+
"",
96+
"false",
97+
],
98+
},
99+
},
100+
"Effect": "Deny",
101+
"Resource": "*",
102+
},
103+
],
104+
"Version": "2012-10-17",
105+
},
106+
"PolicyName": "AfterAllowTrafficHookServiceRoleDefaultPolicyB04250EE",
107+
"Roles": [
108+
{
109+
"Ref": "AfterAllowTrafficHookServiceRoleE545FEA8",
110+
},
111+
],
112+
},
113+
"Type": "AWS::IAM::Policy",
114+
},
115+
"AfterAllowTrafficHookServiceRoleE545FEA8": {
116+
"Properties": {
117+
"AssumeRolePolicyDocument": {
118+
"Statement": [
119+
{
120+
"Action": "sts:AssumeRole",
121+
"Effect": "Allow",
122+
"Principal": {
123+
"Service": "lambda.amazonaws.com",
124+
},
125+
},
126+
],
127+
"Version": "2012-10-17",
128+
},
129+
"ManagedPolicyArns": [
130+
{
131+
"Fn::Join": [
132+
"",
133+
[
134+
"arn:",
135+
{
136+
"Ref": "AWS::Partition",
137+
},
138+
":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
139+
],
140+
],
141+
},
142+
],
143+
},
144+
"Type": "AWS::IAM::Role",
145+
},
14146
"BeforeAllowTrafficHookFCD1BC97": {
15147
"DependsOn": [
16148
"BeforeAllowTrafficHookServiceRoleDefaultPolicyDB153C72",
@@ -92,7 +224,12 @@ exports[`returns expected CloudFormation stack 1`] = `
92224
"Resource": "*",
93225
},
94226
{
95-
"Action": "*",
227+
"Action": [
228+
"codedeploy:GetApplicationRevision",
229+
"codedeploy:GetDeployment",
230+
"codedeploy:PutLifecycleEventHookExecutionStatus",
231+
"lambda:InvokeFunction",
232+
],
96233
"Condition": {
97234
"Null": {
98235
"aws:ResourceTag/aws-codedeploy-hooks": "true",
@@ -102,7 +239,12 @@ exports[`returns expected CloudFormation stack 1`] = `
102239
"Resource": "*",
103240
},
104241
{
105-
"Action": "*",
242+
"Action": [
243+
"codedeploy:GetApplicationRevision",
244+
"codedeploy:GetDeployment",
245+
"codedeploy:PutLifecycleEventHookExecutionStatus",
246+
"lambda:InvokeFunction",
247+
],
106248
"Condition": {
107249
"StringEquals": {
108250
"aws:ResourceTag/aws-codedeploy-hooks": [

packages/infra/src/constructs/lambda.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ import path from 'path';
22

33
import { Duration, aws_lambda } from 'aws-cdk-lib';
44

5-
export const createLambdaHookProps = (): aws_lambda.FunctionProps => ({
5+
export const createLambdaHookProps = (
6+
environment: Record<string, string>,
7+
): aws_lambda.FunctionProps => ({
68
architecture: aws_lambda.Architecture.ARM_64,
79

810
code: aws_lambda.Code.fromAsset(
911
path.join(__dirname, '..', 'assets', 'handlers'),
1012
),
1113

1214
environment: {
15+
...environment,
16+
1317
NODE_ENV: 'production',
1418

1519
// https://nodejs.org/api/cli.html#cli_node_options_options

packages/infra/src/constructs/stack.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ it('returns expected CloudFormation stack', () => {
99

1010
const template = assertions.Template.fromStack(stack);
1111

12-
template.resourceCountIs('AWS::Lambda::Function', 1);
12+
template.resourceCountIs('AWS::Lambda::Function', 2);
1313

1414
template.resourcePropertiesCountIs(
1515
'AWS::Lambda::Function',
@@ -32,5 +32,5 @@ it('supports a custom ID', () => {
3232

3333
const template = assertions.Template.fromStack(stack);
3434

35-
template.resourceCountIs('AWS::Lambda::Function', 1);
35+
template.resourceCountIs('AWS::Lambda::Function', 2);
3636
});

packages/infra/src/constructs/stack.ts

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,70 @@ import type { Construct } from 'constructs';
33

44
import { createLambdaHookProps } from './lambda';
55

6-
export type HookStackProps = Record<string, never>;
6+
const hooks = ['BeforeAllowTraffic', 'AfterAllowTraffic'] as const;
7+
8+
type HookName = (typeof hooks)[number];
9+
10+
export type HookStackProps = {
11+
prune?: {
12+
versionsToKeep?: number;
13+
};
14+
};
715

816
export class HookStack extends Stack {
9-
constructor(scope: Construct, id?: string, _props: HookStackProps = {}) {
17+
constructor(scope: Construct, id?: string, props: HookStackProps = {}) {
1018
super(scope, id ?? 'HookStack', {
1119
description: 'AWS CodeDeploy hooks',
1220
stackName: 'aws-codedeploy-hooks',
1321
terminationProtection: true,
1422
});
1523

16-
const beforeAllowTrafficHook = new aws_lambda.Function(
17-
this,
18-
'BeforeAllowTrafficHook',
24+
this.addHook('BeforeAllowTraffic', {}, ['lambda:InvokeFunction']);
25+
26+
this.addHook(
27+
'AfterAllowTraffic',
1928
{
20-
...createLambdaHookProps(),
21-
description: 'BeforeAllowTraffic hook deployed outside of a VPC',
22-
functionName: 'aws-codedeploy-hook-BeforeAllowTraffic',
23-
vpc: undefined,
29+
VERSIONS_TO_KEEP: (props.prune?.versionsToKeep ?? 3).toString(),
2430
},
31+
[
32+
'lambda:ListAliases',
33+
'lambda:ListVersionsByFunction',
34+
'lambda:DeleteFunction',
35+
],
2536
);
37+
}
38+
39+
private addHook(
40+
hook: HookName,
41+
environment: Record<string, string>,
42+
baseActions: string[],
43+
): void {
44+
const actions = [
45+
'codedeploy:GetApplicationRevision',
46+
'codedeploy:GetDeployment',
47+
'codedeploy:PutLifecycleEventHookExecutionStatus',
48+
...baseActions,
49+
];
50+
51+
const hookFunction = new aws_lambda.Function(this, `${hook}Hook`, {
52+
...createLambdaHookProps(environment),
53+
description: `${hook} hook deployed outside of a VPC`,
54+
functionName: `aws-codedeploy-hook-${hook}`,
55+
vpc: undefined,
56+
});
2657

27-
beforeAllowTrafficHook.addToRolePolicy(
58+
hookFunction.addToRolePolicy(
2859
new aws_iam.PolicyStatement({
29-
actions: [
30-
'codedeploy:GetApplicationRevision',
31-
'codedeploy:GetDeployment',
32-
'codedeploy:PutLifecycleEventHookExecutionStatus',
33-
'lambda:InvokeFunction',
34-
],
60+
actions,
3561
effect: aws_iam.Effect.ALLOW,
3662
resources: ['*'],
3763
}),
3864
);
3965

4066
// Deny access to resources that lack an `aws-codedeploy-hooks` tag.
41-
beforeAllowTrafficHook.addToRolePolicy(
67+
hookFunction.addToRolePolicy(
4268
new aws_iam.PolicyStatement({
43-
actions: ['*'],
69+
actions,
4470
conditions: {
4571
Null: {
4672
'aws:ResourceTag/aws-codedeploy-hooks': 'true',
@@ -52,9 +78,9 @@ export class HookStack extends Stack {
5278
);
5379

5480
// Deny access to resources that have a falsy `aws-codedeploy-hooks` tag.
55-
beforeAllowTrafficHook.addToRolePolicy(
81+
hookFunction.addToRolePolicy(
5682
new aws_iam.PolicyStatement({
57-
actions: ['*'],
83+
actions,
5884
conditions: {
5985
StringEquals: {
6086
'aws:ResourceTag/aws-codedeploy-hooks': ['', 'false'],

packages/infra/src/handlers/process/lambda/lambda.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
jest.mock('./smokeTest');
2+
jest.mock('./prune');
23

34
import 'aws-sdk-client-mock-jest';
45

@@ -9,16 +10,19 @@ import {
910
import { mockClient } from 'aws-sdk-client-mock';
1011

1112
import { type Options, lambda } from './lambda';
13+
import { prune } from './prune';
1214
import type { LambdaAppSpec } from './schema';
1315
import { smokeTest } from './smokeTest';
1416

1517
const codeDeploy = mockClient(CodeDeployClient);
1618

1719
const smokeTestMock = jest.mocked(smokeTest);
20+
const pruneMock = jest.mocked(prune);
1821

1922
afterEach(() => {
2023
codeDeploy.reset();
2124
smokeTestMock.mockReset();
25+
pruneMock.mockReset();
2226
});
2327

2428
describe('lambda', () => {
@@ -150,7 +154,7 @@ describe('lambda', () => {
150154
);
151155
});
152156

153-
it('throws an error if AppSpec specifies the current hook as AfterAllowTraffic', async () => {
157+
it('executes a prune on AfterAllowTraffic', async () => {
154158
codeDeploy.on(GetApplicationRevisionCommand).resolves({
155159
revision: {
156160
string: {
@@ -164,9 +168,9 @@ describe('lambda', () => {
164168
},
165169
});
166170

167-
await expect(lambda(opts)).rejects.toThrowErrorMatchingInlineSnapshot(
168-
`"AfterAllowTraffic is not yet supported"`,
169-
);
171+
await expect(lambda(opts)).resolves.toBeUndefined();
172+
173+
expect(pruneMock).toHaveBeenCalledTimes(1);
170174
});
171175

172176
it('throws an error if AppSpec does not specify the current hook', async () => {

0 commit comments

Comments
 (0)