| 1 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { AWSEnvironment } from "../Environment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | import type { |
| 12 | SecurityGroupArn, |
| 13 | SecurityGroupId, |
| 14 | SecurityGroupRuleData, |
| 15 | } from "./SecurityGroup.ts"; |
| 16 | import { |
| 17 | declaredSecurityGroupRuleIds, |
| 18 | expandSecurityGroupRules, |
| 19 | observedSecurityGroupRuleKey, |
| 20 | resolveSecurityGroupRules, |
| 21 | securityGroupRuleKey, |
| 22 | } from "./SecurityGroupRule.ts"; |
| 23 | import type { VpcId } from "./Vpc.ts"; |
| 24 | |
| 25 | class DefaultSecurityGroupNotFound extends Data.TaggedError( |
| 26 | "DefaultSecurityGroupNotFound", |
| 27 | )<{ vpcId: VpcId }> {} |
| 28 | |
| 29 | class InvalidDefaultSecurityGroupRules extends Data.TaggedError( |
| 30 | "InvalidDefaultSecurityGroupRules", |
| 31 | )<{ message: string }> {} |
| 32 | |
| 33 | class DefaultSecurityGroupRulesNotConverged extends Data.TaggedError( |
| 34 | "DefaultSecurityGroupRulesNotConverged", |
| 35 | )<{ groupId: SecurityGroupId }> {} |
| 36 | |
| 37 | /** An observed rule on the AWS-created default security group. */ |
| 38 | export interface DefaultSecurityGroupRuleAttributes<IsEgress extends boolean> { |
| 39 | /** The physical rule ID, retained when only its description changes. */ |
| 40 | securityGroupRuleId: string; |
| 41 | /** The IP protocol name or number returned by AWS. */ |
| 42 | ipProtocol: string; |
| 43 | /** The first port, or ICMP type. */ |
| 44 | fromPort?: number; |
| 45 | /** The last port, or ICMP code. */ |
| 46 | toPort?: number; |
| 47 | /** The canonical IPv4 source or destination CIDR. */ |
| 48 | cidrIpv4?: string; |
| 49 | /** The canonical IPv6 source or destination CIDR. */ |
| 50 | cidrIpv6?: string; |
| 51 | /** The source or destination security group ID. */ |
| 52 | referencedGroupId?: string; |
| 53 | /** The source or destination managed prefix list ID. */ |
| 54 | prefixListId?: string; |
| 55 | /** The description observed in AWS. */ |
| 56 | description?: string; |
| 57 | /** Whether the rule controls outbound rather than inbound traffic. */ |
| 58 | isEgress: IsEgress; |
| 59 | } |
| 60 | |
| 61 | /** Properties for a VPC's AWS-created `default` security group. */ |
| 62 | export interface DefaultSecurityGroupProps { |
| 63 | /** The VPC whose AWS-created `default` security group is managed. */ |
| 64 | vpcId: VpcId; |
| 65 | |
| 66 | /** |
| 67 | * Desired inline inbound rules. Omitted, undefined, and [] all remove inline |
| 68 | * inbound rules, including AWS's initial self-reference rule. Standalone |
| 69 | * rules owned by current declarations in this stack and stage are preserved. |
| 70 | * @default [] |
| 71 | */ |
| 72 | ingress?: SecurityGroupRuleData[]; |
| 73 | |
| 74 | /** |
| 75 | * Desired inline outbound rules. Omitted or undefined restores IPv4 |
| 76 | * allow-all outbound; [] removes inline outbound rules. Standalone rules |
| 77 | * owned by current declarations in this stack and stage are preserved. |
| 78 | * @default [{ ipProtocol: "-1", cidrIpv4: "0.0.0.0/0" }] |
| 79 | */ |
| 80 | egress?: SecurityGroupRuleData[]; |
| 81 | } |
| 82 | |
| 83 | export interface DefaultSecurityGroup extends Resource< |
| 84 | "AWS.EC2.DefaultSecurityGroup", |
| 85 | DefaultSecurityGroupProps, |
| 86 | { |
| 87 | /** The ID of the AWS-created default security group. */ |
| 88 | groupId: SecurityGroupId; |
| 89 | /** The ARN of the default security group. */ |
| 90 | groupArn: SecurityGroupArn; |
| 91 | /** The AWS-assigned group name, always `default`. */ |
| 92 | groupName: string; |
| 93 | /** The AWS-assigned description of the group. */ |
| 94 | description: string; |
| 95 | /** The VPC that owns this default security group. */ |
| 96 | vpcId: VpcId; |
| 97 | /** The AWS account that owns the group. */ |
| 98 | ownerId: string; |
| 99 | /** The complete inbound rule set observed after reconciliation. */ |
| 100 | ingressRules: DefaultSecurityGroupRuleAttributes<false>[]; |
| 101 | /** The complete outbound rule set observed after reconciliation. */ |
| 102 | egressRules: DefaultSecurityGroupRuleAttributes<true>[]; |
| 103 | }, |
| 104 | never, |
| 105 | Providers |
| 106 | > {} |
| 107 | |
| 108 | /** |
| 109 | * Declaratively manages the rules of the AWS-created `default` security group |
| 110 | * in one VPC. AWS creates this group named `default` whenever it creates a |
| 111 | * VPC; Alchemy looks it up by VPC ID and name, never creates it, and never |
| 112 | * deletes it. |
| 113 | * |
| 114 | * Inline rules use the same defaults as `SecurityGroup`: omitted or undefined |
| 115 | * `ingress` means no inline inbound rules, and omitted or undefined `egress` |
| 116 | * means IPv4 allow-all outbound. Passing `[]` means no inline rules in that |
| 117 | * direction, not unmanaged rules. Defaults apply on initial management, |
| 118 | * property removal, adoption, and drift repair; AWS's initial self-ingress |
| 119 | * rule is removed, not restored. |
| 120 | * |
| 121 | * Current standalone `SecurityGroupRule` declarations in the same stack and |
| 122 | * stage retain ownership of their persisted physical rule IDs. Other rules |
| 123 | * are removed even if they carry Alchemy tags. This resource manages rules, |
| 124 | * not ownership of the AWS-created group; do not also manage this group with |
| 125 | * a `SecurityGroup` resource or a second `DefaultSecurityGroup` manager. |
| 126 | * |
| 127 | * Removing this Alchemy resource leaves both the default group and its last |
| 128 | * applied rules unchanged. Deleting its VPC removes the group as part of AWS's |
| 129 | * VPC lifecycle. |
| 130 | * |
| 131 | * ### Default Inline Rules |
| 132 | * **Example:** Remove AWS self-ingress and allow outbound IPv4 |
| 133 | * ```typescript |
| 134 | * const vpc = yield* AWS.EC2.Vpc("Vpc", { cidrBlock: "10.0.0.0/16" }); |
| 135 | * const group = yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 136 | * vpcId: vpc.vpcId, |
| 137 | * }); |
| 138 | * ``` |
| 139 | * |
| 140 | * **Example:** Explicit undefined has the same meaning as omission |
| 141 | * ```typescript |
| 142 | * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 143 | * vpcId: vpc.vpcId, |
| 144 | * ingress: undefined, |
| 145 | * egress: undefined, |
| 146 | * }); |
| 147 | * ``` |
| 148 | * |
| 149 | * ### Closing the Default Security Group |
| 150 | * **Example:** Deny all traffic when there are no owned standalone rules |
| 151 | * ```typescript |
| 152 | * const vpc = yield* AWS.EC2.Vpc("Vpc", { cidrBlock: "10.0.0.0/16" }); |
| 153 | * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 154 | * vpcId: vpc.vpcId, |
| 155 | * ingress: [], |
| 156 | * egress: [], |
| 157 | * }); |
| 158 | * ``` |
| 159 | * |
| 160 | * ### Updating Rules |
| 161 | * **Example:** Allow only internal HTTPS |
| 162 | * ```typescript |
| 163 | * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 164 | * vpcId: vpc.vpcId, |
| 165 | * ingress: [{ |
| 166 | * ipProtocol: "tcp", |
| 167 | * fromPort: 443, |
| 168 | * toPort: 443, |
| 169 | * cidrIpv4: "10.0.0.0/16", |
| 170 | * description: "Internal HTTPS", |
| 171 | * }], |
| 172 | * egress: [], |
| 173 | * }); |
| 174 | * ``` |
| 175 | * |
| 176 | * Unchanged deployments repair missing rules and remove undeclared rules. |
| 177 | * Updates leave unrelated rule IDs untouched; description changes update in |
| 178 | * place, and removing a description clears it. Equivalent protocol names, |
| 179 | * canonical CIDRs, rule order, and duplicate rules do not cause rule churn. |
| 180 | * Duplicates with conflicting descriptions are rejected before any writes. |
| 181 | * A rule with several source fields expands into one AWS rule per source. |
| 182 | * |
| 183 | * ### Restoring Default Inline Rules |
| 184 | * **Example:** Remove explicit rule lists to restore defaults |
| 185 | * ```diff lang="typescript" |
| 186 | * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 187 | * vpcId: vpc.vpcId, |
| 188 | * - ingress: [], |
| 189 | * - egress: [], |
| 190 | * }); |
| 191 | * ``` |
| 192 | * |
| 193 | * This still denies inline inbound access but restores IPv4 allow-all outbound. |
| 194 | * Removing these properties is different from removing the manager itself: |
| 195 | * deleting the manager leaves its last-applied rules, with no baseline restore. |
| 196 | * |
| 197 | * ### Composing Standalone Rules |
| 198 | * **Example:** Restrict outbound traffic with a standalone rule |
| 199 | * ```typescript |
| 200 | * const group = yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 201 | * vpcId: vpc.vpcId, |
| 202 | * ingress: [], |
| 203 | * egress: [], |
| 204 | * }); |
| 205 | * yield* AWS.EC2.SecurityGroupRule("HttpsEgress", { |
| 206 | * group: group, |
| 207 | * type: "egress", |
| 208 | * ipProtocol: "tcp", |
| 209 | * fromPort: 443, |
| 210 | * toPort: 443, |
| 211 | * cidrIpv4: "10.0.0.0/16", |
| 212 | * }); |
| 213 | * ``` |
| 214 | * |
| 215 | * Pass the whole manager as `group` so rule creation and updates wait for its |
| 216 | * inline reconciliation. Only `group.groupId` is consumed by the rule provider; |
| 217 | * unrelated manager attributes do not trigger rule updates. The stable ID is |
| 218 | * still available for replacement planning. The ID-only `groupId` form cannot |
| 219 | * enforce this ordering when the manager updates without changing its ID. |
| 220 | * Standalone ingress composes the same way. Omitting `egress` instead would |
| 221 | * retain the default allow-all rule alongside this standalone rule. A current |
| 222 | * declaration and its persisted physical ID establish ownership, not cloud |
| 223 | * tags. Removing a declaration ends that protection; its provider handles |
| 224 | * deletion. Cross-stack or cross-stage rule ownership is unsupported. |
| 225 | * Inline and standalone rules must have distinct identities. If a standalone |
| 226 | * rule owns IPv4 allow-all egress, set `egress: []` to disable the inline default. |
| 227 | * |
| 228 | * ### Changing VPCs |
| 229 | * **Example:** Manage another VPC's default group |
| 230 | * ```typescript |
| 231 | * yield* AWS.EC2.DefaultSecurityGroup("DefaultSecurityGroup", { |
| 232 | * vpcId: otherVpc.vpcId, |
| 233 | * ingress: [], |
| 234 | * egress: [], |
| 235 | * }); |
| 236 | * ``` |
| 237 | * |
| 238 | * Changing `vpcId` replaces the Alchemy resource, not either AWS-owned group, |
| 239 | * including when the destination VPC is created or replaced in the same deploy. |
| 240 | * The previous group's last-applied rules remain unchanged. Keep the old VPC |
| 241 | * declared until replacement finishes, then remove it separately if desired. |
| 242 | * |
| 243 | * @resource |
| 244 | */ |
| 245 | export const DefaultSecurityGroup = Resource<DefaultSecurityGroup>( |
| 246 | "AWS.EC2.DefaultSecurityGroup", |
| 247 | ); |
| 248 | |
| 249 | export const DefaultSecurityGroupProvider = () => |
| 250 | Provider.effect( |
| 251 | DefaultSecurityGroup, |
| 252 | Effect.gen(function* () { |
| 253 | const describeGroup = (vpcId: VpcId) => |
| 254 | ec2 |
| 255 | .describeSecurityGroups({ |
| 256 | Filters: [ |
| 257 | { Name: "vpc-id", Values: [vpcId] }, |
| 258 | { Name: "group-name", Values: ["default"] }, |
| 259 | ], |
| 260 | }) |
| 261 | .pipe( |
| 262 | // Filtering by a deleted VPC returns an empty collection. |
| 263 | Effect.map((result) => result.SecurityGroups?.[0]), |
| 264 | ); |
| 265 | |
| 266 | const describeRules = (groupId: SecurityGroupId) => |
| 267 | ec2.describeSecurityGroupRules |
| 268 | .items({ Filters: [{ Name: "group-id", Values: [groupId] }] }) |
| 269 | .pipe( |
| 270 | Stream.runCollect, |
| 271 | Effect.map((rules) => Array.from(rules)), |
| 272 | ); |
| 273 | |
| 274 | const toAttrs = Effect.fn(function* ( |
| 275 | group: ec2.SecurityGroup, |
| 276 | rules: ec2.SecurityGroupRule[], |
| 277 | ) { |
| 278 | const { accountId, region } = yield* AWSEnvironment.current; |
| 279 | const toRule = |
| 280 | <IsEgress extends boolean>(isEgress: IsEgress) => |
| 281 | (rule: ec2.SecurityGroupRule) => ({ |
| 282 | securityGroupRuleId: rule.SecurityGroupRuleId!, |
| 283 | ipProtocol: rule.IpProtocol!, |
| 284 | fromPort: rule.FromPort, |
| 285 | toPort: rule.ToPort, |
| 286 | cidrIpv4: rule.CidrIpv4, |
| 287 | cidrIpv6: rule.CidrIpv6, |
| 288 | referencedGroupId: rule.ReferencedGroupInfo?.GroupId, |
| 289 | prefixListId: rule.PrefixListId, |
| 290 | description: rule.Description, |
| 291 | isEgress, |
| 292 | }); |
| 293 | return { |
| 294 | groupId: group.GroupId as SecurityGroupId, |
| 295 | groupArn: |
| 296 | `arn:aws:ec2:${region}:${accountId}:security-group/${group.GroupId}` as SecurityGroupArn, |
| 297 | groupName: group.GroupName!, |
| 298 | description: group.Description!, |
| 299 | vpcId: group.VpcId as VpcId, |
| 300 | ownerId: group.OwnerId!, |
| 301 | ingressRules: rules |
| 302 | .filter((rule) => !rule.IsEgress) |
| 303 | .map(toRule(false)), |
| 304 | egressRules: rules.filter((rule) => rule.IsEgress).map(toRule(true)), |
| 305 | } satisfies DefaultSecurityGroup["Attributes"]; |
| 306 | }); |
| 307 | |
| 308 | const toPermission = (rule: SecurityGroupRuleData): ec2.IpPermission => ({ |
| 309 | IpProtocol: rule.ipProtocol, |
| 310 | FromPort: rule.fromPort, |
| 311 | ToPort: rule.toPort, |
| 312 | IpRanges: rule.cidrIpv4 |
| 313 | ? [{ CidrIp: rule.cidrIpv4, Description: rule.description }] |
| 314 | : undefined, |
| 315 | Ipv6Ranges: rule.cidrIpv6 |
| 316 | ? [{ CidrIpv6: rule.cidrIpv6, Description: rule.description }] |
| 317 | : undefined, |
| 318 | UserIdGroupPairs: rule.referencedGroupId |
| 319 | ? [{ GroupId: rule.referencedGroupId, Description: rule.description }] |
| 320 | : undefined, |
| 321 | PrefixListIds: rule.prefixListId |
| 322 | ? [{ PrefixListId: rule.prefixListId, Description: rule.description }] |
| 323 | : undefined, |
| 324 | }); |
| 325 | |
| 326 | const desiredRules = Effect.fn(function* ( |
| 327 | rules: SecurityGroupRuleData[], |
| 328 | ) { |
| 329 | if ( |
| 330 | rules.some( |
| 331 | (rule) => |
| 332 | !rule.ipProtocol || |
| 333 | ![ |
| 334 | rule.cidrIpv4, |
| 335 | rule.cidrIpv6, |
| 336 | rule.referencedGroupId, |
| 337 | rule.prefixListId, |
| 338 | ].some(Boolean), |
| 339 | ) |
| 340 | ) { |
| 341 | return yield* new InvalidDefaultSecurityGroupRules({ |
| 342 | message: "Every rule must specify a protocol and a source.", |
| 343 | }); |
| 344 | } |
| 345 | const desired = new Map<string, SecurityGroupRuleData>(); |
| 346 | for (const rule of expandSecurityGroupRules(rules)) { |
| 347 | const key = securityGroupRuleKey({ ...rule, description: undefined }); |
| 348 | const previous = desired.get(key); |
| 349 | if ( |
| 350 | previous && |
| 351 | securityGroupRuleKey(previous) !== securityGroupRuleKey(rule) |
| 352 | ) { |
| 353 | return yield* new InvalidDefaultSecurityGroupRules({ |
| 354 | message: "Duplicate rules must have the same description.", |
| 355 | }); |
| 356 | } |
| 357 | desired.set(key, rule); |
| 358 | } |
| 359 | return desired; |
| 360 | }); |
| 361 | |
| 362 | const rulesMatch = ( |
| 363 | rules: ec2.SecurityGroupRule[], |
| 364 | ingress: Map<string, SecurityGroupRuleData>, |
| 365 | egress: Map<string, SecurityGroupRuleData>, |
| 366 | ) => |
| 367 | [false, true].every((isEgress) => { |
| 368 | const observed = rules.filter((rule) => !!rule.IsEgress === isEgress); |
| 369 | const desired = [...(isEgress ? egress : ingress).values()]; |
| 370 | return ( |
| 371 | JSON.stringify( |
| 372 | observed.map(observedSecurityGroupRuleKey).sort(), |
| 373 | ) === JSON.stringify(desired.map(securityGroupRuleKey).sort()) |
| 374 | ); |
| 375 | }); |
| 376 | |
| 377 | const syncRules = Effect.fn(function* ( |
| 378 | groupId: SecurityGroupId, |
| 379 | isEgress: boolean, |
| 380 | desired: Map<string, SecurityGroupRuleData>, |
| 381 | observed: ec2.SecurityGroupRule[], |
| 382 | ) { |
| 383 | const current = new Map( |
| 384 | observed.map((rule) => [ |
| 385 | observedSecurityGroupRuleKey({ ...rule, Description: undefined }), |
| 386 | rule, |
| 387 | ]), |
| 388 | ); |
| 389 | for (const [key, rule] of current) { |
| 390 | const wanted = desired.get(key); |
| 391 | if (!wanted) { |
| 392 | const request = { |
| 393 | GroupId: groupId, |
| 394 | SecurityGroupRuleIds: [rule.SecurityGroupRuleId!], |
| 395 | DryRun: false, |
| 396 | }; |
| 397 | yield* Effect.gen(function* () { |
| 398 | if (isEgress) yield* ec2.revokeSecurityGroupEgress(request); |
| 399 | else yield* ec2.revokeSecurityGroupIngress(request); |
| 400 | }).pipe( |
| 401 | Effect.catchTag( |
| 402 | [ |
| 403 | "InvalidPermission.NotFound", |
| 404 | "InvalidSecurityGroupRuleId.NotFound", |
| 405 | ], |
| 406 | () => Effect.void, |
| 407 | ), |
| 408 | ); |
| 409 | } else if ((wanted.description ?? "") !== (rule.Description ?? "")) { |
| 410 | yield* ec2 |
| 411 | .modifySecurityGroupRules({ |
| 412 | GroupId: groupId, |
| 413 | SecurityGroupRules: [ |
| 414 | { |
| 415 | SecurityGroupRuleId: rule.SecurityGroupRuleId!, |
| 416 | SecurityGroupRule: { |
| 417 | IpProtocol: rule.IpProtocol, |
| 418 | FromPort: rule.FromPort, |
| 419 | ToPort: rule.ToPort, |
| 420 | CidrIpv4: rule.CidrIpv4, |
| 421 | CidrIpv6: rule.CidrIpv6, |
| 422 | ReferencedGroupId: rule.ReferencedGroupInfo?.GroupId, |
| 423 | PrefixListId: rule.PrefixListId, |
| 424 | Description: wanted.description ?? "", |
| 425 | }, |
| 426 | }, |
| 427 | ], |
| 428 | }) |
| 429 | .pipe( |
| 430 | Effect.catchTag("InvalidSecurityGroupRuleId.NotFound", () => |
| 431 | Effect.fail( |
| 432 | new DefaultSecurityGroupRulesNotConverged({ groupId }), |
| 433 | ), |
| 434 | ), |
| 435 | ); |
| 436 | } |
| 437 | } |
| 438 | for (const [key, rule] of desired) { |
| 439 | if (current.has(key)) continue; |
| 440 | const request = { |
| 441 | GroupId: groupId, |
| 442 | IpPermissions: [toPermission(rule)], |
| 443 | DryRun: false, |
| 444 | }; |
| 445 | yield* ( |
| 446 | isEgress |
| 447 | ? ec2.authorizeSecurityGroupEgress(request) |
| 448 | : ec2.authorizeSecurityGroupIngress(request) |
| 449 | ).pipe( |
| 450 | // Re-observe a racing authorization rather than assuming its description. |
| 451 | Effect.catchTag("InvalidPermission.Duplicate", () => |
| 452 | Effect.fail( |
| 453 | new DefaultSecurityGroupRulesNotConverged({ groupId }), |
| 454 | ), |
| 455 | ), |
| 456 | ); |
| 457 | } |
| 458 | }); |
| 459 | |
| 460 | return { |
| 461 | stables: ["groupId", "groupArn", "groupName", "ownerId"], |
| 462 | |
| 463 | read: Effect.fn(function* ({ olds, output }) { |
| 464 | const vpcId = output?.vpcId ?? olds?.vpcId; |
| 465 | if (!vpcId) return undefined; |
| 466 | const group = yield* describeGroup(vpcId); |
| 467 | if (!group?.GroupId) return undefined; |
| 468 | return yield* toAttrs( |
| 469 | group, |
| 470 | yield* describeRules(group.GroupId as SecurityGroupId), |
| 471 | ); |
| 472 | }), |
| 473 | |
| 474 | diff: Effect.fn(function* ({ news, olds }) { |
| 475 | // An unresolved VPC must not expose the previous group's stable identity. |
| 476 | if ( |
| 477 | !("vpcId" in news) || |
| 478 | !isResolved(news.vpcId) || |
| 479 | news.vpcId !== olds.vpcId |
| 480 | ) { |
| 481 | return { action: "replace" }; |
| 482 | } |
| 483 | if (!isResolved(news.ingress) || !isResolved(news.egress)) return; |
| 484 | const ingress = yield* desiredRules( |
| 485 | resolveSecurityGroupRules(news.ingress, false), |
| 486 | ); |
| 487 | const egress = yield* desiredRules( |
| 488 | resolveSecurityGroupRules(news.egress, true), |
| 489 | ); |
| 490 | const group = yield* describeGroup(news.vpcId); |
| 491 | if (!group?.GroupId) return { action: "update", stables: [] }; |
| 492 | const groupId = group.GroupId as SecurityGroupId; |
| 493 | const owned = yield* declaredSecurityGroupRuleIds(groupId); |
| 494 | const observed = (yield* describeRules(groupId)).filter( |
| 495 | (rule) => !owned.has(rule.SecurityGroupRuleId!), |
| 496 | ); |
| 497 | if (!rulesMatch(observed, ingress, egress)) |
| 498 | return { action: "update" }; |
| 499 | }), |
| 500 | |
| 501 | reconcile: Effect.fn(function* ({ news, session }) { |
| 502 | // Validate both directions before changing either one. |
| 503 | const ingress = yield* desiredRules( |
| 504 | resolveSecurityGroupRules(news.ingress, false), |
| 505 | ); |
| 506 | const egress = yield* desiredRules( |
| 507 | resolveSecurityGroupRules(news.egress, true), |
| 508 | ); |
| 509 | const group = yield* describeGroup(news.vpcId).pipe( |
| 510 | Effect.flatMap((group) => |
| 511 | group?.GroupId |
| 512 | ? Effect.succeed(group) |
| 513 | : Effect.fail( |
| 514 | new DefaultSecurityGroupNotFound({ vpcId: news.vpcId }), |
| 515 | ), |
| 516 | ), |
| 517 | // AWS may expose the VPC before its default group is visible. |
| 518 | Effect.retry({ |
| 519 | while: (error) => error._tag === "DefaultSecurityGroupNotFound", |
| 520 | schedule: Schedule.spaced("1 second"), |
| 521 | times: 8, |
| 522 | }), |
| 523 | ); |
| 524 | const groupId = group.GroupId as SecurityGroupId; |
| 525 | const owned = yield* declaredSecurityGroupRuleIds(groupId); |
| 526 | const inlineRules = (rules: ec2.SecurityGroupRule[]) => |
| 527 | rules.filter((rule) => !owned.has(rule.SecurityGroupRuleId!)); |
| 528 | const finalRules = yield* Effect.gen(function* () { |
| 529 | const observed = yield* describeRules(groupId); |
| 530 | const inline = inlineRules(observed); |
| 531 | if (rulesMatch(inline, ingress, egress)) return observed; |
| 532 | yield* syncRules( |
| 533 | groupId, |
| 534 | false, |
| 535 | ingress, |
| 536 | inline.filter((rule) => !rule.IsEgress), |
| 537 | ); |
| 538 | yield* syncRules( |
| 539 | groupId, |
| 540 | true, |
| 541 | egress, |
| 542 | inline.filter((rule) => rule.IsEgress), |
| 543 | ); |
| 544 | const final = yield* describeRules(groupId); |
| 545 | if (!rulesMatch(inlineRules(final), ingress, egress)) { |
| 546 | return yield* new DefaultSecurityGroupRulesNotConverged({ |
| 547 | groupId, |
| 548 | }); |
| 549 | } |
| 550 | return final; |
| 551 | }).pipe( |
| 552 | Effect.retry({ |
| 553 | while: (error) => |
| 554 | error._tag === "DefaultSecurityGroupRulesNotConverged", |
| 555 | schedule: Schedule.spaced("1 second"), |
| 556 | times: 8, |
| 557 | }), |
| 558 | ); |
| 559 | yield* session.note(`Reconciled default security group: ${groupId}`); |
| 560 | return yield* toAttrs(group, finalRules); |
| 561 | }), |
| 562 | |
| 563 | // AWS owns the default group. Intentionally leave it and its rules as-is. |
| 564 | delete: () => Effect.void, |
| 565 | }; |
| 566 | }), |
| 567 | ); |