Skip to content

Commit cda86ce

Browse files
authored
feat(aws): VPC (#16)
1 parent 7a9e10f commit cda86ce

File tree

10 files changed

+597
-9
lines changed

10 files changed

+597
-9
lines changed

alchemy-effect/src/aws/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ export const createAWSServiceClientLayer =
3838
(...args: any[]) =>
3939
target[prop](...args).pipe(
4040
// TODO(sam): make it easier to set log lever for a client
41-
Logger.withMinimumLogLevel(LogLevel.Info),
41+
Logger.withMinimumLogLevel(
42+
process.env.DEBUG ? LogLevel.Debug : LogLevel.Info,
43+
),
4244
),
4345
});
4446
}),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Context from "effect/Context";
2+
import * as Layer from "effect/Layer";
3+
4+
import { EC2 } from "itty-aws/ec2";
5+
import { createAWSServiceClientLayer } from "../client.ts";
6+
import * as Credentials from "../credentials.ts";
7+
import * as Region from "../region.ts";
8+
9+
export class EC2Client extends Context.Tag("AWS.EC2.Client")<
10+
EC2Client,
11+
EC2
12+
>() {}
13+
14+
export const client = createAWSServiceClientLayer<typeof EC2Client, EC2>(
15+
EC2Client,
16+
EC2,
17+
);
18+
19+
export const clientFromEnv = () =>
20+
Layer.provide(client(), Layer.merge(Credentials.fromEnv(), Region.fromEnv()));
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from "./client.ts";
2+
export * from "./vpc.provider.ts";
3+
export * from "./vpc.ts";
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import * as Effect from "effect/Effect";
2+
import * as Schedule from "effect/Schedule";
3+
4+
import { App, type ProviderService } from "alchemy-effect";
5+
import type { ScopedPlanStatusSession } from "../../apply.ts";
6+
import { createTagger, createTagsList } from "../../tags.ts";
7+
import { Account } from "../account.ts";
8+
import { Region } from "../region.ts";
9+
import { EC2Client } from "./client.ts";
10+
import { Vpc, type VpcAttrs, type VpcProps } from "./vpc.ts";
11+
12+
import type { EC2 } from "itty-aws/ec2";
13+
14+
export const vpcProvider = () =>
15+
Vpc.provider.effect(
16+
Effect.gen(function* () {
17+
const ec2 = yield* EC2Client;
18+
const app = yield* App;
19+
const region = yield* Region;
20+
const accountId = yield* Account;
21+
const tagged = yield* createTagger();
22+
23+
return {
24+
diff: Effect.fn(function* ({ id, news, olds }) {
25+
// 1. CIDR block changes
26+
if (olds.cidrBlock !== news.cidrBlock) {
27+
return { action: "replace" } as const;
28+
}
29+
30+
// 2. Instance tenancy changes
31+
if (olds.instanceTenancy !== news.instanceTenancy) {
32+
return { action: "replace" } as const;
33+
}
34+
35+
// 3. IPAM pool changes
36+
if (olds.ipv4IpamPoolId !== news.ipv4IpamPoolId) {
37+
return { action: "replace" } as const;
38+
}
39+
40+
if (olds.ipv6IpamPoolId !== news.ipv6IpamPoolId) {
41+
return { action: "replace" } as const;
42+
}
43+
44+
// 4. IPv6 CIDR block changes
45+
if (olds.ipv6CidrBlock !== news.ipv6CidrBlock) {
46+
return { action: "replace" } as const;
47+
}
48+
49+
// 5. IPv6 pool changes
50+
if (olds.ipv6Pool !== news.ipv6Pool) {
51+
return { action: "replace" } as const;
52+
}
53+
}),
54+
55+
create: Effect.fn(function* ({ id, news, session }) {
56+
// 1. Prepare tags
57+
const alchemyTags = tagged(id);
58+
const userTags = news.tags ?? {};
59+
const allTags = { ...alchemyTags, ...userTags };
60+
61+
// 2. Call CreateVpc
62+
const createResult = yield* ec2.createVpc({
63+
// TODO(sam): add all properties
64+
AmazonProvidedIpv6CidrBlock: news.amazonProvidedIpv6CidrBlock,
65+
InstanceTenancy: news.instanceTenancy,
66+
CidrBlock: news.cidrBlock,
67+
Ipv4IpamPoolId: news.ipv4IpamPoolId,
68+
Ipv4NetmaskLength: news.ipv4NetmaskLength,
69+
Ipv6Pool: news.ipv6Pool,
70+
Ipv6CidrBlock: news.ipv6CidrBlock,
71+
Ipv6IpamPoolId: news.ipv6IpamPoolId,
72+
Ipv6NetmaskLength: news.ipv6NetmaskLength,
73+
Ipv6CidrBlockNetworkBorderGroup:
74+
news.ipv6CidrBlockNetworkBorderGroup,
75+
TagSpecifications: [
76+
{
77+
ResourceType: "vpc",
78+
Tags: createTagsList(allTags),
79+
},
80+
],
81+
DryRun: false,
82+
});
83+
84+
const vpcId = createResult.Vpc!.VpcId!;
85+
yield* session.note(`VPC created: ${vpcId}`);
86+
87+
// 3. Modify DNS attributes if specified (separate API calls)
88+
yield* ec2.modifyVpcAttribute({
89+
VpcId: vpcId,
90+
EnableDnsSupport: { Value: news.enableDnsSupport ?? true },
91+
});
92+
93+
if (news.enableDnsHostnames !== undefined) {
94+
yield* ec2.modifyVpcAttribute({
95+
VpcId: vpcId,
96+
EnableDnsHostnames: { Value: news.enableDnsHostnames },
97+
});
98+
}
99+
100+
// 4. Wait for VPC to be available
101+
const vpc = yield* waitForVpcAvailable(ec2, vpcId, session);
102+
103+
// 6. Return attributes
104+
return {
105+
vpcId,
106+
vpcArn:
107+
`arn:aws:ec2:${region}:${accountId}:vpc/${vpcId}` as VpcAttrs<VpcProps>["vpcArn"],
108+
cidrBlock: vpc.CidrBlock!,
109+
dhcpOptionsId: vpc.DhcpOptionsId!,
110+
state: vpc.State!,
111+
isDefault: vpc.IsDefault ?? false,
112+
ownerId: vpc.OwnerId,
113+
cidrBlockAssociationSet: vpc.CidrBlockAssociationSet?.map(
114+
(assoc) => ({
115+
associationId: assoc.AssociationId!,
116+
cidrBlock: assoc.CidrBlock!,
117+
cidrBlockState: {
118+
state: assoc.CidrBlockState!.State!,
119+
statusMessage: assoc.CidrBlockState!.StatusMessage,
120+
},
121+
}),
122+
),
123+
ipv6CidrBlockAssociationSet: vpc.Ipv6CidrBlockAssociationSet?.map(
124+
(assoc) => ({
125+
associationId: assoc.AssociationId!,
126+
ipv6CidrBlock: assoc.Ipv6CidrBlock!,
127+
ipv6CidrBlockState: {
128+
state: assoc.Ipv6CidrBlockState!.State!,
129+
statusMessage: assoc.Ipv6CidrBlockState!.StatusMessage,
130+
},
131+
networkBorderGroup: assoc.NetworkBorderGroup,
132+
ipv6Pool: assoc.Ipv6Pool,
133+
}),
134+
),
135+
} satisfies VpcAttrs<VpcProps>;
136+
}),
137+
138+
update: Effect.fn(function* ({ news, olds, output, session }) {
139+
const vpcId = output.vpcId;
140+
141+
// Only DNS and metrics settings can be updated
142+
// Everything else requires replacement (handled by diff)
143+
144+
if (news.enableDnsSupport !== olds.enableDnsSupport) {
145+
yield* ec2.modifyVpcAttribute({
146+
VpcId: vpcId,
147+
EnableDnsSupport: { Value: news.enableDnsSupport ?? true },
148+
});
149+
yield* session.note("Updated DNS support");
150+
}
151+
152+
if (news.enableDnsHostnames !== olds.enableDnsHostnames) {
153+
yield* ec2.modifyVpcAttribute({
154+
VpcId: vpcId,
155+
EnableDnsHostnames: { Value: news.enableDnsHostnames ?? false },
156+
});
157+
yield* session.note("Updated DNS hostnames");
158+
}
159+
160+
// Note: Tag updates would go here if we support user tag changes
161+
162+
return output; // VPC attributes don't change from these updates
163+
}),
164+
165+
delete: Effect.fn(function* ({ output, session }) {
166+
const vpcId = output.vpcId;
167+
168+
yield* session.note(`Deleting VPC: ${vpcId}`);
169+
170+
// 1. Attempt to delete VPC
171+
yield* ec2
172+
.deleteVpc({
173+
VpcId: vpcId,
174+
DryRun: false,
175+
})
176+
.pipe(
177+
Effect.tapError(Effect.logDebug),
178+
Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void),
179+
// Retry on dependency violations (resources still being deleted)
180+
Effect.retry({
181+
while: (e) => {
182+
// DependencyViolation means there are still dependent resources
183+
// This can happen if subnets/IGW are being deleted concurrently
184+
return (
185+
e._tag === "ValidationError" &&
186+
e.message?.includes("DependencyViolation")
187+
);
188+
},
189+
schedule: Schedule.exponential(1000, 1.5).pipe(
190+
Schedule.intersect(Schedule.recurs(10)), // Try up to 10 times
191+
Schedule.tapOutput(([, attempt]) =>
192+
session.note(
193+
`Waiting for dependencies to clear... (attempt ${attempt + 1})`,
194+
),
195+
),
196+
),
197+
}),
198+
);
199+
200+
// 2. Wait for VPC to be fully deleted
201+
yield* waitForVpcDeleted(ec2, vpcId, session);
202+
203+
yield* session.note(`VPC ${vpcId} deleted successfully`);
204+
}),
205+
} satisfies ProviderService<Vpc>;
206+
}),
207+
);
208+
209+
/**
210+
* Wait for VPC to be in available state
211+
*/
212+
const waitForVpcAvailable = (
213+
ec2: EC2,
214+
vpcId: string,
215+
session?: ScopedPlanStatusSession,
216+
) =>
217+
Effect.retry(
218+
Effect.gen(function* () {
219+
const result = yield* ec2
220+
.describeVpcs({ VpcIds: [vpcId] })
221+
.pipe(
222+
Effect.catchTag("InvalidVpcID.NotFound", () =>
223+
Effect.succeed({ Vpcs: [] }),
224+
),
225+
);
226+
const vpc = result.Vpcs![0];
227+
228+
if (vpc.State === "available") {
229+
return vpc;
230+
}
231+
232+
// Still pending, fail to trigger retry
233+
return yield* Effect.fail(new Error("VPC not yet available"));
234+
}),
235+
{
236+
schedule: Schedule.fixed(2000).pipe(
237+
// Check every 2 seconds
238+
Schedule.intersect(Schedule.recurs(30)), // Max 60 seconds
239+
Schedule.tapOutput(([, attempt]) =>
240+
session
241+
? session.note(
242+
`Waiting for VPC to be available... (${(attempt + 1) * 2}s)`,
243+
)
244+
: Effect.void,
245+
),
246+
),
247+
},
248+
);
249+
250+
/**
251+
* Wait for VPC to be deleted
252+
*/
253+
const waitForVpcDeleted = (
254+
ec2: EC2,
255+
vpcId: string,
256+
session: ScopedPlanStatusSession,
257+
) =>
258+
Effect.gen(function* () {
259+
yield* Effect.retry(
260+
Effect.gen(function* () {
261+
const result = yield* ec2.describeVpcs({ VpcIds: [vpcId] }).pipe(
262+
Effect.tapError(Effect.logDebug),
263+
Effect.catchTag("InvalidVpcID.NotFound", () =>
264+
Effect.succeed({ Vpcs: [] }),
265+
),
266+
);
267+
268+
if (!result.Vpcs || result.Vpcs.length === 0) {
269+
return; // Successfully deleted
270+
}
271+
272+
// Still exists, fail to trigger retry
273+
return yield* Effect.fail(new Error("VPC still exists"));
274+
}),
275+
{
276+
schedule: Schedule.fixed(2000).pipe(
277+
// Check every 2 seconds
278+
Schedule.intersect(Schedule.recurs(15)), // Max 30 seconds
279+
Schedule.tapOutput(([, attempt]) =>
280+
session.note(`Waiting for VPC deletion... (${(attempt + 1) * 2}s)`),
281+
),
282+
),
283+
},
284+
);
285+
});

0 commit comments

Comments
 (0)