1import * as rds from "@distilled.cloud/aws/rds";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { toWireSeconds } from "../../Util/Duration.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12import { createInternalTags, diffTags } from "../../Tags.ts";
13
14export interface DBProxyProps {
15 /**
16 * Proxy name. If omitted, Alchemy generates one.
17 */
18 dbProxyName?: string;
19 /**
20 * Engine family such as `POSTGRESQL`.
21 */
22 engineFamily: rds.EngineFamily;
23 /**
24 * Authentication config for the proxy.
25 */
26 auth: rds.UserAuthConfig[];
27 /**
28 * IAM role ARN used by the proxy to read secrets.
29 */
30 roleArn: string;
31 /**
32 * Subnets used by the proxy.
33 */
34 vpcSubnetIds: string[];
35 /**
36 * Security groups attached to the proxy.
37 */
38 vpcSecurityGroupIds?: string[];
39 /**
40 * Require TLS from clients.
41 */
42 requireTLS?: boolean;
43 /**
44 * Idle client timeout (e.g. `"30 minutes"` or `Duration.minutes(30)`).
45 * Sent to the API in whole seconds.
46 */
47 idleClientTimeout?: Duration.Input;
48 /**
49 * Enable debug logging.
50 */
51 debugLogging?: boolean;
52 /**
53 * Endpoint network type.
54 */
55 endpointNetworkType?: rds.EndpointNetworkType;
56 /**
57 * Target connection network type.
58 */
59 targetConnectionNetworkType?: rds.TargetConnectionNetworkType;
60 /**
61 * User-defined tags.
62 */
63 tags?: Record<string, string>;
64}
65
66export interface DBProxy extends Resource<
67 "AWS.RDS.DBProxy",
68 DBProxyProps,
69 {
70 /**
71 * Name of the proxy.
72 */
73 dbProxyName: string;
74 /**
75 * ARN of the proxy.
76 */
77 dbProxyArn: string;
78 /**
79 * DNS endpoint applications connect to.
80 */
81 endpoint: string | undefined;
82 /**
83 * Status of the proxy (e.g. `available`).
84 */
85 status: string | undefined;
86 /**
87 * Engine family the proxy fronts (`POSTGRESQL`, `MYSQL`).
88 */
89 engineFamily: string | undefined;
90 /**
91 * IAM role the proxy uses to read credentials secrets.
92 */
93 roleArn: string | undefined;
94 /**
95 * VPC the proxy runs in.
96 */
97 vpcId: string | undefined;
98 /**
99 * Subnets the proxy is attached to.
100 */
101 vpcSubnetIds: string[];
102 /**
103 * Security groups attached to the proxy.
104 */
105 vpcSecurityGroupIds: string[];
106 /**
107 * Whether clients must use TLS.
108 */
109 requireTLS: boolean | undefined;
110 /**
111 * Idle client timeout in seconds.
112 */
113 idleClientTimeout: number | undefined;
114 /**
115 * Whether debug logging is enabled.
116 */
117 debugLogging: boolean | undefined;
118 /**
119 * Tags on the proxy.
120 */
121 tags: Record<string, string>;
122 },
123 never,
124 Providers
125> {}
126
127/**
128 * An RDS Proxy for pooled Lambda-to-Aurora connectivity.
129 *
130 * The proxy multiplexes many short-lived function connections over a small
131 * pool of database connections, absorbing connection storms from Lambda
132 * scale-out. It authenticates against the database with credentials read
133 * from Secrets Manager via the provided IAM role, then registers targets
134 * through a `DBProxyTargetGroup`. Changing the name, engine family, or
135 * subnets replaces the proxy; auth, TLS, timeout, and security groups
136 * update in place.
137 *
138 * For the common case, `Aurora("Db", { proxy: true })` wires the role,
139 * proxy, target group, and secret automatically.
140 * ### Creating a Proxy
141 * **Example:** Proxy in Front of an Aurora Cluster
142 * ```typescript
143 * const proxy = yield* DBProxy("Proxy", {
144 * engineFamily: "POSTGRESQL",
145 * auth: [
146 * {
147 * AuthScheme: "SECRETS",
148 * SecretArn: secret.secretArn,
149 * IAMAuth: "DISABLED",
150 * },
151 * ],
152 * roleArn: proxyRole.roleArn,
153 * vpcSubnetIds: [privateSubnetA.subnetId, privateSubnetB.subnetId],
154 * vpcSecurityGroupIds: [dbSecurityGroup.groupId],
155 * requireTLS: true,
156 * });
157 *
158 * // register the cluster behind the proxy
159 * const targets = yield* DBProxyTargetGroup("ProxyTargets", {
160 * dbProxyName: proxy.dbProxyName,
161 * dbClusterIdentifiers: [cluster.dbClusterIdentifier],
162 * });
163 * ```
164 *
165 * @resource
166 */
167export const DBProxy = Resource<DBProxy>("AWS.RDS.DBProxy");
168
169const toAttrs = ({
170 proxy,
171 tags,
172}: {
173 proxy: rds.DBProxy;
174 tags: Record<string, string>;
175}): DBProxy["Attributes"] => ({
176 dbProxyName: proxy.DBProxyName ?? "",
177 dbProxyArn: proxy.DBProxyArn ?? "",
178 endpoint: proxy.Endpoint,
179 status: proxy.Status,
180 engineFamily: proxy.EngineFamily,
181 roleArn: proxy.RoleArn,
182 vpcId: proxy.VpcId,
183 vpcSubnetIds: proxy.VpcSubnetIds ?? [],
184 vpcSecurityGroupIds: proxy.VpcSecurityGroupIds ?? [],
185 requireTLS: proxy.RequireTLS,
186 idleClientTimeout: proxy.IdleClientTimeout,
187 debugLogging: proxy.DebugLogging,
188 tags,
189});
190
191export const DBProxyProvider = () =>
192 Provider.effect(
193 DBProxy,
194 Effect.gen(function* () {
195 const toName = (id: string, props: DBProxyProps) =>
196 props.dbProxyName
197 ? Effect.succeed(props.dbProxyName)
198 : createPhysicalName({ id, maxLength: 63 });
199
200 const readProxy = Effect.fn(function* (name: string) {
201 const response = yield* rds
202 .describeDBProxies({
203 DBProxyName: name,
204 })
205 .pipe(
206 Effect.catchTag("DBProxyNotFoundFault", () =>
207 Effect.succeed(undefined),
208 ),
209 );
210 return response?.DBProxies?.[0];
211 });
212
213 const waitForProxy = Effect.fn(function* (name: string) {
214 const readinessPolicy = Schedule.max([
215 Schedule.fixed("2 seconds"),
216 Schedule.recurs(30),
217 ]);
218 return yield* readProxy(name).pipe(
219 Effect.flatMap((proxy) =>
220 proxy?.DBProxyArn
221 ? Effect.succeed(proxy)
222 : Effect.fail(new Error(`DB proxy '${name}' not ready`)),
223 ),
224 Effect.retry({ schedule: readinessPolicy }),
225 );
226 });
227
228 return {
229 stables: ["dbProxyArn", "dbProxyName"],
230 // Enumerate every DB proxy in the account/region via the paginated
231 // `describeDBProxies`, then hydrate each proxy's tags via
232 // `listTagsForResource` (describe does not return tags inline) so each
233 // element matches `read`'s `Attributes` shape exactly. A proxy deleted
234 // between the describe and the tag fetch surfaces `DBProxyNotFoundFault`
235 // per item and is dropped.
236 list: () =>
237 Effect.gen(function* () {
238 const proxies = yield* rds.describeDBProxies.pages({}).pipe(
239 Stream.runCollect,
240 Effect.map((chunk) =>
241 Array.from(chunk).flatMap((page) => page.DBProxies ?? []),
242 ),
243 );
244 const rows = yield* Effect.forEach(
245 proxies,
246 (proxy) =>
247 Effect.gen(function* () {
248 if (!proxy.DBProxyArn) return undefined;
249 const tagResponse = yield* rds
250 .listTagsForResource({ ResourceName: proxy.DBProxyArn })
251 .pipe(
252 Effect.catchTag("DBProxyNotFoundFault", () =>
253 Effect.succeed(undefined),
254 ),
255 );
256 if (tagResponse === undefined) return undefined;
257 const tags = Object.fromEntries(
258 (tagResponse.TagList ?? [])
259 .filter(
260 (tag): tag is rds.Tag & { Key: string } =>
261 tag.Key != null,
262 )
263 .map((tag) => [tag.Key, tag.Value ?? ""] as const),
264 );
265 return toAttrs({ proxy, tags });
266 }),
267 { concurrency: 10 },
268 );
269 return rows.filter(
270 (row): row is DBProxy["Attributes"] => row !== undefined,
271 );
272 }),
273 diff: Effect.fn(function* ({ id, olds, news }) {
274 if (!isResolved(news)) return undefined;
275 if (
276 (yield* toName(id, olds ?? ({} as DBProxyProps))) !==
277 (yield* toName(id, news))
278 ) {
279 return { action: "replace" } as const;
280 }
281 if (olds?.engineFamily !== news.engineFamily) {
282 return { action: "replace" } as const;
283 }
284 }),
285 read: Effect.fn(function* ({ id, olds, output }) {
286 const name =
287 output?.dbProxyName ??
288 (yield* toName(
289 id,
290 olds ??
291 ({
292 engineFamily: "POSTGRESQL",
293 auth: [],
294 roleArn: "",
295 vpcSubnetIds: [],
296 } as DBProxyProps),
297 ));
298 const proxy = yield* readProxy(name);
299 if (!proxy?.DBProxyArn) {
300 return undefined;
301 }
302 return toAttrs({ proxy, tags: output?.tags ?? {} });
303 }),
304 reconcile: Effect.fn(function* ({ id, news, output, session }) {
305 const name = output?.dbProxyName ?? (yield* toName(id, news));
306 const internalTags = yield* createInternalTags(id);
307 const desiredTags = { ...internalTags, ...news.tags };
308 // Duration prop → the wire unit the RDS API expects (whole seconds).
309 const idleClientTimeoutSeconds = toWireSeconds(
310 news.idleClientTimeout,
311 );
312
313 // Observe — fetch live proxy state.
314 let observed = yield* readProxy(name);
315
316 // Ensure — create if missing. Tolerate
317 // `DBProxyAlreadyExistsFault` as a race with a peer reconciler.
318 if (!observed?.DBProxyArn) {
319 yield* rds
320 .createDBProxy({
321 DBProxyName: name,
322 EngineFamily: news.engineFamily,
323 Auth: news.auth,
324 RoleArn: news.roleArn,
325 VpcSubnetIds: news.vpcSubnetIds,
326 VpcSecurityGroupIds: news.vpcSecurityGroupIds,
327 RequireTLS: news.requireTLS,
328 IdleClientTimeout: idleClientTimeoutSeconds,
329 DebugLogging: news.debugLogging,
330 EndpointNetworkType: news.endpointNetworkType,
331 TargetConnectionNetworkType: news.targetConnectionNetworkType,
332 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
333 Key,
334 Value,
335 })),
336 })
337 .pipe(
338 Effect.catchTag("DBProxyAlreadyExistsFault", () => Effect.void),
339 );
340
341 observed = yield* waitForProxy(name);
342 } else {
343 // Sync mutable proxy config — push desired shape via
344 // `modifyDBProxy`. Rename via `NewDBProxyName` is a no-op when
345 // the desired name already matches the observed name.
346 yield* rds.modifyDBProxy({
347 DBProxyName: name,
348 Auth: news.auth,
349 RoleArn: news.roleArn,
350 SecurityGroups: news.vpcSecurityGroupIds,
351 RequireTLS: news.requireTLS,
352 IdleClientTimeout: idleClientTimeoutSeconds,
353 DebugLogging: news.debugLogging,
354 NewDBProxyName:
355 news.dbProxyName && news.dbProxyName !== name
356 ? news.dbProxyName
357 : undefined,
358 });
359 observed = yield* waitForProxy(news.dbProxyName ?? name);
360 }
361
362 const dbProxyArn = observed.DBProxyArn ?? "";
363 const finalName = observed.DBProxyName ?? name;
364
365 // Sync tags — diff prior recorded tags against desired (describe
366 // does not surface tags inline).
367 const observedTags = output?.tags ?? {};
368 const { removed, upsert } = diffTags(observedTags, desiredTags);
369 if (upsert.length > 0 && dbProxyArn) {
370 yield* rds.addTagsToResource({
371 ResourceName: dbProxyArn,
372 Tags: upsert,
373 });
374 }
375 if (removed.length > 0 && dbProxyArn) {
376 yield* rds.removeTagsFromResource({
377 ResourceName: dbProxyArn,
378 TagKeys: removed,
379 });
380 }
381
382 yield* session.note(dbProxyArn || finalName);
383 return toAttrs({ proxy: observed, tags: desiredTags });
384 }),
385 delete: Effect.fn(function* ({ output }) {
386 yield* rds
387 .deleteDBProxy({
388 DBProxyName: output.dbProxyName,
389 })
390 .pipe(Effect.catchTag("DBProxyNotFoundFault", () => Effect.void));
391 }),
392 };
393 }),
394 );