1import * as AWS from "@/AWS";
2import { SecurityGroup, SecurityGroupRule, Vpc } from "@/AWS/EC2";
3import * as Provider from "@/Provider";
4import * as Test from "./VpcTest.ts";
5import * as EC2 from "@distilled.cloud/aws/ec2";
6import * as Schedule from "effect/Schedule";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import { assertSecurityGroupGone, assertVpcGone } from "./Gone.ts";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19test.provider(
20 "list enumerates the deployed Security Group Rule",
21 (stack) =>
22 Effect.gen(function* () {
23 yield* stack.destroy();
24
25 const { vpc, sg, rule } = yield* stack.deploy(
26 Effect.gen(function* () {
27 const vpc = yield* Vpc("ListSgrVpc", {
28 cidrBlock: "10.0.0.0/16",
29 });
30 const sg = yield* SecurityGroup("ListSgrSg", {
31 vpcId: vpc.vpcId,
32 });
33 const rule = yield* SecurityGroupRule("ListSgr", {
34 groupId: sg.groupId,
35 type: "ingress",
36 ipProtocol: "tcp",
37 fromPort: 443,
38 toPort: 443,
39 cidrIpv4: "10.0.0.0/16",
40 });
41 return { vpc, sg, rule };
42 }),
43 );
44
45 // Modification requires canonical CIDRs; authorization normalizes them.
46 const rejection = yield* EC2.modifySecurityGroupRules({
47 GroupId: sg.groupId,
48 SecurityGroupRules: [
49 {
50 SecurityGroupRuleId: rule.securityGroupRuleId,
51 SecurityGroupRule: {
52 IpProtocol: "tcp",
53 FromPort: 443,
54 ToPort: 443,
55 CidrIpv4: "10.0.0.7/16",
56 },
57 },
58 ],
59 }).pipe(
60 Effect.as(undefined),
61 Effect.catchTag("InvalidParameterValue", (error) =>
62 Effect.succeed(error),
63 ),
64 );
65 expect(rejection?._tag).toBe("InvalidParameterValue");
66 expect(rejection?.message).toBe(
67 "CIDR block 10.0.0.7/16 is not in canonical form",
68 );
69
70 const provider = yield* Provider.findProvider(SecurityGroupRule);
71 const all = yield* provider.list();
72
73 expect(
74 all.some((x) => x.securityGroupRuleId === rule.securityGroupRuleId),
75 ).toBe(true);
76
77 yield* stack.destroy();
78
79 // The rule dies with its security group; group + VPC gone proves full
80 // teardown.
81 yield* assertSecurityGroupGone(sg.groupId);
82 yield* assertVpcGone(vpc.vpcId);
83 }).pipe(logLevel),
84 { tags: ["provider:aws", "provider:aws:ec2", "live"] },
85);
86
87for (const type of ["ingress", "egress"] as const) {
88 test.provider(
89 `repairs standalone ${type} rule drift with unchanged inputs`,
90 (stack) =>
91 Effect.gen(function* () {
92 yield* stack.destroy();
93 const program = (description?: string) =>
94 Effect.gen(function* () {
95 const vpc = yield* Vpc("DriftVpc", { cidrBlock: "10.0.0.0/16" });
96 const sg = yield* SecurityGroup("DriftSg", {
97 vpcId: vpc.vpcId,
98 egress: [],
99 });
100 const rule = yield* SecurityGroupRule("DriftRule", {
101 groupId: sg.groupId,
102 type,
103 ipProtocol: "6",
104 fromPort: 443,
105 toPort: 443,
106 cidrIpv4: "10.0.0.7/16",
107 description,
108 tags: { purpose: "managed" },
109 });
110 return { sg, vpc, rule };
111 });
112 const desired = program("managed rule");
113 const { sg, vpc, rule } = yield* stack.deploy(desired);
114 const readRule = EC2.describeSecurityGroupRules({
115 SecurityGroupRuleIds: [rule.securityGroupRuleId],
116 }).pipe(Effect.map((result) => result.SecurityGroupRules![0]!));
117 expect((yield* stack.plan(desired)).resources.DriftRule?.action).toBe(
118 "noop",
119 );
120 const rogue = yield* EC2.authorizeSecurityGroupIngress({
121 GroupId: sg.groupId,
122 IpPermissions: [
123 {
124 IpProtocol: "tcp",
125 FromPort: 22,
126 ToPort: 22,
127 IpRanges: [{ CidrIp: "0.0.0.0/0" }],
128 },
129 ],
130 });
131 const rogueId = rogue.SecurityGroupRules![0]!.SecurityGroupRuleId!;
132 yield* EC2.deleteTags({
133 Resources: [rule.securityGroupRuleId],
134 Tags: [{ Key: "alchemy::id" }],
135 });
136 yield* EC2.createTags({
137 Resources: [rule.securityGroupRuleId],
138 Tags: [{ Key: "purpose", Value: "external" }],
139 });
140 for (const drift of [
141 {
142 IpProtocol: "udp",
143 FromPort: 443,
144 ToPort: 443,
145 CidrIpv4: "10.0.0.0/16",
146 },
147 {
148 IpProtocol: "tcp",
149 FromPort: 8443,
150 ToPort: 8443,
151 CidrIpv4: "10.0.0.0/16",
152 },
153 {
154 IpProtocol: "tcp",
155 FromPort: 443,
156 ToPort: 443,
157 CidrIpv4: "0.0.0.0/0",
158 },
159 ]) {
160 yield* EC2.modifySecurityGroupRules({
161 GroupId: sg.groupId,
162 SecurityGroupRules: [
163 {
164 SecurityGroupRuleId: rule.securityGroupRuleId,
165 SecurityGroupRule: { ...drift, Description: "managed rule" },
166 },
167 ],
168 });
169 const observed = yield* readRule.pipe(
170 Effect.repeat({
171 until: (value) =>
172 value.IpProtocol === drift.IpProtocol &&
173 value.FromPort === drift.FromPort &&
174 value.CidrIpv4 === drift.CidrIpv4,
175 schedule: Schedule.spaced("1 second"),
176 times: 8,
177 }),
178 );
179 expect(observed.IpProtocol).toBe(drift.IpProtocol);
180 expect(observed.FromPort).toBe(drift.FromPort);
181 expect(observed.CidrIpv4).toBe(drift.CidrIpv4);
182 expect(observed.Description).toBe("managed rule");
183 expect((yield* stack.plan(desired)).resources.DriftRule?.action).toBe(
184 "update",
185 );
186 const repaired = yield* stack.deploy(desired);
187 expect(repaired.rule.securityGroupRuleId).toBe(
188 rule.securityGroupRuleId,
189 );
190 const actual = yield* readRule;
191 expect(actual.IpProtocol).toBe("tcp");
192 expect(actual.FromPort).toBe(443);
193 expect(actual.ToPort).toBe(443);
194 expect(actual.CidrIpv4).toBe("10.0.0.0/16");
195 expect(actual.Description).toBe("managed rule");
196 expect(actual.Tags?.find((tag) => tag.Key === "purpose")?.Value).toBe(
197 "managed",
198 );
199 expect(
200 actual.Tags?.find((tag) => tag.Key === "alchemy::id")?.Value,
201 ).toBe("DriftRule");
202 expect((yield* stack.plan(desired)).resources.DriftRule?.action).toBe(
203 "noop",
204 );
205 }
206 const groupRules = (yield* EC2.describeSecurityGroupRules({
207 Filters: [{ Name: "group-id", Values: [sg.groupId] }],
208 })).SecurityGroupRules!;
209 expect(
210 groupRules.some((value) => value.SecurityGroupRuleId === rogueId),
211 ).toBe(false);
212 expect(
213 groupRules.some(
214 (value) => value.SecurityGroupRuleId === rule.securityGroupRuleId,
215 ),
216 ).toBe(true);
217 yield* stack.deploy(program());
218 expect((yield* readRule).Description ?? "").toBe("");
219 expect((yield* stack.plan(program())).resources.DriftRule?.action).toBe(
220 "noop",
221 );
222 yield* stack.destroy();
223 const absent = yield* EC2.describeSecurityGroupRules({
224 SecurityGroupRuleIds: [rule.securityGroupRuleId],
225 }).pipe(
226 Effect.as(false),
227 Effect.catchTag("InvalidSecurityGroupRuleId.NotFound", () =>
228 Effect.succeed(true),
229 ),
230 Effect.repeat({
231 until: Boolean,
232 schedule: Schedule.spaced("1 second"),
233 times: 8,
234 }),
235 );
236 expect(absent).toBe(true);
237 yield* assertSecurityGroupGone(sg.groupId);
238 yield* assertVpcGone(vpc.vpcId);
239 }),
240 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
241 );
242}