1import * as AWS from "@/AWS";
2import {
3 DefaultSecurityGroup,
4 NetworkInterface,
5 PrefixList,
6 SecurityGroup,
7 SecurityGroupRule,
8 Subnet,
9 Vpc,
10} from "@/AWS/EC2";
11import type {
12 DefaultSecurityGroupProps,
13 SecurityGroupRuleData,
14 VpcId,
15} from "@/AWS/EC2";
16import * as Drift from "@/Drift";
17import * as Output from "@/Output";
18import { isActionState, State } from "@/State/State";
19import * as Core from "@/Test/Core";
20import * as EC2 from "@distilled.cloud/aws/ec2";
21import { expect } from "alchemy-test";
22import * as Effect from "effect/Effect";
23import { MinimumLogLevel } from "effect/References";
24import * as Stream from "effect/Stream";
25import * as Schedule from "effect/Schedule";
26import * as HttpClient from "effect/http/HttpClient";
27import * as Test from "./VpcTest.ts";
28import { assertVpcGone } from "./Gone.ts";
29
30const { test } = Test.make({ providers: AWS.providers() }, 2);
31const logLevel = Effect.provideService(
32 MinimumLogLevel,
33 process.env.DEBUG ? "Debug" : "Info",
34);
35
36test.provider(
37 "AWS creates the default group with its initial rules",
38 (stack) =>
39 Effect.gen(function* () {
40 yield* stack.destroy();
41 const vpc = yield* stack.deploy(
42 Effect.gen(function* () {
43 return yield* Vpc("DefaultSecurityGroupInitialVpc", {
44 cidrBlock: "10.43.0.0/16",
45 });
46 }),
47 );
48
49 const group = yield* findDefaultGroup(vpc.vpcId);
50 yield* expectRules(
51 group.GroupId!,
52 [
53 {
54 IpProtocol: "-1",
55 ReferencedGroupInfo: { GroupId: group.GroupId! },
56 },
57 ],
58 [{ IpProtocol: "-1", CidrIpv4: "0.0.0.0/0" }],
59 );
60
61 yield* stack.destroy();
62 yield* assertVpcGone(vpc.vpcId);
63 }).pipe(logLevel),
64 { tags: ["provider:aws", "provider:aws:ec2", "live"] },
65);
66
67// This changes the AWS-created default security group only inside the VPC this
68// test creates and destroys.
69test.provider(
70 "manages a temporary VPC's default security group without deleting it",
71 (stack) =>
72 Effect.gen(function* () {
73 yield* stack.destroy();
74
75 // The VPC output is consumed in this same first deployment. This proves
76 // that the default group can be found and closed without a second deploy.
77 const initial = yield* stack.deploy(
78 Effect.gen(function* () {
79 const vpc = yield* Vpc("DefaultSecurityGroupVpc", {
80 cidrBlock: "10.42.0.0/16",
81 });
82 const defaultSecurityGroup = yield* DefaultSecurityGroup(
83 "DefaultSecurityGroup",
84 {
85 vpcId: vpc.vpcId,
86 ingress: [],
87 egress: [],
88 },
89 );
90 return { vpc, defaultSecurityGroup };
91 }),
92 );
93
94 const defaultGroup = yield* findDefaultGroup(initial.vpc.vpcId);
95 expect(initial.defaultSecurityGroup.groupId).toEqual(
96 defaultGroup.GroupId,
97 );
98 yield* expectRules(initial.defaultSecurityGroup.groupId, [], []);
99
100 // A second identical deployment verifies idempotence against AWS readback.
101 yield* stack.deploy(
102 Effect.gen(function* () {
103 const vpc = yield* Vpc("DefaultSecurityGroupVpc", {
104 cidrBlock: "10.42.0.0/16",
105 });
106 return yield* DefaultSecurityGroup("DefaultSecurityGroup", {
107 vpcId: vpc.vpcId,
108 ingress: [],
109 egress: [],
110 });
111 }),
112 );
113 yield* expectRules(initial.defaultSecurityGroup.groupId, [], []);
114
115 // A changed inline declaration replaces the inline rule set.
116 yield* stack.deploy(
117 Effect.gen(function* () {
118 const vpc = yield* Vpc("DefaultSecurityGroupVpc", {
119 cidrBlock: "10.42.0.0/16",
120 });
121 return yield* DefaultSecurityGroup("DefaultSecurityGroup", {
122 vpcId: vpc.vpcId,
123 ingress: [
124 {
125 ipProtocol: "tcp",
126 fromPort: 443,
127 toPort: 443,
128 cidrIpv4: "10.42.0.0/16",
129 },
130 ],
131 egress: [],
132 });
133 }),
134 );
135 yield* expectRules(
136 initial.defaultSecurityGroup.groupId,
137 [
138 {
139 IpProtocol: "tcp",
140 FromPort: 443,
141 ToPort: 443,
142 CidrIpv4: "10.42.0.0/16",
143 },
144 ],
145 [],
146 );
147
148 // Removing the Alchemy resource must not delete the AWS-owned group or
149 // restore its initial AWS rules.
150 yield* stack.deploy(
151 Effect.gen(function* () {
152 return yield* Vpc("DefaultSecurityGroupVpc", {
153 cidrBlock: "10.42.0.0/16",
154 });
155 }),
156 );
157 const preserved = yield* findDefaultGroup(initial.vpc.vpcId);
158 expect(preserved.GroupId).toEqual(initial.defaultSecurityGroup.groupId);
159 yield* expectRules(
160 initial.defaultSecurityGroup.groupId,
161 [
162 {
163 IpProtocol: "tcp",
164 FromPort: 443,
165 ToPort: 443,
166 CidrIpv4: "10.42.0.0/16",
167 },
168 ],
169 [],
170 );
171
172 yield* stack.destroy();
173 yield* assertVpcGone(initial.vpc.vpcId);
174 }).pipe(logLevel),
175 { tags: ["provider:aws", "provider:aws:ec2", "live"] },
176);
177
178for (const direction of ["ingress", "egress"] as const) {
179 test.provider(
180 `repairs ${direction} drift and applies only changed rules`,
181 (stack) =>
182 Effect.gen(function* () {
183 yield* stack.destroy();
184 const retained: SecurityGroupRuleData = {
185 ipProtocol: "tcp",
186 fromPort: 443,
187 toPort: 443,
188 cidrIpv4: "10.44.0.0/16",
189 description: "retained",
190 };
191 const changed: SecurityGroupRuleData = {
192 ipProtocol: "udp",
193 fromPort: 53,
194 toPort: 53,
195 cidrIpv4: "10.44.0.0/16",
196 description: "DNS",
197 };
198 const program = (rules = [retained, changed]) =>
199 Effect.gen(function* () {
200 const vpc = yield* Vpc("DriftVpc", { cidrBlock: "10.44.0.0/16" });
201 const group = yield* DefaultSecurityGroup("DriftGroup", {
202 vpcId: vpc.vpcId,
203 ingress: direction === "ingress" ? rules : [retained],
204 egress: direction === "egress" ? rules : [retained],
205 });
206 return { vpc, group };
207 });
208 const created = yield* stack.deploy(program());
209 const groupId = created.group.groupId;
210 const initial = yield* readRules(groupId);
211 const stable = initial.filter((rule) => rule.FromPort === 443);
212 expect(stable).toHaveLength(2);
213 const missing = initial.find((rule) => rule.FromPort === 53)!;
214 const revoke =
215 direction === "ingress"
216 ? EC2.revokeSecurityGroupIngress
217 : EC2.revokeSecurityGroupEgress;
218 const authorize =
219 direction === "ingress"
220 ? EC2.authorizeSecurityGroupIngress
221 : EC2.authorizeSecurityGroupEgress;
222 yield* revoke({
223 GroupId: groupId,
224 SecurityGroupRuleIds: [missing.SecurityGroupRuleId!],
225 });
226 const rogue = yield* authorize({
227 GroupId: groupId,
228 IpPermissions: [
229 {
230 IpProtocol: "tcp",
231 FromPort: 22,
232 ToPort: 22,
233 IpRanges: [{ CidrIp: "0.0.0.0/0" }],
234 },
235 ],
236 TagSpecifications: [
237 {
238 ResourceType: "security-group-rule",
239 Tags: [{ Key: "alchemy::id", Value: "DriftGroup" }],
240 },
241 ],
242 });
243 const rogueId = rogue.SecurityGroupRules![0]!.SecurityGroupRuleId!;
244 yield* waitForRules(
245 groupId,
246 (rules) =>
247 !rules.some(
248 (rule) =>
249 rule.SecurityGroupRuleId === missing.SecurityGroupRuleId,
250 ) && rules.some((rule) => rule.SecurityGroupRuleId === rogueId),
251 );
252 const missingRuleError = yield* EC2.modifySecurityGroupRules({
253 GroupId: groupId,
254 SecurityGroupRules: [
255 {
256 SecurityGroupRuleId: missing.SecurityGroupRuleId!,
257 SecurityGroupRule: {
258 IpProtocol: "udp",
259 FromPort: 53,
260 ToPort: 53,
261 CidrIpv4: "10.44.0.0/16",
262 Description: "missing rule probe",
263 },
264 },
265 ],
266 }).pipe(Effect.flip);
267 expect(missingRuleError).toMatchObject({
268 _tag: "InvalidSecurityGroupRuleId.NotFound",
269 });
270 expect(
271 (yield* stack.plan(program())).resources.DriftGroup?.action,
272 ).toBe("update");
273 yield* stack.deploy(program());
274 const repaired = yield* readRules(groupId);
275 expect(repaired).toHaveLength(3);
276 expect(repaired).toEqual(expect.arrayContaining(stable));
277 const restored = repaired.find((rule) => rule.FromPort === 53)!;
278 expect(restored.SecurityGroupRuleId).not.toBe(
279 missing.SecurityGroupRuleId,
280 );
281 expect(restored.IsEgress).toBe(direction === "egress");
282 expect(restored.Description).toBe("DNS");
283 expect(
284 repaired.some((rule) => rule.SecurityGroupRuleId === rogueId),
285 ).toBe(false);
286
287 yield* EC2.modifySecurityGroupRules({
288 GroupId: groupId,
289 SecurityGroupRules: [
290 {
291 SecurityGroupRuleId: restored.SecurityGroupRuleId!,
292 SecurityGroupRule: {
293 IpProtocol: "udp",
294 FromPort: 53,
295 ToPort: 53,
296 CidrIpv4: "10.44.0.0/16",
297 Description: "external description",
298 },
299 },
300 ],
301 });
302 yield* waitForRules(groupId, (rules) =>
303 rules.some(
304 (rule) =>
305 rule.SecurityGroupRuleId === restored.SecurityGroupRuleId &&
306 rule.Description === "external description",
307 ),
308 );
309 expect(
310 (yield* stack.plan(program())).resources.DriftGroup?.action,
311 ).toBe("update");
312 yield* stack.deploy(program());
313 const descriptions = yield* readRules(groupId);
314 expect(descriptions).toEqual(
315 expect.arrayContaining([...stable, restored]),
316 );
317
318 // External identity edits are removed, not preserved as extra access.
319 yield* EC2.modifySecurityGroupRules({
320 GroupId: groupId,
321 SecurityGroupRules: [
322 {
323 SecurityGroupRuleId: restored.SecurityGroupRuleId!,
324 SecurityGroupRule: {
325 IpProtocol: "tcp",
326 FromPort: 25,
327 ToPort: 25,
328 CidrIpv4: "0.0.0.0/0",
329 Description: "external access",
330 },
331 },
332 ],
333 });
334 yield* waitForRules(groupId, (rules) =>
335 rules.some(
336 (rule) =>
337 rule.SecurityGroupRuleId === restored.SecurityGroupRuleId &&
338 rule.FromPort === 25,
339 ),
340 );
341 expect(
342 (yield* stack.plan(program())).resources.DriftGroup?.action,
343 ).toBe("update");
344 yield* stack.deploy(program());
345 const repairedIdentity = yield* readRules(groupId);
346 expect(repairedIdentity).toHaveLength(3);
347 expect(repairedIdentity).toEqual(expect.arrayContaining(stable));
348 expect(repairedIdentity.some((rule) => rule.FromPort === 25)).toBe(
349 false,
350 );
351 expect(
352 repairedIdentity.find((rule) => rule.FromPort === 53)?.Description,
353 ).toBe("DNS");
354
355 const replacement = {
356 ...changed,
357 fromPort: 123,
358 toPort: 123,
359 description: "NTP",
360 };
361 yield* stack.deploy(program([retained, replacement]));
362 const edited = yield* readRules(groupId);
363 expect(edited).toHaveLength(3);
364 expect(edited).toEqual(expect.arrayContaining(stable));
365 expect(edited.some((rule) => rule.FromPort === 53)).toBe(false);
366 expect(edited.find((rule) => rule.FromPort === 123)?.Description).toBe(
367 "NTP",
368 );
369
370 yield* stack.deploy(program([]));
371 const opposite = stable.filter(
372 (rule) => rule.IsEgress !== (direction === "egress"),
373 );
374 expect(yield* readRules(groupId)).toEqual(opposite);
375 const external = yield* authorize({
376 GroupId: groupId,
377 IpPermissions: [
378 { IpProtocol: "-1", IpRanges: [{ CidrIp: "0.0.0.0/0" }] },
379 ],
380 });
381 yield* waitForRules(groupId, (rules) =>
382 rules.some(
383 (rule) =>
384 rule.SecurityGroupRuleId ===
385 external.SecurityGroupRules![0]!.SecurityGroupRuleId,
386 ),
387 );
388 expect(
389 (yield* stack.plan(program([]))).resources.DriftGroup?.action,
390 ).toBe("update");
391 yield* stack.deploy(program([]));
392 expect(yield* readRules(groupId)).toEqual(opposite);
393
394 const observer = yield* observeRuleRequests;
395 yield* Effect.gen(function* () {
396 expect(
397 (yield* stack.plan(program([]))).resources.DriftGroup?.action,
398 ).toBe("noop");
399 yield* stack.deploy(program([]));
400 }).pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
401 expect(observer.requests.length).toBeGreaterThan(0);
402 expect(observer.requests.filter((request) => request.write)).toEqual(
403 [],
404 );
405 expect(yield* readRules(groupId)).toEqual(opposite);
406 yield* stack.destroy();
407 yield* assertVpcGone(created.vpc.vpcId);
408 }).pipe(logLevel),
409 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
410 );
411}
412
413test.provider(
414 "canonicalizes and deduplicates rules while preserving IDs and updating descriptions in place",
415 (stack) =>
416 Effect.gen(function* () {
417 yield* stack.destroy();
418 const dual: SecurityGroupRuleData = {
419 ipProtocol: "6",
420 fromPort: 443,
421 toPort: 443,
422 cidrIpv4: "10.45.0.7/16",
423 cidrIpv6: "2001:0DB8:0:0:0:0:0:5/64",
424 description: "HTTPS",
425 };
426 const supplied: SecurityGroupRuleData[] = [
427 dual,
428 { ...dual, cidrIpv4: "10.45.0.0/16" },
429 { ipProtocol: "58", cidrIpv6: "2001:db8::1/64" },
430 {
431 ipProtocol: "-1",
432 fromPort: 0,
433 toPort: 65535,
434 cidrIpv4: "10.46.0.1/16",
435 },
436 {
437 ipProtocol: "50",
438 fromPort: 0,
439 toPort: 65535,
440 cidrIpv4: "10.47.0.1/16",
441 },
442 {
443 ipProtocol: "17",
444 fromPort: 53,
445 toPort: 53,
446 cidrIpv4: "10.45.0.7/16",
447 },
448 { ipProtocol: "1", fromPort: 8, toPort: -1, cidrIpv4: "10.45.0.7/16" },
449 ];
450 const canonical: SecurityGroupRuleData[] = [
451 {
452 ipProtocol: "icmp",
453 fromPort: 8,
454 toPort: -1,
455 cidrIpv4: "10.45.0.0/16",
456 },
457 {
458 ipProtocol: "udp",
459 fromPort: 53,
460 toPort: 53,
461 cidrIpv4: "10.45.0.0/16",
462 },
463 { ipProtocol: "50", cidrIpv4: "10.47.0.0/16" },
464 { ipProtocol: "-1", cidrIpv4: "10.46.0.0/16" },
465 {
466 ipProtocol: "icmpv6",
467 fromPort: -1,
468 toPort: -1,
469 cidrIpv6: "2001:db8::/64",
470 },
471 {
472 ipProtocol: "tcp",
473 fromPort: 443,
474 toPort: 443,
475 cidrIpv6: "2001:db8::/64",
476 description: "HTTPS",
477 },
478 {
479 ipProtocol: "tcp",
480 fromPort: 443,
481 toPort: 443,
482 cidrIpv4: "10.45.0.0/16",
483 description: "HTTPS",
484 },
485 ];
486 const program = (rules: SecurityGroupRuleData[]) =>
487 Effect.gen(function* () {
488 const vpc = yield* Vpc("CanonicalVpc", { cidrBlock: "10.45.0.0/16" });
489 const group = yield* DefaultSecurityGroup("CanonicalGroup", {
490 vpcId: vpc.vpcId,
491 ingress: rules,
492 egress: rules,
493 });
494 return { vpc, group };
495 });
496 const created = yield* stack.deploy(program(supplied));
497 const groupId = created.group.groupId;
498 const initial = yield* readRules(groupId);
499 expect(initial).toHaveLength(14);
500 for (const isEgress of [false, true]) {
501 const rules = initial.filter((rule) => rule.IsEgress === isEgress);
502 expect(rules).toHaveLength(7);
503 expect(rules.filter((rule) => rule.IpProtocol === "tcp")).toEqual(
504 expect.arrayContaining([
505 expect.objectContaining({
506 CidrIpv4: "10.45.0.0/16",
507 Description: "HTTPS",
508 }),
509 expect.objectContaining({
510 CidrIpv6: "2001:db8::/64",
511 Description: "HTTPS",
512 }),
513 ]),
514 );
515 expect(
516 rules.some(
517 (rule) =>
518 ["58", "icmpv6"].includes(rule.IpProtocol!) &&
519 rule.FromPort === -1 &&
520 rule.ToPort === -1,
521 ),
522 ).toBe(true);
523 }
524 expect(
525 (yield* stack.plan(program(supplied))).resources.CanonicalGroup?.action,
526 ).toBe("noop");
527 const observer = yield* observeRuleRequests;
528 yield* stack
529 .deploy(program(canonical))
530 .pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
531 expect(observer.requests.length).toBeGreaterThan(0);
532 expect(observer.requests.filter((request) => request.write)).toEqual([]);
533 expect(yield* readRules(groupId)).toEqual(
534 expect.arrayContaining(initial),
535 );
536 expect(yield* readRules(groupId)).toHaveLength(initial.length);
537
538 for (const description of ["Updated HTTPS", undefined]) {
539 const desired = canonical.map((rule) =>
540 rule.ipProtocol === "tcp" ? { ...rule, description } : rule,
541 );
542 const writes = yield* observeRuleRequests;
543 yield* stack
544 .deploy(program(desired))
545 .pipe(Effect.provideService(HttpClient.HttpClient, writes.client));
546 const mutations = writes.requests.filter((request) => request.write);
547 expect(mutations.length).toBeGreaterThan(0);
548 expect(
549 mutations.every(
550 (request) => request.action === "ModifySecurityGroupRules",
551 ),
552 ).toBe(true);
553 expect(
554 [...new Set(mutations.flatMap((request) => request.ruleIds))].sort(),
555 ).toEqual(
556 initial
557 .filter((rule) => rule.IpProtocol === "tcp")
558 .map((rule) => rule.SecurityGroupRuleId)
559 .sort(),
560 );
561 const observed = yield* readRules(groupId);
562 expect(observed.map((rule) => rule.SecurityGroupRuleId).sort()).toEqual(
563 initial.map((rule) => rule.SecurityGroupRuleId).sort(),
564 );
565 for (const rule of observed) {
566 if (rule.IpProtocol === "tcp")
567 expect(rule.Description ?? "").toBe(description ?? "");
568 }
569 expect(
570 (yield* stack.plan(program(desired))).resources.CanonicalGroup
571 ?.action,
572 ).toBe("noop");
573 const noop = yield* observeRuleRequests;
574 yield* stack
575 .deploy(program(desired))
576 .pipe(Effect.provideService(HttpClient.HttpClient, noop.client));
577 expect(noop.requests.length).toBeGreaterThan(0);
578 expect(noop.requests.filter((request) => request.write)).toEqual([]);
579 }
580 yield* stack.destroy();
581 yield* assertVpcGone(created.vpc.vpcId);
582 }).pipe(logLevel),
583 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
584);
585
586test.provider(
587 "recovers cold state and adopts existing default-group rules authoritatively",
588 (stack) =>
589 Effect.gen(function* () {
590 yield* stack.destroy();
591 const program = (port: number, vpcId?: VpcId) =>
592 Effect.gen(function* () {
593 const vpc = yield* Vpc("RecoveryVpc", { cidrBlock: "10.48.0.0/16" });
594 const group = yield* DefaultSecurityGroup("RecoveryGroup", {
595 vpcId: vpcId ?? vpc.vpcId,
596 ingress: [
597 {
598 ipProtocol: "tcp",
599 fromPort: port,
600 toPort: port,
601 cidrIpv4: "10.48.0.0/16",
602 },
603 ],
604 egress: [],
605 });
606 return { vpc, group };
607 });
608 const created = yield* stack.deploy(program(443));
609 const key = {
610 stack: stack.name,
611 stage: stack.stage,
612 fqn: "RecoveryGroup",
613 };
614 const state = yield* Effect.gen(function* () {
615 return yield* yield* State;
616 }).pipe(Effect.provide(stack.state));
617 const row = yield* state.get(key);
618 if (
619 !row ||
620 isActionState(row) ||
621 (row.status !== "created" && row.status !== "updated")
622 ) {
623 return yield* Effect.fail(
624 new Error("Expected a persisted default security group"),
625 );
626 }
627 const rules = yield* readRules(created.group.groupId);
628 yield* state.set({
629 ...key,
630 value: { ...row, status: "creating", attr: undefined },
631 });
632 const recoveryPlan = yield* stack.plan(program(443, created.vpc.vpcId));
633 expect(recoveryPlan.resources.RecoveryGroup?.state?.attr?.groupId).toBe(
634 created.group.groupId,
635 );
636 const observer = yield* observeRuleRequests;
637 const recovered = yield* stack
638 .deploy(program(443, created.vpc.vpcId))
639 .pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
640 expect(recovered.group.groupId).toBe(created.group.groupId);
641 expect(observer.requests.length).toBeGreaterThan(0);
642 expect(observer.requests.filter((request) => request.write)).toEqual([]);
643 expect(yield* readRules(created.group.groupId)).toEqual(rules);
644
645 // Lose only the managed-group row; the VPC remains tracked for cleanup.
646 yield* state.delete(key);
647 const adoptedPlan = yield* stack.plan(program(8443, created.vpc.vpcId));
648 expect(adoptedPlan.resources.RecoveryGroup?.state?.attr?.groupId).toBe(
649 created.group.groupId,
650 );
651 const adopted = yield* stack.deploy(program(8443, created.vpc.vpcId));
652 expect(adopted.group.groupId).toBe(created.group.groupId);
653 yield* expectRules(
654 created.group.groupId,
655 [
656 {
657 IpProtocol: "tcp",
658 FromPort: 8443,
659 ToPort: 8443,
660 CidrIpv4: "10.48.0.0/16",
661 },
662 ],
663 [],
664 );
665 expect(
666 (yield* stack.plan(program(8443, created.vpc.vpcId))).resources
667 .RecoveryGroup?.action,
668 ).toBe("noop");
669 yield* stack.destroy();
670 yield* assertVpcGone(created.vpc.vpcId);
671 }).pipe(logLevel),
672 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
673);
674
675test.provider(
676 "reads a missing VPC as absent and destroys stale state idempotently",
677 (stack) =>
678 Effect.gen(function* () {
679 yield* stack.destroy();
680 const created = yield* stack.deploy(
681 Effect.gen(function* () {
682 const vpc = yield* Vpc("MissingVpc", { cidrBlock: "10.49.0.0/16" });
683 const group = yield* DefaultSecurityGroup("MissingGroup", {
684 vpcId: vpc.vpcId,
685 ingress: [],
686 egress: [],
687 });
688 return { vpc, group };
689 }),
690 );
691 yield* EC2.deleteVpc({ VpcId: created.vpc.vpcId });
692 yield* assertVpcGone(created.vpc.vpcId);
693 const drift = yield* Drift.detect(stack).pipe(
694 Effect.provide(stack.state),
695 );
696 expect(drift.resources.MissingGroup?.action).toBe("missing");
697 expect(drift.resources.MissingGroup?.attr).toBeUndefined();
698 yield* Effect.gen(function* () {
699 const state = yield* yield* State;
700 yield* state.delete({
701 stack: stack.name,
702 stage: stack.stage,
703 fqn: "MissingGroup",
704 });
705 }).pipe(Effect.provide(stack.state));
706 const cold = yield* stack.plan(
707 Effect.gen(function* () {
708 yield* Vpc("MissingVpc", { cidrBlock: "10.49.0.0/16" });
709 return yield* DefaultSecurityGroup("MissingGroup", {
710 vpcId: created.vpc.vpcId,
711 ingress: [],
712 egress: [],
713 });
714 }),
715 );
716 expect(cold.resources.MissingGroup?.action).toBe("create");
717 expect(cold.resources.MissingGroup?.state).toBeUndefined();
718 yield* stack.destroy();
719 yield* stack.destroy();
720 yield* assertVpcGone(created.vpc.vpcId);
721 }).pipe(logLevel),
722 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
723);
724
725test.provider(
726 "replaces VPC identity while retaining the old group's last-applied rules",
727 (stack) =>
728 Effect.gen(function* () {
729 yield* stack.destroy();
730 const program = (useSecond: boolean, keepFirst = true) =>
731 Effect.gen(function* () {
732 const first = keepFirst
733 ? yield* Vpc("FirstVpc", { cidrBlock: "10.50.0.0/16" })
734 : undefined;
735 const second = yield* Vpc("SecondVpc", { cidrBlock: "10.51.0.0/16" });
736 const group = yield* DefaultSecurityGroup("MovingGroup", {
737 vpcId: useSecond ? second.vpcId : first!.vpcId,
738 ingress: [
739 {
740 ipProtocol: "tcp",
741 fromPort: 443,
742 toPort: 443,
743 cidrIpv4: "10.50.0.0/16",
744 },
745 ],
746 egress: [],
747 });
748 return { first, second, group };
749 });
750 const created = yield* stack.deploy(program(false));
751 const oldVpcId = created.first!.vpcId;
752 const oldRules = yield* readRules(created.group.groupId);
753 expect(oldRules).toHaveLength(1);
754 expect(
755 (yield* stack.plan(program(true))).resources.MovingGroup?.action,
756 ).toBe("replace");
757 const moved = yield* stack.deploy(program(true));
758 expect(moved.group.groupId).not.toBe(created.group.groupId);
759 expect(moved.group.vpcId).toBe(created.second.vpcId);
760 expect((yield* findDefaultGroup(oldVpcId)).GroupId).toBe(
761 created.group.groupId,
762 );
763 expect(yield* readRules(created.group.groupId)).toEqual(oldRules);
764 yield* expectRules(
765 moved.group.groupId,
766 [
767 {
768 IpProtocol: "tcp",
769 FromPort: 443,
770 ToPort: 443,
771 CidrIpv4: "10.50.0.0/16",
772 },
773 ],
774 [],
775 );
776 const newRules = yield* readRules(moved.group.groupId);
777 // Remove the old dependency only after its resource replacement is complete.
778 yield* stack.deploy(program(true, false));
779 yield* assertVpcGone(oldVpcId);
780 expect(yield* readRules(moved.group.groupId)).toEqual(newRules);
781 expect(
782 (yield* stack.plan(program(true, false))).resources.MovingGroup?.action,
783 ).toBe("noop");
784 yield* stack.destroy();
785 yield* assertVpcGone(created.second.vpcId);
786 }).pipe(logLevel),
787 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
788);
789
790for (const scenario of [
791 "new destination VPC",
792 "upstream VPC replacement",
793] as const) {
794 test.provider(
795 `propagates unresolved group identity to a real ENI during ${scenario}`,
796 (stack) =>
797 Effect.gen(function* () {
798 yield* stack.destroy();
799 const program = (switchVpc: boolean) =>
800 Effect.gen(function* () {
801 const original = yield* Vpc("IdentityVpc", {
802 cidrBlock:
803 switchVpc && scenario === "upstream VPC replacement"
804 ? "10.53.0.0/16"
805 : "10.52.0.0/16",
806 });
807 // Keep the original VPC declared while introducing its destination.
808 const destination =
809 switchVpc && scenario === "new destination VPC"
810 ? yield* Vpc("DestinationVpc", { cidrBlock: "10.53.0.0/16" })
811 : original;
812 const group = yield* DefaultSecurityGroup("IdentityGroup", {
813 vpcId: destination.vpcId,
814 ingress: [
815 {
816 ipProtocol: "tcp",
817 fromPort: 443,
818 toPort: 443,
819 cidrIpv4: "10.52.0.0/16",
820 },
821 ],
822 egress: [],
823 });
824 const subnet = switchVpc
825 ? yield* Subnet("ConsumerSubnet", {
826 vpcId: destination.vpcId,
827 cidrBlock: "10.53.1.0/24",
828 })
829 : undefined;
830 const eni = subnet
831 ? yield* NetworkInterface("ConsumerInterface", {
832 subnetId: subnet.subnetId,
833 securityGroupIds: [group.groupId],
834 })
835 : undefined;
836 return { original, destination, group, subnet, eni };
837 });
838 const created = yield* stack.deploy(program(false));
839 const oldRules = yield* readRules(created.group.groupId);
840 const plan = yield* stack.plan(program(true));
841 expect(plan.resources.IdentityGroup?.action).toBe("replace");
842 expect(plan.resources.IdentityVpc?.action).toBe(
843 scenario === "upstream VPC replacement" ? "replace" : "noop",
844 );
845 if (scenario === "new destination VPC") {
846 expect(plan.resources.DestinationVpc?.action).toBe("create");
847 }
848 expect(plan.resources.ConsumerInterface?.action).toBe("create");
849 const moved = yield* stack.deploy(program(true));
850 expect(moved.group.groupId).not.toBe(created.group.groupId);
851 expect(moved.destination.vpcId).not.toBe(created.destination.vpcId);
852 expect(moved.group.vpcId).toBe(moved.destination.vpcId);
853 const observed = (yield* EC2.describeNetworkInterfaces({
854 NetworkInterfaceIds: [moved.eni!.networkInterfaceId],
855 })).NetworkInterfaces?.[0];
856 expect(observed?.VpcId).toBe(moved.destination.vpcId);
857 expect(observed?.SubnetId).toBe(moved.subnet!.subnetId);
858 expect(observed?.Groups?.map((group) => group.GroupId)).toEqual([
859 moved.group.groupId,
860 ]);
861 if (scenario === "new destination VPC") {
862 expect(
863 (yield* findDefaultGroup(created.original.vpcId)).GroupId,
864 ).toBe(created.group.groupId);
865 expect(yield* readRules(created.group.groupId)).toEqual(oldRules);
866 } else {
867 yield* assertVpcGone(created.original.vpcId);
868 }
869 const noop = yield* stack.plan(program(true));
870 expect(noop.resources.IdentityGroup?.action).toBe("noop");
871 expect(noop.resources.ConsumerInterface?.action).toBe("noop");
872 yield* stack.destroy();
873 yield* assertVpcGone(created.original.vpcId);
874 yield* assertVpcGone(moved.destination.vpcId);
875 }).pipe(logLevel),
876 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
877 );
878}
879
880test.provider(
881 "manages group and prefix-list sources and rejects invalid rules without writes",
882 (stack) =>
883 Effect.gen(function* () {
884 yield* stack.destroy();
885 const program = (
886 description?: string,
887 directions?: {
888 ingress: SecurityGroupRuleData[];
889 egress: SecurityGroupRuleData[];
890 },
891 ) =>
892 Effect.gen(function* () {
893 const vpc = yield* Vpc("SourcesVpc", { cidrBlock: "10.54.0.0/16" });
894 const peer = yield* SecurityGroup("SourcePeer", {
895 vpcId: vpc.vpcId,
896 egress: [],
897 });
898 const prefix = yield* PrefixList("SourcePrefix", {
899 maxEntries: 1,
900 entries: [{ cidr: "10.54.0.0/16" }],
901 });
902 const rule = {
903 ipProtocol: "tcp",
904 fromPort: 443,
905 toPort: 443,
906 referencedGroupId: peer.groupId,
907 prefixListId: prefix.prefixListId,
908 description,
909 };
910 const group = yield* DefaultSecurityGroup("SourcesGroup", {
911 vpcId: vpc.vpcId,
912 ingress: directions?.ingress ?? [rule],
913 egress: directions?.egress ?? [rule],
914 });
915 return { vpc, peer, prefix, group };
916 });
917 const created = yield* stack.deploy(program("Before"));
918 const groupId = created.group.groupId;
919 yield* Effect.gen(function* () {
920 const initial = yield* readRules(groupId);
921 expect(initial).toHaveLength(4);
922 for (const isEgress of [false, true]) {
923 const rules = initial.filter((rule) => rule.IsEgress === isEgress);
924 expect(rules).toHaveLength(2);
925 expect(rules).toEqual(
926 expect.arrayContaining([
927 expect.objectContaining({
928 ReferencedGroupInfo: expect.objectContaining({
929 GroupId: created.peer.groupId,
930 }),
931 Description: "Before",
932 }),
933 expect.objectContaining({
934 PrefixListId: created.prefix.prefixListId,
935 Description: "Before",
936 }),
937 ]),
938 );
939 }
940 for (const description of ["After", undefined]) {
941 const observer = yield* observeRuleRequests;
942 yield* stack
943 .deploy(program(description))
944 .pipe(
945 Effect.provideService(HttpClient.HttpClient, observer.client),
946 );
947 const rules = yield* readRules(groupId);
948 expect(rules).toHaveLength(4);
949 expect(rules.map((rule) => rule.SecurityGroupRuleId).sort()).toEqual(
950 initial.map((rule) => rule.SecurityGroupRuleId).sort(),
951 );
952 for (const original of initial) {
953 const rule = rules.find(
954 (rule) =>
955 rule.SecurityGroupRuleId === original.SecurityGroupRuleId,
956 )!;
957 expect(rule.IsEgress).toBe(original.IsEgress);
958 expect(rule.ReferencedGroupInfo?.GroupId).toBe(
959 original.ReferencedGroupInfo?.GroupId,
960 );
961 expect(rule.PrefixListId).toBe(original.PrefixListId);
962 expect(rule.Description ?? "").toBe(description ?? "");
963 }
964 const writes = observer.requests.filter((request) => request.write);
965 expect(writes.length).toBeGreaterThan(0);
966 expect(
967 writes.every(
968 (request) => request.action === "ModifySecurityGroupRules",
969 ),
970 ).toBe(true);
971 expect(
972 [...new Set(writes.flatMap((request) => request.ruleIds))].sort(),
973 ).toEqual(initial.map((rule) => rule.SecurityGroupRuleId).sort());
974 }
975 const settled = yield* readRules(groupId);
976 const invalid: Array<{
977 rules: SecurityGroupRuleData[];
978 message: string;
979 }> = [
980 {
981 rules: [
982 {
983 ipProtocol: "tcp",
984 fromPort: 443,
985 toPort: 443,
986 cidrIpv4: "10.54.0.7/16",
987 description: "one",
988 },
989 {
990 ipProtocol: "6",
991 fromPort: 443,
992 toPort: 443,
993 cidrIpv4: "10.54.0.0/16",
994 description: "two",
995 },
996 ],
997 message: "Duplicate rules must have the same description.",
998 },
999 {
1000 rules: [{ ipProtocol: "tcp", fromPort: 443, toPort: 443 }],
1001 message: "Every rule must specify a protocol and a source.",
1002 },
1003 {
1004 rules: [{ ipProtocol: "", cidrIpv4: "10.54.0.0/16" }],
1005 message: "Every rule must specify a protocol and a source.",
1006 },
1007 ];
1008 for (const { rules, message } of invalid) {
1009 const observer = yield* observeRuleRequests;
1010 const error = yield* stack
1011 .deploy(program(undefined, { ingress: [], egress: rules }))
1012 .pipe(
1013 Effect.provideService(HttpClient.HttpClient, observer.client),
1014 Effect.flip,
1015 );
1016 expect(error).toMatchObject({
1017 _tag: "InvalidDefaultSecurityGroupRules",
1018 message,
1019 });
1020 expect(observer.requests.filter((request) => request.write)).toEqual(
1021 [],
1022 );
1023 expect(yield* readRules(groupId)).toEqual(
1024 expect.arrayContaining(settled),
1025 );
1026 expect(yield* readRules(groupId)).toHaveLength(settled.length);
1027 }
1028 expect(
1029 (yield* stack.plan(program())).resources.SourcesGroup?.action,
1030 ).toBe("noop");
1031 }).pipe(
1032 // Release source references even when an assertion fails before teardown.
1033 Effect.ensuring(
1034 stack
1035 .deploy(program(undefined, { ingress: [], egress: [] }))
1036 .pipe(Effect.ignore),
1037 ),
1038 );
1039 expect(yield* readRules(groupId)).toEqual([]);
1040 yield* stack.destroy();
1041 yield* assertVpcGone(created.vpc.vpcId);
1042 const prefixGone = yield* EC2.describeManagedPrefixLists({
1043 PrefixListIds: [created.prefix.prefixListId],
1044 }).pipe(
1045 Effect.map((result) =>
1046 (result.PrefixLists ?? []).every(
1047 (list) => list.State === "delete-complete",
1048 ),
1049 ),
1050 Effect.catchTag("InvalidPrefixListID.NotFound", () =>
1051 Effect.succeed(true),
1052 ),
1053 Effect.repeat({
1054 until: Boolean,
1055 schedule: Schedule.spaced("1 second"),
1056 times: 8,
1057 }),
1058 );
1059 expect(prefixGone).toBe(true);
1060 }).pipe(logLevel),
1061 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
1062);
1063
1064test.provider(
1065 "uses inline defaults on omission, property removal, and explicit undefined",
1066 (stack) =>
1067 Effect.gen(function* () {
1068 yield* stack.destroy();
1069 const program = (
1070 rules: Pick<DefaultSecurityGroupProps, "ingress" | "egress"> = {},
1071 ) =>
1072 Effect.gen(function* () {
1073 const vpc = yield* Vpc("DefaultsVpc", { cidrBlock: "10.55.0.0/16" });
1074 const group = yield* DefaultSecurityGroup("DefaultsGroup", {
1075 vpcId: vpc.vpcId,
1076 ...rules,
1077 });
1078 return { vpc, group };
1079 });
1080 const created = yield* stack.deploy(program());
1081 const groupId = created.group.groupId;
1082 expect((yield* findDefaultGroup(created.vpc.vpcId)).GroupId).toBe(
1083 groupId,
1084 );
1085 yield* expectRules(
1086 groupId,
1087 [],
1088 [{ IpProtocol: "-1", CidrIpv4: "0.0.0.0/0" }],
1089 );
1090 expect(created.group.ingressRules).toEqual([]);
1091 expect(created.group.egressRules).toHaveLength(1);
1092
1093 const custom = [
1094 {
1095 ipProtocol: "tcp",
1096 fromPort: 443,
1097 toPort: 443,
1098 cidrIpv4: "10.55.0.0/16",
1099 },
1100 ];
1101 yield* stack.deploy(program({ ingress: custom, egress: custom }));
1102 expect(yield* readRules(groupId)).toHaveLength(2);
1103 yield* stack.deploy(program());
1104 yield* expectRules(
1105 groupId,
1106 [],
1107 [{ IpProtocol: "-1", CidrIpv4: "0.0.0.0/0" }],
1108 );
1109 for (const defaults of [{}, { ingress: undefined, egress: undefined }]) {
1110 yield* stack.deploy(program({ ingress: [], egress: [] }));
1111 yield* expectRules(groupId, [], []);
1112 const restored = yield* stack.deploy(program(defaults));
1113 expect(restored.group.groupId).toBe(groupId);
1114 yield* expectRules(
1115 groupId,
1116 [],
1117 [{ IpProtocol: "-1", CidrIpv4: "0.0.0.0/0" }],
1118 );
1119 }
1120 const observer = yield* observeRuleRequests;
1121 yield* Effect.gen(function* () {
1122 expect(
1123 (yield* stack.plan(program())).resources.DefaultsGroup?.action,
1124 ).toBe("noop");
1125 yield* stack.deploy(program());
1126 }).pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
1127 expect(observer.requests.length).toBeGreaterThan(0);
1128 expect(observer.requests.filter((request) => request.write)).toEqual([]);
1129 yield* stack.destroy();
1130 yield* assertVpcGone(created.vpc.vpcId);
1131 }).pipe(logLevel),
1132 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
1133);
1134
1135test.provider(
1136 "repairs unchanged omitted defaults and adopts them without restoring AWS self-ingress",
1137 (stack) =>
1138 Effect.gen(function* () {
1139 yield* stack.destroy();
1140 const program = (vpcId?: VpcId) =>
1141 Effect.gen(function* () {
1142 const vpc = yield* Vpc("DefaultDriftVpc", {
1143 cidrBlock: "10.56.0.0/16",
1144 });
1145 const group = yield* DefaultSecurityGroup("DefaultDriftGroup", {
1146 vpcId: vpcId ?? vpc.vpcId,
1147 });
1148 return { vpc, group };
1149 });
1150 const created = yield* stack.deploy(program());
1151 const groupId = created.group.groupId;
1152 const state = yield* Effect.gen(function* () {
1153 return yield* yield* State;
1154 }).pipe(Effect.provide(stack.state));
1155 for (const adopt of [false, true]) {
1156 const outbound = (yield* readRules(groupId)).filter(
1157 (rule) => rule.IsEgress,
1158 );
1159 yield* EC2.revokeSecurityGroupEgress({
1160 GroupId: groupId,
1161 SecurityGroupRuleIds: outbound.map(
1162 (rule) => rule.SecurityGroupRuleId!,
1163 ),
1164 });
1165 const self = yield* EC2.authorizeSecurityGroupIngress({
1166 GroupId: groupId,
1167 IpPermissions: [
1168 { IpProtocol: "-1", UserIdGroupPairs: [{ GroupId: groupId }] },
1169 ],
1170 });
1171 const rogue = yield* EC2.authorizeSecurityGroupEgress({
1172 GroupId: groupId,
1173 IpPermissions: [
1174 {
1175 IpProtocol: "tcp",
1176 FromPort: 25,
1177 ToPort: 25,
1178 IpRanges: [{ CidrIp: "0.0.0.0/0" }],
1179 },
1180 ],
1181 });
1182 const rogueIds = [
1183 ...self.SecurityGroupRules!,
1184 ...rogue.SecurityGroupRules!,
1185 ].map((rule) => rule.SecurityGroupRuleId!);
1186 yield* waitForRules(
1187 groupId,
1188 (rules) =>
1189 rules.length === 2 &&
1190 rogueIds.every((id) =>
1191 rules.some((rule) => rule.SecurityGroupRuleId === id),
1192 ),
1193 );
1194 if (adopt) {
1195 yield* state.delete({
1196 stack: stack.name,
1197 stage: stack.stage,
1198 fqn: "DefaultDriftGroup",
1199 });
1200 } else {
1201 expect(
1202 (yield* stack.plan(program())).resources.DefaultDriftGroup?.action,
1203 ).toBe("update");
1204 }
1205 const repaired = yield* stack.deploy(
1206 program(adopt ? created.vpc.vpcId : undefined),
1207 );
1208 expect(repaired.group.groupId).toBe(groupId);
1209 yield* expectRules(
1210 groupId,
1211 [],
1212 [{ IpProtocol: "-1", CidrIpv4: "0.0.0.0/0" }],
1213 );
1214 expect(
1215 (yield* readRules(groupId)).some((rule) =>
1216 rogueIds.includes(rule.SecurityGroupRuleId!),
1217 ),
1218 ).toBe(false);
1219 }
1220 expect(
1221 (yield* stack.plan(program())).resources.DefaultDriftGroup?.action,
1222 ).toBe("noop");
1223 yield* stack.destroy();
1224 yield* assertVpcGone(created.vpc.vpcId);
1225 }).pipe(logLevel),
1226 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
1227);
1228
1229for (const mode of ["omitted", "empty", "inline"] as const) {
1230 test.provider(
1231 `composes standalone ingress and egress with ${mode} inline rules`,
1232 (stack) =>
1233 Effect.gen(function* () {
1234 yield* stack.destroy();
1235 const program = (
1236 inlinePort?: number,
1237 description = "Standalone",
1238 replace = false,
1239 standalone = true,
1240 ) =>
1241 Effect.gen(function* () {
1242 const vpc = yield* Vpc("CompositionVpc", {
1243 cidrBlock: "10.57.0.0/16",
1244 });
1245 const port = inlinePort ?? (mode === "inline" ? 443 : undefined);
1246 const inline =
1247 port === undefined
1248 ? undefined
1249 : [
1250 {
1251 ipProtocol: "tcp",
1252 fromPort: port,
1253 toPort: port,
1254 cidrIpv4: "10.57.0.0/16",
1255 },
1256 ];
1257 const group = yield* DefaultSecurityGroup("CompositionGroup", {
1258 vpcId: vpc.vpcId,
1259 ...(inline
1260 ? { ingress: inline, egress: inline }
1261 : mode === "empty"
1262 ? { ingress: [], egress: [] }
1263 : {}),
1264 });
1265 const ingress = standalone
1266 ? yield* SecurityGroupRule("CompositionIngress", {
1267 group: group,
1268 type: "ingress",
1269 ipProtocol: "tcp",
1270 fromPort: replace ? 5433 : 5432,
1271 toPort: replace ? 5433 : 5432,
1272 cidrIpv4: "10.57.0.0/16",
1273 description,
1274 })
1275 : undefined;
1276 const egress = standalone
1277 ? yield* SecurityGroupRule("CompositionEgress", {
1278 group: group,
1279 type: "egress",
1280 ipProtocol: "udp",
1281 fromPort: replace ? 123 : 53,
1282 toPort: replace ? 123 : 53,
1283 cidrIpv4: "10.57.0.0/16",
1284 description,
1285 })
1286 : undefined;
1287 return { vpc, group, ingress, egress };
1288 });
1289 const created = yield* stack.deploy(program());
1290 const groupId = created.group.groupId;
1291 const ownedIds = [
1292 created.ingress!.securityGroupRuleId,
1293 created.egress!.securityGroupRuleId,
1294 ];
1295 const initial = yield* readRules(groupId);
1296 expect(initial).toHaveLength(
1297 mode === "omitted" ? 3 : mode === "empty" ? 2 : 4,
1298 );
1299 expect(initial.filter((rule) => !rule.IsEgress)).toHaveLength(
1300 mode === "inline" ? 2 : 1,
1301 );
1302 expect(initial.filter((rule) => rule.IsEgress)).toHaveLength(
1303 mode === "empty" ? 1 : 2,
1304 );
1305 if (mode === "omitted") {
1306 expect(
1307 initial.find((rule) => rule.IpProtocol === "-1"),
1308 ).toMatchObject({
1309 IsEgress: true,
1310 CidrIpv4: "0.0.0.0/0",
1311 });
1312 }
1313 const observer = yield* observeRuleRequests;
1314 yield* Effect.gen(function* () {
1315 const plan = yield* stack.plan(program());
1316 expect(plan.resources.CompositionGroup?.action).toBe("noop");
1317 expect(plan.resources.CompositionIngress?.action).toBe("noop");
1318 expect(plan.resources.CompositionEgress?.action).toBe("noop");
1319 yield* stack.deploy(program());
1320 }).pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
1321 expect(observer.requests.length).toBeGreaterThan(0);
1322 expect(observer.requests.filter((request) => request.write)).toEqual(
1323 [],
1324 );
1325
1326 if (mode === "inline") {
1327 const state = yield* Effect.gen(function* () {
1328 return yield* yield* State;
1329 }).pipe(Effect.provide(stack.state));
1330 const rows = yield* Effect.forEach(
1331 ["CompositionIngress", "CompositionEgress"],
1332 Effect.fn(function* (fqn: string) {
1333 const key = { stack: stack.name, stage: stack.stage, fqn };
1334 const row = yield* state.get(key);
1335 if (
1336 !row ||
1337 isActionState(row) ||
1338 (row.status !== "created" && row.status !== "updated")
1339 ) {
1340 return yield* Effect.fail(
1341 new Error("Expected a persisted standalone rule"),
1342 );
1343 }
1344 return {
1345 key,
1346 updating: {
1347 ...row,
1348 status: "updating" as const,
1349 old: {
1350 props: row.props,
1351 attr: row.attr,
1352 bindings: row.bindings,
1353 },
1354 },
1355 };
1356 }),
1357 );
1358 // Planning recognizes old.attr even before a current snapshot is available.
1359 yield* Effect.gen(function* () {
1360 for (const { key, updating } of rows) {
1361 yield* state.set({
1362 ...key,
1363 value: { ...updating, attr: undefined },
1364 });
1365 }
1366 expect(
1367 (yield* stack.plan(program())).resources.CompositionGroup?.action,
1368 ).toBe("noop");
1369 }).pipe(
1370 Effect.ensuring(
1371 Effect.forEach(rows, ({ key, updating }) =>
1372 state.set({ ...key, value: updating }),
1373 ).pipe(Effect.ignore),
1374 ),
1375 );
1376 }
1377 const updated = yield* stack.deploy(program(8443));
1378 expect([
1379 updated.ingress!.securityGroupRuleId,
1380 updated.egress!.securityGroupRuleId,
1381 ]).toEqual(ownedIds);
1382 const updatedRules = yield* readRules(groupId);
1383 expect(updatedRules).toHaveLength(4);
1384 expect(
1385 updatedRules.filter((rule) => rule.FromPort === 8443),
1386 ).toHaveLength(2);
1387 expect(
1388 updatedRules.filter((rule) =>
1389 ownedIds.some((id) => id === rule.SecurityGroupRuleId),
1390 ),
1391 ).toEqual(
1392 expect.arrayContaining(
1393 initial.filter((rule) =>
1394 ownedIds.some((id) => id === rule.SecurityGroupRuleId),
1395 ),
1396 ),
1397 );
1398 expect(
1399 [...updated.group.ingressRules, ...updated.group.egressRules].map(
1400 (rule) => rule.securityGroupRuleId,
1401 ),
1402 ).toEqual(expect.arrayContaining(ownedIds));
1403
1404 const described = yield* stack.deploy(
1405 program(8443, "Updated standalone"),
1406 );
1407 expect([
1408 described.ingress!.securityGroupRuleId,
1409 described.egress!.securityGroupRuleId,
1410 ]).toEqual(ownedIds);
1411 const descriptions = yield* readRules(groupId);
1412 for (const id of ownedIds) {
1413 expect(
1414 descriptions.find((rule) => rule.SecurityGroupRuleId === id)
1415 ?.Description,
1416 ).toBe("Updated standalone");
1417 }
1418 const replaced = yield* stack.deploy(
1419 program(8443, "Updated standalone", true),
1420 );
1421 expect(replaced.ingress!.securityGroupRuleId).not.toBe(ownedIds[0]);
1422 expect(replaced.egress!.securityGroupRuleId).not.toBe(ownedIds[1]);
1423 const replacementRules = yield* readRules(groupId);
1424 expect(replacementRules).toHaveLength(4);
1425 expect(
1426 replacementRules.some((rule) =>
1427 ownedIds.some((id) => id === rule.SecurityGroupRuleId),
1428 ),
1429 ).toBe(false);
1430 expect(
1431 replacementRules.find(
1432 (rule) =>
1433 rule.SecurityGroupRuleId ===
1434 replaced.ingress!.securityGroupRuleId,
1435 ),
1436 ).toMatchObject({ IsEgress: false, FromPort: 5433, ToPort: 5433 });
1437 expect(
1438 replacementRules.find(
1439 (rule) =>
1440 rule.SecurityGroupRuleId === replaced.egress!.securityGroupRuleId,
1441 ),
1442 ).toMatchObject({ IsEgress: true, FromPort: 123, ToPort: 123 });
1443 expect(
1444 replacementRules.filter((rule) => rule.FromPort === 8443),
1445 ).toEqual(
1446 expect.arrayContaining(
1447 updatedRules.filter((rule) => rule.FromPort === 8443),
1448 ),
1449 );
1450
1451 expect(
1452 (yield* stack.plan(program(8443, "Updated standalone", true, false)))
1453 .resources.CompositionGroup?.action,
1454 ).toBe("update");
1455 yield* stack.deploy(program(8443, "Updated standalone", true, false));
1456 const final = yield* readRules(groupId);
1457 expect(final).toHaveLength(2);
1458 expect(final.every((rule) => rule.FromPort === 8443)).toBe(true);
1459
1460 // New standalone records must wait for the existing manager's inline update.
1461 const recreated = yield* stack.deploy(program(9443, "Recreated"));
1462 expect(recreated.ingress!.securityGroupRuleId).not.toBe(
1463 replaced.ingress!.securityGroupRuleId,
1464 );
1465 expect(recreated.egress!.securityGroupRuleId).not.toBe(
1466 replaced.egress!.securityGroupRuleId,
1467 );
1468 const recreatedRules = yield* readRules(groupId);
1469 expect(recreatedRules).toHaveLength(4);
1470 expect(
1471 recreatedRules.filter((rule) => rule.FromPort === 9443),
1472 ).toHaveLength(2);
1473 expect(
1474 recreatedRules.find(
1475 (rule) =>
1476 rule.SecurityGroupRuleId ===
1477 recreated.ingress!.securityGroupRuleId,
1478 ),
1479 ).toMatchObject({
1480 IsEgress: false,
1481 FromPort: 5432,
1482 Description: "Recreated",
1483 });
1484 expect(
1485 recreatedRules.find(
1486 (rule) =>
1487 rule.SecurityGroupRuleId ===
1488 recreated.egress!.securityGroupRuleId,
1489 ),
1490 ).toMatchObject({
1491 IsEgress: true,
1492 FromPort: 53,
1493 Description: "Recreated",
1494 });
1495 const noop = yield* observeRuleRequests;
1496 yield* stack
1497 .deploy(program(9443, "Recreated"))
1498 .pipe(Effect.provideService(HttpClient.HttpClient, noop.client));
1499 expect(noop.requests.length).toBeGreaterThan(0);
1500 expect(noop.requests.filter((request) => request.write)).toEqual([]);
1501 yield* stack.destroy();
1502 yield* assertVpcGone(created.vpc.vpcId);
1503 }).pipe(logLevel),
1504 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
1505 );
1506}
1507
1508test.provider(
1509 "preserves standalone replacement classification during inline updates and new rule creation",
1510 (stack) =>
1511 Effect.gen(function* () {
1512 yield* stack.destroy();
1513 const phases = [
1514 {
1515 inlinePort: 443,
1516 direction: "ingress",
1517 directionPort: 5000,
1518 directionCidr: "10.59.1.0/24",
1519 protocol: "tcp",
1520 identityPort: 5432,
1521 identityCidr: "10.59.3.0/24",
1522 },
1523 {
1524 inlinePort: 8443,
1525 direction: "egress",
1526 directionPort: 5001,
1527 directionCidr: "10.59.2.0/24",
1528 protocol: "udp",
1529 identityPort: 5353,
1530 identityCidr: "10.59.4.0/24",
1531 },
1532 {
1533 inlinePort: 9443,
1534 direction: "ingress",
1535 directionPort: 5002,
1536 directionCidr: "10.59.6.0/24",
1537 protocol: "tcp",
1538 identityPort: 8444,
1539 identityCidr: "10.59.7.0/24",
1540 },
1541 ] as const;
1542 const program = (phase: 0 | 1 | 2) =>
1543 Effect.gen(function* () {
1544 const desired = phases[phase];
1545 const vpc = yield* Vpc("ReplacementOrderingVpc", {
1546 cidrBlock: "10.59.0.0/16",
1547 });
1548 const inline = [
1549 {
1550 ipProtocol: "tcp",
1551 fromPort: desired.inlinePort,
1552 toPort: desired.inlinePort,
1553 cidrIpv4: "10.59.0.0/16",
1554 },
1555 ];
1556 const group = yield* DefaultSecurityGroup(
1557 "ReplacementOrderingGroup",
1558 {
1559 vpcId: vpc.vpcId,
1560 ingress: inline,
1561 egress: inline,
1562 },
1563 );
1564 const direction = yield* SecurityGroupRule("DirectionReplacement", {
1565 group: group,
1566 type: desired.direction,
1567 ipProtocol: "tcp",
1568 fromPort: desired.directionPort,
1569 toPort: desired.directionPort,
1570 cidrIpv4: desired.directionCidr,
1571 });
1572 const identity = yield* SecurityGroupRule("IdentityReplacement", {
1573 group: group,
1574 type: "egress",
1575 ipProtocol: desired.protocol,
1576 fromPort: desired.identityPort,
1577 toPort: desired.identityPort,
1578 cidrIpv4: desired.identityCidr,
1579 });
1580 const added =
1581 phase === 0
1582 ? undefined
1583 : yield* SecurityGroupRule("AddedDuringReplacement", {
1584 group: group,
1585 type: "ingress",
1586 ipProtocol: "udp",
1587 fromPort: 1234,
1588 toPort: 1234,
1589 cidrIpv4: "10.59.5.0/24",
1590 });
1591 return { vpc, group, direction, identity, added };
1592 });
1593 let previous = yield* stack.deploy(program(0));
1594 const groupId = previous.group.groupId;
1595 const vpcId = previous.vpc.vpcId;
1596 const initial = yield* readRules(groupId);
1597 expect(initial).toHaveLength(4);
1598 expect(
1599 initial.find(
1600 (rule) =>
1601 rule.SecurityGroupRuleId === previous.direction.securityGroupRuleId,
1602 ),
1603 ).toMatchObject({
1604 GroupId: groupId,
1605 IsEgress: false,
1606 IpProtocol: "tcp",
1607 FromPort: 5000,
1608 ToPort: 5000,
1609 CidrIpv4: "10.59.1.0/24",
1610 });
1611 expect(
1612 initial.find(
1613 (rule) =>
1614 rule.SecurityGroupRuleId === previous.identity.securityGroupRuleId,
1615 ),
1616 ).toMatchObject({
1617 GroupId: groupId,
1618 IsEgress: true,
1619 IpProtocol: "tcp",
1620 FromPort: 5432,
1621 ToPort: 5432,
1622 CidrIpv4: "10.59.3.0/24",
1623 });
1624 const retiredIds: string[] = [];
1625 for (const phase of [1, 2] as const) {
1626 const desired = phases[phase];
1627 const plan = yield* stack.plan(program(phase));
1628 expect(plan.resources.ReplacementOrderingGroup?.action).toBe("update");
1629 expect(plan.resources.DirectionReplacement?.action).toBe("replace");
1630 expect(plan.resources.IdentityReplacement?.action).toBe("replace");
1631 expect(plan.resources.AddedDuringReplacement?.action).toBe(
1632 phase === 1 ? "create" : "noop",
1633 );
1634 retiredIds.push(
1635 previous.direction.securityGroupRuleId,
1636 previous.identity.securityGroupRuleId,
1637 );
1638 const deployed = yield* stack.deploy(program(phase));
1639 expect(deployed.group.groupId).toBe(groupId);
1640 expect(deployed.vpc.vpcId).toBe(vpcId);
1641 expect(deployed.direction.securityGroupRuleId).not.toBe(
1642 previous.direction.securityGroupRuleId,
1643 );
1644 expect(deployed.identity.securityGroupRuleId).not.toBe(
1645 previous.identity.securityGroupRuleId,
1646 );
1647 if (previous.added) {
1648 expect(deployed.added!.securityGroupRuleId).toBe(
1649 previous.added.securityGroupRuleId,
1650 );
1651 }
1652 const currentIds = [
1653 deployed.direction.securityGroupRuleId,
1654 deployed.identity.securityGroupRuleId,
1655 deployed.added!.securityGroupRuleId,
1656 ];
1657 const observed = yield* waitForRules(
1658 groupId,
1659 (rules) =>
1660 rules.length === 5 &&
1661 !rules.some((rule) =>
1662 retiredIds.includes(rule.SecurityGroupRuleId!),
1663 ) &&
1664 currentIds.every((id) =>
1665 rules.some((rule) => rule.SecurityGroupRuleId === id),
1666 ),
1667 );
1668 expect(
1669 observed.find(
1670 (rule) =>
1671 rule.SecurityGroupRuleId ===
1672 deployed.direction.securityGroupRuleId,
1673 ),
1674 ).toMatchObject({
1675 GroupId: groupId,
1676 IsEgress: desired.direction === "egress",
1677 IpProtocol: "tcp",
1678 FromPort: desired.directionPort,
1679 ToPort: desired.directionPort,
1680 CidrIpv4: desired.directionCidr,
1681 });
1682 expect(
1683 observed.find(
1684 (rule) =>
1685 rule.SecurityGroupRuleId ===
1686 deployed.identity.securityGroupRuleId,
1687 ),
1688 ).toMatchObject({
1689 GroupId: groupId,
1690 IsEgress: true,
1691 IpProtocol: desired.protocol,
1692 FromPort: desired.identityPort,
1693 ToPort: desired.identityPort,
1694 CidrIpv4: desired.identityCidr,
1695 });
1696 expect(
1697 observed.find(
1698 (rule) =>
1699 rule.SecurityGroupRuleId === deployed.added!.securityGroupRuleId,
1700 ),
1701 ).toMatchObject({
1702 GroupId: groupId,
1703 IsEgress: false,
1704 IpProtocol: "udp",
1705 FromPort: 1234,
1706 ToPort: 1234,
1707 CidrIpv4: "10.59.5.0/24",
1708 });
1709 const inline = observed.filter(
1710 (rule) => !currentIds.some((id) => id === rule.SecurityGroupRuleId),
1711 );
1712 expect(inline).toHaveLength(2);
1713 for (const isEgress of [false, true]) {
1714 expect(
1715 inline.find((rule) => rule.IsEgress === isEgress),
1716 ).toMatchObject({
1717 GroupId: groupId,
1718 IpProtocol: "tcp",
1719 FromPort: desired.inlinePort,
1720 ToPort: desired.inlinePort,
1721 CidrIpv4: "10.59.0.0/16",
1722 });
1723 }
1724 previous = deployed;
1725 }
1726 const settled = yield* readRules(groupId);
1727 const observer = yield* observeRuleRequests;
1728 yield* Effect.gen(function* () {
1729 const plan = yield* stack.plan(program(2));
1730 for (const fqn of [
1731 "ReplacementOrderingGroup",
1732 "DirectionReplacement",
1733 "IdentityReplacement",
1734 "AddedDuringReplacement",
1735 ]) {
1736 expect(plan.resources[fqn]?.action).toBe("noop");
1737 }
1738 yield* stack.deploy(program(2));
1739 }).pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
1740 expect(observer.requests.length).toBeGreaterThan(0);
1741 expect(observer.requests.filter((request) => request.write)).toEqual([]);
1742 const final = yield* readRules(groupId);
1743 expect(final).toHaveLength(5);
1744 expect(final).toEqual(expect.arrayContaining(settled));
1745 yield* stack.destroy();
1746 yield* assertVpcGone(vpcId);
1747 }).pipe(logLevel),
1748 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
1749);
1750
1751test.provider(
1752 "rejects copied persisted-rule tags and cross-stack ownership in both directions",
1753 (stack) =>
1754 Effect.gen(function* () {
1755 const foreign = Core.scratchStack(
1756 { providers: AWS.providers(), stage: stack.stage },
1757 `${stack.name}-foreign`,
1758 "test/AWS/EC2/DefaultSecurityGroup.test.ts",
1759 );
1760 yield* foreign.destroy();
1761 yield* stack.destroy();
1762 yield* Effect.gen(function* () {
1763 const program = Effect.gen(function* () {
1764 const vpc = yield* Vpc("OwnershipVpc", { cidrBlock: "10.58.0.0/16" });
1765 const group = yield* DefaultSecurityGroup("OwnershipGroup", {
1766 vpcId: vpc.vpcId,
1767 ingress: [],
1768 egress: [],
1769 });
1770 yield* SecurityGroupRule("TargetIngress", {
1771 group: group,
1772 type: "ingress",
1773 ipProtocol: "tcp",
1774 fromPort: 443,
1775 toPort: 443,
1776 cidrIpv4: "10.58.0.0/16",
1777 });
1778 yield* SecurityGroupRule("TargetEgress", {
1779 group: group,
1780 type: "egress",
1781 ipProtocol: "udp",
1782 fromPort: 53,
1783 toPort: 53,
1784 cidrIpv4: "10.58.0.0/16",
1785 });
1786 const peer = yield* SecurityGroup("OwnershipPeer", {
1787 vpcId: vpc.vpcId,
1788 egress: [],
1789 });
1790 const ingress = yield* SecurityGroupRule("OwnedIngress", {
1791 groupId: peer.groupId,
1792 type: "ingress",
1793 ipProtocol: "tcp",
1794 fromPort: 5432,
1795 toPort: 5432,
1796 cidrIpv4: "10.58.0.0/16",
1797 });
1798 const egress = yield* SecurityGroupRule("OwnedEgress", {
1799 groupId: peer.groupId,
1800 type: "egress",
1801 ipProtocol: "udp",
1802 fromPort: 53,
1803 toPort: 53,
1804 cidrIpv4: "10.58.0.0/16",
1805 });
1806 return { vpc, group, peer, ingress, egress };
1807 });
1808 const created = yield* stack.deploy(program);
1809 const groupId = created.group.groupId;
1810 const peerRules = yield* readRules(created.peer.groupId);
1811 const targetRules = yield* readRules(groupId);
1812 expect(peerRules).toHaveLength(2);
1813 expect(targetRules).toHaveLength(2);
1814 const copiedIds: string[] = [];
1815 for (const isEgress of [false, true]) {
1816 const source = targetRules.find(
1817 (rule) => !!rule.IsEgress === isEgress,
1818 )!;
1819 expect(source.Tags?.some((tag) => tag.Key === "alchemy::id")).toBe(
1820 true,
1821 );
1822 const authorize = isEgress
1823 ? EC2.authorizeSecurityGroupEgress
1824 : EC2.authorizeSecurityGroupIngress;
1825 const copied = yield* authorize({
1826 GroupId: groupId,
1827 IpPermissions: [
1828 {
1829 IpProtocol: "tcp",
1830 FromPort: 22,
1831 ToPort: 22,
1832 IpRanges: [{ CidrIp: "0.0.0.0/0" }],
1833 },
1834 ],
1835 TagSpecifications: [
1836 { ResourceType: "security-group-rule", Tags: source.Tags },
1837 ],
1838 });
1839 copiedIds.push(copied.SecurityGroupRules![0]!.SecurityGroupRuleId!);
1840 }
1841 // The same logical IDs in another stack do not delegate physical rules.
1842 const elsewhere = yield* foreign.deploy(
1843 Effect.gen(function* () {
1844 const ingress = yield* SecurityGroupRule("OwnedIngress", {
1845 groupId,
1846 type: "ingress",
1847 ipProtocol: "tcp",
1848 fromPort: 5433,
1849 toPort: 5433,
1850 cidrIpv4: "10.58.0.0/16",
1851 });
1852 const egress = yield* SecurityGroupRule("OwnedEgress", {
1853 groupId,
1854 type: "egress",
1855 ipProtocol: "udp",
1856 fromPort: 123,
1857 toPort: 123,
1858 cidrIpv4: "10.58.0.0/16",
1859 });
1860 return { ingress, egress };
1861 }),
1862 );
1863 const foreignState = yield* Effect.gen(function* () {
1864 return yield* yield* State;
1865 }).pipe(Effect.provide(foreign.state));
1866 for (const [fqn, attrs] of [
1867 ["OwnedIngress", elsewhere.ingress],
1868 ["OwnedEgress", elsewhere.egress],
1869 ] as const) {
1870 const row = yield* foreignState.get({
1871 stack: foreign.name,
1872 stage: foreign.stage,
1873 fqn,
1874 });
1875 if (!row || isActionState(row)) {
1876 return yield* Effect.fail(
1877 new Error("Expected a persisted cross-stack rule"),
1878 );
1879 }
1880 expect(row.attr?.securityGroupRuleId).toBe(attrs.securityGroupRuleId);
1881 expect(row.attr?.groupId).toBe(groupId);
1882 }
1883 const unownedIds = [
1884 ...copiedIds,
1885 elsewhere.ingress.securityGroupRuleId,
1886 elsewhere.egress.securityGroupRuleId,
1887 ];
1888 yield* waitForRules(
1889 groupId,
1890 (rules) =>
1891 rules.length === 6 &&
1892 unownedIds.every((id) =>
1893 rules.some((rule) => rule.SecurityGroupRuleId === id),
1894 ),
1895 );
1896 expect(
1897 (yield* stack.plan(program)).resources.OwnershipGroup?.action,
1898 ).toBe("update");
1899 const repaired = yield* stack.deploy(program);
1900 expect(yield* readRules(groupId)).toHaveLength(2);
1901 expect(yield* readRules(groupId)).toEqual(
1902 expect.arrayContaining(targetRules),
1903 );
1904 expect(repaired.group.ingressRules).toHaveLength(1);
1905 expect(repaired.group.egressRules).toHaveLength(1);
1906 expect(
1907 [...repaired.group.ingressRules, ...repaired.group.egressRules]
1908 .map((rule) => rule.securityGroupRuleId)
1909 .sort(),
1910 ).toEqual(targetRules.map((rule) => rule.SecurityGroupRuleId).sort());
1911 expect(yield* readRules(created.peer.groupId)).toEqual(
1912 expect.arrayContaining(peerRules),
1913 );
1914 expect(yield* readRules(created.peer.groupId)).toHaveLength(2);
1915 const observer = yield* observeRuleRequests;
1916 yield* Effect.gen(function* () {
1917 expect(
1918 (yield* stack.plan(program)).resources.OwnershipGroup?.action,
1919 ).toBe("noop");
1920 yield* stack.deploy(program);
1921 }).pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
1922 expect(observer.requests.length).toBeGreaterThan(0);
1923 expect(observer.requests.filter((request) => request.write)).toEqual(
1924 [],
1925 );
1926 yield* foreign.destroy();
1927 yield* stack.destroy();
1928 yield* assertVpcGone(created.vpc.vpcId);
1929 }).pipe(Effect.ensuring(foreign.destroy().pipe(Effect.ignore)));
1930 }).pipe(logLevel),
1931 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
1932);
1933
1934for (const kind of ["default", "custom"] as const) {
1935 test.provider(
1936 `normalizes standalone input forms and ignores unrelated ${kind} group attributes`,
1937 (stack) =>
1938 Effect.gen(function* () {
1939 yield* stack.destroy();
1940 const program = (
1941 form:
1942 | "id"
1943 | "resource"
1944 | "attributes"
1945 | "flatMap-id"
1946 | "flatMap-resource"
1947 | "both"
1948 | "neither",
1949 description = "Standalone",
1950 inlineDescription = "Inline",
1951 move = false,
1952 ) =>
1953 Effect.gen(function* () {
1954 const vpc = yield* Vpc("InputFormsVpc", {
1955 cidrBlock: "10.60.0.0/16",
1956 });
1957 const props = {
1958 vpcId: vpc.vpcId,
1959 ingress: [
1960 {
1961 ipProtocol: "tcp",
1962 fromPort: 443,
1963 toPort: 443,
1964 cidrIpv4: "10.60.0.0/16",
1965 description: inlineDescription,
1966 },
1967 ],
1968 egress: [],
1969 };
1970 const group =
1971 kind === "default"
1972 ? yield* DefaultSecurityGroup("InputFormsGroup", props)
1973 : yield* SecurityGroup("InputFormsGroup", props);
1974 const peer = yield* SecurityGroup("InputFormsPeer", {
1975 vpcId: vpc.vpcId,
1976 ingress: [],
1977 egress: [],
1978 });
1979 const targetGroup = move ? peer : group;
1980 const target =
1981 form === "id"
1982 ? { groupId: targetGroup.groupId }
1983 : form === "resource"
1984 ? { group: targetGroup }
1985 : form === "attributes"
1986 ? { group: { groupId: targetGroup.groupId } }
1987 : form === "flatMap-id"
1988 ? {
1989 groupId: vpc.vpcId.pipe(
1990 Output.flatMap(() => targetGroup.groupId),
1991 ),
1992 }
1993 : form === "flatMap-resource"
1994 ? {
1995 group: vpc.vpcId.pipe(
1996 Output.flatMap(() => Output.of(targetGroup)),
1997 ),
1998 }
1999 : form === "both"
2000 ? { group: targetGroup, groupId: targetGroup.groupId }
2001 : {};
2002 const rule = yield* SecurityGroupRule("InputFormsRule", {
2003 ...target,
2004 type: "egress",
2005 ipProtocol: "tcp",
2006 fromPort: 5432,
2007 toPort: 5432,
2008 cidrIpv4: "10.60.0.0/16",
2009 description,
2010 tags: { purpose: description },
2011 });
2012 return { vpc, group, peer, rule };
2013 });
2014 const created = yield* stack.deploy(program("id"));
2015 const groupId = created.group.groupId;
2016 const ruleId = created.rule.securityGroupRuleId;
2017 const initial = yield* readRules(groupId);
2018 for (const form of [
2019 "flatMap-id",
2020 "id",
2021 "flatMap-resource",
2022 "resource",
2023 "attributes",
2024 "id",
2025 ] as const) {
2026 const observer = yield* observeRuleRequests;
2027 yield* Effect.gen(function* () {
2028 const plan = yield* stack.plan(program(form));
2029 expect(plan.resources.InputFormsGroup?.action).toBe("noop");
2030 expect(plan.resources.InputFormsRule?.action).toBe("noop");
2031 const deployed = yield* stack.deploy(program(form));
2032 expect(deployed.rule.securityGroupRuleId).toBe(ruleId);
2033 expect(deployed.rule.groupId).toBe(groupId);
2034 }).pipe(
2035 Effect.provideService(HttpClient.HttpClient, observer.client),
2036 );
2037 expect(observer.requests.length).toBeGreaterThan(0);
2038 expect(observer.requests.filter((request) => request.write)).toEqual(
2039 [],
2040 );
2041 const rules = yield* readRules(groupId);
2042 expect(rules).toEqual(expect.arrayContaining(initial));
2043 expect(rules).toHaveLength(2);
2044 expect(
2045 rules.find((rule) => rule.SecurityGroupRuleId === ruleId),
2046 ).toMatchObject({
2047 GroupId: groupId,
2048 SecurityGroupRuleId: ruleId,
2049 IsEgress: true,
2050 FromPort: 5432,
2051 ToPort: 5432,
2052 });
2053 }
2054 for (const form of ["both", "neither"] as const) {
2055 const observer = yield* observeRuleRequests;
2056 const error = yield* stack
2057 .plan(program(form))
2058 .pipe(
2059 Effect.provideService(HttpClient.HttpClient, observer.client),
2060 Effect.flip,
2061 );
2062 expect(error).toMatchObject({
2063 _tag: "InvalidSecurityGroupRuleGroup",
2064 message: "Specify exactly one of group or groupId.",
2065 });
2066 expect(observer.requests.filter((request) => request.write)).toEqual(
2067 [],
2068 );
2069 }
2070 expect(
2071 (yield* stack.plan(program("resource", "Updated"))).resources
2072 .InputFormsRule?.action,
2073 ).toBe("update");
2074 const updated = yield* stack.deploy(program("resource", "Updated"));
2075 expect(updated.rule.securityGroupRuleId).toBe(ruleId);
2076 const observed = (yield* readRules(groupId)).find(
2077 (rule) => rule.SecurityGroupRuleId === ruleId,
2078 )!;
2079 expect(observed.Description).toBe("Updated");
2080 expect(observed.Tags).toEqual(
2081 expect.arrayContaining([{ Key: "purpose", Value: "Updated" }]),
2082 );
2083 const observer = yield* observeRuleRequests;
2084 yield* Effect.gen(function* () {
2085 const plan = yield* stack.plan(
2086 program("resource", "Updated", "Changed inline"),
2087 );
2088 expect(plan.resources.InputFormsGroup?.action).toBe("update");
2089 expect(plan.resources.InputFormsRule?.action).toBe("noop");
2090 const deployed = yield* stack.deploy(
2091 program("resource", "Updated", "Changed inline"),
2092 );
2093 expect(deployed.rule.securityGroupRuleId).toBe(ruleId);
2094 }).pipe(Effect.provideService(HttpClient.HttpClient, observer.client));
2095 const writes = observer.requests.filter((request) => request.write);
2096 expect(writes.length).toBeGreaterThan(0);
2097 expect(
2098 writes.every(
2099 (request) => request.action === "ModifySecurityGroupRules",
2100 ),
2101 ).toBe(true);
2102 expect(writes.flatMap((request) => request.ruleIds)).not.toContain(
2103 ruleId,
2104 );
2105 expect(
2106 (yield* readRules(groupId)).find(
2107 (rule) => rule.SecurityGroupRuleId === ruleId,
2108 ),
2109 ).toEqual(observed);
2110 for (const form of ["resource", "id"] as const) {
2111 const noop = yield* observeRuleRequests;
2112 yield* Effect.gen(function* () {
2113 expect(
2114 (yield* stack.plan(program(form, "Updated", "Changed inline")))
2115 .resources.InputFormsRule?.action,
2116 ).toBe("noop");
2117 yield* stack.deploy(program(form, "Updated", "Changed inline"));
2118 }).pipe(Effect.provideService(HttpClient.HttpClient, noop.client));
2119 expect(noop.requests.length).toBeGreaterThan(0);
2120 expect(noop.requests.filter((request) => request.write)).toEqual([]);
2121 }
2122 // A current declaration targeting another group no longer delegates its old ID.
2123 for (const form of ["flatMap-id", "flatMap-resource"] as const) {
2124 const movePlan = yield* stack.plan(
2125 program(form, "Updated", "Changed inline", true),
2126 );
2127 expect(movePlan.resources.InputFormsGroup?.action).toBe("update");
2128 expect(movePlan.resources.InputFormsRule?.action).toBe("replace");
2129 }
2130 const moving = program(
2131 kind === "default" ? "resource" : "id",
2132 "Updated",
2133 "Changed inline",
2134 true,
2135 );
2136 const movePlan = yield* stack.plan(moving);
2137 expect(movePlan.resources.InputFormsGroup?.action).toBe("update");
2138 expect(movePlan.resources.InputFormsRule?.action).toBe("replace");
2139 const moved = yield* stack.deploy(moving);
2140 expect(moved.rule.groupId).toBe(moved.peer.groupId);
2141 expect(moved.rule.securityGroupRuleId).not.toBe(ruleId);
2142 const remaining = yield* readRules(groupId);
2143 expect(remaining).toHaveLength(1);
2144 expect(remaining[0]?.FromPort).toBe(443);
2145 expect(remaining[0]?.Description).toBe("Changed inline");
2146 const peerRules = yield* readRules(moved.peer.groupId);
2147 expect(peerRules).toHaveLength(1);
2148 expect(peerRules[0]).toMatchObject({
2149 SecurityGroupRuleId: moved.rule.securityGroupRuleId,
2150 IsEgress: true,
2151 FromPort: 5432,
2152 ToPort: 5432,
2153 });
2154 yield* stack.destroy();
2155 yield* assertVpcGone(created.vpc.vpcId);
2156 }).pipe(logLevel),
2157 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
2158 );
2159}
2160
2161for (const scenario of [
2162 "new destination VPC",
2163 "upstream VPC replacement",
2164] as const) {
2165 test.provider(
2166 `replaces whole-group standalone rules during ${scenario}`,
2167 (stack) =>
2168 Effect.gen(function* () {
2169 yield* stack.destroy();
2170 const program = (move: boolean) =>
2171 Effect.gen(function* () {
2172 const original = yield* Vpc("RuleIdentityVpc", {
2173 cidrBlock:
2174 move && scenario === "upstream VPC replacement"
2175 ? "10.62.0.0/16"
2176 : "10.61.0.0/16",
2177 });
2178 const destination =
2179 move && scenario === "new destination VPC"
2180 ? yield* Vpc("RuleDestinationVpc", {
2181 cidrBlock: "10.62.0.0/16",
2182 })
2183 : original;
2184 const group = yield* DefaultSecurityGroup("RuleIdentityGroup", {
2185 vpcId: destination.vpcId,
2186 ingress: [],
2187 egress: [],
2188 });
2189 const ingress = yield* SecurityGroupRule("MovingIngress", {
2190 group,
2191 type: "ingress",
2192 ipProtocol: "tcp",
2193 fromPort: 443,
2194 toPort: 443,
2195 cidrIpv4: "10.61.0.0/16",
2196 });
2197 const egress = yield* SecurityGroupRule("MovingEgress", {
2198 group,
2199 type: "egress",
2200 ipProtocol: "udp",
2201 fromPort: 53,
2202 toPort: 53,
2203 cidrIpv4: "10.61.0.0/16",
2204 });
2205 return { original, destination, group, ingress, egress };
2206 });
2207 const created = yield* stack.deploy(program(false));
2208 const plan = yield* stack.plan(program(true));
2209 for (const fqn of [
2210 "RuleIdentityGroup",
2211 "MovingIngress",
2212 "MovingEgress",
2213 ]) {
2214 expect(plan.resources[fqn]?.action).toBe("replace");
2215 }
2216 const moved = yield* stack.deploy(program(true));
2217 expect(moved.group.groupId).not.toBe(created.group.groupId);
2218 expect(moved.ingress.securityGroupRuleId).not.toBe(
2219 created.ingress.securityGroupRuleId,
2220 );
2221 expect(moved.egress.securityGroupRuleId).not.toBe(
2222 created.egress.securityGroupRuleId,
2223 );
2224 expect(moved.ingress.groupId).toBe(moved.group.groupId);
2225 expect(moved.egress.groupId).toBe(moved.group.groupId);
2226 yield* expectRules(
2227 moved.group.groupId,
2228 [
2229 {
2230 SecurityGroupRuleId: moved.ingress.securityGroupRuleId,
2231 IsEgress: false,
2232 IpProtocol: "tcp",
2233 FromPort: 443,
2234 ToPort: 443,
2235 CidrIpv4: "10.61.0.0/16",
2236 },
2237 ],
2238 [
2239 {
2240 SecurityGroupRuleId: moved.egress.securityGroupRuleId,
2241 IsEgress: true,
2242 IpProtocol: "udp",
2243 FromPort: 53,
2244 ToPort: 53,
2245 CidrIpv4: "10.61.0.0/16",
2246 },
2247 ],
2248 );
2249 if (scenario === "new destination VPC") {
2250 yield* expectRules(created.group.groupId, [], []);
2251 } else {
2252 yield* assertVpcGone(created.original.vpcId);
2253 }
2254 const noop = yield* stack.plan(program(true));
2255 for (const fqn of [
2256 "RuleIdentityGroup",
2257 "MovingIngress",
2258 "MovingEgress",
2259 ]) {
2260 expect(noop.resources[fqn]?.action).toBe("noop");
2261 }
2262 yield* stack.destroy();
2263 yield* assertVpcGone(created.original.vpcId);
2264 yield* assertVpcGone(moved.destination.vpcId);
2265 }).pipe(logLevel),
2266 { tags: ["provider:aws", "provider:aws:ec2", "live"], timeout: 120_000 },
2267 );
2268}
2269
2270const observeRuleRequests = Effect.gen(function* () {
2271 const client = yield* HttpClient.HttpClient;
2272 const requests: Array<{ action: string; write: boolean; ruleIds: string[] }> =
2273 [];
2274 return {
2275 requests,
2276 client: client.pipe(
2277 HttpClient.tapRequest((request) =>
2278 Effect.sync(() => {
2279 if (request.body._tag !== "Uint8Array") return;
2280 const parameters = new URLSearchParams(
2281 new TextDecoder().decode(request.body.body),
2282 );
2283 const action = parameters.get("Action");
2284 if (
2285 !action ||
2286 (!action.includes("SecurityGroup") &&
2287 action !== "CreateTags" &&
2288 action !== "DeleteTags")
2289 )
2290 return;
2291 requests.push({
2292 action,
2293 write: !action.startsWith("Describe"),
2294 ruleIds: [...parameters.entries()]
2295 .filter(
2296 ([key]) =>
2297 key.endsWith(".SecurityGroupRuleId") ||
2298 key.startsWith("SecurityGroupRuleId."),
2299 )
2300 .map(([, value]) => value),
2301 });
2302 }),
2303 ),
2304 ),
2305 };
2306});
2307
2308const waitForRules = Effect.fn(function* (
2309 groupId: string,
2310 matches: (rules: EC2.SecurityGroupRule[]) => boolean,
2311) {
2312 const rules = yield* readRules(groupId).pipe(
2313 Effect.repeat({
2314 until: matches,
2315 schedule: Schedule.spaced("1 second"),
2316 times: 8,
2317 }),
2318 );
2319 expect(matches(rules)).toBe(true);
2320 return rules;
2321});
2322
2323const findDefaultGroup = Effect.fn(function* (vpcId: string) {
2324 const group = yield* EC2.describeSecurityGroups({
2325 Filters: [
2326 { Name: "vpc-id", Values: [vpcId] },
2327 { Name: "group-name", Values: ["default"] },
2328 ],
2329 }).pipe(
2330 Effect.map((result) => result.SecurityGroups?.[0]),
2331 Effect.repeat({
2332 until: (group) => !!group?.GroupId,
2333 schedule: Schedule.spaced("1 second"),
2334 times: 8,
2335 }),
2336 );
2337 if (!group?.GroupId) {
2338 return yield* Effect.fail(
2339 new Error(`Default group for ${vpcId} was not found`),
2340 );
2341 }
2342 return group;
2343});
2344
2345const readRules = (groupId: string) =>
2346 EC2.describeSecurityGroupRules
2347 .items({ Filters: [{ Name: "group-id", Values: [groupId] }] })
2348 .pipe(
2349 Stream.runCollect,
2350 Effect.map((rules) => Array.from(rules)),
2351 );
2352
2353const expectRules = Effect.fn(function* (
2354 groupId: string,
2355 ingress: Partial<EC2.SecurityGroupRule>[],
2356 egress: Partial<EC2.SecurityGroupRule>[],
2357) {
2358 const rules = yield* readRules(groupId);
2359 expect(rules.filter((rule) => !rule.IsEgress)).toEqual(
2360 ingress.map((rule) => expect.objectContaining(rule)),
2361 );
2362 expect(rules.filter((rule) => rule.IsEgress)).toEqual(
2363 egress.map((rule) => expect.objectContaining(rule)),
2364 );
2365});