From df3833bc19908192c9f34608d43dcb61d9950d64 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Tue, 5 May 2026 00:09:03 -0700 Subject: [PATCH] fix(aws/ec2): harden SecurityGroup reconcile + add lifecycle convergence tests - Wrap describeSecurityGroups in a bounded eventual-consistency retry that rides out InvalidGroup.NotFound and the SG-not-yet-visible window immediately after createSecurityGroup. Both the post-create lookup and the final re-read now use the same helper. - Replace the full-revoke/full-reauthorize rule sync with a canonical-key diff (protocol + port range + source + description), so reconcile only applies the delta. AWS deduplicates identical rule shapes, so this canonicalization is what AWS uses internally. - read now searches by alchemy tag filters when state lacks a groupId, treats a vanished SG as undefined (so reconcile recreates it), and returns Unowned(attrs) for foreign-tagged SGs to gate adoption behind adopt(true). - delete previously matched DependencyViolation by both its proper tag AND a brittle e._tag === "ValidationError" && e.message?.includes("DependencyViolation") substring check. Distilled tags DependencyViolation directly via withDependencyViolationError, so the substring branch is dead code that swallowed unrelated ValidationErrors. Drop it. - Add lifecycle convergence tests: idempotent redeploy, drift convergence after out-of-band ingress/egress mutation, recreate after out-of-band deletion (rides eventual-consistency), replace on groupName change, rule-set diff on add/remove/change, double-destroy idempotency, foreign-tag takeover via adopt(true). .worktrees/ joins .gitignore (matching the VPC hardening PR) so per-resource hardening worktrees stay untracked. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + packages/alchemy/src/AWS/EC2/SecurityGroup.ts | 309 ++++++++---- .../test/AWS/EC2/SecurityGroup.test.ts | 456 ++++++++++++++++++ 3 files changed, 681 insertions(+), 85 deletions(-) create mode 100644 packages/alchemy/test/AWS/EC2/SecurityGroup.test.ts diff --git a/.gitignore b/.gitignore index 181b85b2c..571c542ba 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dist/ .env *.tsbuildinfo .claude/ +.worktrees/ .alchemy/ # !.alchemy/github:alchemy/ # examples/*/.alchemy diff --git a/packages/alchemy/src/AWS/EC2/SecurityGroup.ts b/packages/alchemy/src/AWS/EC2/SecurityGroup.ts index a265f043b..ecc36f52b 100644 --- a/packages/alchemy/src/AWS/EC2/SecurityGroup.ts +++ b/packages/alchemy/src/AWS/EC2/SecurityGroup.ts @@ -2,12 +2,19 @@ import * as ec2 from "@distilled.cloud/aws/ec2"; import { Region } from "@distilled.cloud/aws/Region"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; +import { Unowned } from "../../AdoptPolicy.ts"; import { isResolved } from "../../Diff.ts"; import { createPhysicalName } from "../../PhysicalName.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import type { Providers } from "../Providers.ts"; -import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts"; +import { + createAlchemyTagFilters, + createInternalTags, + createTagsList, + diffTags, + hasAlchemyTags, +} from "../../Tags.ts"; import type { AccountID } from "../Environment.ts"; import { AWSEnvironment } from "../Environment.ts"; import type { RegionID } from "../Region.ts"; @@ -200,21 +207,110 @@ export const SecurityGroupProvider = () => return yield* createPhysicalName({ id, maxLength: 255 }); }); + // Bounded retry for the eventual-consistency window right after + // createSecurityGroup — describeSecurityGroups can briefly miss a + // freshly-minted SG, surfacing as InvalidGroup.NotFound or an empty + // result. + const retryEventuallyConsistent = ( + eff: Effect.Effect, + ) => + eff.pipe( + Effect.retry({ + while: (e: { readonly _tag?: string } | unknown) => + (e as { readonly _tag?: string })?._tag === + "InvalidGroup.NotFound" || + (e as { readonly _tag?: string })?._tag === + "SecurityGroupNotVisible", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(10)), + ), + }), + ); + + // Hard variant — fails if the SG isn't returned. Use only after we've + // confirmed (or just minted) the SG and a missing result indicates a + // genuine inconsistency. Wraps the lookup in `retryEventuallyConsistent` + // so post-create races don't surface as hard failures. const describeSecurityGroup = (groupId: string) => - ec2.describeSecurityGroups({ GroupIds: [groupId] }).pipe( - Effect.map((r) => r.SecurityGroups?.[0]), - Effect.flatMap((sg) => - sg - ? Effect.succeed(sg) - : Effect.fail(new Error(`Security Group ${groupId} not found`)), + retryEventuallyConsistent( + ec2.describeSecurityGroups({ GroupIds: [groupId] }).pipe( + Effect.flatMap((r) => + r.SecurityGroups?.[0] + ? Effect.succeed(r.SecurityGroups[0]) + : Effect.fail({ + _tag: "SecurityGroupNotVisible" as const, + groupId, + }), + ), ), ); + // Soft variant — InvalidGroup.NotFound (e.g. SG deleted out-of-band) is + // collapsed into `undefined`. Use anywhere we want missing-as-empty + // semantics during reconcile/read instead of propagating the error. + const findSecurityGroup = (groupId: string) => + ec2 + .describeSecurityGroups({ GroupIds: [groupId] }) + .pipe( + Effect.map((r) => r.SecurityGroups?.[0]), + Effect.catchTag("InvalidGroup.NotFound", () => + Effect.succeed(undefined), + ), + ); + const describeSecurityGroupRules = (groupId: string) => ec2.describeSecurityGroupRules({ Filters: [{ Name: "group-id", Values: [groupId] }], }); + // Stable canonical form of an SG rule used for diffing observed cloud + // rules against the desired set. Includes everything that affects rule + // identity in AWS (protocol/ports/source) plus the description. AWS + // collapses identical rule shapes into one rule even when authorize is + // called multiple times, so this canonicalization is what matches. + const canonicalRuleKey = (rule: { + ipProtocol: string; + fromPort: number | undefined; + toPort: number | undefined; + cidrIpv4: string | undefined; + cidrIpv6: string | undefined; + referencedGroupId: string | undefined; + prefixListId: string | undefined; + description: string | undefined; + }) => + JSON.stringify({ + ipProtocol: rule.ipProtocol, + fromPort: rule.fromPort ?? null, + toPort: rule.toPort ?? null, + cidrIpv4: rule.cidrIpv4 ?? null, + cidrIpv6: rule.cidrIpv6 ?? null, + referencedGroupId: rule.referencedGroupId ?? null, + prefixListId: rule.prefixListId ?? null, + description: rule.description ?? null, + }); + + const observedToCanonical = (rule: ec2.SecurityGroupRule) => ({ + ipProtocol: rule.IpProtocol!, + fromPort: rule.FromPort, + toPort: rule.ToPort, + cidrIpv4: rule.CidrIpv4, + cidrIpv6: rule.CidrIpv6, + referencedGroupId: rule.ReferencedGroupInfo?.GroupId, + prefixListId: rule.PrefixListId, + description: rule.Description, + }); + + const desiredToCanonical = (rule: SecurityGroupRuleData) => ({ + ipProtocol: rule.ipProtocol, + fromPort: rule.fromPort, + toPort: rule.toPort, + cidrIpv4: rule.cidrIpv4, + cidrIpv6: rule.cidrIpv6, + referencedGroupId: rule.referencedGroupId, + prefixListId: rule.prefixListId, + description: rule.description, + }); + const toAttrs = ( sg: ec2.SecurityGroup, rules: ec2.SecurityGroupRule[], @@ -289,11 +385,33 @@ export const SecurityGroupProvider = () => return { stables: ["groupId", "groupArn", "ownerId"], - read: Effect.fn(function* ({ output }) { - if (!output) return undefined; - const sg = yield* describeSecurityGroup(output.groupId); - const rulesResult = yield* describeSecurityGroupRules(output.groupId); - return toAttrs(sg, rulesResult.SecurityGroupRules ?? []); + read: Effect.fn(function* ({ id, output }) { + // Fast path — if state has the groupId, look it up directly. A + // missing result here means the SG was deleted out of band; surface + // as `undefined` so the engine treats it as a fresh create rather + // than failing the read. + let sg: ec2.SecurityGroup | undefined; + if (output?.groupId) { + sg = yield* findSecurityGroup(output.groupId); + } else { + // Slow path / adoption — search by alchemy tags so a wiped state + // file can re-discover an SG we previously created. + const filters = yield* createAlchemyTagFilters(id); + const found = yield* ec2.describeSecurityGroups({ + Filters: filters, + }); + sg = found.SecurityGroups?.[0]; + } + if (!sg) return undefined; + const rulesResult = yield* describeSecurityGroupRules(sg.GroupId!); + const attrs = toAttrs(sg, rulesResult.SecurityGroupRules ?? []); + // Foreign-tagged SGs require explicit `adopt(true)` to take over. + const tagRecord = Object.fromEntries( + (sg.Tags ?? []).map((t) => [t.Key!, t.Value!]), + ); + return (yield* hasAlchemyTags(id, tagRecord)) + ? attrs + : Unowned(attrs); }), diff: Effect.fn(function* ({ id, news, olds, output }) { @@ -377,77 +495,100 @@ export const SecurityGroupProvider = () => }); } - // Sync ingress + egress rules — revoke whatever is observed and - // reapply the desired set. SG rule diffing on this SDK is non- - // trivial because each rule has many possible source shapes - // (cidr/group ref/prefix list), so the simplest convergent strategy - // is full-replace each reconcile. Default egress (-1, 0.0.0.0/0) - // is restored when no explicit egress is desired. + // Sync ingress + egress rules — diff observed canonical rules + // against desired and apply only the delta. AWS deduplicates + // identical rule shapes into a single rule, so a stable canonical + // key (protocol + port range + source + description) drives the + // diff. Default egress (-1, 0.0.0.0/0) is the AWS-side default + // when an SG is created and is reapplied here when no explicit + // egress is desired so out-of-band revocation converges back. const currentRulesResult = yield* describeSecurityGroupRules(groupId); const currentRules = currentRulesResult.SecurityGroupRules ?? []; - const currentIngress = currentRules.filter((r) => !r.IsEgress); - const currentEgress = currentRules.filter((r) => r.IsEgress); - if (currentIngress.length > 0) { - yield* ec2 - .revokeSecurityGroupIngress({ - GroupId: groupId, - SecurityGroupRuleIds: currentIngress.map( - (r) => r.SecurityGroupRuleId!, - ), - DryRun: false, - }) - .pipe( - Effect.catchTag( - "InvalidPermission.NotFound", - () => Effect.void, - ), + const observedIngress = currentRules.filter((r) => !r.IsEgress); + const observedEgress = currentRules.filter((r) => r.IsEgress); + + const desiredIngress = news.ingress ?? []; + const desiredEgress: SecurityGroupRuleData[] = + news.egress && news.egress.length > 0 + ? news.egress + : [{ ipProtocol: "-1", cidrIpv4: "0.0.0.0/0" }]; + + const syncRules = ( + observed: ec2.SecurityGroupRule[], + desired: SecurityGroupRuleData[], + kind: "ingress" | "egress", + ) => + Effect.gen(function* () { + const observedKeys = new Map( + observed.map((r) => [ + canonicalRuleKey(observedToCanonical(r)), + r, + ]), ); - } - if (currentEgress.length > 0) { - yield* ec2 - .revokeSecurityGroupEgress({ - GroupId: groupId, - SecurityGroupRuleIds: currentEgress.map( - (r) => r.SecurityGroupRuleId!, - ), - DryRun: false, - }) - .pipe( - Effect.catchTag( - "InvalidPermission.NotFound", - () => Effect.void, - ), + const desiredKeys = new Map( + desired.map((r) => [ + canonicalRuleKey(desiredToCanonical(r)), + r, + ]), ); - } - if (news.ingress && news.ingress.length > 0) { - yield* ec2.authorizeSecurityGroupIngress({ - GroupId: groupId, - IpPermissions: news.ingress.map(toIpPermission), - DryRun: false, - }); - yield* session.note(`Applied ${news.ingress.length} ingress rules`); - } - if (news.egress && news.egress.length > 0) { - yield* ec2.authorizeSecurityGroupEgress({ - GroupId: groupId, - IpPermissions: news.egress.map(toIpPermission), - DryRun: false, + const toRevoke = [...observedKeys.entries()] + .filter(([k]) => !desiredKeys.has(k)) + .map(([, r]) => r.SecurityGroupRuleId!) + .filter((x): x is string => Boolean(x)); + const toAuthorize = [...desiredKeys.entries()] + .filter(([k]) => !observedKeys.has(k)) + .map(([, r]) => r); + + if (toRevoke.length > 0) { + const revokeReq = { + GroupId: groupId, + SecurityGroupRuleIds: toRevoke, + DryRun: false, + }; + if (kind === "ingress") { + yield* ec2 + .revokeSecurityGroupIngress(revokeReq) + .pipe( + Effect.catchTag( + "InvalidPermission.NotFound", + () => Effect.void, + ), + ); + } else { + yield* ec2 + .revokeSecurityGroupEgress(revokeReq) + .pipe( + Effect.catchTag( + "InvalidPermission.NotFound", + () => Effect.void, + ), + ); + } + yield* session.note( + `Revoked ${toRevoke.length} ${kind} rules`, + ); + } + if (toAuthorize.length > 0) { + const authorizeReq = { + GroupId: groupId, + IpPermissions: toAuthorize.map(toIpPermission), + DryRun: false, + }; + if (kind === "ingress") { + yield* ec2.authorizeSecurityGroupIngress(authorizeReq); + } else { + yield* ec2.authorizeSecurityGroupEgress(authorizeReq); + } + yield* session.note( + `Authorized ${toAuthorize.length} ${kind} rules`, + ); + } }); - yield* session.note(`Applied ${news.egress.length} egress rules`); - } else { - yield* ec2.authorizeSecurityGroupEgress({ - GroupId: groupId, - IpPermissions: [ - { - IpProtocol: "-1", - IpRanges: [{ CidrIp: "0.0.0.0/0" }], - }, - ], - DryRun: false, - }); - } - // Re-read final state. + yield* syncRules(observedIngress, desiredIngress, "ingress"); + yield* syncRules(observedEgress, desiredEgress, "egress"); + + // Re-read final state via the bounded eventual-consistency retry. const finalSg = yield* describeSecurityGroup(groupId); const finalRules = yield* describeSecurityGroupRules(groupId); return toAttrs(finalSg, finalRules.SecurityGroupRules ?? []); @@ -465,15 +606,13 @@ export const SecurityGroupProvider = () => }) .pipe( Effect.catchTag("InvalidGroup.NotFound", () => Effect.void), - // Retry on dependency violations (e.g., ENIs still using the security group) + // Retry on dependency violations (e.g., ENIs still using the + // security group). Distilled tags `DependencyViolation` + // directly via `withDependencyViolationError`, so a substring + // match against `ValidationError.message` is dead code that + // would only swallow unrelated ValidationErrors. Effect.retry({ - while: (e) => { - return ( - e._tag === "DependencyViolation" || - (e._tag === "ValidationError" && - e.message?.includes("DependencyViolation")) - ); - }, + while: (e) => e._tag === "DependencyViolation", schedule: Schedule.fixed(5000).pipe( Schedule.both(Schedule.recurs(30)), // Up to ~2.5 minutes Schedule.tapOutput(([, attempt]) => diff --git a/packages/alchemy/test/AWS/EC2/SecurityGroup.test.ts b/packages/alchemy/test/AWS/EC2/SecurityGroup.test.ts new file mode 100644 index 000000000..1c0f229d8 --- /dev/null +++ b/packages/alchemy/test/AWS/EC2/SecurityGroup.test.ts @@ -0,0 +1,456 @@ +import { adopt } from "@/AdoptPolicy"; +import * as AWS from "@/AWS"; +import { SecurityGroup, Vpc } from "@/AWS/EC2"; +import { State } from "@/State"; +import * as Test from "@/Test/Vitest"; +import * as EC2 from "@distilled.cloud/aws/ec2"; +import { expect } from "@effect/vitest"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; + +const { test } = Test.make({ providers: AWS.providers() }); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const baseVpc = (logicalId: string, cidrBlock: string) => + Vpc(logicalId, { cidrBlock }); + +test.provider( + "create, update, delete security group", + (stack) => + Effect.gen(function* () { + const sg = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.30.0.0/16"); + return yield* SecurityGroup("TestSg", { + vpcId: vpc.vpcId, + description: "Initial", + ingress: [ + { + ipProtocol: "tcp", + fromPort: 22, + toPort: 22, + cidrIpv4: "10.30.0.0/16", + description: "ssh", + }, + ], + }); + }), + ); + + const observed = yield* EC2.describeSecurityGroups({ + GroupIds: [sg.groupId], + }); + expect(observed.SecurityGroups?.[0]?.GroupId).toEqual(sg.groupId); + expect(observed.SecurityGroups?.[0]?.Description).toEqual("Initial"); + + yield* stack.destroy(); + yield* assertSgDeleted(sg.groupId); + }).pipe(logLevel), +); + +test.provider( + "redeploy with same props is a no-op (reconcile is idempotent)", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.31.0.0/16"); + return yield* SecurityGroup("IdempotentSg", { + vpcId: vpc.vpcId, + ingress: [ + { + ipProtocol: "tcp", + fromPort: 80, + toPort: 80, + cidrIpv4: "0.0.0.0/0", + description: "http", + }, + ], + }); + }), + ); + + const second = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.31.0.0/16"); + return yield* SecurityGroup("IdempotentSg", { + vpcId: vpc.vpcId, + ingress: [ + { + ipProtocol: "tcp", + fromPort: 80, + toPort: 80, + cidrIpv4: "0.0.0.0/0", + description: "http", + }, + ], + }); + }), + ); + + expect(second.groupId).toEqual(initial.groupId); + expect(second.groupArn).toEqual(initial.groupArn); + + const rules = yield* EC2.describeSecurityGroupRules({ + Filters: [{ Name: "group-id", Values: [second.groupId] }], + }); + const ingress = (rules.SecurityGroupRules ?? []).filter( + (r) => !r.IsEgress, + ); + expect(ingress).toHaveLength(1); + expect(ingress[0]?.FromPort).toEqual(80); + + yield* stack.destroy(); + yield* assertSgDeleted(initial.groupId); + }).pipe(logLevel), +); + +test.provider( + "reconcile resets ingress + egress rules mutated out-of-band", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const sg = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.32.0.0/16"); + return yield* SecurityGroup("DriftRulesSg", { + vpcId: vpc.vpcId, + ingress: [ + { + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIpv4: "0.0.0.0/0", + description: "https", + }, + ], + egress: [ + { + ipProtocol: "tcp", + fromPort: 1024, + toPort: 65535, + cidrIpv4: "0.0.0.0/0", + description: "ephemeral", + }, + ], + }); + }), + ); + + // Drift: revoke the desired ingress out-of-band and authorize an + // unwanted one. Same for egress. + const beforeRules = yield* EC2.describeSecurityGroupRules({ + Filters: [{ Name: "group-id", Values: [sg.groupId] }], + }); + const ingressIds = (beforeRules.SecurityGroupRules ?? []) + .filter((r) => !r.IsEgress) + .map((r) => r.SecurityGroupRuleId!); + if (ingressIds.length > 0) { + yield* EC2.revokeSecurityGroupIngress({ + GroupId: sg.groupId, + SecurityGroupRuleIds: ingressIds, + }); + } + yield* EC2.authorizeSecurityGroupIngress({ + GroupId: sg.groupId, + IpPermissions: [ + { + IpProtocol: "tcp", + FromPort: 22, + ToPort: 22, + IpRanges: [{ CidrIp: "0.0.0.0/0", Description: "drifted" }], + }, + ], + }); + + // Re-deploy the original desired set; reconcile must converge. + const redeployed = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.32.0.0/16"); + return yield* SecurityGroup("DriftRulesSg", { + vpcId: vpc.vpcId, + ingress: [ + { + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIpv4: "0.0.0.0/0", + description: "https", + }, + ], + egress: [ + { + ipProtocol: "tcp", + fromPort: 1024, + toPort: 65535, + cidrIpv4: "0.0.0.0/0", + description: "ephemeral", + }, + ], + }); + }), + ); + expect(redeployed.groupId).toEqual(sg.groupId); + + const finalRules = yield* EC2.describeSecurityGroupRules({ + Filters: [{ Name: "group-id", Values: [redeployed.groupId] }], + }); + const finalIngress = (finalRules.SecurityGroupRules ?? []).filter( + (r) => !r.IsEgress, + ); + const finalEgress = (finalRules.SecurityGroupRules ?? []).filter( + (r) => r.IsEgress, + ); + expect(finalIngress).toHaveLength(1); + expect(finalIngress[0]?.FromPort).toEqual(443); + expect(finalEgress).toHaveLength(1); + expect(finalEgress[0]?.FromPort).toEqual(1024); + + yield* stack.destroy(); + yield* assertSgDeleted(sg.groupId); + }).pipe(logLevel), +); + +test.provider( + "reconcile re-creates a security group deleted out-of-band", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const initial = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.33.0.0/16"); + return yield* SecurityGroup("RecreateSg", { + vpcId: vpc.vpcId, + }); + }), + ); + + yield* EC2.deleteSecurityGroup({ GroupId: initial.groupId }); + yield* assertSgDeleted(initial.groupId); + + const recreated = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.33.0.0/16"); + return yield* SecurityGroup("RecreateSg", { + vpcId: vpc.vpcId, + }); + }), + ); + expect(recreated.groupId).not.toEqual(initial.groupId); + + yield* stack.destroy(); + yield* assertSgDeleted(recreated.groupId); + }).pipe(logLevel), + { timeout: 180_000 }, +); + +test.provider( + "changing groupName triggers replace; old SG is deleted", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const suffix = Math.random().toString(36).slice(2, 8); + + const a = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.34.0.0/16"); + return yield* SecurityGroup("ReplaceSg", { + vpcId: vpc.vpcId, + groupName: `alchemy-test-sg-a-${suffix}`, + }); + }), + ); + expect(a.groupName).toEqual(`alchemy-test-sg-a-${suffix}`); + + const b = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.34.0.0/16"); + return yield* SecurityGroup("ReplaceSg", { + vpcId: vpc.vpcId, + groupName: `alchemy-test-sg-b-${suffix}`, + }); + }), + ); + expect(b.groupName).toEqual(`alchemy-test-sg-b-${suffix}`); + expect(b.groupId).not.toEqual(a.groupId); + + yield* assertSgDeleted(a.groupId); + + yield* stack.destroy(); + yield* assertSgDeleted(b.groupId); + }).pipe(logLevel), + { timeout: 180_000 }, +); + +test.provider( + "adding/removing/changing rules diffs against cloud state", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const v1 = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.35.0.0/16"); + return yield* SecurityGroup("RuleDiffSg", { + vpcId: vpc.vpcId, + ingress: [ + { + ipProtocol: "tcp", + fromPort: 80, + toPort: 80, + cidrIpv4: "0.0.0.0/0", + description: "http", + }, + { + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIpv4: "0.0.0.0/0", + description: "https", + }, + ], + }); + }), + ); + expect(v1.groupId).toBeDefined(); + + const v2 = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.35.0.0/16"); + return yield* SecurityGroup("RuleDiffSg", { + vpcId: vpc.vpcId, + ingress: [ + // Drop port 80, keep 443, add 8080 + { + ipProtocol: "tcp", + fromPort: 443, + toPort: 443, + cidrIpv4: "0.0.0.0/0", + description: "https", + }, + { + ipProtocol: "tcp", + fromPort: 8080, + toPort: 8080, + cidrIpv4: "10.35.0.0/16", + description: "metrics", + }, + ], + }); + }), + ); + expect(v2.groupId).toEqual(v1.groupId); + + const rules = yield* EC2.describeSecurityGroupRules({ + Filters: [{ Name: "group-id", Values: [v2.groupId] }], + }); + const ingress = (rules.SecurityGroupRules ?? []) + .filter((r) => !r.IsEgress) + .map((r) => r.FromPort) + .sort((a, b) => (a ?? 0) - (b ?? 0)); + expect(ingress).toEqual([443, 8080]); + + yield* stack.destroy(); + yield* assertSgDeleted(v1.groupId); + }).pipe(logLevel), +); + +test.provider( + "destroying an already-deleted security group is a no-op", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const sg = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.36.0.0/16"); + return yield* SecurityGroup("DoubleDestroySg", { + vpcId: vpc.vpcId, + }); + }), + ); + + yield* EC2.deleteSecurityGroup({ GroupId: sg.groupId }); + yield* assertSgDeleted(sg.groupId); + + yield* stack.destroy(); + }).pipe(logLevel), +); + +test.provider( + "foreign-tagged security group requires adopt(true) to take over", + (stack) => + Effect.gen(function* () { + yield* stack.destroy(); + + const suffix = Math.random().toString(36).slice(2, 8); + const groupName = `alchemy-test-sg-takeover-${suffix}`; + + const original = yield* stack.deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.37.0.0/16"); + return yield* SecurityGroup("Original", { + vpcId: vpc.vpcId, + groupName, + }); + }), + ); + + // Wipe the SG entry from state — SG remains in EC2. + yield* Effect.gen(function* () { + const state = yield* State; + yield* state.delete({ + stack: stack.name, + stage: "test", + fqn: "Original", + }); + }).pipe(Effect.provide(stack.state)); + + const takenOver = yield* stack + .deploy( + Effect.gen(function* () { + const vpc = yield* baseVpc("SgVpc", "10.37.0.0/16"); + return yield* SecurityGroup("Different", { + vpcId: vpc.vpcId, + groupName, + }); + }), + ) + .pipe(adopt(true)); + + expect(takenOver.groupName).toEqual(groupName); + expect(takenOver.groupId).toEqual(original.groupId); + + yield* stack.destroy(); + yield* assertSgDeleted(original.groupId); + }).pipe(logLevel), + { timeout: 180_000 }, +); + +class SgStillExists extends Data.TaggedError("SgStillExists") {} + +export const assertSgDeleted = Effect.fn(function* (groupId: string) { + yield* EC2.describeSecurityGroups({ + GroupIds: [groupId], + }).pipe( + Effect.flatMap(() => Effect.fail(new SgStillExists())), + Effect.retry({ + while: (e) => e._tag === "SgStillExists", + schedule: Schedule.exponential(100).pipe( + Schedule.both(Schedule.recurs(20)), + ), + }), + Effect.catchTag("InvalidGroup.NotFound", () => Effect.void), + ); +});