| 1 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 2 | import { Region } from "@distilled.cloud/aws/Region"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Namespace from "../../Namespace.ts"; |
| 5 | import * as Output from "../../Output.ts"; |
| 6 | import type { EIP as EIPResource } from "./EIP.ts"; |
| 7 | import { EIP } from "./EIP.ts"; |
| 8 | import type { InternetGateway as InternetGatewayResource } from "./InternetGateway.ts"; |
| 9 | import { InternetGateway } from "./InternetGateway.ts"; |
| 10 | import type { NatGateway as NatGatewayResource } from "./NatGateway.ts"; |
| 11 | import { NatGateway } from "./NatGateway.ts"; |
| 12 | import type { Route as RouteResource } from "./Route.ts"; |
| 13 | import { Route } from "./Route.ts"; |
| 14 | import type { RouteTable as RouteTableResource } from "./RouteTable.ts"; |
| 15 | import { RouteTable } from "./RouteTable.ts"; |
| 16 | import type { RouteTableAssociation as RouteTableAssociationResource } from "./RouteTableAssociation.ts"; |
| 17 | import { RouteTableAssociation } from "./RouteTableAssociation.ts"; |
| 18 | import type { Subnet as SubnetResource } from "./Subnet.ts"; |
| 19 | import { Subnet } from "./Subnet.ts"; |
| 20 | import type { Vpc as VpcResource } from "./Vpc.ts"; |
| 21 | import { Vpc } from "./Vpc.ts"; |
| 22 | import type { VpcEndpoint as VpcEndpointResource } from "./VpcEndpoint.ts"; |
| 23 | import { VpcEndpoint } from "./VpcEndpoint.ts"; |
| 24 | |
| 25 | export type NetworkNat = "none" | "single" | "per-az"; |
| 26 | |
| 27 | export type NetworkGatewayEndpoint = "s3" | "dynamodb"; |
| 28 | |
| 29 | export 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 | |
| 75 | export 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 | |
| 95 | export 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 | */ |
| 133 | export 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 | |
| 376 | const 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 | |
| 434 | const 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 | |
| 466 | const 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 | |
| 472 | const 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 | |
| 486 | const 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 | |
| 494 | const toCidr = (value: number, prefix: number) => |
| 495 | `${numberToIpv4(value)}/${prefix}`; |
| 496 | |
| 497 | const uniqueGatewayEndpoints = (services: NetworkGatewayEndpoint[] = []) => [ |
| 498 | ...new Set(services), |
| 499 | ]; |
| 500 | |
| 501 | const toEndpointId = (service: NetworkGatewayEndpoint) => |
| 502 | service === "s3" ? "S3" : "DynamoDb"; |