1import * as ec2 from "@distilled.cloud/aws/ec2";
2import { Region } from "@distilled.cloud/aws/Region";
3import * as Effect from "effect/Effect";
4import * as Namespace from "../../Namespace.ts";
5import * as Output from "../../Output.ts";
6import type { EIP as EIPResource } from "./EIP.ts";
7import { EIP } from "./EIP.ts";
8import type { InternetGateway as InternetGatewayResource } from "./InternetGateway.ts";
9import { InternetGateway } from "./InternetGateway.ts";
10import type { NatGateway as NatGatewayResource } from "./NatGateway.ts";
11import { NatGateway } from "./NatGateway.ts";
12import type { Route as RouteResource } from "./Route.ts";
13import { Route } from "./Route.ts";
14import type { RouteTable as RouteTableResource } from "./RouteTable.ts";
15import { RouteTable } from "./RouteTable.ts";
16import type { RouteTableAssociation as RouteTableAssociationResource } from "./RouteTableAssociation.ts";
17import { RouteTableAssociation } from "./RouteTableAssociation.ts";
18import type { Subnet as SubnetResource } from "./Subnet.ts";
19import { Subnet } from "./Subnet.ts";
20import type { Vpc as VpcResource } from "./Vpc.ts";
21import { Vpc } from "./Vpc.ts";
22import type { VpcEndpoint as VpcEndpointResource } from "./VpcEndpoint.ts";
23import { VpcEndpoint } from "./VpcEndpoint.ts";
24
25export type NetworkNat = "none" | "single" | "per-az";
26
27export type NetworkGatewayEndpoint = "s3" | "dynamodb";
28
29export interface NetworkProps {
30 /**
31 * IPv4 CIDR block for the VPC.
32 * @example "10.42.0.0/16"
33 */
34 cidrBlock: string;
35
36 /**
37 * Number of Availability Zones to span or an explicit ordered list of zone names.
38 * Defaults to 2 available zones.
39 */
40 availabilityZones?: number | string[];
41
42 /**
43 * NAT strategy for private subnets.
44 * - `"none"` keeps private subnets isolated
45 * - `"single"` creates one shared NAT gateway
46 * - `"per-az"` creates one NAT gateway per Availability Zone
47 * @default "none"
48 */
49 nat?: NetworkNat;
50
51 /**
52 * Gateway endpoints to attach to the private route tables.
53 * Supported in v1: S3 and DynamoDB.
54 */
55 gatewayEndpoints?: NetworkGatewayEndpoint[];
56
57 /**
58 * Whether DNS resolution is supported for the VPC.
59 * @default true
60 */
61 enableDnsSupport?: boolean;
62
63 /**
64 * Whether instances launched in the VPC receive DNS hostnames.
65 * @default true
66 */
67 enableDnsHostnames?: boolean;
68
69 /**
70 * Tags to apply to all created resources.
71 */
72 tags?: Record<string, string>;
73}
74
75export interface NetworkResources {
76 availabilityZones: string[];
77 vpc: VpcResource;
78 internetGateway?: InternetGatewayResource;
79 elasticIps: EIPResource[];
80 natGateways: NatGatewayResource[];
81 publicSubnets: SubnetResource[];
82 privateSubnets: SubnetResource[];
83 publicRouteTables: RouteTableResource[];
84 privateRouteTables: RouteTableResource[];
85 publicRoutes: RouteResource[];
86 privateRoutes: RouteResource[];
87 publicRouteAssociations: RouteTableAssociationResource[];
88 privateRouteAssociations: RouteTableAssociationResource[];
89 gatewayEndpoints: VpcEndpointResource[];
90 vpcId: VpcResource["vpcId"];
91 publicSubnetIds: Array<SubnetResource["subnetId"]>;
92 privateSubnetIds: Array<SubnetResource["subnetId"]>;
93}
94
95export type Network = Effect.Success<ReturnType<typeof Network>>;
96
97/**
98 * Creates a production-shaped VPC network from the low-level EC2 primitives.
99 *
100 * `Network` is the ergonomic entry point for users who want a ready-to-use VPC
101 * layout without manually creating route tables, internet gateways, NAT
102 * gateways, and subnet associations by hand.
103 *
104 * The helper still returns the underlying canonical resources so callers can
105 * keep composing with raw `AWS.EC2.*` APIs when they need more control.
106 * **Example:** Minimal network
107 * ```typescript
108 * const network = yield* AWS.EC2.Network("AppNetwork", {
109 * cidrBlock: "10.42.0.0/16",
110 * });
111 * ```
112 *
113 * **Example:** ECS-ready network with shared NAT
114 * ```typescript
115 * const network = yield* AWS.EC2.Network("AppNetwork", {
116 * cidrBlock: "10.42.0.0/16",
117 * availabilityZones: 2,
118 * nat: "single",
119 * gatewayEndpoints: ["s3"],
120 * });
121 *
122 * yield* AWS.ECS.Service("ApiService", {
123 * cluster,
124 * task: apiTask,
125 * vpcId: network.vpcId,
126 * subnets: network.publicSubnetIds,
127 * assignPublicIp: true,
128 * });
129 * ```
130 *
131 * @resource
132 */
133export const Network = (id: string, props: NetworkProps) =>
134 Namespace.push(
135 id,
136 Effect.gen(function* () {
137 const availabilityZones = yield* resolveAvailabilityZones(
138 props.availabilityZones,
139 );
140 const subnetCidrs = deriveSubnetCidrs(
141 props.cidrBlock,
142 availabilityZones.length,
143 );
144 const tags = props.tags;
145
146 const vpc = yield* Vpc("Vpc", {
147 cidrBlock: props.cidrBlock,
148 enableDnsSupport: props.enableDnsSupport ?? true,
149 enableDnsHostnames: props.enableDnsHostnames ?? true,
150 tags,
151 });
152
153 const internetGateway = yield* InternetGateway("InternetGateway", {
154 vpcId: vpc.vpcId,
155 tags,
156 });
157
158 const publicSubnets: SubnetResource[] = [];
159 const privateSubnets: SubnetResource[] = [];
160
161 for (const [index, availabilityZone] of availabilityZones.entries()) {
162 const publicSubnet = yield* Subnet(`PublicSubnet${index + 1}`, {
163 vpcId: vpc.vpcId,
164 cidrBlock: subnetCidrs.public[index],
165 availabilityZone,
166 mapPublicIpOnLaunch: true,
167 tags: {
168 // Kubernetes load-balancer subnet discovery (EKS Auto Mode and the
169 // AWS Load Balancer Controller both select subnets by this tag).
170 "kubernetes.io/role/elb": "1",
171 ...tags,
172 Tier: "public",
173 },
174 });
175 publicSubnets.push(publicSubnet);
176
177 const privateSubnet = yield* Subnet(`PrivateSubnet${index + 1}`, {
178 vpcId: vpc.vpcId,
179 cidrBlock: subnetCidrs.private[index],
180 availabilityZone,
181 tags: {
182 // Internal load-balancer subnet discovery (see the public-subnet
183 // `kubernetes.io/role/elb` note above).
184 "kubernetes.io/role/internal-elb": "1",
185 ...tags,
186 Tier: "private",
187 },
188 });
189 privateSubnets.push(privateSubnet);
190 }
191
192 const publicRouteTable = yield* RouteTable("PublicRouteTable", {
193 vpcId: vpc.vpcId,
194 tags: {
195 ...tags,
196 Tier: "public",
197 },
198 });
199
200 const publicInternetRoute = yield* Route("PublicInternetRoute", {
201 routeTableId: publicRouteTable.routeTableId,
202 destinationCidrBlock: "0.0.0.0/0",
203 gatewayId: internetGateway.internetGatewayId,
204 });
205
206 const publicRouteAssociations: RouteTableAssociationResource[] = [];
207
208 for (const [index, subnet] of publicSubnets.entries()) {
209 publicRouteAssociations.push(
210 yield* RouteTableAssociation(`PublicSubnetAssociation${index + 1}`, {
211 routeTableId: publicRouteTable.routeTableId,
212 subnetId: subnet.subnetId,
213 }),
214 );
215 }
216
217 const nat = props.nat ?? "none";
218 const elasticIps: EIPResource[] = [];
219 const natGateways: NatGatewayResource[] = [];
220 const privateRouteTables: RouteTableResource[] = [];
221 const privateRoutes: RouteResource[] = [];
222 const privateRouteAssociations: RouteTableAssociationResource[] = [];
223
224 if (nat === "per-az") {
225 for (const [index, subnet] of publicSubnets.entries()) {
226 const eip = yield* EIP(`NatEip${index + 1}`, {
227 domain: "vpc",
228 tags,
229 });
230 elasticIps.push(eip);
231
232 const natGateway = yield* NatGateway(`NatGateway${index + 1}`, {
233 subnetId: subnet.subnetId,
234 allocationId: eip.allocationId,
235 connectivityType: "public",
236 tags,
237 });
238 natGateways.push(natGateway);
239
240 const privateRouteTable = yield* RouteTable(
241 `PrivateRouteTable${index + 1}`,
242 {
243 vpcId: vpc.vpcId,
244 tags: {
245 ...tags,
246 Tier: "private",
247 },
248 },
249 );
250 privateRouteTables.push(privateRouteTable);
251
252 privateRoutes.push(
253 yield* Route(`PrivateInternetRoute${index + 1}`, {
254 routeTableId: privateRouteTable.routeTableId,
255 destinationCidrBlock: "0.0.0.0/0",
256 natGatewayId: natGateway.natGatewayId,
257 }),
258 );
259
260 privateRouteAssociations.push(
261 yield* RouteTableAssociation(
262 `PrivateSubnetAssociation${index + 1}`,
263 {
264 routeTableId: privateRouteTable.routeTableId,
265 subnetId: privateSubnets[index].subnetId,
266 },
267 ),
268 );
269 }
270 } else {
271 const privateRouteTable = yield* RouteTable("PrivateRouteTable", {
272 vpcId: vpc.vpcId,
273 tags: {
274 ...tags,
275 Tier: "private",
276 },
277 });
278 privateRouteTables.push(privateRouteTable);
279
280 if (nat === "single") {
281 const eip = yield* EIP("NatEip", {
282 domain: "vpc",
283 tags,
284 });
285 elasticIps.push(eip);
286
287 const natGateway = yield* NatGateway("NatGateway", {
288 subnetId: publicSubnets[0].subnetId,
289 allocationId: eip.allocationId,
290 connectivityType: "public",
291 tags,
292 });
293 natGateways.push(natGateway);
294
295 privateRoutes.push(
296 yield* Route("PrivateInternetRoute", {
297 routeTableId: privateRouteTable.routeTableId,
298 destinationCidrBlock: "0.0.0.0/0",
299 natGatewayId: natGateway.natGatewayId,
300 }),
301 );
302 }
303
304 for (const [index, subnet] of privateSubnets.entries()) {
305 privateRouteAssociations.push(
306 yield* RouteTableAssociation(
307 `PrivateSubnetAssociation${index + 1}`,
308 {
309 routeTableId: privateRouteTable.routeTableId,
310 subnetId: subnet.subnetId,
311 },
312 ),
313 );
314 }
315 }
316
317 const gatewayEndpoints: VpcEndpointResource[] = [];
318 const endpointServices = uniqueGatewayEndpoints(props.gatewayEndpoints);
319 if (endpointServices.length > 0) {
320 // Resolve the region lazily and via the `Region` service, which is
321 // provided both at deploy time and by the Lambda runtime.
322 // `AWSEnvironment` is deploy-time-only: this layer is re-executed at
323 // Function init, where `AWS::Environment` is not provided and
324 // resolving it crashes the runtime.
325 const region = yield* yield* Region;
326 for (const service of endpointServices) {
327 gatewayEndpoints.push(
328 yield* VpcEndpoint(`${toEndpointId(service)}Endpoint`, {
329 vpcId: vpc.vpcId,
330 serviceName: `com.amazonaws.${region}.${service}`,
331 vpcEndpointType: "Gateway",
332 routeTableIds: privateRouteTables.map(
333 (table) => table.routeTableId,
334 ),
335 tags,
336 }),
337 );
338 }
339 }
340
341 return {
342 availabilityZones,
343 vpc,
344 internetGateway,
345 elasticIps,
346 natGateways,
347 publicSubnets,
348 privateSubnets,
349 publicRouteTables: [publicRouteTable],
350 privateRouteTables,
351 publicRoutes: [publicInternetRoute],
352 privateRoutes,
353 publicRouteAssociations,
354 privateRouteAssociations,
355 gatewayEndpoints,
356 vpcId: vpc.vpcId,
357 // A "public subnet" is usable for public IPv4 only after both its
358 // route-table association and the route through the internet gateway
359 // exist. Preserve those dependencies in the convenience IDs returned
360 // to downstream resources. Besides preventing a launch/readiness race,
361 // this makes teardown order those consumers before the route and IGW;
362 // EC2 refuses to detach an IGW while an instance in the VPC still owns
363 // a public IPv4 address.
364 publicSubnetIds: publicSubnets.map((subnet, index) =>
365 Output.all(
366 subnet.subnetId,
367 publicRouteAssociations[index].associationId,
368 publicInternetRoute.routeTableId,
369 ).pipe(Output.map(([subnetId]) => subnetId)),
370 ),
371 privateSubnetIds: privateSubnets.map((subnet) => subnet.subnetId),
372 } satisfies NetworkResources;
373 }).pipe(Effect.orDie),
374 );
375
376const resolveAvailabilityZones = (input?: number | string[]) =>
377 Effect.gen(function* () {
378 if (Array.isArray(input)) {
379 if (input.length === 0) {
380 return yield* Effect.fail(
381 new Error("EC2.Network requires at least one availability zone"),
382 );
383 }
384 if (new Set(input).size !== input.length) {
385 return yield* Effect.fail(
386 new Error(
387 "EC2.Network availabilityZones must not contain duplicates",
388 ),
389 );
390 }
391 return input;
392 }
393
394 const desiredCount = input ?? 2;
395 if (!Number.isInteger(desiredCount) || desiredCount <= 0) {
396 return yield* Effect.fail(
397 new Error(
398 "EC2.Network availabilityZones count must be a positive integer",
399 ),
400 );
401 }
402
403 if (globalThis.__ALCHEMY_RUNTIME__) {
404 // Inside a deployed Function this composition is re-executed at init,
405 // where every `yield* Subnet(...)` resolves its attributes from the
406 // injected environment — the zone names below only shape input props
407 // that the runtime ignores. Skip ec2:DescribeAvailabilityZones
408 // entirely: the function role does not (and should not) have that
409 // permission, so calling it at init crashes with UnauthorizedOperation.
410 const region = yield* yield* Region;
411 return Array.from(
412 { length: desiredCount },
413 (_, index) => `${region}${String.fromCharCode(97 + index)}`,
414 );
415 }
416
417 const result = yield* ec2.describeAvailabilityZones({});
418 const zones = (result.AvailabilityZones ?? [])
419 .filter((zone) => zone.State === "available" && zone.ZoneName)
420 .map((zone) => zone.ZoneName!)
421 .sort((a, b) => a.localeCompare(b));
422
423 if (zones.length < desiredCount) {
424 return yield* Effect.fail(
425 new Error(
426 `EC2.Network requested ${desiredCount} availability zones, but only ${zones.length} are available`,
427 ),
428 );
429 }
430
431 return zones.slice(0, desiredCount);
432 });
433
434const deriveSubnetCidrs = (cidrBlock: string, azCount: number) => {
435 const [baseAddress, prefixText] = cidrBlock.split("/");
436 const prefix = Number(prefixText);
437 if (!baseAddress || !Number.isInteger(prefix) || prefix < 0 || prefix > 28) {
438 throw new Error(
439 `EC2.Network requires a valid IPv4 CIDR block, got '${cidrBlock}'`,
440 );
441 }
442
443 const totalSubnets = azCount * 2;
444 const additionalBits = Math.ceil(Math.log2(totalSubnets));
445 const subnetPrefix = Math.max(prefix + additionalBits, 24);
446
447 if (subnetPrefix > 28) {
448 throw new Error(
449 `EC2.Network CIDR block '${cidrBlock}' is too small for ${totalSubnets} subnets`,
450 );
451 }
452
453 const subnetSize = 2 ** (32 - subnetPrefix);
454 const base = toNetworkAddress(baseAddress, prefix);
455
456 return {
457 public: Array.from({ length: azCount }, (_, index) =>
458 toCidr(base + subnetSize * index, subnetPrefix),
459 ),
460 private: Array.from({ length: azCount }, (_, index) =>
461 toCidr(base + subnetSize * (index + azCount), subnetPrefix),
462 ),
463 };
464};
465
466const toNetworkAddress = (ip: string, prefix: number) => {
467 const value = ipv4ToNumber(ip);
468 const blockSize = 2 ** (32 - prefix);
469 return Math.floor(value / blockSize) * blockSize;
470};
471
472const ipv4ToNumber = (ip: string) => {
473 const octets = ip.split(".").map((part) => Number(part));
474 if (
475 octets.length !== 4 ||
476 octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
477 ) {
478 throw new Error(`Invalid IPv4 address '${ip}'`);
479 }
480
481 return (
482 octets[0] * 256 ** 3 + octets[1] * 256 ** 2 + octets[2] * 256 + octets[3]
483 );
484};
485
486const numberToIpv4 = (value: number) =>
487 [
488 Math.floor(value / 256 ** 3) % 256,
489 Math.floor(value / 256 ** 2) % 256,
490 Math.floor(value / 256) % 256,
491 value % 256,
492 ].join(".");
493
494const toCidr = (value: number, prefix: number) =>
495 `${numberToIpv4(value)}/${prefix}`;
496
497const uniqueGatewayEndpoints = (services: NetworkGatewayEndpoint[] = []) => [
498 ...new Set(services),
499];
500
501const toEndpointId = (service: NetworkGatewayEndpoint) =>
502 service === "s3" ? "S3" : "DynamoDb";