Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/v2/components/vpc/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as pulumi from '@pulumi/pulumi';
import * as awsx from '@pulumi/awsx';
import { commonTags } from '../../../constants';
import { enums } from '@pulumi/awsx/types';

export type VpcArgs = {
/**
* Number of availability zones to which the subnets defined in subnetSpecs will be deployed
* @default '2'
*/
numberOfAvailabilityZones?: number;
tags?: pulumi.Input<{
[key: string]: pulumi.Input<string>;
}>;
};

export const defaults = {
numberOfAvailabilityZones: 2,
};

export class Vpc extends pulumi.ComponentResource {
vpc: awsx.ec2.Vpc;

constructor(
name: string,
args: VpcArgs,
opts: pulumi.ComponentResourceOptions = {},
) {
super('studion:Vpc', name, {}, opts);

const argsWithDefaults = Object.assign({}, defaults, args);

this.vpc = new awsx.ec2.Vpc(
`${name}-vpc`,
{
numberOfAvailabilityZones: argsWithDefaults.numberOfAvailabilityZones,
enableDnsHostnames: true,
enableDnsSupport: true,
subnetStrategy: enums.ec2.SubnetAllocationStrategy.Auto,
subnetSpecs: [
{ type: awsx.ec2.SubnetType.Public, cidrMask: 24 },
{ type: awsx.ec2.SubnetType.Private, cidrMask: 24 },
{ type: awsx.ec2.SubnetType.Isolated, cidrMask: 24 },
],
tags: { ...commonTags, ...argsWithDefaults.tags },
},
{ parent: this },
);

this.registerOutputs();
}
}
1 change: 1 addition & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { WebServerBuilder } from './components/web-server/builder';
export { WebServerLoadBalancer } from './components/web-server/load-balancer';
export { ElastiCacheRedis } from './components/redis/elasticache-redis';
export { UpstashRedis } from './components/redis/upstash-redis';
export { Vpc } from './components/vpc';

import { OtelCollectorBuilder } from './otel/builder';
import { OtelCollector } from './otel';
Expand Down
4 changes: 0 additions & 4 deletions tests/redis/test-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs';
import { ECSClient } from '@aws-sdk/client-ecs';

interface ConfigContext {
config: RedisTestConfig;
}

