1import * as AWS from "@/AWS";
2import { SecurityGroup, SecurityGroupRule, Vpc } from "@/AWS/EC2";
3import type {
4 SecurityGroupProps,
5 SecurityGroupRuleData,
6} from "@/AWS/EC2/SecurityGroup.ts";
7import * as Provider from "@/Provider";
8import * as Test from "./VpcTest.ts";
9import * as EC2 from "@distilled.cloud/aws/ec2";
10import { expect } from "alchemy-test";
11import * as Effect from "effect/Effect";
12import * as Schedule from "effect/Schedule";
13import { MinimumLogLevel } from "effect/References";
14import { assertSecurityGroupGone, assertVpcGone } from "./Gone.ts";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18const logLevel = Effect.provideService(
19 MinimumLogLevel,
20 process.env.DEBUG ? "Debug" : "Info",
21);
22
23const describeRules = (groupId: string) =>
24 EC2.describeSecurityGroupRules({
25 Filters: [{ Name: "group-id", Values: [groupId] }],
26 }).pipe(Effect.map((result) => result.SecurityGroupRules ?? []));
27
28test.provider(
29 "list enumerates the deployed Security Group",
30 (stack) =>
31 Effect.gen(function* () {
32 yield* stack.destroy();
33
34 const { vpc, sg } = yield* stack.deploy(
35 Effect.gen(function* () {
36 const vpc = yield* Vpc("ListSgVpc", {
37 cidrBlock: "10.0.0.0/16",
38 });
39 const sg = yield* SecurityGroup("ListSg", {
40 vpcId: vpc.vpcId,
41 });
42 return { vpc, sg };
43 }),
44 );
45
46 const provider = yield* Provider.findProvider(SecurityGroup);
47 const all = yield* provider.list();
48
49 expect(all.some((x) => x.groupId === sg.groupId)).toBe(true);
50
51 yield* stack.destroy();
52
53 yield* assertSecurityGroupGone(sg.groupId);
54 yield* assertVpcGone(vpc.vpcId);
55 }).pipe(logLevel),
56 { tags: ["provider:aws", "provider:aws:ec2", "live"] },
57);
58
59test.provider(
60 "reconciles inline rules without taking over standalone rules",
61 (stack) =>
62 Effect.gen(function* () {
63 yield* stack.destroy();
64
65 const makeStack = (inlinePort: number, standalone = true) =>
66 Effect.gen(function* () {
67 const vpc = yield* Vpc("MixedRulesVpc", {
68 cidrBlock: "10.0.0.0/16",
69 });
70 const sg = yield* SecurityGroup("MixedRulesSg", {
71 vpcId: vpc.vpcId,
72 ingress: [
73 {
74 ipProtocol: "6",
75 fromPort: inlinePort,
76 toPort: inlinePort,
77 cidrIpv4: "10.0.0.7/16",
78 },
79 {
80 ipProtocol: "58",
81 fromPort: -1,
82 toPort: -1,
83 cidrIpv6: "2001:0db8:0:0:0:0:0:5/64",
84 },
85 ],
86 egress: [
87 {
88 ipProtocol: "6",
89 fromPort: inlinePort,
90 toPort: inlinePort,
91 cidrIpv4: "10.0.0.7/16",
92 },
93 ],
94 });
95 const ingressRule = standalone
96 ? yield* SecurityGroupRule("StandaloneIngress", {
97 groupId: sg.groupId,
98 type: "ingress",
99 ipProtocol: "tcp",
100 fromPort: 5432,
101 toPort: 5432,
102 cidrIpv4: "10.0.0.0/16",
103 })
104 : undefined;
105 const egressRule = standalone
106 ? yield* SecurityGroupRule("StandaloneEgress", {
107 groupId: sg.groupId,
108 type: "egress",
109 ipProtocol: "udp",
110 fromPort: 53,
111 toPort: 53,
112 cidrIpv4: "10.0.0.0/16",
113 })
114 : undefined;
115 return { egressRule, ingressRule, sg, vpc };
116 });
117
118 const deployed = yield* stack.deploy(makeStack(443));
119 const declared = (yield* describeRules(deployed.sg.groupId)).find(
120 (rule) =>
121 rule.SecurityGroupRuleId ===
122 deployed.ingressRule?.securityGroupRuleId,
123 )!;
124 // Even copied ownership tags do not delegate an undeclared physical rule.
125 const rogue = yield* EC2.authorizeSecurityGroupIngress({
126 GroupId: deployed.sg.groupId,
127 IpPermissions: [
128 {
129 IpProtocol: "tcp",
130 FromPort: 22,
131 ToPort: 22,
132 IpRanges: [{ CidrIp: "0.0.0.0/0" }],
133 },
134 ],
135 TagSpecifications: [
136 { ResourceType: "security-group-rule", Tags: declared.Tags },
137 ],
138 });
139 const rogueId = rogue.SecurityGroupRules![0]!.SecurityGroupRuleId!;
140 const stale = yield* EC2.authorizeSecurityGroupEgress({
141 GroupId: deployed.sg.groupId,
142 IpPermissions: [
143 {
144 IpProtocol: "tcp",
145 FromPort: 25,
146 ToPort: 25,
147 IpRanges: [{ CidrIp: "0.0.0.0/0" }],
148 },
149 ],
150 TagSpecifications: [
151 {
152 ResourceType: "security-group-rule",
153 Tags: [{ Key: "alchemy::id", Value: "RemovedRule" }],
154 },
155 ],
156 });
157 const staleId = stale.SecurityGroupRules![0]!.SecurityGroupRuleId!;
158 expect(
159 (yield* describeRules(deployed.sg.groupId).pipe(
160 Effect.repeat({
161 until: (rules) =>
162 rules.some((rule) => rule.SecurityGroupRuleId === rogueId) &&
163 rules.some((rule) => rule.SecurityGroupRuleId === staleId),
164 schedule: Schedule.spaced("1 second"),
165 times: 8,
166 }),
167 )).some((rule) => rule.SecurityGroupRuleId === rogueId),
168 ).toBe(true);
169 expect(
170 (yield* stack.plan(makeStack(443))).resources.MixedRulesSg?.action,
171 ).toBe("update");
172 yield* stack.deploy(makeStack(443));
173 const repaired = yield* describeRules(deployed.sg.groupId);
174 expect(
175 repaired.some((rule) =>
176 [rogueId, staleId].includes(rule.SecurityGroupRuleId!),
177 ),
178 ).toBe(false);
179 expect(
180 repaired.some(
181 (rule) =>
182 rule.SecurityGroupRuleId ===
183 deployed.ingressRule?.securityGroupRuleId,
184 ),
185 ).toBe(true);
186 expect(
187 repaired.some(
188 (rule) =>
189 rule.SecurityGroupRuleId ===
190 deployed.egressRule?.securityGroupRuleId,
191 ),
192 ).toBe(true);
193 expect(
194 (yield* stack.plan(makeStack(443))).resources.MixedRulesSg?.action,
195 ).toBe("noop");
196 const updated = yield* stack.deploy(makeStack(8443));
197
198 expect(updated.ingressRule?.securityGroupRuleId).toEqual(
199 deployed.ingressRule?.securityGroupRuleId,
200 );
201 expect(updated.egressRule?.securityGroupRuleId).toEqual(
202 deployed.egressRule?.securityGroupRuleId,
203 );
204
205 const updatedRules = yield* describeRules(updated.sg.groupId);
206 expect(
207 updatedRules.some(
208 (rule) =>
209 rule.SecurityGroupRuleId ===
210 updated.ingressRule?.securityGroupRuleId &&
211 rule.Tags?.some((tag) => tag.Key === "alchemy::id"),
212 ),
213 ).toBe(true);
214 expect(
215 updatedRules.some(
216 (rule) =>
217 rule.SecurityGroupRuleId ===
218 updated.egressRule?.securityGroupRuleId &&
219 rule.Tags?.some((tag) => tag.Key === "alchemy::id"),
220 ),
221 ).toBe(true);
222 expect(
223 updatedRules.filter(
224 (rule) => rule.FromPort === 8443 && rule.ToPort === 8443,
225 ),
226 ).toHaveLength(2);
227 expect(updatedRules.some((rule) => rule.FromPort === 443)).toBe(false);
228
229 yield* stack.deploy(makeStack(8443, false));
230 const finalRules = yield* describeRules(updated.sg.groupId);
231 expect(
232 finalRules.some(
233 (rule) =>
234 rule.SecurityGroupRuleId ===
235 updated.ingressRule?.securityGroupRuleId ||
236 rule.SecurityGroupRuleId ===
237 updated.egressRule?.securityGroupRuleId,
238 ),
239 ).toBe(false);
240 expect(
241 finalRules.filter(
242 (rule) => rule.FromPort === 8443 && rule.ToPort === 8443,
243 ),
244 ).toHaveLength(2);
245
246 yield* stack.destroy();
247 yield* assertSecurityGroupGone(updated.sg.groupId);
248 yield* assertVpcGone(updated.vpc.vpcId);
249 }).pipe(logLevel),
250 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
251);
252
253test.provider(
254 "creates dual-stack permissions and keeps unchanged rules stable",
255 (stack) =>
256 Effect.gen(function* () {
257 yield* stack.destroy();
258 const program = Effect.gen(function* () {
259 const vpc = yield* Vpc("DualStackVpc", { cidrBlock: "10.0.0.0/16" });
260 const permission = {
261 ipProtocol: "6",
262 fromPort: 443,
263 toPort: 443,
264 cidrIpv4: "10.0.0.7/16",
265 cidrIpv6: "2001:0db8:0:0:0:0:0:5/64",
266 description: "dual-stack HTTPS",
267 };
268 const sg = yield* SecurityGroup("DualStackSg", {
269 vpcId: vpc.vpcId,
270 ingress: [
271 permission,
272 { ipProtocol: "58", cidrIpv6: "2001:db8::/64" },
273 ],
274 egress: [permission],
275 });
276 return { sg, vpc };
277 });
278 const { sg, vpc } = yield* stack.deploy(program);
279 const rules = yield* describeRules(sg.groupId);
280 expect(rules).toHaveLength(5);
281 for (const isEgress of [false, true]) {
282 const https = rules.filter(
283 (rule) => rule.IsEgress === isEgress && rule.IpProtocol === "tcp",
284 );
285 expect(https).toHaveLength(2);
286 expect(https.some((rule) => rule.CidrIpv4 === "10.0.0.0/16")).toBe(
287 true,
288 );
289 expect(https.some((rule) => rule.CidrIpv6 === "2001:db8::/64")).toBe(
290 true,
291 );
292 }
293 expect((yield* stack.plan(program)).resources.DualStackSg?.action).toBe(
294 "noop",
295 );
296 yield* stack.deploy(program);
297 expect(
298 (yield* describeRules(sg.groupId))
299 .map((rule) => rule.SecurityGroupRuleId)
300 .sort(),
301 ).toEqual(rules.map((rule) => rule.SecurityGroupRuleId).sort());
302 yield* stack.destroy();
303 yield* assertSecurityGroupGone(sg.groupId);
304 yield* assertVpcGone(vpc.vpcId);
305 }),
306 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
307);
308
309const securityGroupStack = (props: { egress?: [] }) =>
310 Effect.gen(function* () {
311 const vpc = yield* Vpc("EmptyEgressVpc", {
312 cidrBlock: "10.0.0.0/16",
313 });
314 const sg = yield* SecurityGroup("EmptyEgressSg", {
315 vpcId: vpc.vpcId,
316 ...props,
317 });
318 return { sg, vpc };
319 });
320
321const describeEgress = (groupId: string) =>
322 EC2.describeSecurityGroupRules({
323 Filters: [{ Name: "group-id", Values: [groupId] }],
324 }).pipe(
325 Effect.map((result) =>
326 (result.SecurityGroupRules ?? []).filter((rule) => rule.IsEgress),
327 ),
328 );
329
330test.provider(
331 "distinguishes explicit empty egress from omitted egress",
332 (stack) =>
333 Effect.gen(function* () {
334 yield* stack.destroy();
335
336 // Creating with explicitly empty egress removes AWS's default allow-all rule.
337 const noOutbound = yield* stack.deploy(
338 securityGroupStack({ egress: [] }),
339 );
340 expect(yield* describeEgress(noOutbound.sg.groupId)).toEqual([]);
341
342 // Redeploying the same configuration must keep outbound access disabled.
343 const stillNoOutbound = yield* stack.deploy(
344 securityGroupStack({ egress: [] }),
345 );
346 expect(yield* describeEgress(stillNoOutbound.sg.groupId)).toEqual([]);
347
348 // Omitting the egress property restores default allow-all IPv4 access.
349 const defaultOutbound = yield* stack.deploy(securityGroupStack({}));
350 const defaultEgress = yield* describeEgress(defaultOutbound.sg.groupId);
351 expect(defaultEgress).toHaveLength(1);
352 expect(defaultEgress[0]?.IpProtocol).toEqual("-1");
353 expect(defaultEgress[0]?.CidrIpv4).toEqual("0.0.0.0/0");
354
355 // Switching back to explicitly empty egress removes allow-all again.
356 const outboundDisabledAgain = yield* stack.deploy(
357 securityGroupStack({ egress: [] }),
358 );
359 expect(yield* describeEgress(outboundDisabledAgain.sg.groupId)).toEqual(
360 [],
361 );
362
363 yield* stack.destroy();
364 yield* assertSecurityGroupGone(outboundDisabledAgain.sg.groupId);
365 yield* assertVpcGone(outboundDisabledAgain.vpc.vpcId);
366 }).pipe(logLevel),
367 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
368);
369
370test.provider(
371 "creates and updates explicitly empty egress",
372 (stack) =>
373 Effect.gen(function* () {
374 yield* stack.destroy();
375 const deploy = (egress: SecurityGroupRuleData[]) =>
376 stack.deploy(
377 Effect.gen(function* () {
378 const vpc = yield* Vpc("EmptyEgressVpc", {
379 cidrBlock: "10.0.0.0/16",
380 });
381 const sg = yield* SecurityGroup("EmptyEgressSg", {
382 vpcId: vpc.vpcId,
383 egress,
384 });
385 return { vpc, sg };
386 }),
387 );
388 const created = yield* deploy([]);
389 const initial = yield* EC2.describeSecurityGroupRules({
390 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
391 });
392 expect(initial.SecurityGroupRules).toEqual([]);
393 const configured = yield* deploy([
394 {
395 ipProtocol: "tcp",
396 fromPort: 443,
397 toPort: 443,
398 cidrIpv4: "0.0.0.0/0",
399 },
400 ]);
401 expect(configured.sg.groupId).toBe(created.sg.groupId);
402 const configuredRules = yield* EC2.describeSecurityGroupRules({
403 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
404 });
405 expect(configuredRules.SecurityGroupRules).toEqual([
406 expect.objectContaining({
407 IsEgress: true,
408 IpProtocol: "tcp",
409 FromPort: 443,
410 ToPort: 443,
411 CidrIpv4: "0.0.0.0/0",
412 }),
413 ]);
414 const updated = yield* deploy([]);
415 expect(updated.sg.groupId).toBe(created.sg.groupId);
416 const final = yield* EC2.describeSecurityGroupRules({
417 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
418 });
419 expect(final.SecurityGroupRules).toEqual([]);
420 yield* stack.destroy();
421 yield* assertSecurityGroupGone(created.sg.groupId);
422 yield* assertVpcGone(created.vpc.vpcId);
423 }).pipe(logLevel),
424 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
425);
426
427test.provider(
428 "repairs inline drift without replacing unchanged rules",
429 (stack) =>
430 Effect.gen(function* () {
431 yield* stack.destroy();
432 const deploy = () =>
433 stack.deploy(
434 Effect.gen(function* () {
435 const vpc = yield* Vpc("InlineDriftVpc", {
436 cidrBlock: "10.0.0.0/16",
437 });
438 const sg = yield* SecurityGroup("InlineDriftSg", {
439 vpcId: vpc.vpcId,
440 ingress: [
441 {
442 ipProtocol: "tcp",
443 fromPort: 443,
444 toPort: 443,
445 cidrIpv4: "10.0.0.0/16",
446 },
447 ],
448 egress: [],
449 });
450 return { vpc, sg };
451 }),
452 );
453 const created = yield* deploy();
454 const originalRuleId = created.sg.ingressRules?.[0]?.securityGroupRuleId;
455 yield* EC2.authorizeSecurityGroupIngress({
456 GroupId: created.sg.groupId,
457 IpPermissions: [
458 {
459 IpProtocol: "tcp",
460 FromPort: 22,
461 ToPort: 22,
462 IpRanges: [{ CidrIp: "10.0.0.0/16" }],
463 },
464 ],
465 });
466 const updated = yield* deploy();
467 expect(updated.sg.groupId).toBe(created.sg.groupId);
468 const rules = yield* EC2.describeSecurityGroupRules({
469 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
470 });
471 expect(rules.SecurityGroupRules).toEqual([
472 expect.objectContaining({
473 SecurityGroupRuleId: originalRuleId,
474 FromPort: 443,
475 }),
476 ]);
477 yield* stack.destroy();
478 yield* assertSecurityGroupGone(created.sg.groupId);
479 yield* assertVpcGone(created.vpc.vpcId);
480 }).pipe(logLevel),
481 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
482);
483
484test.provider(
485 "omitted directions restore defaults while empty directions disable traffic",
486 (stack) =>
487 Effect.gen(function* () {
488 yield* stack.destroy();
489 const makeStack = (
490 directions: Pick<SecurityGroupProps, "ingress" | "egress">,
491 label: string,
492 ) =>
493 Effect.gen(function* () {
494 const vpc = yield* Vpc("DirectionsVpc", { cidrBlock: "10.0.0.0/16" });
495 const sg = yield* SecurityGroup("DirectionsSg", {
496 vpcId: vpc.vpcId,
497 ...directions,
498 tags: { Label: label },
499 });
500 return { vpc, sg };
501 });
502 const rule: SecurityGroupRuleData = {
503 ipProtocol: "tcp",
504 fromPort: 443,
505 toPort: 443,
506 cidrIpv4: "10.0.0.0/16",
507 };
508 const created = yield* stack.deploy(
509 makeStack({ ingress: [rule], egress: [rule] }, "managed"),
510 );
511 const readRules = () =>
512 EC2.describeSecurityGroupRules({
513 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
514 });
515 const initial = yield* readRules();
516 expect(initial.SecurityGroupRules).toHaveLength(2);
517 const defaults = yield* stack.deploy(makeStack({}, "defaults"));
518 expect(defaults.sg.groupId).toBe(created.sg.groupId);
519 const defaultRules = yield* readRules();
520 expect(defaultRules.SecurityGroupRules).toEqual([
521 expect.objectContaining({
522 IsEgress: true,
523 IpProtocol: "-1",
524 CidrIpv4: "0.0.0.0/0",
525 }),
526 ]);
527
528 const ingressEmpty = yield* stack.deploy(
529 makeStack({ ingress: [] }, "ingress-empty"),
530 );
531 expect(ingressEmpty.sg.groupId).toBe(created.sg.groupId);
532 const egressOnly = yield* readRules();
533 expect(egressOnly.SecurityGroupRules).toEqual(
534 defaultRules.SecurityGroupRules,
535 );
536
537 const emptyStack = makeStack({ egress: [] }, "egress-empty");
538 const empty = yield* stack.deploy(emptyStack);
539 expect(empty.sg.groupId).toBe(created.sg.groupId);
540 expect((yield* readRules()).SecurityGroupRules).toEqual([]);
541 const plan = yield* stack.plan(emptyStack);
542 expect(plan.resources.DirectionsSg).toMatchObject({ action: "noop" });
543 yield* stack.deploy(emptyStack);
544 expect((yield* readRules()).SecurityGroupRules).toEqual([]);
545 yield* stack.destroy();
546 yield* assertSecurityGroupGone(created.sg.groupId);
547 yield* assertVpcGone(created.vpc.vpcId);
548 }).pipe(logLevel),
549 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
550);
551
552test.provider(
553 "updates and clears inline descriptions without replacing ingress or egress rule IDs",
554 (stack) =>
555 Effect.gen(function* () {
556 yield* stack.destroy();
557 const makeStack = (description: string | undefined) =>
558 Effect.gen(function* () {
559 const vpc = yield* Vpc("DescriptionsVpc", {
560 cidrBlock: "10.0.0.0/16",
561 });
562 const rule: SecurityGroupRuleData = {
563 ipProtocol: "tcp",
564 fromPort: 443,
565 toPort: 443,
566 cidrIpv4: "10.0.0.0/16",
567 description,
568 };
569 const sg = yield* SecurityGroup("DescriptionsSg", {
570 vpcId: vpc.vpcId,
571 ingress: [rule],
572 egress: [rule],
573 });
574 return { vpc, sg };
575 });
576 const created = yield* stack.deploy(makeStack("before"));
577 const readRules = () =>
578 EC2.describeSecurityGroupRules({
579 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
580 });
581 const initial = yield* readRules();
582 expect(initial.SecurityGroupRules).toHaveLength(2);
583 for (const rule of initial.SecurityGroupRules ?? []) {
584 expect(rule.SecurityGroupRuleId).toMatch(/^sgr-/);
585 expect(rule.Description).toBe("before");
586 }
587 for (const description of ["after", "", undefined]) {
588 const updated = yield* stack.deploy(makeStack(description));
589 expect(updated.sg.groupId).toBe(created.sg.groupId);
590 expect(updated.sg.groupArn).toBe(created.sg.groupArn);
591 expect(updated.sg.ownerId).toBe(created.sg.ownerId);
592 const observed = yield* readRules();
593 expect(observed.SecurityGroupRules).toHaveLength(2);
594 for (const original of initial.SecurityGroupRules ?? []) {
595 const rule = observed.SecurityGroupRules?.find(
596 (rule) => rule.SecurityGroupRuleId === original.SecurityGroupRuleId,
597 );
598 expect(rule).toBeDefined();
599 expect(rule?.IsEgress).toBe(original.IsEgress);
600 expect(rule?.Description ?? "").toBe(description ?? "");
601 }
602 }
603 const plan = yield* stack.plan(makeStack(undefined));
604 expect(plan.resources.DescriptionsSg).toMatchObject({ action: "noop" });
605 yield* stack.destroy();
606 yield* assertSecurityGroupGone(created.sg.groupId);
607 yield* assertVpcGone(created.vpc.vpcId);
608 }).pipe(logLevel),
609 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
610);
611
612test.provider(
613 "updates only changed inline rules and repairs a missing rule before a noop deploy",
614 (stack) =>
615 Effect.gen(function* () {
616 yield* stack.destroy();
617 const retained: SecurityGroupRuleData = {
618 ipProtocol: "tcp",
619 fromPort: 443,
620 toPort: 443,
621 cidrIpv4: "10.0.0.0/16",
622 };
623 const before: SecurityGroupRuleData = {
624 ipProtocol: "tcp",
625 fromPort: 22,
626 toPort: 22,
627 cidrIpv4: "10.0.0.0/16",
628 };
629 const after: SecurityGroupRuleData = {
630 ipProtocol: "udp",
631 fromPort: 53,
632 toPort: 53,
633 cidrIpv4: "10.1.0.0/16",
634 };
635 const makeStack = (rule: SecurityGroupRuleData) =>
636 Effect.gen(function* () {
637 const vpc = yield* Vpc("RuleDeltaVpc", { cidrBlock: "10.0.0.0/16" });
638 const sg = yield* SecurityGroup("RuleDeltaSg", {
639 vpcId: vpc.vpcId,
640 ingress: [retained, rule],
641 egress: [],
642 });
643 return { vpc, sg };
644 });
645 const created = yield* stack.deploy(makeStack(before));
646 const readRules = () =>
647 EC2.describeSecurityGroupRules({
648 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
649 });
650 const initial = yield* readRules();
651 expect(initial.SecurityGroupRules).toHaveLength(2);
652 const retainedRule = initial.SecurityGroupRules?.find(
653 (rule) => rule.FromPort === 443,
654 );
655 const removedRule = initial.SecurityGroupRules?.find(
656 (rule) => rule.FromPort === 22,
657 );
658 expect(retainedRule?.SecurityGroupRuleId).toMatch(/^sgr-/);
659 expect(removedRule?.SecurityGroupRuleId).toMatch(/^sgr-/);
660 const desiredStack = makeStack(after);
661 const updatePlan = yield* stack.plan(desiredStack);
662 expect(updatePlan.resources.RuleDeltaSg).toMatchObject({
663 action: "update",
664 });
665 const updated = yield* stack.deploy(desiredStack);
666 expect(updated.sg.groupId).toBe(created.sg.groupId);
667 expect(updated.sg.groupArn).toBe(created.sg.groupArn);
668 expect(updated.sg.ownerId).toBe(created.sg.ownerId);
669 const changed = yield* readRules();
670 expect(changed.SecurityGroupRules).toHaveLength(2);
671 expect(changed.SecurityGroupRules).toEqual(
672 expect.arrayContaining([
673 retainedRule,
674 expect.objectContaining({
675 IsEgress: false,
676 IpProtocol: "udp",
677 FromPort: 53,
678 ToPort: 53,
679 CidrIpv4: "10.1.0.0/16",
680 }),
681 ]),
682 );
683 expect(
684 changed.SecurityGroupRules?.some(
685 (rule) =>
686 rule.SecurityGroupRuleId === removedRule?.SecurityGroupRuleId,
687 ),
688 ).toBe(false);
689 const addedRule = changed.SecurityGroupRules?.find(
690 (rule) => rule.FromPort === 53,
691 );
692 expect(addedRule?.SecurityGroupRuleId).toMatch(/^sgr-/);
693 yield* EC2.revokeSecurityGroupIngress({
694 GroupId: created.sg.groupId,
695 SecurityGroupRuleIds: [addedRule!.SecurityGroupRuleId!],
696 });
697 const drifted = yield* readRules().pipe(
698 Effect.repeat({
699 schedule: Schedule.spaced("2 seconds"),
700 times: 8,
701 until: (response) =>
702 response.SecurityGroupRules?.length === 1 &&
703 response.SecurityGroupRules[0]?.SecurityGroupRuleId ===
704 retainedRule?.SecurityGroupRuleId,
705 }),
706 );
707 expect(drifted.SecurityGroupRules).toEqual([retainedRule]);
708 const repairPlan = yield* stack.plan(desiredStack);
709 expect(repairPlan.resources.RuleDeltaSg).toMatchObject({
710 action: "update",
711 });
712 const repaired = yield* stack.deploy(desiredStack);
713 expect(repaired.sg.groupId).toBe(created.sg.groupId);
714 const restored = yield* readRules();
715 expect(restored.SecurityGroupRules).toHaveLength(2);
716 expect(restored.SecurityGroupRules).toEqual(
717 expect.arrayContaining([
718 retainedRule,
719 expect.objectContaining({
720 IsEgress: false,
721 IpProtocol: "udp",
722 FromPort: 53,
723 ToPort: 53,
724 CidrIpv4: "10.1.0.0/16",
725 }),
726 ]),
727 );
728 expect(
729 restored.SecurityGroupRules?.some(
730 (rule) => rule.SecurityGroupRuleId === addedRule?.SecurityGroupRuleId,
731 ),
732 ).toBe(false);
733 const noopPlan = yield* stack.plan(desiredStack);
734 expect(noopPlan.resources.RuleDeltaSg).toMatchObject({ action: "noop" });
735 yield* stack.deploy(desiredStack);
736 const unchanged = yield* readRules();
737 expect(unchanged.SecurityGroupRules).toHaveLength(2);
738 expect(unchanged.SecurityGroupRules).toEqual(
739 expect.arrayContaining(restored.SecurityGroupRules ?? []),
740 );
741 yield* stack.destroy();
742 yield* assertSecurityGroupGone(created.sg.groupId);
743 yield* assertVpcGone(created.vpc.vpcId);
744 }).pipe(logLevel),
745 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
746);
747
748test.provider(
749 "recreates a deleted group and updates its standalone rule dependency",
750 (stack) =>
751 Effect.gen(function* () {
752 yield* stack.destroy();
753 const resources = Effect.gen(function* () {
754 const vpc = yield* Vpc("MissingGroupVpc", { cidrBlock: "10.0.0.0/16" });
755 const sg = yield* SecurityGroup("MissingGroupSg", {
756 vpcId: vpc.vpcId,
757 egress: [],
758 });
759 const rule = yield* SecurityGroupRule("MissingGroupRule", {
760 groupId: sg.groupId,
761 type: "ingress",
762 ipProtocol: "tcp",
763 fromPort: 443,
764 toPort: 443,
765 cidrIpv4: "10.0.0.0/16",
766 });
767 return { vpc, sg, rule };
768 });
769 const created = yield* stack.deploy(resources);
770 yield* EC2.deleteSecurityGroup({ GroupId: created.sg.groupId });
771 yield* assertSecurityGroupGone(created.sg.groupId);
772 const repaired = yield* stack.deploy(resources);
773 expect(repaired.sg.groupId).not.toBe(created.sg.groupId);
774 expect(repaired.rule.groupId).toBe(repaired.sg.groupId);
775 expect(repaired.rule.securityGroupRuleId).not.toBe(
776 created.rule.securityGroupRuleId,
777 );
778 const observed = yield* EC2.describeSecurityGroupRules({
779 Filters: [{ Name: "group-id", Values: [repaired.sg.groupId] }],
780 });
781 expect(observed.SecurityGroupRules).toEqual([
782 expect.objectContaining({
783 SecurityGroupRuleId: repaired.rule.securityGroupRuleId,
784 IsEgress: false,
785 FromPort: 443,
786 }),
787 ]);
788 const plan = yield* stack.plan(resources);
789 expect(plan.resources.MissingGroupSg).toMatchObject({ action: "noop" });
790 yield* stack.destroy();
791 yield* assertSecurityGroupGone(repaired.sg.groupId);
792 yield* assertVpcGone(repaired.vpc.vpcId);
793 }).pipe(logLevel),
794 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
795);
796
797test.provider(
798 "canonical inline rules retain IDs across equivalent updates and noop deploys",
799 (stack) =>
800 Effect.gen(function* () {
801 yield* stack.destroy();
802 const supplied: SecurityGroupRuleData[] = [
803 {
804 ipProtocol: "6",
805 fromPort: 443,
806 toPort: 443,
807 cidrIpv4: "100.68.0.18/18",
808 cidrIpv6: "2001:DB8:0:0:ABCD:0123:4567:89ab/64",
809 description: "",
810 },
811 {
812 ipProtocol: "17",
813 fromPort: 53,
814 toPort: 53,
815 cidrIpv4: "10.0.0.1/16",
816 },
817 {
818 ipProtocol: "1",
819 fromPort: 8,
820 toPort: -1,
821 cidrIpv4: "10.0.0.1/16",
822 },
823 {
824 ipProtocol: "-1",
825 fromPort: 0,
826 toPort: 65535,
827 cidrIpv4: "10.1.0.1/16",
828 },
829 {
830 ipProtocol: "50",
831 fromPort: 0,
832 toPort: 65535,
833 cidrIpv4: "10.2.0.1/16",
834 },
835 { ipProtocol: "icmpv6", cidrIpv6: "::/0" },
836 ];
837 const canonical: SecurityGroupRuleData[] = [
838 {
839 ipProtocol: "58",
840 fromPort: -1,
841 toPort: -1,
842 cidrIpv6: "::/0",
843 },
844 { ipProtocol: "50", cidrIpv4: "10.2.0.0/16" },
845 { ipProtocol: "-1", cidrIpv4: "10.1.0.0/16" },
846 {
847 ipProtocol: "icmp",
848 fromPort: 8,
849 toPort: -1,
850 cidrIpv4: "10.0.0.0/16",
851 },
852 {
853 ipProtocol: "udp",
854 fromPort: 53,
855 toPort: 53,
856 cidrIpv4: "10.0.0.0/16",
857 },
858 {
859 ipProtocol: "tcp",
860 fromPort: 443,
861 toPort: 443,
862 cidrIpv6: "2001:db8::/64",
863 },
864 {
865 ipProtocol: "tcp",
866 fromPort: 443,
867 toPort: 443,
868 cidrIpv4: "100.68.0.0/18",
869 },
870 ];
871 const makeStack = (ingress: SecurityGroupRuleData[], label: string) =>
872 Effect.gen(function* () {
873 const vpc = yield* Vpc("CanonicalVpc", { cidrBlock: "10.0.0.0/16" });
874 const sg = yield* SecurityGroup("CanonicalSg", {
875 vpcId: vpc.vpcId,
876 ingress,
877 egress: [],
878 tags: { Label: label },
879 });
880 return { vpc, sg };
881 });
882 const created = yield* stack.deploy(makeStack(supplied, "before"));
883 const readRules = () =>
884 EC2.describeSecurityGroupRules({
885 Filters: [{ Name: "group-id", Values: [created.sg.groupId] }],
886 });
887 const initial = yield* readRules();
888 expect(initial.SecurityGroupRules).toHaveLength(7);
889 for (const rule of canonical) {
890 expect(initial.SecurityGroupRules).toEqual(
891 expect.arrayContaining([
892 expect.objectContaining({
893 IsEgress: false,
894 IpProtocol:
895 rule.ipProtocol === "58"
896 ? expect.stringMatching(/^(58|icmpv6)$/)
897 : rule.ipProtocol,
898 ...(rule.fromPort === undefined
899 ? {}
900 : { FromPort: rule.fromPort }),
901 ...(rule.toPort === undefined ? {} : { ToPort: rule.toPort }),
902 ...(rule.cidrIpv4 === undefined
903 ? {}
904 : { CidrIpv4: rule.cidrIpv4 }),
905 ...(rule.cidrIpv6 === undefined
906 ? {}
907 : { CidrIpv6: rule.cidrIpv6 }),
908 }),
909 ]),
910 );
911 }
912 const originalPlan = yield* stack.plan(makeStack(supplied, "before"));
913 expect(originalPlan.resources.CanonicalSg).toMatchObject({
914 action: "noop",
915 });
916
917 const equivalentStack = makeStack(canonical, "after");
918 const updated = yield* stack.deploy(equivalentStack);
919 expect(updated.sg.groupId).toBe(created.sg.groupId);
920 const equivalent = yield* readRules();
921 expect(equivalent.SecurityGroupRules).toHaveLength(7);
922 expect(equivalent.SecurityGroupRules).toEqual(
923 expect.arrayContaining(initial.SecurityGroupRules ?? []),
924 );
925 const plan = yield* stack.plan(equivalentStack);
926 expect(plan.resources.CanonicalSg).toMatchObject({ action: "noop" });
927 yield* stack.deploy(equivalentStack);
928 const unchanged = yield* readRules();
929 expect(unchanged.SecurityGroupRules).toHaveLength(7);
930 expect(unchanged.SecurityGroupRules).toEqual(
931 expect.arrayContaining(initial.SecurityGroupRules ?? []),
932 );
933 yield* stack.destroy();
934 yield* assertSecurityGroupGone(created.sg.groupId);
935 yield* assertVpcGone(created.vpc.vpcId);
936 }).pipe(logLevel),
937 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120000 },
938);