Skip to content
Source
Sign inSign up
Open History to see the last commit

SecurityGroup.ts

958 lines32.6 KB

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