1import * as redshift from "@distilled.cloud/aws/redshift";
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 { SubnetId } from "../EC2/Subnet.ts";
12import { AWSEnvironment } from "../Environment.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 applyRedshiftTagDelta,
16 redshiftArn,
17 sameStringSet,
18 toTagRecord,
19} from "./internal.ts";
20
21export interface ClusterSubnetGroupProps {
22 /**
23 * Name of the cluster subnet group. Must contain no more than 255
24 * lowercase alphanumeric characters or hyphens and must not be `default`.
25 * If omitted, a deterministic physical name is generated. Changing the
26 * name replaces the subnet group.
27 */
28 clusterSubnetGroupName?: string;
29 /**
30 * Human-readable description of the subnet group.
31 * @default "Managed by Alchemy"
32 */
33 description?: string;
34 /**
35 * VPC subnet IDs the subnet group spans. Redshift places cluster nodes
36 * into these subnets; span at least two Availability Zones so the cluster
37 * can relocate during maintenance.
38 */
39 subnetIds: SubnetId[];
40 /**
41 * User-defined tags for the subnet group.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface ClusterSubnetGroup extends Resource<
47 "AWS.Redshift.ClusterSubnetGroup",
48 ClusterSubnetGroupProps,
49 {
50 /**
51 * Name of the subnet group.
52 */
53 clusterSubnetGroupName: string;
54 /**
55 * ARN of the subnet group.
56 */
57 clusterSubnetGroupArn: string;
58 /**
59 * Description of the subnet group.
60 */
61 description: string | undefined;
62 /**
63 * ID of the VPC the subnets belong to.
64 */
65 vpcId: string | undefined;
66 /**
67 * IDs of the subnets in the group.
68 */
69 subnetIds: string[];
70 /**
71 * Status of the subnet group (e.g. `"Complete"`).
72 */
73 subnetGroupStatus: string | undefined;
74 /**
75 * Tags on the subnet group (including internal Alchemy tags).
76 */
77 tags: Record<string, string>;
78 },
79 never,
80 Providers
81> {}
82
83/**
84 * An Amazon Redshift cluster subnet group — the set of VPC subnets a
85 * provisioned Redshift cluster's nodes are placed into.
86 *
87 * Subnet groups are free and provision instantly. A {@link Cluster}
88 * references one by name via `clusterSubnetGroupName`.
89 * ### Creating a Cluster Subnet Group
90 * **Example:** Subnet Group Spanning Two Subnets
91 * ```typescript
92 * const subnetGroup = yield* Redshift.ClusterSubnetGroup("WarehouseSubnets", {
93 * description: "Subnets for the analytics warehouse",
94 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
95 * });
96 * ```
97 * **Example:** Tagged Subnet Group
98 * ```typescript
99 * const subnetGroup = yield* Redshift.ClusterSubnetGroup("WarehouseSubnets", {
100 * subnetIds: [subnetA.subnetId, subnetB.subnetId],
101 * tags: { team: "analytics" },
102 * });
103 * ```
104 *
105 * @resource
106 */
107export const ClusterSubnetGroup = Resource<ClusterSubnetGroup>(
108 "AWS.Redshift.ClusterSubnetGroup",
109);
110
111/**
112 * Retry an effect while the subnet group is still held by a cluster that is
113 * mid-deletion (`InvalidClusterSubnetGroupStateFault`), bounded to ~10
114 * minutes — a Redshift cluster releases its subnet group only once its own
115 * deletion completes, several minutes after `deleteCluster` returns.
116 *
117 * Expressed as an explicitly-typed module-scope helper: inlining
118 * `Effect.retry` in a lifecycle operation leaves `Retry.Return`'s
119 * conditional type unresolved in the provider's inferred layer type, which
120 * declaration emit widens to an `unknown` R — poisoning `AWS.providers()`
121 * for every downstream consumer.
122 */
123const retryWhileSubnetGroupInUse = <A, E extends { _tag: string }, R>(
124 self: Effect.Effect<A, E, R>,
125): Effect.Effect<A, E, R> =>
126 Effect.retry(self, {
127 while: (e) => e._tag === "InvalidClusterSubnetGroupStateFault",
128 schedule: Schedule.max([Schedule.fixed("15 seconds"), Schedule.recurs(40)]),
129 });
130
131export const ClusterSubnetGroupProvider = () =>
132 Provider.effect(
133 ClusterSubnetGroup,
134 Effect.gen(function* () {
135 const toName = (id: string, props: Partial<ClusterSubnetGroupProps>) =>
136 props.clusterSubnetGroupName
137 ? Effect.succeed(props.clusterSubnetGroupName)
138 : createPhysicalName({ id, maxLength: 255, lowercase: true });
139
140 const readGroup = Effect.fn(function* (name: string) {
141 const response = yield* redshift
142 .describeClusterSubnetGroups({ ClusterSubnetGroupName: name })
143 .pipe(
144 Effect.catchTag("ClusterSubnetGroupNotFoundFault", () =>
145 Effect.succeed(undefined),
146 ),
147 );
148 return response?.ClusterSubnetGroups?.[0];
149 });
150
151 const toAttrs = Effect.fn(function* (group: redshift.ClusterSubnetGroup) {
152 const { accountId, region } = yield* AWSEnvironment.current;
153 if (!group.ClusterSubnetGroupName) {
154 return yield* Effect.fail(
155 new Error("Cluster subnet group is missing its name"),
156 );
157 }
158 return {
159 clusterSubnetGroupName: group.ClusterSubnetGroupName,
160 clusterSubnetGroupArn: redshiftArn(
161 region,
162 accountId,
163 "subnetgroup",
164 group.ClusterSubnetGroupName,
165 ),
166 description: group.Description,
167 vpcId: group.VpcId,
168 subnetIds: (group.Subnets ?? []).flatMap((subnet) =>
169 subnet.SubnetIdentifier ? [subnet.SubnetIdentifier] : [],
170 ),
171 subnetGroupStatus: group.SubnetGroupStatus,
172 tags: toTagRecord(group.Tags),
173 };
174 });
175
176 return {
177 stables: ["clusterSubnetGroupName", "clusterSubnetGroupArn", "vpcId"],
178
179 diff: Effect.fn(function* ({ id, olds, news }) {
180 if (!isResolved(news)) return undefined;
181 if (
182 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
183 ) {
184 return { action: "replace" } as const;
185 }
186 }),
187
188 read: Effect.fn(function* ({ id, olds, output }) {
189 const name =
190 output?.clusterSubnetGroupName ?? (yield* toName(id, olds ?? {}));
191 const group = yield* readGroup(name);
192 if (!group?.ClusterSubnetGroupName) return undefined;
193 const attrs = yield* toAttrs(group);
194 return (yield* hasAlchemyTags(id, attrs.tags))
195 ? attrs
196 : Unowned(attrs);
197 }),
198
199 reconcile: Effect.fn(function* ({ id, news, output, session }) {
200 const name =
201 output?.clusterSubnetGroupName ?? (yield* toName(id, news));
202 const internalTags = yield* createInternalTags(id);
203 const desiredTags = { ...internalTags, ...news.tags };
204 const description = news.description ?? "Managed by Alchemy";
205
206 // 1. Observe — cloud state is authoritative.
207 let observed = yield* readGroup(name);
208
209 // 2. Ensure — create if missing; tolerate AlreadyExists as a race
210 // with a peer reconciler by re-reading.
211 if (!observed?.ClusterSubnetGroupName) {
212 yield* redshift
213 .createClusterSubnetGroup({
214 ClusterSubnetGroupName: name,
215 Description: description,
216 SubnetIds: news.subnetIds,
217 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
218 Key,
219 Value,
220 })),
221 })
222 .pipe(
223 Effect.catchTag(
224 "ClusterSubnetGroupAlreadyExistsFault",
225 () => Effect.void,
226 ),
227 );
228 observed = yield* readGroup(name);
229 if (!observed?.ClusterSubnetGroupName) {
230 return yield* Effect.fail(
231 new Error(`Failed to create cluster subnet group '${name}'`),
232 );
233 }
234 }
235
236 // 3. Sync — diff OBSERVED description/subnets against desired;
237 // modifyClusterSubnetGroup replaces the whole subnet list.
238 const observedSubnets = (observed.Subnets ?? []).flatMap((subnet) =>
239 subnet.SubnetIdentifier ? [subnet.SubnetIdentifier] : [],
240 );
241 if (
242 observed.Description !== description ||
243 !sameStringSet(observedSubnets, news.subnetIds)
244 ) {
245 yield* redshift.modifyClusterSubnetGroup({
246 ClusterSubnetGroupName: name,
247 Description: description,
248 SubnetIds: news.subnetIds,
249 });
250 observed = yield* readGroup(name);
251 if (!observed?.ClusterSubnetGroupName) {
252 return yield* Effect.fail(
253 new Error(
254 `Cluster subnet group '${name}' not found after update`,
255 ),
256 );
257 }
258 }
259
260 // 3b. Sync tags — diff against OBSERVED cloud tags (describe
261 // surfaces them inline).
262 const { accountId, region } = yield* AWSEnvironment.current;
263 const arn = redshiftArn(region, accountId, "subnetgroup", name);
264 const { removed, upsert } = diffTags(
265 toTagRecord(observed.Tags),
266 desiredTags,
267 );
268 yield* applyRedshiftTagDelta({ arn, upsert, removed });
269
270 yield* session.note(arn);
271 const attrs = yield* toAttrs(observed);
272 return { ...attrs, tags: desiredTags };
273 }),
274
275 delete: Effect.fn(function* ({ output }) {
276 // A cluster deleting out of this group holds it until the
277 // cluster's own deletion completes — retry through that window.
278 yield* retryWhileSubnetGroupInUse(
279 redshift
280 .deleteClusterSubnetGroup({
281 ClusterSubnetGroupName: output.clusterSubnetGroupName,
282 })
283 .pipe(
284 Effect.catchTag("ClusterSubnetGroupNotFoundFault", () =>
285 Effect.succeed(undefined),
286 ),
287 ),
288 );
289 }),
290
291 list: () =>
292 // Top-level account/region collection: exhaustively paginate
293 // describeClusterSubnetGroups; tags come inline on each group.
294 redshift.describeClusterSubnetGroups.pages({}).pipe(
295 Stream.runCollect,
296 Effect.map((chunk) =>
297 Array.from(chunk).flatMap((page) =>
298 (page.ClusterSubnetGroups ?? []).filter(
299 (group) => group.ClusterSubnetGroupName !== undefined,
300 ),
301 ),
302 ),
303 Effect.flatMap(
304 Effect.forEach((group) => toAttrs(group), { concurrency: 4 }),
305 ),
306 ),
307 };
308 }),
309 );