| 1 | import * as rds from "@distilled.cloud/aws/rds"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { toWireSeconds } from "../../Util/Duration.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import { createInternalTags, diffTags } from "../../Tags.ts"; |
| 13 | |
| 14 | export 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 | |
| 66 | export 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 | */ |
| 167 | export const DBProxy = Resource<DBProxy>("AWS.RDS.DBProxy"); |
| 168 | |
| 169 | const 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 | |
| 191 | export 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 | ); |