interface RedisTestConfig {
defaultElastiCacheRedisName: string;
elastiCacheRedisName: string;
Expand Down
168 changes: 168 additions & 0 deletions tests/vpc/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { describe, it, before, after } from 'node:test';
import * as assert from 'node:assert';
import { InlineProgramArgs } from '@pulumi/pulumi/automation';
import * as automation from '../automation';
import { VpcTestContext } from './test-context';
import {
DescribeInternetGatewaysCommand,
DescribeNatGatewaysCommand,
DescribeRouteTablesCommand,
DescribeSubnetsCommand,
DescribeVpcsCommand,
EC2Client,
SubnetState,
VpcState,
} from '@aws-sdk/client-ec2';
import { defaults as vpcDefaults } from '../../src/v2/components/vpc';

const programArgs: InlineProgramArgs = {
stackName: 'dev',
projectName: 'icb-test-vpc',
program: () => import('./infrastructure'),
};

describe('Vpc component deployment', () => {
const region = process.env.AWS_REGION;
if (!region) {
throw new Error('AWS_REGION environment variable is required');
}

const ctx: VpcTestContext = {
outputs: {},
config: {},
clients: {
ec2: new EC2Client({ region }),
},
};

before(async () => {
ctx.outputs = await automation.deploy(programArgs);
});

after(() => automation.destroy(programArgs));

it('should create a default VPC with the correct configuration', async () => {
const defaultVpc = ctx.outputs.defaultVpc.value;
await testVpcConfiguration(
ctx,
defaultVpc.vpc.vpcId,
6,
vpcDefaults.numberOfAvailabilityZones,
);
});

it('should create a VPC with the correct configuration', async () => {
const vpcOutput = ctx.outputs.vpc.value;
await testVpcConfiguration(ctx, vpcOutput.vpc.vpcId, 9, 3);
});

it('should have internet gateway for public subnets', async () => {
const defaultVpc = ctx.outputs.defaultVpc.value;
const vpcId = defaultVpc.vpc.vpcId;

const igwResult = await ctx.clients.ec2.send(
new DescribeInternetGatewaysCommand({
Filters: [{ Name: 'attachment.vpc-id', Values: [vpcId] }],
}),
);
const igws = igwResult.InternetGateways || [];
assert.strictEqual(
igws.length,
1,
'Should have exactly one internet gateway',
);

const attachment = igws[0].Attachments?.find(a => a.VpcId === vpcId);
assert.ok(attachment, 'Internet gateway attachment should exist');
assert.strictEqual(
attachment.State,
'available',
'Internet gateway attachment should be available',
);
});

it('should have a route table for each subnet type', async () => {
const defaultVpc = ctx.outputs.defaultVpc.value;

const routeTablesResult = await ctx.clients.ec2.send(
new DescribeRouteTablesCommand({
Filters: [{ Name: 'vpc-id', Values: [defaultVpc.vpc.vpcId] }],
}),
);
const routeTables = routeTablesResult.RouteTables || [];
assert.ok(
routeTables.length >= 3,
'Should have route tables for different subnet types',
);
});

it('should have NAT gateways for private subnets', async () => {
const defaultVpc = ctx.outputs.defaultVpc.value;
const vpcId = defaultVpc.vpc.vpcId;

const natGwResult = await ctx.clients.ec2.send(
new DescribeNatGatewaysCommand({
Filter: [{ Name: 'vpc-id', Values: [vpcId] }],
}),
);
const natGateways = natGwResult.NatGateways || [];
assert.strictEqual(
natGateways.length,
vpcDefaults.numberOfAvailabilityZones,
`Should have ${vpcDefaults.numberOfAvailabilityZones} NAT gateways`,
);

natGateways.forEach(nat => {
assert.strictEqual(
nat.State,
'available',
'NAT gateway should be available',
);
});
});

async function testVpcConfiguration(
ctx: VpcTestContext,
vpcId: string,
expectedSubnetCount: number,
expectedAzCount: number,
) {
const vpcResult = await ctx.clients.ec2.send(
new DescribeVpcsCommand({
VpcIds: [vpcId],
}),
);
const vpc = vpcResult.Vpcs?.[0];
assert.ok(vpc, 'VPC should exist');
assert.strictEqual(
vpc.State,
VpcState.available,
'VPC should be available',
);

const subnetsResult = await ctx.clients.ec2.send(
new DescribeSubnetsCommand({
Filters: [{ Name: 'vpc-id', Values: [vpcId] }],
}),
);
const subnets = subnetsResult.Subnets || [];
assert.ok(
subnets.length === expectedSubnetCount,
`Should have ${expectedSubnetCount} subnets defined`,
);
subnets.forEach(subnet => {
assert.strictEqual(
subnet.State,
SubnetState.available,
'Subnets should be available',
);
});

const uniqueAzs = new Set(subnets.map(s => s.AvailabilityZone));
assert.strictEqual(
uniqueAzs.size,
expectedAzCount,
`Subnets should span ${expectedAzCount} availability zones`,
);
}
});
21 changes: 21 additions & 0 deletions tests/vpc/infrastructure/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { next as studion } from '@studion/infra-code-blocks';
import * as pulumi from '@pulumi/pulumi';

const appName = 'vpc-test';
const stackName = pulumi.getStack();
const tags = {
Project: appName,
Environment: stackName,
};

const defaultVpc = new studion.Vpc(`${appName}-default`, {});

const vpc = new studion.Vpc(`${appName}`, {
numberOfAvailabilityZones: 3,
tags,
});

module.exports = {
defaultVpc,
vpc,
};
23 changes: 23 additions & 0 deletions tests/vpc/test-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { EC2Client } from '@aws-sdk/client-ec2';
import { OutputMap } from '@pulumi/pulumi/automation';

interface VpcTestConfig {}

interface ConfigContext {
config: VpcTestConfig;
}

interface PulumiProgramContext {
outputs: OutputMap;
}

interface AwsContext {
clients: {
ec2: EC2Client;
};
}

export interface VpcTestContext
extends ConfigContext,
PulumiProgramContext,
AwsContext {}