1import * as AWS from "@/AWS";
2import type { SecurityGroupId } from "@/AWS/EC2/SecurityGroup.ts";
3import type { SubnetId } from "@/AWS/EC2/Subnet.ts";
4import type { VpcId } from "@/AWS/EC2/Vpc.ts";
5import * as Core from "@/Test/Core";
6import * as EC2 from "@distilled.cloud/aws/ec2";
7import * as ElastiCache from "@distilled.cloud/aws/elasticache";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Schedule from "effect/Schedule";
11import { getDefaultVpc } from "../DefaultVpc.ts";
12
13export interface ProvisionedNetwork {
14 vpcId: VpcId;
15 subnetIds: SubnetId[];
16 securityGroupId: SecurityGroupId;
17 subnetGroupName: string;
18}
19
20const testOptions = { providers: AWS.providers() };
21const networkStack = Core.scratchStack(
22 testOptions,
23 "Network",
24 "test/AWS/ElastiCache/ProvisionedFixture.ts",
25);
26
27let ready = Deferred.makeUnsafe<ProvisionedNetwork, unknown>();
28let started = false;
29let holders = 0;
30let deployed = false;
31
32// The account's default VPC costs no VPC quota, so cache suites never queue
33// behind EC2 suites for VPC capacity. Lambdas reach the caches over private
34// addresses, so public default subnets need no NAT.
35const findDefaultSubnets = Effect.gen(function* () {
36 const vpc = yield* getDefaultVpc;
37 const subnets = yield* EC2.describeSubnets({
38 Filters: [
39 { Name: "vpc-id", Values: [vpc.vpcId] },
40 { Name: "default-for-az", Values: ["true"] },
41 { Name: "state", Values: ["available"] },
42 ],
43 });
44 // Not every AZ offers every cache node type; stay within the first three.
45 const subnetIds = (subnets.Subnets ?? [])
46 .filter((subnet) => /[abc]$/.test(subnet.AvailabilityZone ?? ""))
47 .sort((l, r) =>
48 (l.AvailabilityZone ?? "").localeCompare(r.AvailabilityZone ?? ""),
49 )
50 .flatMap((subnet) => (subnet.SubnetId ? [subnet.SubnetId] : []))
51 .slice(0, 2) as SubnetId[];
52 if (subnetIds.length < 2) {
53 return yield* Effect.fail(
54 new Error("ElastiCache tests require two default subnets in AZs a-c"),
55 );
56 }
57 return { vpcId: vpc.vpcId, subnetIds };
58});
59
60const deployNetwork = Effect.gen(function* () {
61 const { vpcId, subnetIds } = yield* Core.withProviders(
62 findDefaultSubnets,
63 testOptions,
64 "Network",
65 );
66 yield* networkStack.destroy();
67 return yield* networkStack.deploy(
68 Effect.gen(function* () {
69 const securityGroup = yield* AWS.EC2.SecurityGroup("CacheSecurityGroup", {
70 vpcId,
71 description: "ElastiCache shared cache access",
72 tags: { fixture: "elasticache-provisioned" },
73 });
74 const subnetGroup = yield* AWS.ElastiCache.SubnetGroup("Subnets", {
75 description: "alchemy provisioned cache subnets",
76 subnetIds,
77 tags: { fixture: "elasticache-provisioned" },
78 });
79 return {
80 vpcId,
81 subnetIds,
82 securityGroupId: securityGroup.groupId,
83 subnetGroupName: subnetGroup.subnetGroupName,
84 } as unknown as ProvisionedNetwork;
85 }),
86 );
87});
88
89/** First caller deploys the shared VPC; everyone else waits for it. */
90export const acquireProvisionedNetwork = Effect.gen(function* () {
91 holders += 1;
92 if (started) {
93 return yield* Deferred.await(ready);
94 }
95 started = true;
96 const attrs = yield* deployNetwork.pipe(
97 Effect.tapError((error) =>
98 Effect.gen(function* () {
99 started = false;
100 yield* Deferred.fail(ready, error);
101 ready = Deferred.makeUnsafe();
102 }),
103 ),
104 );
105 deployed = true;
106 yield* Deferred.succeed(ready, attrs);
107 return attrs;
108});
109
110/** Resolved IDs of the process-wide provisioned-cache VPC. */
111export const getProvisionedNetwork = Effect.suspend(() =>
112 started
113 ? Deferred.await(ready).pipe(Effect.orDie)
114 : Effect.die(
115 "provisioned network was not acquired; call shareProvisionedNetwork in the test file",
116 ),
117);
118
119export const releaseProvisionedNetwork = Effect.suspend(() => {
120 holders = Math.max(0, holders - 1);
121 if (holders > 0 || !deployed) return Effect.void;
122 deployed = false;
123 started = false;
124 ready = Deferred.makeUnsafe();
125 return networkStack.destroy();
126});
127
128export const shareProvisionedNetwork = (hooks: {
129 beforeAll: (
130 eff: Effect.Effect<unknown, any, any>,
131 options?: { timeout?: number },
132 ) => unknown;
133 afterAll: (
134 eff: Effect.Effect<unknown, any, any>,
135 options?: { timeout?: number },
136 ) => void;
137}) => {
138 hooks.beforeAll(acquireProvisionedNetwork, { timeout: 180_000 });
139 hooks.afterAll(releaseProvisionedNetwork, { timeout: 180_000 });
140};
141
142export const assertReplicationGroupGone = (name: string) =>
143 ElastiCache.describeReplicationGroups({ ReplicationGroupId: name }).pipe(
144 Effect.flatMap(() =>
145 Effect.fail(new Error(`replication group '${name}' still exists`)),
146 ),
147 Effect.catchTag("ReplicationGroupNotFoundFault", () => Effect.void),
148 Effect.retry({
149 schedule: Schedule.max([
150 Schedule.fixed("10 seconds"),
151 Schedule.recurs(18),
152 ]),
153 }),
154 );
155
156export const assertCacheClusterGone = (name: string) =>
157 ElastiCache.describeCacheClusters({ CacheClusterId: name }).pipe(
158 Effect.flatMap(() =>
159 Effect.fail(new Error(`cache cluster '${name}' still exists`)),
160 ),
161 Effect.catchTag("CacheClusterNotFoundFault", () => Effect.void),
162 Effect.retry({
163 schedule: Schedule.max([
164 Schedule.fixed("10 seconds"),
165 Schedule.recurs(18),
166 ]),
167 }),
168 );