1import * as memorydb from "@distilled.cloud/aws/memorydb";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { readMemoryDbTags, sameStringSet } from "./internal.ts";
13
14export interface SubnetGroupProps {
15 /**
16 * Name of the subnet group. Must be 1-40 characters. If omitted, a
17 * deterministic physical name is generated. Changing the name replaces the
18 * subnet group.
19 */
20 subnetGroupName?: string;
21 /**
22 * Human-readable description of the subnet group.
23 */
24 description?: string;
25 /**
26 * VPC subnet IDs the subnet group spans. MemoryDB places cluster nodes into
27 * these subnets, so they should cover at least two Availability Zones for a
28 * multi-AZ cluster.
29 */
30 subnetIds: string[];
31 /**
32 * User-defined tags for the subnet group.
33 */
34 tags?: Record<string, string>;
35}
36
37export interface SubnetGroup extends Resource<
38 "AWS.MemoryDB.SubnetGroup",
39 SubnetGroupProps,
40 {
41 /** Name of the subnet group. */
42 subnetGroupName: string;
43 /** ARN of the subnet group. */
44 subnetGroupArn: string;
45 /** Description of the subnet group. */
46 description: string | undefined;
47 /** ID of the VPC the subnets belong to. */
48 vpcId: string | undefined;
49 /** IDs of the subnets in the group. */
50 subnetIds: string[];
51 /** Tags on the subnet group (user + internal Alchemy tags). */
52 tags: Record<string, string>;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * A MemoryDB subnet group — the set of VPC subnets a MemoryDB cluster's nodes
60 * are placed into.
61 *
62 * Subnet groups are free and provision instantly. A cluster references one by
63 * name via `subnetGroupName`.
64 * ### Creating a Subnet Group
65 * **Example:** Subnet Group Spanning Two Subnets
66 * ```typescript
67 * const subnetGroup = yield* SubnetGroup("CacheSubnets", {
68 * description: "MemoryDB cluster subnets",
69 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
70 * });
71 * ```
72 *
73 * @resource
74 */
75export const SubnetGroup = Resource<SubnetGroup>("AWS.MemoryDB.SubnetGroup");
76
77export const SubnetGroupProvider = () =>
78 Provider.effect(
79 SubnetGroup,
80 Effect.gen(function* () {
81 const toName = (id: string, props: SubnetGroupProps) =>
82 props.subnetGroupName
83 ? Effect.succeed(props.subnetGroupName)
84 : createPhysicalName({ id, maxLength: 40, lowercase: true });
85
86 const readGroup = Effect.fn(function* (name: string) {
87 const response = yield* memorydb
88 .describeSubnetGroups({ SubnetGroupName: name })
89 .pipe(
90 Effect.catchTag("SubnetGroupNotFoundFault", () =>
91 Effect.succeed(undefined),
92 ),
93 );
94 return response?.SubnetGroups?.[0];
95 });
96
97 const toAttrs = Effect.fn(function* (group: memorydb.SubnetGroup) {
98 if (!group.Name || !group.ARN) {
99 return yield* Effect.fail(
100 new Error(`Subnet group '${group.Name}' is missing its ARN`),
101 );
102 }
103 return {
104 subnetGroupName: group.Name,
105 subnetGroupArn: group.ARN,
106 description: group.Description,
107 vpcId: group.VpcId,
108 subnetIds: (group.Subnets ?? [])
109 .map((s) => s.Identifier)
110 .filter((id): id is string => id !== undefined),
111 tags: yield* readMemoryDbTags(group.ARN),
112 };
113 });
114
115 return {
116 stables: ["subnetGroupName", "subnetGroupArn"],
117
118 diff: Effect.fn(function* ({ id, olds, news }) {
119 if (!isResolved(news)) return undefined;
120 if (
121 (yield* toName(id, olds ?? { subnetIds: [] })) !==
122 (yield* toName(id, news ?? { subnetIds: [] }))
123 ) {
124 return { action: "replace" } as const;
125 }
126 }),
127
128 read: Effect.fn(function* ({ id, olds, output }) {
129 const name =
130 output?.subnetGroupName ??
131 (yield* toName(id, olds ?? { subnetIds: [] }));
132 const group = yield* readGroup(name);
133 if (!group?.ARN) return undefined;
134 const attrs = yield* toAttrs(group);
135 return (yield* hasAlchemyTags(id, attrs.tags))
136 ? attrs
137 : Unowned(attrs);
138 }),
139
140 reconcile: Effect.fn(function* ({ id, news, output, session }) {
141 const props = news ?? { subnetIds: [] };
142 const name = output?.subnetGroupName ?? (yield* toName(id, props));
143 const internalTags = yield* createInternalTags(id);
144 const desiredTags = { ...internalTags, ...props.tags };
145
146 // 1. Observe — cloud state is authoritative.
147 let observed = yield* readGroup(name);
148
149 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
150 if (observed === undefined) {
151 yield* memorydb
152 .createSubnetGroup({
153 SubnetGroupName: name,
154 Description: props.description,
155 SubnetIds: props.subnetIds,
156 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
157 Key,
158 Value,
159 })),
160 })
161 .pipe(
162 Effect.catchTag(
163 "SubnetGroupAlreadyExistsFault",
164 () => Effect.void,
165 ),
166 );
167 observed = yield* readGroup(name);
168 }
169 if (observed === undefined) {
170 return yield* Effect.fail(
171 new Error(`Subnet group '${name}' not found after create`),
172 );
173 }
174
175 // 3. Sync — apply description / subnet delta from OBSERVED state.
176 const update: memorydb.UpdateSubnetGroupRequest = {
177 SubnetGroupName: name,
178 };
179 let mutated = false;
180 if (
181 props.description !== undefined &&
182 props.description !== observed.Description
183 ) {
184 update.Description = props.description;
185 mutated = true;
186 }
187 const observedSubnets = (observed.Subnets ?? [])
188 .map((s) => s.Identifier)
189 .filter((s): s is string => s !== undefined);
190 if (!sameStringSet(props.subnetIds, observedSubnets)) {
191 update.SubnetIds = props.subnetIds;
192 mutated = true;
193 }
194 if (mutated) {
195 const response = yield* memorydb.updateSubnetGroup(update);
196 observed = response.SubnetGroup ?? observed;
197 }
198
199 // 3b. Sync tags — diff against OBSERVED cloud tags.
200 const arn = observed.ARN;
201 if (arn) {
202 const observedTags = yield* readMemoryDbTags(arn);
203 const { removed, upsert } = diffTags(observedTags, desiredTags);
204 if (upsert.length > 0) {
205 yield* memorydb.tagResource({ ResourceArn: arn, Tags: upsert });
206 }
207 if (removed.length > 0) {
208 yield* memorydb.untagResource({
209 ResourceArn: arn,
210 TagKeys: removed,
211 });
212 }
213 }
214
215 yield* session.note(name);
216 return yield* toAttrs(observed);
217 }),
218
219 delete: Effect.fn(function* ({ output }) {
220 // A subnet group still attached to a cluster rejects deletion with
221 // SubnetGroupInUseFault — retry (bounded) while the cluster releases
222 // it. NotFound is success (idempotent delete).
223 yield* memorydb
224 .deleteSubnetGroup({ SubnetGroupName: output.subnetGroupName })
225 .pipe(
226 Effect.catchTag("SubnetGroupNotFoundFault", () => Effect.void),
227 Effect.retry({
228 while: (e) => e._tag === "SubnetGroupInUseFault",
229 schedule: Schedule.max([
230 Schedule.fixed("5 seconds"),
231 Schedule.recurs(12),
232 ]),
233 }),
234 );
235 }),
236
237 list: () =>
238 memorydb.describeSubnetGroups.pages({}).pipe(
239 Stream.runCollect,
240 Effect.map((chunk) =>
241 Array.from(chunk).flatMap((page) =>
242 (page.SubnetGroups ?? []).filter(
243 (group) =>
244 group.Name !== undefined && group.ARN !== undefined,
245 ),
246 ),
247 ),
248 Effect.flatMap(
249 Effect.forEach((group) => toAttrs(group), { concurrency: 4 }),
250 ),
251 ),
252 };
253 }),
254 );