1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as EC2 from "@distilled.cloud/aws/ec2";
4import * as Lambda from "@distilled.cloud/aws/lambda";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import { fileURLToPath } from "node:url";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12const handlerPath = fileURLToPath(
13 new URL("./timeout-handler.ts", import.meta.url),
14);
15
16// Repro for https://github.com/xofromthemoon/alchemy-aws-demo — a VPC-attached
17// Lambda leaves Hyperplane ENIs in its subnets/security group after
18// DeleteFunction. AWS releases them asynchronously (up to ~20 minutes), which
19// used to outlive the Subnet/SecurityGroup DependencyViolation retry budgets
20// and fail the destroy, forcing users to run destroy multiple times. The
21// delete paths now reap detached Lambda ENIs explicitly so a single destroy
22// converges. Slow (minutes of AWS-side ENI release), so FAST-gated.
23test.provider.skipIf(!!process.env.FAST)(
24 "destroy converges in one pass with a VPC-attached function",
25 (stack) =>
26 Effect.gen(function* () {
27 yield* stack.destroy();
28
29 const program = Effect.gen(function* () {
30 const network = yield* AWS.EC2.Network("Network", {
31 cidrBlock: "10.42.0.0/16",
32 availabilityZones: 2,
33 nat: "none",
34 });
35
36 const sg = yield* AWS.EC2.SecurityGroup("FunctionSecurityGroup", {
37 vpcId: network.vpcId,
38 description: "VPC lambda destroy repro",
39 ingress: [
40 {
41 ipProtocol: "tcp",
42 fromPort: 443,
43 toPort: 443,
44 cidrIpv4: "10.42.0.0/16",
45 description: "intra-vpc https",
46 },
47 ],
48 });
49
50 const fn = yield* AWS.Lambda.Function("VpcFn", {
51 main: handlerPath,
52 handler: "handler",
53 isExternal: true,
54 functionUrl: false,
55 vpc: {
56 subnetIds: network.privateSubnetIds,
57 securityGroupIds: [sg.groupId],
58 },
59 });
60
61 return {
62 vpcId: network.vpcId,
63 privateSubnetIds: network.privateSubnetIds,
64 groupId: sg.groupId,
65 functionName: fn.functionName,
66 };
67 });
68
69 const deployed = yield* stack.deploy(program);
70 const configuration = yield* Lambda.getFunctionConfiguration({
71 FunctionName: deployed.functionName,
72 });
73 expect(configuration.State).toBe("Active");
74 expect(configuration.LastUpdateStatus).toBe("Successful");
75 expect(configuration.VpcConfig?.VpcId).toBe(deployed.vpcId);
76
77 // The function's Hyperplane ENI(s) materialize in the private subnets
78 // shortly after the function goes Active — poll (describe is
79 // eventually consistent right after deploy). Note: attached Hyperplane
80 // ENIs report InterfaceType "lambda"; once detached they flip to plain
81 // "interface" and only the description identifies them.
82 const isLambdaEni = (eni: EC2.NetworkInterface): boolean =>
83 eni.InterfaceType === "lambda" ||
84 (eni.Description?.startsWith("AWS Lambda VPC ENI") ?? false);
85 const before = yield* EC2.describeNetworkInterfaces({
86 Filters: [{ Name: "vpc-id", Values: [deployed.vpcId] }],
87 }).pipe(
88 Effect.repeat({
89 schedule: Schedule.spaced("5 seconds"),
90 until: (page) => (page.NetworkInterfaces ?? []).some(isLambdaEni),
91 times: 36,
92 }),
93 );
94 expect((before.NetworkInterfaces ?? []).some(isLambdaEni)).toBe(true);
95
96 // One destroy must fully tear down: function, SG, subnets, VPC.
97 yield* stack.destroy();
98
99 const vpcs = yield* EC2.describeVpcs({
100 VpcIds: [deployed.vpcId],
101 }).pipe(
102 Effect.catchTag("InvalidVpcID.NotFound", () =>
103 Effect.succeed({ Vpcs: [] }),
104 ),
105 );
106 expect(vpcs.Vpcs ?? []).toHaveLength(0);
107 }),
108 {
109 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:lambda", "live"],
110 timeout: 30 * 60 * 1000,
111 },
112);