1import * as dax from "@distilled.cloud/aws/dax";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { sameStringSet } from "./internal.ts";
10
11export interface SubnetGroupProps {
12 /**
13 * Name of the subnet group. Must be 1-255 characters. If omitted, a
14 * deterministic physical name is generated. Changing the name replaces the
15 * subnet group.
16 */
17 subnetGroupName?: string;
18 /**
19 * Human-readable description of the subnet group.
20 */
21 description?: string;
22 /**
23 * VPC subnet IDs the subnet group spans. DAX places cluster nodes into
24 * these subnets; cover at least two Availability Zones for a multi-node
25 * cluster.
26 */
27 subnetIds: string[];
28}
29
30export interface SubnetGroup extends Resource<
31 "AWS.DAX.SubnetGroup",
32 SubnetGroupProps,
33 {
34 /** Name of the subnet group. */
35 subnetGroupName: string;
36 /** Description of the subnet group. */
37 description: string | undefined;
38 /** ID of the VPC the subnets belong to. */
39 vpcId: string | undefined;
40 /** IDs of the subnets in the group. */
41 subnetIds: string[];
42 },
43 never,
44 Providers
45> {}
46
47/**
48 * A DAX subnet group — the set of VPC subnets a DAX cluster's nodes are
49 * placed into.
50 *
51 * Subnet groups are free and provision instantly. A {@link Cluster}
52 * references one by name via `subnetGroupName`. DAX does not support tags on
53 * subnet groups.
54 * ### Creating a Subnet Group
55 * **Example:** Subnet Group Spanning Two Subnets
56 * ```typescript
57 * const subnetGroup = yield* SubnetGroup("DaxSubnets", {
58 * description: "DAX cluster subnets",
59 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
60 * });
61 * ```
62 *
63 * ### Placing a Cluster
64 * **Example:** Cluster in the Subnet Group
65 * ```typescript
66 * const cluster = yield* Cluster("Cache", {
67 * nodeType: "dax.t3.small",
68 * replicationFactor: 1,
69 * iamRoleArn: role.roleArn,
70 * subnetGroupName: subnetGroup.subnetGroupName,
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const SubnetGroup = Resource<SubnetGroup>("AWS.DAX.SubnetGroup");
77
78export const SubnetGroupProvider = () =>
79 Provider.effect(
80 SubnetGroup,
81 Effect.gen(function* () {
82 const toName = (id: string, props: SubnetGroupProps) =>
83 props.subnetGroupName
84 ? Effect.succeed(props.subnetGroupName)
85 : createPhysicalName({ id, maxLength: 255, lowercase: true });
86
87 const readGroup = Effect.fn(function* (name: string) {
88 const response = yield* dax
89 .describeSubnetGroups({ SubnetGroupNames: [name] })
90 .pipe(
91 Effect.catchTag("SubnetGroupNotFoundFault", () =>
92 Effect.succeed(undefined),
93 ),
94 );
95 return response?.SubnetGroups?.[0];
96 });
97
98 const toAttrs = (group: dax.SubnetGroup) => ({
99 subnetGroupName: group.SubnetGroupName ?? "",
100 description: group.Description,
101 vpcId: group.VpcId,
102 subnetIds: (group.Subnets ?? [])
103 .map((s) => s.SubnetIdentifier)
104 .filter((id): id is string => id !== undefined),
105 });
106
107 return {
108 stables: ["subnetGroupName"],
109
110 diff: Effect.fn(function* ({ id, olds, news }) {
111 if (!isResolved(news)) return undefined;
112 if (
113 (yield* toName(id, olds ?? { subnetIds: [] })) !==
114 (yield* toName(id, news ?? { subnetIds: [] }))
115 ) {
116 return { action: "replace" } as const;
117 }
118 }),
119
120 read: Effect.fn(function* ({ id, olds, output }) {
121 const name =
122 output?.subnetGroupName ??
123 (yield* toName(id, olds ?? { subnetIds: [] }));
124 const group = yield* readGroup(name);
125 if (group?.SubnetGroupName === undefined) return undefined;
126 // DAX subnet groups do not support tags, so ownership cannot be
127 // verified — return the attributes directly.
128 return toAttrs(group);
129 }),
130
131 reconcile: Effect.fn(function* ({ id, news, output, session }) {
132 const props = news ?? { subnetIds: [] };
133 const name = output?.subnetGroupName ?? (yield* toName(id, props));
134
135 // 1. Observe — cloud state is authoritative.
136 let observed = yield* readGroup(name);
137
138 // 2. Ensure — create if missing; tolerate AlreadyExists as a race.
139 if (observed === undefined) {
140 yield* dax
141 .createSubnetGroup({
142 SubnetGroupName: name,
143 Description: props.description,
144 SubnetIds: props.subnetIds,
145 })
146 .pipe(
147 Effect.catchTag(
148 "SubnetGroupAlreadyExistsFault",
149 () => Effect.void,
150 ),
151 );
152 observed = yield* readGroup(name);
153 }
154 if (observed === undefined) {
155 return yield* Effect.fail(
156 new Error(`DAX subnet group '${name}' not found after create`),
157 );
158 }
159
160 // 3. Sync — apply description / subnet delta from OBSERVED state.
161 const update: dax.UpdateSubnetGroupRequest = {
162 SubnetGroupName: name,
163 };
164 let mutated = false;
165 if (
166 props.description !== undefined &&
167 props.description !== observed.Description
168 ) {
169 update.Description = props.description;
170 mutated = true;
171 }
172 const observedSubnets = (observed.Subnets ?? [])
173 .map((s) => s.SubnetIdentifier)
174 .filter((s): s is string => s !== undefined);
175 if (!sameStringSet(props.subnetIds, observedSubnets)) {
176 update.SubnetIds = props.subnetIds;
177 mutated = true;
178 }
179 if (mutated) {
180 const response = yield* dax.updateSubnetGroup(update);
181 observed = response.SubnetGroup ?? observed;
182 }
183
184 yield* session.note(name);
185 return toAttrs(observed);
186 }),
187
188 delete: Effect.fn(function* ({ output }) {
189 // A subnet group still attached to a cluster rejects deletion with
190 // SubnetGroupInUseFault — retry (bounded) while the cluster
191 // releases it. NotFound is success (idempotent delete).
192 yield* dax
193 .deleteSubnetGroup({ SubnetGroupName: output.subnetGroupName })
194 .pipe(
195 Effect.catchTag("SubnetGroupNotFoundFault", () => Effect.void),
196 Effect.retry({
197 while: (e) => e._tag === "SubnetGroupInUseFault",
198 schedule: Schedule.max([
199 Schedule.fixed("5 seconds"),
200 Schedule.recurs(10),
201 ]),
202 }),
203 );
204 }),
205
206 list: () =>
207 Effect.gen(function* () {
208 // DAX has no paginated distilled ops — hand-roll a bounded loop
209 // and treat an empty/absent NextToken as terminal.
210 const groups: dax.SubnetGroup[] = [];
211 let nextToken: string | undefined;
212 for (let page = 0; page < 20; page++) {
213 const response = yield* dax.describeSubnetGroups({
214 NextToken: nextToken,
215 });
216 groups.push(...(response.SubnetGroups ?? []));
217 nextToken = response.NextToken;
218 if (!nextToken) break;
219 }
220 return groups
221 .filter((group) => group.SubnetGroupName !== undefined)
222 .map(toAttrs);
223 }),
224 };
225 }),
226 );