Skip to content

Commit b1ef9e9

Browse files
authored
feat: VPC component (#69)
* Create VPC component * Fix test context duplicated types * Implement vpc tests * Set vpc default subnet strategy
1 parent 55abae3 commit b1ef9e9

File tree

6 files changed

+265
-4
lines changed

6 files changed

+265
-4
lines changed

src/v2/components/vpc/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as pulumi from '@pulumi/pulumi';
2+
import * as awsx from '@pulumi/awsx';
3+
import { commonTags } from '../../../constants';
4+
import { enums } from '@pulumi/awsx/types';
5+
6+
export type VpcArgs = {
7+
/**
8+
* Number of availability zones to which the subnets defined in subnetSpecs will be deployed
9+
* @default '2'
10+
*/
11+
numberOfAvailabilityZones?: number;
12+
tags?: pulumi.Input<{
13+
[key: string]: pulumi.Input<string>;
14+
}>;
15+
};
16+
17+
export const defaults = {
18+
numberOfAvailabilityZones: 2,
19+
};
20+
21+
export class Vpc extends pulumi.ComponentResource {
22+
vpc: awsx.ec2.Vpc;
23+
24+
constructor(
25+
name: string,
26+
args: VpcArgs,
27+
opts: pulumi.ComponentResourceOptions = {},
28+
) {
29+
super('studion:Vpc', name, {}, opts);
30+
31+
const argsWithDefaults = Object.assign({}, defaults, args);
32+
33+
this.vpc = new awsx.ec2.Vpc(
34+
`${name}-vpc`,
35+
{
36+
numberOfAvailabilityZones: argsWithDefaults.numberOfAvailabilityZones,
37+
enableDnsHostnames: true,
38+
enableDnsSupport: true,
39+
subnetStrategy: enums.ec2.SubnetAllocationStrategy.Auto,
40+
subnetSpecs: [
41+
{ type: awsx.ec2.SubnetType.Public, cidrMask: 24 },
42+
{ type: awsx.ec2.SubnetType.Private, cidrMask: 24 },
43+
{ type: awsx.ec2.SubnetType.Isolated, cidrMask: 24 },
44+
],
45+
tags: { ...commonTags, ...argsWithDefaults.tags },
46+
},
47+
{ parent: this },
48+
);
49+
50+
this.registerOutputs();
51+
}
52+
}

src/v2/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { WebServerBuilder } from './components/web-server/builder';
44
export { WebServerLoadBalancer } from './components/web-server/load-balancer';
55
export { ElastiCacheRedis } from './components/redis/elasticache-redis';
66
export { UpstashRedis } from './components/redis/upstash-redis';
7+
export { Vpc } from './components/vpc';
78

89
import { OtelCollectorBuilder } from './otel/builder';
910
import { OtelCollector } from './otel';

tests/redis/test-context.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@ import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
55
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs';
66
import { ECSClient } from '@aws-sdk/client-ecs';
77

8-
interface ConfigContext {
9-
config: RedisTestConfig;
10-
}
11-
128
interface RedisTestConfig {
139
defaultElastiCacheRedisName: string;
1410
elastiCacheRedisName: string;

tests/vpc/index.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { describe, it, before, after } from 'node:test';
2+
import * as assert from 'node:assert';
3+
import { InlineProgramArgs } from '@pulumi/pulumi/automation';
4+
import * as automation from '../automation';
5+
import { VpcTestContext } from './test-context';
6+
import {
7+
DescribeInternetGatewaysCommand,
8+
DescribeNatGatewaysCommand,
9+
DescribeRouteTablesCommand,
10+
DescribeSubnetsCommand,
11+
DescribeVpcsCommand,
12+
EC2Client,
13+
SubnetState,
14+
VpcState,
15+
} from '@aws-sdk/client-ec2';
16+
import { defaults as vpcDefaults } from '../../src/v2/components/vpc';
17+
18+
const programArgs: InlineProgramArgs = {
19+
stackName: 'dev',
20+
projectName: 'icb-test-vpc',
21+
program: () => import('./infrastructure'),
22+
};
23+
24+
describe('Vpc component deployment', () => {
25+
const region = process.env.AWS_REGION;
26+
if (!region) {
27+
throw new Error('AWS_REGION environment variable is required');
28+
}
29+
30+
const ctx: VpcTestContext = {
31+
outputs: {},
32+
config: {},
33+
clients: {
34+
ec2: new EC2Client({ region }),
35+
},
36+
};
37+
38+
before(async () => {
39+
ctx.outputs = await automation.deploy(programArgs);
40+
});
41+
42+
after(() => automation.destroy(programArgs));
43+
44+
it('should create a default VPC with the correct configuration', async () => {
45+
const defaultVpc = ctx.outputs.defaultVpc.value;
46+
await testVpcConfiguration(
47+
ctx,
48+
defaultVpc.vpc.vpcId,
49+
6,
50+
vpcDefaults.numberOfAvailabilityZones,
51+
);
52+
});
53+
54+
it('should create a VPC with the correct configuration', async () => {
55+
const vpcOutput = ctx.outputs.vpc.value;
56+
await testVpcConfiguration(ctx, vpcOutput.vpc.vpcId, 9, 3);
57+
});
58+
59+
it('should have internet gateway for public subnets', async () => {
60+
const defaultVpc = ctx.outputs.defaultVpc.value;
61+
const vpcId = defaultVpc.vpc.vpcId;
62+
63+
const igwResult = await ctx.clients.ec2.send(
64+
new DescribeInternetGatewaysCommand({
65+
Filters: [{ Name: 'attachment.vpc-id', Values: [vpcId] }],
66+
}),
67+
);
68+
const igws = igwResult.InternetGateways || [];
69+
assert.strictEqual(
70+
igws.length,
71+
1,
72+
'Should have exactly one internet gateway',
73+
);
74+
75+
const attachment = igws[0].Attachments?.find(a => a.VpcId === vpcId);
76+
assert.ok(attachment, 'Internet gateway attachment should exist');
77+
assert.strictEqual(
78+
attachment.State,
79+
'available',
80+
'Internet gateway attachment should be available',
81+
);
82+
});
83+
84+
it('should have a route table for each subnet type', async () => {
85+
const defaultVpc = ctx.outputs.defaultVpc.value;
86+
87+
const routeTablesResult = await ctx.clients.ec2.send(
88+
new DescribeRouteTablesCommand({
89+
Filters: [{ Name: 'vpc-id', Values: [defaultVpc.vpc.vpcId] }],
90+
}),
91+
);
92+
const routeTables = routeTablesResult.RouteTables || [];
93+
assert.ok(
94+
routeTables.length >= 3,
95+
'Should have route tables for different subnet types',
96+
);
97+
});
98+
99+
it('should have NAT gateways for private subnets', async () => {
100+
const defaultVpc = ctx.outputs.defaultVpc.value;
101+
const vpcId = defaultVpc.vpc.vpcId;
102+
103+
const natGwResult = await ctx.clients.ec2.send(
104+
new DescribeNatGatewaysCommand({
105+
Filter: [{ Name: 'vpc-id', Values: [vpcId] }],
106+
}),
107+
);
108+
const natGateways = natGwResult.NatGateways || [];
109+
assert.strictEqual(
110+
natGateways.length,
111+
vpcDefaults.numberOfAvailabilityZones,
112+
`Should have ${vpcDefaults.numberOfAvailabilityZones} NAT gateways`,
113+
);
114+
115+
natGateways.forEach(nat => {
116+
assert.strictEqual(
117+
nat.State,
118+
'available',
119+
'NAT gateway should be available',
120+
);
121+
});
122+
});
123+
124+
async function testVpcConfiguration(
125+
ctx: VpcTestContext,
126+
vpcId: string,
127+
expectedSubnetCount: number,
128+
expectedAzCount: number,
129+
) {
130+
const vpcResult = await ctx.clients.ec2.send(
131+
new DescribeVpcsCommand({
132+
VpcIds: [vpcId],
133+
}),
134+
);
135+
const vpc = vpcResult.Vpcs?.[0];
136+
assert.ok(vpc, 'VPC should exist');
137+
assert.strictEqual(
138+
vpc.State,
139+
VpcState.available,
140+
'VPC should be available',
141+
);
142+
143+
const subnetsResult = await ctx.clients.ec2.send(
144+
new DescribeSubnetsCommand({
145+
Filters: [{ Name: 'vpc-id', Values: [vpcId] }],
146+
}),
147+
);
148+
const subnets = subnetsResult.Subnets || [];
149+
assert.ok(
150+
subnets.length === expectedSubnetCount,
151+
`Should have ${expectedSubnetCount} subnets defined`,
152+
);
153+
subnets.forEach(subnet => {
154+
assert.strictEqual(
155+
subnet.State,
156+
SubnetState.available,
157+
'Subnets should be available',
158+
);
159+
});
160+
161+
const uniqueAzs = new Set(subnets.map(s => s.AvailabilityZone));
162+
assert.strictEqual(
163+
uniqueAzs.size,
164+
expectedAzCount,
165+
`Subnets should span ${expectedAzCount} availability zones`,
166+
);
167+
}
168+
});

tests/vpc/infrastructure/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { next as studion } from '@studion/infra-code-blocks';
2+
import * as pulumi from '@pulumi/pulumi';
3+
4+
const appName = 'vpc-test';
5+
const stackName = pulumi.getStack();
6+
const tags = {
7+
Project: appName,
8+
Environment: stackName,
9+
};
10+
11+
const defaultVpc = new studion.Vpc(`${appName}-default`, {});
12+
13+
const vpc = new studion.Vpc(`${appName}`, {
14+
numberOfAvailabilityZones: 3,
15+
tags,
16+
});
17+
18+
module.exports = {
19+
defaultVpc,
20+
vpc,
21+
};

tests/vpc/test-context.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { EC2Client } from '@aws-sdk/client-ec2';
2+
import { OutputMap } from '@pulumi/pulumi/automation';
3+
4+
interface VpcTestConfig {}
5+
6+
interface ConfigContext {
7+
config: VpcTestConfig;
8+
}
9+
10+
interface PulumiProgramContext {
11+
outputs: OutputMap;
12+
}
13+
14+
interface AwsContext {
15+
clients: {
16+
ec2: EC2Client;
17+
};
18+
}
19+
20+
export interface VpcTestContext
21+
extends ConfigContext,
22+
PulumiProgramContext,
23+
AwsContext {}

0 commit comments

Comments
 (0)