1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import type { ScopedPlanStatusSession } from "../../Report.ts";
10import { Resource } from "../../Resource.ts";
11import {
12 createAlchemyTagFilters,
13 createInternalTags,
14 createTagsList,
15 diffTags,
16} from "../../Tags.ts";
17import type { AccountID } from "../Environment.ts";
18import { AWSEnvironment } from "../Environment.ts";
19import type { Providers } from "../Providers.ts";
20import type { RegionID } from "../Region.ts";
21import { retryWhileLingeringEnis } from "./LingeringEnis.ts";
22import {
23 declaredSecurityGroupRuleIds,
24 expandSecurityGroupRules,
25 observedSecurityGroupRuleKey,
26 resolveSecurityGroupRules,
27 securityGroupRuleKey,
28} from "./SecurityGroupRule.ts";
29import type { VpcId } from "./Vpc.ts";
30
31export type SecurityGroupId<ID extends string = string> = `sg-${ID}`;
32export const SecurityGroupId = <ID extends string>(
33 id: ID,
34): ID & SecurityGroupId<ID> => `sg-${id}` as ID & SecurityGroupId<ID>;
35
36export type SecurityGroupArn<
37 GroupId extends SecurityGroupId = SecurityGroupId,
38> = `arn:aws:ec2:${RegionID}:${AccountID}:security-group/${GroupId}`;
39
40/**
41 * Ingress or egress rule for a security group.
42 */
43export interface SecurityGroupRuleData {
44 /**
45 * The IP protocol name or number.
46 * Use -1 to specify all protocols.
47 */
48 ipProtocol: string;
49
50 /**
51 * The start of the port range.
52 * For ICMP, use the ICMP type number.
53 */
54 fromPort?: number;
55
56 /**
57 * The end of the port range.
58 * For ICMP, use the ICMP code.
59 */
60 toPort?: number;
61
62 /**
63 * IPv4 CIDR ranges to allow.
64 */
65 cidrIpv4?: string;
66
67 /**
68 * IPv6 CIDR ranges to allow.
69 */
70 cidrIpv6?: string;
71
72 /**
73 * ID of a security group to allow traffic from/to.
74 */
75 referencedGroupId?: SecurityGroupId;
76
77 /**
78 * ID of a prefix list.
79 */
80 prefixListId?: string;
81
82 /**
83 * Description for the rule.
84 */
85 description?: string;
86}
87
88export interface SecurityGroupProps {
89 /**
90 * The VPC to create the security group in.
91 */
92 vpcId: VpcId;
93
94 /**
95 * The name of the security group.
96 * If not provided, a name will be generated.
97 */
98 groupName?: string;
99
100 /**
101 * A description for the security group.
102 * @default "Managed by Alchemy"
103 */
104 description?: string;
105
106 /**
107 * Inbound rules for the security group.
108 */
109 ingress?: SecurityGroupRuleData[];
110
111 /**
112 * Outbound rules for the security group.
113 * If not specified, allows all outbound traffic by default.
114 */
115 egress?: SecurityGroupRuleData[];
116
117 /**
118 * Tags to assign to the security group.
119 */
120 tags?: Record<string, string>;
121}
122
123export interface SecurityGroup extends Resource<
124 "AWS.EC2.SecurityGroup",
125 SecurityGroupProps,
126 {
127 /**
128 * The ID of the security group.
129 */
130 groupId: SecurityGroupId;
131
132 /**
133 * The Amazon Resource Name (ARN) of the security group.
134 */
135 groupArn: SecurityGroupArn;
136
137 /**
138 * The name of the security group.
139 */
140 groupName: string;
141
142 /**
143 * The description of the security group.
144 */
145 description: string;
146
147 /**
148 * The ID of the VPC for the security group.
149 */
150 vpcId: VpcId;
151
152 /**
153 * The ID of the AWS account that owns the security group.
154 */
155 ownerId: string;
156
157 /**
158 * The inbound rules associated with the security group.
159 */
160 ingressRules?: Array<{
161 securityGroupRuleId: string;
162 ipProtocol: string;
163 fromPort?: number;
164 toPort?: number;
165 cidrIpv4?: string;
166 cidrIpv6?: string;
167 referencedGroupId?: string;
168 prefixListId?: string;
169 description?: string;
170 isEgress: false;
171 }>;
172
173 /**
174 * The outbound rules associated with the security group.
175 */
176 egressRules?: Array<{
177 securityGroupRuleId: string;
178 ipProtocol: string;
179 fromPort?: number;
180 toPort?: number;
181 cidrIpv4?: string;
182 cidrIpv6?: string;
183 referencedGroupId?: string;
184 prefixListId?: string;
185 description?: string;
186 isEgress: true;
187 }>;
188 },
189 never,
190 Providers
191> {}
192/**
193 * An EC2 security group — a stateful virtual firewall that controls inbound
194 * (`ingress`) and outbound (`egress`) traffic for resources in a VPC. Rules can
195 * allow traffic from CIDR ranges, IPv6 ranges, managed prefix lists, or other
196 * security groups. Because it is stateful, return traffic for an allowed
197 * connection is permitted automatically regardless of the opposite-direction
198 * rules.
199 *
200 * If no `egress` rules are specified, all outbound traffic is allowed by
201 * default. Changing the `vpcId` or `groupName` replaces the security group.
202 * Inline rules are authoritative: undeclared rules are removed even if they
203 * carry Alchemy tags. Standalone `SecurityGroupRule` resources declared in the
204 * same stack and stage retain ownership of their persisted physical rule IDs;
205 * their own providers manage their updates and deletion. Cloud tags alone do
206 * not establish ownership.
207 *
208 * ### Creating a Security Group
209 * Every security group belongs to a VPC. `groupName` and `description` are
210 * optional — alchemy generates a deterministic name and a default description
211 * when they are omitted. Both the VPC and the name are immutable, so changing
212 * either replaces the group.
213 *
214 * **Example:** Empty Security Group
215 * ```typescript
216 * const sg = yield* AWS.EC2.SecurityGroup("AppSg", {
217 * vpcId: vpc.vpcId,
218 * });
219 * ```
220 *
221 * With no rules, this group denies all inbound traffic and (since no `egress` is
222 * given) allows all outbound. It's a useful starting point you attach rules to
223 * later, or a target other groups can reference.
224 *
225 * **Example:** Named group with a description
226 * ```typescript
227 * const sg = yield* AWS.EC2.SecurityGroup("AppSg", {
228 * vpcId: vpc.vpcId,
229 * groupName: "app-tier",
230 * description: "Application tier security group",
231 * });
232 * ```
233 *
234 * Set an explicit `groupName` when you need a stable, human-readable identifier
235 * (for example to reference the group by name elsewhere). The `description` is
236 * shown in the EC2 console and cannot be changed after creation.
237 *
238 * ### Ingress Rules
239 * Inbound rules are declared inline via `ingress`. Each rule specifies an
240 * `ipProtocol` (`tcp`, `udp`, `icmp`, or `-1` for all), an optional port range
241 * (`fromPort`/`toPort`), and a source — most commonly an IPv4 `cidrIpv4`.
242 *
243 * **Example:** Allow HTTP and HTTPS from anywhere
244 * ```typescript
245 * const webSg = yield* AWS.EC2.SecurityGroup("WebSecurityGroup", {
246 * vpcId: vpc.vpcId,
247 * description: "Web tier security group",
248 * ingress: [
249 * {
250 * ipProtocol: "tcp",
251 * fromPort: 80,
252 * toPort: 80,
253 * cidrIpv4: "0.0.0.0/0",
254 * description: "Allow HTTP",
255 * },
256 * {
257 * ipProtocol: "tcp",
258 * fromPort: 443,
259 * toPort: 443,
260 * cidrIpv4: "0.0.0.0/0",
261 * description: "Allow HTTPS",
262 * },
263 * ],
264 * tags: { Name: "web-sg" },
265 * });
266 * ```
267 *
268 * Two rules open the standard web ports to the whole internet (`0.0.0.0/0`).
269 * Setting `fromPort` equal to `toPort` opens a single port; widen the range to
270 * open a contiguous span.
271 *
272 * ### Egress Rules
273 * Outbound traffic is governed by `egress`. If you omit it entirely, the group
274 * enforces the default "allow all outbound" rule. Supplying `egress` replaces
275 * that default with exactly the rules you list — so you must re-add an
276 * allow-all rule if you still want unrestricted outbound.
277 *
278 * **Example:** Restrict outbound to HTTPS only
279 * ```typescript
280 * const lockedSg = yield* AWS.EC2.SecurityGroup("LockedSg", {
281 * vpcId: vpc.vpcId,
282 * description: "Outbound restricted to HTTPS",
283 * egress: [
284 * {
285 * ipProtocol: "tcp",
286 * fromPort: 443,
287 * toPort: 443,
288 * cidrIpv4: "0.0.0.0/0",
289 * description: "Allow outbound HTTPS",
290 * },
291 * ],
292 * });
293 * ```
294 *
295 * This locks egress down to port 443 only — useful for instances that should
296 * only call out to HTTPS APIs. Any other outbound traffic (DNS, NTP, etc.) would
297 * need explicit rules added here.
298 *
299 * ### Updating Inline Rules
300 * Changing a rule description updates the existing physical rule in place.
301 * Adding or removing a rule leaves unrelated rule IDs unchanged. Redeploying
302 * unchanged code repairs missing rules and removes undeclared rules.
303 *
304 * **Example:** Change a description without replacing the rule
305 * ```diff lang="typescript"
306 * const sg = yield* AWS.EC2.SecurityGroup("AppSg", {
307 * vpcId: vpc.vpcId,
308 * ingress: [{
309 * ipProtocol: "tcp",
310 * fromPort: 443,
311 * toPort: 443,
312 * cidrIpv4: "10.0.0.0/16",
313 * - description: "HTTPS",
314 * + description: "Internal HTTPS",
315 * }],
316 * });
317 * ```
318 *
319 * Removing `description` resets it to an empty description without replacing
320 * the rule.
321 *
322 * ### Restoring Default Rules
323 * Omitting `ingress` restores no inbound rules. Omitting `egress` restores the
324 * default IPv4 allow-all outbound rule; `egress: []` disables outbound traffic.
325 * These defaults apply on creation, property removal, and drift repair.
326 *
327 * **Example:** Reset custom outbound rules to the default
328 * ```diff lang="typescript"
329 * const sg = yield* AWS.EC2.SecurityGroup("AppSg", {
330 * vpcId: vpc.vpcId,
331 * - egress: [{
332 * - ipProtocol: "tcp",
333 * - fromPort: 443,
334 * - toPort: 443,
335 * - cidrIpv4: "0.0.0.0/0",
336 * - }],
337 * });
338 * ```
339 *
340 * ### Composing Standalone Rules
341 * Standalone rules must be declared in the same stack and stage as this group.
342 * Pass the whole resource as `group` to order creation and updates after inline
343 * reconciliation. The ID-only `groupId` form remains supported, but a stable ID
344 * alone does not order concurrent inline updates. Ownership is verified against
345 * each current declaration's persisted physical rule ID.
346 * Removing a declaration ends that ownership; tags alone do not protect rules.
347 * Cross-stack or cross-stage rule ownership is unsupported: this group removes
348 * rules declared elsewhere when reconciling its authoritative configuration.
349 * Inline and standalone rules must have distinct identities. If a standalone
350 * rule owns IPv4 allow-all egress, set `egress: []` to disable the inline default.
351 *
352 * **Example:** Add a standalone rule in the group's stack
353 * ```typescript
354 * yield* AWS.EC2.SecurityGroupRule("HttpsIngress", {
355 * group: sg,
356 * type: "ingress",
357 * ipProtocol: "tcp",
358 * fromPort: 443,
359 * toPort: 443,
360 * cidrIpv4: "10.0.0.0/16",
361 * });
362 * ```
363 *
364 * ### Referencing Other Groups
365 * Instead of a CIDR, a rule's source can be another security group via
366 * `referencedGroupId`. This is the idiomatic way to express tier-to-tier trust
367 * ("the database accepts connections from anything in the app tier") without
368 * pinning IP addresses.
369 *
370 * **Example:** Database tier allowing traffic from the web tier
371 * ```typescript
372 * const dbSg = yield* AWS.EC2.SecurityGroup("DbSecurityGroup", {
373 * vpcId: vpc.vpcId,
374 * description: "Database tier security group",
375 * ingress: [
376 * {
377 * ipProtocol: "tcp",
378 * fromPort: 5432,
379 * toPort: 5432,
380 * referencedGroupId: webSg.groupId,
381 * description: "Allow PostgreSQL from web tier",
382 * },
383 * ],
384 * tags: { Name: "db-sg" },
385 * });
386 * ```
387 *
388 * Only instances in `webSg` can reach PostgreSQL on this group, regardless of
389 * their IPs. As the web tier scales up and down, the rule keeps working without
390 * any change.
391 *
392 * ### IPv6, Prefix Lists & ICMP
393 * Beyond IPv4 CIDRs, a rule source can be an IPv6 range (`cidrIpv6`) or a managed
394 * prefix list (`prefixListId`). For ICMP, set `ipProtocol: "icmp"` and use
395 * `fromPort`/`toPort` as the ICMP type and code (`-1` for all).
396 *
397 * **Example:** Mixed IPv6, prefix-list, and ICMP rules
398 * ```typescript
399 * const sg = yield* AWS.EC2.SecurityGroup("EdgeSg", {
400 * vpcId: vpc.vpcId,
401 * description: "Edge security group",
402 * ingress: [
403 * {
404 * ipProtocol: "tcp",
405 * fromPort: 443,
406 * toPort: 443,
407 * cidrIpv6: "::/0",
408 * description: "Allow HTTPS over IPv6",
409 * },
410 * {
411 * ipProtocol: "tcp",
412 * fromPort: 22,
413 * toPort: 22,
414 * prefixListId: "pl-0123456789abcdef0",
415 * description: "Allow SSH from corporate prefix list",
416 * },
417 * {
418 * ipProtocol: "icmp",
419 * fromPort: -1,
420 * toPort: -1,
421 * cidrIpv4: "10.0.0.0/16",
422 * description: "Allow all ICMP from within the VPC",
423 * },
424 * ],
425 * });
426 * ```
427 *
428 * Prefix lists let you reference a centrally-maintained set of CIDRs (e.g. your
429 * corporate egress IPs) by ID, so the rule updates automatically as the list
430 * changes. The ICMP rule with type/code `-1` permits ping and other ICMP within
431 * the VPC.
432 *
433 * @resource
434 */
435export const SecurityGroup = Resource<SecurityGroup>("AWS.EC2.SecurityGroup");
436
437class InvalidSecurityGroupRules extends Data.TaggedError(
438 "InvalidSecurityGroupRules",
439)<{
440 groupId: string;
441 message: string;
442}> {}
443
444class SecurityGroupRulesNotSettled extends Data.TaggedError(
445 "SecurityGroupRulesNotSettled",
446)<{
447 groupId: string;
448}> {}
449
450export const SecurityGroupProvider = () =>
451 Provider.effect(
452 SecurityGroup,
453 Effect.gen(function* () {
454 const createTags = Effect.fn(function* (
455 id: string,
456 tags?: Record<string, string>,
457 ) {
458 return {
459 Name: id,
460 ...(yield* createInternalTags(id)),
461 ...tags,
462 };
463 });
464
465 const createGroupName = (id: string, name: string | undefined) =>
466 Effect.gen(function* () {
467 if (name) return name;
468 return yield* createPhysicalName({ id, maxLength: 255 });
469 });
470
471 const describeSecurityGroup = (groupId: string) =>
472 ec2.describeSecurityGroups({ GroupIds: [groupId] }).pipe(
473 Effect.map((r) => r.SecurityGroups?.[0]),
474 Effect.flatMap((sg) =>
475 sg
476 ? Effect.succeed(sg)
477 : Effect.fail(new Error(`Security Group ${groupId} not found`)),
478 ),
479 );
480
481 const findOwnedGroup = Effect.fn(function* (
482 id: string,
483 groupName: string,
484 vpcId?: VpcId,
485 ) {
486 return (yield* ec2.describeSecurityGroups({
487 Filters: [
488 { Name: "group-name", Values: [groupName] },
489 ...(vpcId ? [{ Name: "vpc-id", Values: [vpcId] }] : []),
490 ...(yield* createAlchemyTagFilters(id)),
491 ],
492 })).SecurityGroups?.[0];
493 });
494
495 const describeSecurityGroupRules = (groupId: string) =>
496 ec2.describeSecurityGroupRules
497 .items({
498 Filters: [{ Name: "group-id", Values: [groupId] }],
499 })
500 .pipe(
501 Stream.runCollect,
502 Effect.map((chunk) => Array.from(chunk)),
503 );
504
505 const rulesMatch = (
506 observed: ec2.SecurityGroupRule[],
507 desired: SecurityGroupRuleData[],
508 ) =>
509 JSON.stringify(observed.map(observedSecurityGroupRuleKey).sort()) ===
510 JSON.stringify(
511 expandSecurityGroupRules(desired).map(securityGroupRuleKey).sort(),
512 );
513
514 const toAttrs = Effect.fn(function* (
515 sg: ec2.SecurityGroup,
516 rules: ec2.SecurityGroupRule[],
517 ) {
518 const { accountId, region } = yield* AWSEnvironment.current;
519 return {
520 groupId: sg.GroupId as SecurityGroupId,
521 groupArn:
522 `arn:aws:ec2:${region}:${accountId}:security-group/${sg.GroupId as SecurityGroupId}` as SecurityGroupArn,
523 groupName: sg.GroupName!,
524 description: sg.Description!,
525 vpcId: sg.VpcId as VpcId,
526 ownerId: sg.OwnerId!,
527 ingressRules: rules
528 .filter((r) => !r.IsEgress)
529 .map((r) => ({
530 securityGroupRuleId: r.SecurityGroupRuleId!,
531 ipProtocol: r.IpProtocol!,
532 fromPort: r.FromPort,
533 toPort: r.ToPort,
534 cidrIpv4: r.CidrIpv4,
535 cidrIpv6: r.CidrIpv6,
536 referencedGroupId: r.ReferencedGroupInfo?.GroupId,
537 prefixListId: r.PrefixListId,
538 description: r.Description,
539 isEgress: false as const,
540 })),
541 egressRules: rules
542 .filter((r) => r.IsEgress)
543 .map((r) => ({
544 securityGroupRuleId: r.SecurityGroupRuleId!,
545 ipProtocol: r.IpProtocol!,
546 fromPort: r.FromPort,
547 toPort: r.ToPort,
548 cidrIpv4: r.CidrIpv4,
549 cidrIpv6: r.CidrIpv6,
550 referencedGroupId: r.ReferencedGroupInfo?.GroupId,
551 prefixListId: r.PrefixListId,
552 description: r.Description,
553 isEgress: true as const,
554 })),
555 } satisfies SecurityGroup["Attributes"];
556 });
557
558 const toIpPermission = (
559 rule: SecurityGroupRuleData,
560 ): ec2.IpPermission => ({
561 IpProtocol: rule.ipProtocol,
562 FromPort: rule.fromPort,
563 ToPort: rule.toPort,
564 IpRanges: rule.cidrIpv4
565 ? [{ CidrIp: rule.cidrIpv4, Description: rule.description }]
566 : undefined,
567 Ipv6Ranges: rule.cidrIpv6
568 ? [{ CidrIpv6: rule.cidrIpv6, Description: rule.description }]
569 : undefined,
570 UserIdGroupPairs: rule.referencedGroupId
571 ? [
572 {
573 GroupId: rule.referencedGroupId as string,
574 Description: rule.description,
575 },
576 ]
577 : undefined,
578 PrefixListIds: rule.prefixListId
579 ? [
580 {
581 PrefixListId: rule.prefixListId as string,
582 Description: rule.description,
583 },
584 ]
585 : undefined,
586 });
587
588 const syncRules = Effect.fn(function* (
589 groupId: SecurityGroupId,
590 isEgress: boolean,
591 desired: SecurityGroupRuleData[],
592 observed: ec2.SecurityGroupRule[],
593 session: ScopedPlanStatusSession,
594 ) {
595 if (
596 desired.some((rule) =>
597 [
598 rule.cidrIpv4,
599 rule.cidrIpv6,
600 rule.referencedGroupId,
601 rule.prefixListId,
602 ].every((source) => !source),
603 )
604 ) {
605 return yield* new InvalidSecurityGroupRules({
606 groupId,
607 message: "Inline rules must specify a source.",
608 });
609 }
610 const desiredByKey = new Map<string, SecurityGroupRuleData>();
611 for (const rule of expandSecurityGroupRules(desired)) {
612 const key = securityGroupRuleKey({ ...rule, description: undefined });
613 if (!rule.ipProtocol || desiredByKey.has(key)) {
614 return yield* new InvalidSecurityGroupRules({
615 groupId,
616 message:
617 "Inline rules must have a protocol, a source, and distinct identities.",
618 });
619 }
620 desiredByKey.set(key, rule);
621 }
622 if (rulesMatch(observed, desired)) return;
623 const current: Array<{
624 key: string;
625 id: string;
626 rule: ec2.SecurityGroupRule;
627 }> = [];
628 for (const rule of observed) {
629 const key = observedSecurityGroupRuleKey({
630 ...rule,
631 Description: undefined,
632 });
633 if (
634 rule.IpProtocol === undefined ||
635 rule.SecurityGroupRuleId === undefined
636 ) {
637 return yield* new InvalidSecurityGroupRules({
638 groupId,
639 message: "EC2 returned a rule without its identity or source.",
640 });
641 }
642 current.push({ key, id: rule.SecurityGroupRuleId, rule });
643 }
644 const removed = current.filter(({ key }) => !desiredByKey.has(key));
645 if (removed.length > 0) {
646 const request = {
647 GroupId: groupId,
648 SecurityGroupRuleIds: removed.map(({ id }) => id),
649 DryRun: false,
650 };
651 yield* isEgress
652 ? ec2
653 .revokeSecurityGroupEgress(request)
654 .pipe(
655 Effect.catchTag(
656 [
657 "InvalidPermission.NotFound",
658 "InvalidSecurityGroupRuleId.NotFound",
659 ],
660 () => Effect.void,
661 ),
662 )
663 : ec2
664 .revokeSecurityGroupIngress(request)
665 .pipe(
666 Effect.catchTag(
667 [
668 "InvalidPermission.NotFound",
669 "InvalidSecurityGroupRuleId.NotFound",
670 ],
671 () => Effect.void,
672 ),
673 );
674 }
675 const descriptions = current.flatMap(({ key, id, rule }) => {
676 const desired = desiredByKey.get(key);
677 return desired === undefined ||
678 (desired.description ?? "") === (rule.Description ?? "")
679 ? []
680 : [
681 {
682 SecurityGroupRuleId: id,
683 SecurityGroupRule: {
684 IpProtocol: rule.IpProtocol,
685 FromPort: rule.FromPort,
686 ToPort: rule.ToPort,
687 CidrIpv4: rule.CidrIpv4,
688 CidrIpv6: rule.CidrIpv6,
689 ReferencedGroupId: rule.ReferencedGroupInfo?.GroupId,
690 PrefixListId: rule.PrefixListId,
691 Description: desired.description ?? "",
692 },
693 },
694 ];
695 });
696 if (descriptions.length > 0) {
697 yield* ec2.modifySecurityGroupRules({
698 GroupId: groupId,
699 SecurityGroupRules: descriptions,
700 });
701 }
702 const added = [...desiredByKey]
703 .filter(([key]) => !current.some((rule) => rule.key === key))
704 .map(([, rule]) => rule);
705 if (added.length > 0) {
706 const authorize = isEgress
707 ? ec2.authorizeSecurityGroupEgress
708 : ec2.authorizeSecurityGroupIngress;
709 yield* authorize({
710 GroupId: groupId,
711 IpPermissions: added.map(toIpPermission),
712 DryRun: false,
713 });
714 }
715 yield* session.note(
716 `Reconciled ${isEgress ? "egress" : "ingress"} rules`,
717 );
718 });
719
720 return {
721 stables: ["groupId", "groupArn", "ownerId"],
722
723 read: Effect.fn(function* ({ id, olds, output }) {
724 const sg = output
725 ? yield* describeSecurityGroup(output.groupId).pipe(
726 Effect.catchTag("InvalidGroup.NotFound", () =>
727 Effect.succeed(undefined),
728 ),
729 )
730 : yield* findOwnedGroup(
731 id,
732 yield* createGroupName(id, olds?.groupName),
733 olds?.vpcId,
734 );
735 if (!sg?.GroupId) return undefined;
736 const rules = yield* describeSecurityGroupRules(sg.GroupId);
737 return yield* toAttrs(sg, rules);
738 }),
739
740 list: () =>
741 Effect.gen(function* () {
742 const groups = yield* ec2.describeSecurityGroups.pages({}).pipe(
743 Stream.runCollect,
744 Effect.map((chunk) =>
745 Array.from(chunk).flatMap((page) =>
746 (page.SecurityGroups ?? []).filter(
747 (sg): sg is ec2.SecurityGroup & { GroupId: string } =>
748 sg.GroupId != null &&
749 // Every VPC's `default` group is AWS-managed and can
750 // never be deleted (CannotDelete) — don't enumerate it.
751 sg.GroupName !== "default",
752 ),
753 ),
754 ),
755 );
756 return yield* Effect.forEach(
757 groups,
758 (sg) =>
759 Effect.gen(function* () {
760 const rules = yield* describeSecurityGroupRules(sg.GroupId);
761 return yield* toAttrs(sg, rules);
762 }),
763 { concurrency: 10 },
764 );
765 }),
766
767 diff: Effect.fn(function* ({ id, news, olds, output }) {
768 if (!isResolved(news)) return;
769 // VPC change requires replacement
770 if (news.vpcId !== olds.vpcId) {
771 return { action: "replace" };
772 }
773
774 // Group name change requires replacement
775 const oldGroupName =
776 output?.groupName ?? (yield* createGroupName(id, olds.groupName));
777 // Auto-generated names are engine-owned: the deployed name stays
778 // authoritative even if the generator would name this id differently
779 // today. Only an explicit user-provided name can force a replace.
780 const newGroupName = news.groupName ?? oldGroupName;
781 if (newGroupName !== oldGroupName) {
782 return { action: "replace" };
783 }
784
785 if (output) {
786 const group = yield* describeSecurityGroup(output.groupId).pipe(
787 Effect.catchTag("InvalidGroup.NotFound", () =>
788 Effect.succeed(undefined),
789 ),
790 );
791 if (group === undefined) return { action: "update", stables: [] };
792 const owned = yield* declaredSecurityGroupRuleIds(output.groupId);
793 const observed = (yield* describeSecurityGroupRules(
794 output.groupId,
795 )).filter((rule) => !owned.has(rule.SecurityGroupRuleId!));
796 if (
797 !rulesMatch(
798 observed.filter((rule) => !rule.IsEgress),
799 resolveSecurityGroupRules(news.ingress, false),
800 ) ||
801 !rulesMatch(
802 observed.filter((rule) => rule.IsEgress),
803 resolveSecurityGroupRules(news.egress, true),
804 )
805 ) {
806 return { action: "update" };
807 }
808 }
809 }),
810
811 reconcile: Effect.fn(function* ({ id, news, output, session }) {
812 // Prefer the deployed name: regenerating would target a different
813 // resource if the generator's output for this id ever drifts. (An
814 // explicit groupName change arrives here as a fresh replacement
815 // instance with no output.)
816 const groupName =
817 output?.groupName ?? (yield* createGroupName(id, news.groupName));
818 const desiredTags = yield* createTags(id, news.tags);
819
820 // Observe — find the SG via cached id, else fall through to create.
821 let sg: ec2.SecurityGroup | undefined;
822 if (output?.groupId) {
823 const lookup = yield* ec2
824 .describeSecurityGroups({ GroupIds: [output.groupId] })
825 .pipe(
826 Effect.catchTag("InvalidGroup.NotFound", () =>
827 Effect.succeed({ SecurityGroups: [] }),
828 ),
829 );
830 sg = lookup.SecurityGroups?.[0];
831 }
832
833 if (sg === undefined) {
834 sg = yield* findOwnedGroup(id, groupName, news.vpcId);
835 }
836
837 // Ensure — create the SG when missing.
838 if (sg === undefined) {
839 yield* session.note(`Creating Security Group: ${groupName}`);
840 const result = yield* ec2
841 .createSecurityGroup({
842 GroupName: groupName,
843 Description: news.description ?? "Managed by Alchemy",
844 VpcId: news.vpcId as string,
845 TagSpecifications: [
846 {
847 ResourceType: "security-group",
848 Tags: createTagsList(desiredTags),
849 },
850 ],
851 DryRun: false,
852 })
853 .pipe(
854 // A just-created VPC can lag visibility to the SG service
855 // (EC2 eventual consistency), so the create races with
856 // `InvalidVpcID.NotFound`. Retry, bounded.
857 Effect.retry({
858 while: (e) => e._tag === "InvalidVpcID.NotFound",
859 schedule: Schedule.fixed("1 second"),
860 times: 8,
861 }),
862 );
863 const newGroupId = result.GroupId! as SecurityGroupId;
864 yield* session.note(`Security Group created: ${newGroupId}`);
865 sg = yield* describeSecurityGroup(newGroupId);
866 }
867
868 const groupId = sg.GroupId! as SecurityGroupId;
869
870 // Sync tags — observed cloud tags vs desired.
871 const currentTags = Object.fromEntries(
872 (sg.Tags ?? []).map((t) => [t.Key!, t.Value!]),
873 ) as Record<string, string>;
874 const { removed: removedTags, upsert: upsertTags } = diffTags(
875 currentTags,
876 desiredTags,
877 );
878 if (removedTags.length > 0) {
879 yield* ec2.deleteTags({
880 Resources: [groupId],
881 Tags: removedTags.map((key) => ({ Key: key })),
882 DryRun: false,
883 });
884 }
885 if (upsertTags.length > 0) {
886 yield* ec2.createTags({
887 Resources: [groupId],
888 Tags: upsertTags,
889 DryRun: false,
890 });
891 }
892
893 // Only current declarations with persisted physical ownership are delegated.
894 const owned = yield* declaredSecurityGroupRuleIds(groupId);
895 const currentRules = yield* describeSecurityGroupRules(groupId);
896 const currentIngress = currentRules.filter(
897 (rule) => !rule.IsEgress && !owned.has(rule.SecurityGroupRuleId!),
898 );
899 const currentEgress = currentRules.filter(
900 (rule) => rule.IsEgress && !owned.has(rule.SecurityGroupRuleId!),
901 );
902 yield* syncRules(
903 groupId,
904 false,
905 resolveSecurityGroupRules(news.ingress, false),
906 currentIngress,
907 session,
908 );
909 yield* syncRules(
910 groupId,
911 true,
912 resolveSecurityGroupRules(news.egress, true),
913 currentEgress,
914 session,
915 );
916
917 // Re-read final state.
918 const finalSg = yield* describeSecurityGroup(groupId);
919 const matches = (rules: ec2.SecurityGroupRule[]) => {
920 const inline = rules.filter(
921 (rule) => !owned.has(rule.SecurityGroupRuleId!),
922 );
923 return (
924 rulesMatch(
925 inline.filter((rule) => !rule.IsEgress),
926 resolveSecurityGroupRules(news.ingress, false),
927 ) &&
928 rulesMatch(
929 inline.filter((rule) => rule.IsEgress),
930 resolveSecurityGroupRules(news.egress, true),
931 )
932 );
933 };
934 const finalRules = yield* describeSecurityGroupRules(groupId).pipe(
935 Effect.repeat({
936 until: matches,
937 schedule: Schedule.spaced("1 second"),
938 times: 8,
939 }),
940 );
941 if (!matches(finalRules)) {
942 return yield* Effect.fail(
943 new SecurityGroupRulesNotSettled({ groupId }),
944 );
945 }
946 return yield* toAttrs(finalSg, finalRules);
947 }),
948
949 delete: Effect.fn(function* ({ output, session }) {
950 const groupId = output.groupId;
951
952 yield* session.note(`Deleting Security Group: ${groupId}`);
953
954 // DependencyViolation means ENIs still reference the group — ALB,
955 // ECS task, or VPC-attached Lambda ENIs release minutes after the
956 // owning resource is deleted. Lambda Hyperplane ENIs are reaped
957 // explicitly between attempts (they otherwise linger up to ~20
958 // minutes and used to force a second `destroy` run).
959 yield* retryWhileLingeringEnis(
960 ec2
961 .deleteSecurityGroup({
962 GroupId: groupId,
963 DryRun: false,
964 })
965 .pipe(
966 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
967 ),
968 {
969 scope: { name: "group-id", value: groupId },
970 isDependencyViolation: (e) =>
971 e._tag === "DependencyViolation" ||
972 (e._tag === "ValidationError" &&
973 (e.message?.includes("DependencyViolation") ?? false)),
974 session,
975 },
976 );
977
978 yield* session.note(`Security Group ${groupId} deleted`);
979 }),
980 };
981 }),
982 );