| 1 | import * as AWS from "@/AWS"; |
| 2 | import { SecurityGroup, Subnet, Vpc } from "@/AWS/EC2"; |
| 3 | import { Role } from "@/AWS/IAM"; |
| 4 | import * as Output from "@/Output"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import * as EC2 from "@distilled.cloud/aws/ec2"; |
| 8 | import * as lambdacore from "@distilled.cloud/aws/lambda-core"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: AWS.providers() }); |
| 13 | |
| 14 | // Lambda MicroVM network connectors are a preview feature. Creation provisions |
| 15 | // ENIs in the VPC and can take several minutes to reach ACTIVE (we've observed |
| 16 | // the full lifecycle blowing a 25-minute budget and leaking its VPC — which |
| 17 | // then starves the account's 5-VPC quota and fails unrelated EC2 suites), and |
| 18 | // the API requires an account that is onboarded to the preview. Gate the live |
| 19 | // lifecycle behind LAMBDA_TEST_NETWORK_CONNECTOR=1 so an entitled account runs |
| 20 | // it unchanged. |
| 21 | test.provider.skipIf(!process.env.LAMBDA_TEST_NETWORK_CONNECTOR)( |
| 22 | "create, update, list, delete network connector", |
| 23 | (stack) => |
| 24 | Effect.gen(function* () { |
| 25 | yield* stack.destroy(); |
| 26 | |
| 27 | const azResult = yield* EC2.describeAvailabilityZones({}); |
| 28 | const az = azResult.AvailabilityZones?.find( |
| 29 | (z) => z.State === "available", |
| 30 | )?.ZoneName!; |
| 31 | |
| 32 | const infra = (networkProtocol: lambdacore.NetworkProtocol) => |
| 33 | Effect.gen(function* () { |
| 34 | // The DualStack update step requires the subnet to carry an IPv6 |
| 35 | // CIDR, so provision the VPC with an Amazon-provided /56 and carve |
| 36 | // the subnet the first /64 out of it. |
| 37 | const vpc = yield* Vpc("ConnectorVpc", { |
| 38 | cidrBlock: "10.0.0.0/16", |
| 39 | amazonProvidedIpv6CidrBlock: true, |
| 40 | }); |
| 41 | const subnet = yield* Subnet("ConnectorSubnet", { |
| 42 | vpcId: vpc.vpcId, |
| 43 | cidrBlock: "10.0.1.0/24", |
| 44 | availabilityZone: az, |
| 45 | ipv6CidrBlock: vpc.ipv6CidrBlockAssociationSet.pipe( |
| 46 | Output.map((set) => set![0]!.ipv6CidrBlock.replace("/56", "/64")), |
| 47 | ), |
| 48 | }); |
| 49 | const sg = yield* SecurityGroup("ConnectorSg", { |
| 50 | vpcId: vpc.vpcId, |
| 51 | description: "MicroVM egress", |
| 52 | }); |
| 53 | const role = yield* Role("ConnectorOperator", { |
| 54 | assumeRolePolicyDocument: { |
| 55 | Version: "2012-10-17", |
| 56 | Statement: [ |
| 57 | { |
| 58 | Effect: "Allow", |
| 59 | Principal: { Service: "lambda.amazonaws.com" }, |
| 60 | Action: ["sts:AssumeRole"], |
| 61 | }, |
| 62 | ], |
| 63 | }, |
| 64 | // The connector service assumes this role to manage ENIs in the VPC. |
| 65 | inlinePolicies: { |
| 66 | "eni-management": { |
| 67 | Version: "2012-10-17", |
| 68 | Statement: [ |
| 69 | { |
| 70 | Effect: "Allow", |
| 71 | Action: [ |
| 72 | "ec2:CreateNetworkInterface", |
| 73 | "ec2:DescribeNetworkInterfaces", |
| 74 | "ec2:DeleteNetworkInterface", |
| 75 | "ec2:DescribeSubnets", |
| 76 | "ec2:DescribeSecurityGroups", |
| 77 | "ec2:DescribeVpcs", |
| 78 | "ec2:AssignPrivateIpAddresses", |
| 79 | "ec2:UnassignPrivateIpAddresses", |
| 80 | "ec2:CreateTags", |
| 81 | ], |
| 82 | Resource: "*", |
| 83 | }, |
| 84 | ], |
| 85 | }, |
| 86 | }, |
| 87 | }); |
| 88 | const connector = yield* AWS.Lambda.NetworkConnector("Connector", { |
| 89 | subnetIds: [subnet.subnetId], |
| 90 | securityGroupIds: [sg.groupId], |
| 91 | networkProtocol, |
| 92 | operatorRole: role.roleArn, |
| 93 | }); |
| 94 | return { connector }; |
| 95 | }); |
| 96 | |
| 97 | // --- create --- |
| 98 | const created = yield* stack.deploy(infra("IPv4")); |
| 99 | expect(created.connector.state).toBe("ACTIVE"); |
| 100 | expect(created.connector.networkConnectorArn).toContain( |
| 101 | "network-connector", |
| 102 | ); |
| 103 | |
| 104 | const fetched = yield* lambdacore.getNetworkConnector({ |
| 105 | Identifier: created.connector.networkConnectorId, |
| 106 | }); |
| 107 | expect(fetched.State).toBe("ACTIVE"); |
| 108 | |
| 109 | // --- update (network protocol) --- |
| 110 | const updated = yield* stack.deploy(infra("DualStack")); |
| 111 | expect(updated.connector.networkConnectorArn).toBe( |
| 112 | created.connector.networkConnectorArn, |
| 113 | ); |
| 114 | expect(updated.connector.networkProtocol).toBe("DualStack"); |
| 115 | |
| 116 | // --- list --- |
| 117 | const provider = yield* Provider.findProvider( |
| 118 | AWS.Lambda.NetworkConnector, |
| 119 | ); |
| 120 | const all = yield* provider.list(); |
| 121 | expect( |
| 122 | all.some( |
| 123 | (c) => c.networkConnectorId === created.connector.networkConnectorId, |
| 124 | ), |
| 125 | ).toBe(true); |
| 126 | |
| 127 | // --- delete --- |
| 128 | yield* stack.destroy(); |
| 129 | const afterDestroy = yield* lambdacore |
| 130 | .getNetworkConnector({ |
| 131 | Identifier: created.connector.networkConnectorId, |
| 132 | }) |
| 133 | .pipe( |
| 134 | Effect.catchTag("ResourceNotFoundException", () => |
| 135 | Effect.succeed(undefined), |
| 136 | ), |
| 137 | ); |
| 138 | expect(afterDestroy).toBeUndefined(); |
| 139 | }).pipe( |
| 140 | Effect.tap(() => stack.destroy()), |
| 141 | Effect.onError(() => stack.destroy().pipe(Effect.ignore)), |
| 142 | ), |
| 143 | { |
| 144 | tags: [ |
| 145 | "provider:aws", |
| 146 | "provider:aws:ec2", |
| 147 | "provider:aws:iam", |
| 148 | "provider:aws:lambda", |
| 149 | "live", |
| 150 | ], |
| 151 | timeout: 1_500_000, |
| 152 | }, |
| 153 | ); |