Skip to content
Source
Sign inSign up

DBParameterGroup.ts

442 lines16.8 KB

1import * as rds from "@distilled.cloud/aws/rds";
2import { deepEqual, isResolved } from "alchemy/Diff";
3import { createPhysicalName } from "alchemy/PhysicalName";
4import * as Provider from "alchemy/Provider";
5import { Resource } from "alchemy/Resource";
6import { createInternalTags, diffTags } from "alchemy/Tags";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import * as Stream from "effect/Stream";
11import type { Providers } from "../Providers.ts";
12​
13export class RDSDBParameterGroupUnexpectedResponse extends Data.TaggedError(
14 "RDSDBParameterGroupUnexpectedResponse",
15)<{
16 readonly message: string;
17 readonly cause?: unknown;
18}> {}
19​
20export interface DBParameterGroupProps {
21 /**
22 * Name of the parameter group. If omitted, Alchemy generates one.
23 */
24 dbParameterGroupName?: string;
25 /**
26 * Parameter group family, for example `aurora-postgresql16`.
27 */
28 family: string;
29 /**
30 * Human-readable description.
31 */
32 description?: string;
33 /**
34 * Instance parameter overrides, e.g. `{ time_zone: "Australia/Sydney" }`.
35 *
36 * This map is the group's entire user-owned surface: entries are written,
37 * and undeclared user overrides are reset to engine defaults. Omitting the
38 * map is equivalent to `{}`, including on adoption and unchanged-input deploys.
39 *
40 * Values must be in the form RDS reports back (it canonicalises some — a
41 * boolean set as `ON` reads back as `1`). Reconciliation waits for those
42 * reported values and fails if they do not converge within the observation budget.
43 *
44 * Static parameters are applied with `pending-reboot`, dynamic parameters
45 * with `immediate`.
46 */
47 parameters?: Record<string, string>;
48 /**
49 * User-defined tags.
50 */
51 tags?: Record<string, string>;
53​
54export interface DBParameterGroup extends Resource<
55 "AWS.RDS.DBParameterGroup",
56 DBParameterGroupProps,
57 {
58 /**
59 * Name of the parameter group.
60 */
61 dbParameterGroupName: string;
62 /**
63 * ARN of the parameter group.
64 */
65 dbParameterGroupArn: string | undefined;
66 /**
67 * Parameter group family (e.g. `aurora-postgresql16`).
68 */
69 family: string;
70 /**
71 * Description of the parameter group.
72 */
73 description: string | undefined;
74 /**
75 * Observed user overrides and explicitly managed engine defaults.
76 * Omitting `parameters` removes all user overrides during reconciliation.
77 * These are group settings; static values still require an instance reboot.
78 */
79 parameters: Record<string, string>;
80 /**
81 * Tags on the parameter group.
82 */
83 tags: Record<string, string>;
84 },
85 never,
86 Providers
87> {}
88​
89/**
90 * An RDS DB parameter group — instance-level engine settings, applied to a
91 * `DBInstance` (as opposed to the cluster-wide `DBClusterParameterGroup`).
92 *
93 * Name, family, and description changes force a replacement (RDS has no
94 * modify API for these); parameters and tags update in place.
95 * ### Creating a Parameter Group
96 * **Example:** Parameter Group for Aurora Postgres 16 Instances
97 * ```typescript
98 * const instanceParams = yield* DBParameterGroup("InstanceParams", {
99 * family: "aurora-postgresql16",
100 * description: "Instance-level settings for the app database",
101 * });
102 * ```
103 *
104 * **Example:** Set Engine Parameters
105 * ```typescript
106 * const params = yield* DBParameterGroup("MysqlParams", {
107 * family: "mysql8.4",
108 * parameters: {
109 * time_zone: "Australia/Sydney",
110 * max_connections: "200",
111 * },
112 * });
113 * ```
114 * Dynamic parameters apply immediately, static ones on the next reboot.
115 * Cluster-wide settings belong on `DBClusterParameterGroup` instead — Postgres
116 * `timezone`, for example, is a cluster parameter on Aurora.
117 *
118 * **Example:** Attach to an Instance
119 * ```typescript
120 * const writer = yield* DBInstance("Writer", {
121 * dbClusterIdentifier: cluster.dbClusterIdentifier,
122 * dbInstanceClass: "db.serverless",
123 * engine: "aurora-postgresql",
124 * dbParameterGroupName: instanceParams.dbParameterGroupName,
125 * });
126 * ```
127 *
128 * @resource
129 */
130export const DBParameterGroup = Resource<DBParameterGroup>("AWS.RDS.DBParameterGroup");
131​
132/**
133 * A parameter modify/reset issued immediately after another change fails with
134 * `InvalidDBParameterGroupStateFault` ("has pending changes") until the prior
135 * change settles — retry it on a short bounded schedule.
136 */
137const retryWhileParameterGroupBusy = <A, E extends { _tag: string }, R>(
138 self: Effect.Effect<A, E, R>,
139): Effect.Effect<A, E, R> =>
140 Effect.retry(self, {
141 while: (e) => e._tag === "InvalidDBParameterGroupStateFault",
142 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(10)]),
143 });
144​
145class DBParameterGroupNotSettled extends Data.TaggedError("DBParameterGroupNotSettled")<{
146 resourceName: string;
147}> {}
148​
149export const DBParameterGroupProvider = () =>
150 Provider.effect(
151 DBParameterGroup,
152 Effect.gen(function* () {
153 const toName = (id: string, props: DBParameterGroupProps) =>
154 props.dbParameterGroupName
155 ? Effect.succeed(props.dbParameterGroupName)
156 : createPhysicalName({ id, maxLength: 255 });
157​
158 const readGroup = Effect.fn(function* (name: string) {
159 const response = yield* rds
160 .describeDBParameterGroups({
161 DBParameterGroupName: name,
162 })
163 .pipe(Effect.catchTag("DBParameterGroupNotFoundFault", () => Effect.undefined));
164 return response?.DBParameterGroups?.[0];
165 });
166​
167 const readTags = Effect.fn(function* (arn: string | undefined) {
168 if (!arn) return {};
169 const response = yield* rds.listTagsForResource({ ResourceName: arn });
170 return Object.fromEntries(
171 (response.TagList ?? []).flatMap(({ Key, Value }) =>
172 Key !== undefined && Value !== undefined ? [[Key, Value]] : [],
173 ),
174 );
175 });
176​
177 // All parameters (defaults + overrides) with their current values and
178 // apply types — the observed baseline for the parameter sync.
179 const readParameters = Effect.fn(function* (name: string) {
180 return yield* rds.describeDBParameters.pages({ DBParameterGroupName: name }).pipe(
181 Stream.runCollect,
182 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Parameters ?? [])),
183 );
184 });
185​
186 // The subset the user has overridden (Source `user`) — used to compute
187 // resets when a prop entry is removed.
188 const readUserParameters = Effect.fn(function* (name: string) {
189 return yield* rds.describeDBParameters
190 .pages({ DBParameterGroupName: name, Source: "user" })
191 .pipe(
192 Stream.runCollect,
193 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.Parameters ?? [])),
194 );
195 });
196​
197 const toManagedParameterRecord = (
198 parameters: rds.Parameter[],
199 desired: Record<string, string> = {},
200 ): Record<string, string> =>
201 Object.fromEntries(
202 parameters.flatMap((p) =>
203 p.ParameterName !== undefined &&
204 p.ParameterValue !== undefined &&
205 (p.Source === "user" || Object.hasOwn(desired, p.ParameterName))
206 ? [[p.ParameterName, p.ParameterValue]]
207 : [],
208 ),
209 );
210​
211 return {
212 stables: ["dbParameterGroupArn", "dbParameterGroupName"],
213 diff: Effect.fn(function* ({ id, olds, news, output }) {
214 if (!isResolved(news)) return undefined;
215 if (
216 (yield* toName(id, olds ?? ({} as DBParameterGroupProps))) !== (yield* toName(id, news))
217 ) {
218 return { action: "replace" } as const;
219 }
220 if (olds?.family !== news.family || olds?.description !== news.description) {
221 return { action: "replace" } as const;
222 }
223 if (output !== undefined) {
224 const group = yield* readGroup(output.dbParameterGroupName);
225 if (!group) return { action: "update", stables: [] } as const;
226 const desired = news.parameters ?? {};
227 const parameters = yield* readParameters(output.dbParameterGroupName).pipe(
228 Effect.catchTag("DBParameterGroupNotFoundFault", () => Effect.undefined),
229 );
230 if (parameters === undefined) {
231 return { action: "update", stables: [] } as const;
232 }
233 const observed = toManagedParameterRecord(parameters, desired);
234 const desiredTags = {
235 ...news.tags,
236 ...(yield* createInternalTags(id)),
237 };
238 if (
239 !deepEqual(desired, observed) ||
240 !deepEqual(desiredTags, yield* readTags(group.DBParameterGroupArn))
241 ) {
242 return { action: "update" } as const;
243 }
244 }
245 }),
246 list: () =>
247 // Collection reads omit per-group tags and parameters; read hydrates them.
248 rds.describeDBParameterGroups.pages({}).pipe(
249 Stream.runCollect,
250 Effect.map((chunk) =>
251 Array.from(chunk).flatMap((page) =>
252 (page.DBParameterGroups ?? [])
253 .filter(
254 (
255 g,
256 ): g is rds.DBParameterGroup & {
257 DBParameterGroupName: string;
258 } =>
259 g.DBParameterGroupName != null &&
260 // AWS-managed `default.*` groups cannot be deleted
261 // (InvalidDBParameterGroupStateFault) — don't enumerate.
262 !g.DBParameterGroupName.startsWith("default."),
263 )
264 .map((g) => ({
265 dbParameterGroupName: g.DBParameterGroupName,
266 dbParameterGroupArn: g.DBParameterGroupArn,
267 family: g.DBParameterGroupFamily ?? "",
268 description: g.Description,
269 // Like tags: a describe per group would be O(groups) calls.
270 parameters: {} as Record<string, string>,
271 tags: {} as Record<string, string>,
272 })),
273 ),
274 ),
275 ),
276 read: Effect.fn(function* ({ id, olds, output }) {
277 const name =
278 output?.dbParameterGroupName ??
279 (yield* toName(id, olds ?? ({ family: "" } as DBParameterGroupProps)));
280 const group = yield* readGroup(name);
281 if (!group?.DBParameterGroupName) {
282 return undefined;
283 }
284 const parameters = toManagedParameterRecord(
285 yield* readParameters(group.DBParameterGroupName),
286 olds?.parameters,
287 );
288 return {
289 dbParameterGroupName: group.DBParameterGroupName,
290 dbParameterGroupArn: group.DBParameterGroupArn,
291 family: group.DBParameterGroupFamily ?? olds?.family ?? "",
292 description: group.Description,
293 parameters,
294 tags: yield* readTags(group.DBParameterGroupArn),
295 };
296 }),
297 reconcile: Effect.fn(function* ({ id, news, output, session }) {
298 const name = output?.dbParameterGroupName ?? (yield* toName(id, news));
299 const internalTags = yield* createInternalTags(id);
300 const desiredTags = { ...news.tags, ...internalTags };
301​
302 // Observe — fetch live parameter-group state.
303 let observed = yield* readGroup(name);
304​
305 // Ensure — create if missing. Tolerate
306 // `DBParameterGroupAlreadyExistsFault` as a race with a peer
307 // reconciler by re-reading.
308 if (!observed?.DBParameterGroupName) {
309 yield* rds
310 .createDBParameterGroup({
311 DBParameterGroupName: name,
312 DBParameterGroupFamily: news.family,
313 Description: news.description ?? `Alchemy parameter group ${name}`,
314 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
315 Key,
316 Value,
317 })),
318 })
319 .pipe(Effect.catchTag("DBParameterGroupAlreadyExistsFault", () => Effect.void));
320 observed = yield* readGroup(name);
321 if (!observed?.DBParameterGroupName) {
322 return yield* new RDSDBParameterGroupUnexpectedResponse({
323 message: `Failed to create DB parameter group '${name}'`,
324 });
325 }
326 }
327​
328 // Omission owns an empty override map, including on adoption.
329 const desiredParameters = news.parameters ?? {};
330 {
331 const byName = new Map(
332 (yield* readParameters(name)).flatMap((p) =>
333 p.ParameterName !== undefined ? [[p.ParameterName, p] as const] : [],
334 ),
335 );
336​
337 const toModify: rds.Parameter[] = Object.entries(desiredParameters).flatMap(
338 ([ParameterName, ParameterValue]) => {
339 const current = byName.get(ParameterName);
340 if (current?.ParameterValue === ParameterValue) return [];
341 return [
342 {
343 ParameterName,
344 ParameterValue,
345 ApplyMethod: current?.ApplyType === "static" ? "pending-reboot" : "immediate",
346 },
347 ];
348 },
349 );
350 if (toModify.length > 0) {
351 // The API caps a single call at 20 parameters.
352 for (let i = 0; i < toModify.length; i += 20) {
353 yield* retryWhileParameterGroupBusy(
354 rds.modifyDBParameterGroup({
355 DBParameterGroupName: name,
356 Parameters: toModify.slice(i, i + 20),
357 }),
358 );
359 }
360 }
361​
362 // Reset user-overridden parameters that were removed from props.
363 const toReset = (yield* readUserParameters(name)).flatMap((p) =>
364 p.ParameterName !== undefined && !(p.ParameterName in desiredParameters)
365 ? [
366 {
367 ParameterName: p.ParameterName,
368 ApplyMethod:
369 p.ApplyType === "static"
370 ? ("pending-reboot" as const)
371 : ("immediate" as const),
372 },
373 ]
374 : [],
375 );
376 if (toReset.length > 0) {
377 for (let i = 0; i < toReset.length; i += 20) {
378 yield* retryWhileParameterGroupBusy(
379 rds.resetDBParameterGroup({
380 DBParameterGroupName: name,
381 ResetAllParameters: false,
382 Parameters: toReset.slice(i, i + 20),
383 }),
384 );
385 }
386 }
387 }
388​
389 const dbParameterGroupArn = observed.DBParameterGroupArn;
390​
391 const observedTags = yield* readTags(dbParameterGroupArn);
392 const { removed, upsert } = diffTags(observedTags, desiredTags);
393 if (upsert.length > 0 && dbParameterGroupArn) {
394 yield* rds.addTagsToResource({
395 ResourceName: dbParameterGroupArn,
396 Tags: upsert,
397 });
398 }
399 if (removed.length > 0 && dbParameterGroupArn) {
400 yield* rds.removeTagsFromResource({
401 ResourceName: dbParameterGroupArn,
402 TagKeys: removed,
403 });
404 }
405​
406 // A successful modify/reset only acknowledges the request. Read back
407 // the managed group values, including absence of undeclared overrides.
408 // Reuse the existing parameter-group consistency budget; retry reads
409 // for a pending observation, never repeat accepted mutations here.
410 const parameters = yield* readParameters(name).pipe(
411 Effect.flatMap((parameters) => {
412 const observed = toManagedParameterRecord(parameters, desiredParameters);
413 return deepEqual(desiredParameters, observed)
414 ? Effect.succeed(observed)
415 : Effect.fail(new DBParameterGroupNotSettled({ resourceName: name }));
416 }),
417 Effect.retry({
418 while: (error) => error._tag === "DBParameterGroupNotSettled",
419 schedule: Schedule.spaced("5 seconds"),
420 times: 8,
421 }),
422 );
423 yield* session.note(dbParameterGroupArn ?? name);
424 return {
425 dbParameterGroupName: observed.DBParameterGroupName,
426 dbParameterGroupArn,
427 family: observed.DBParameterGroupFamily ?? news.family,
428 description: observed.Description,
429 parameters,
430 tags: desiredTags,
431 };
432 }),
433 delete: Effect.fn(function* ({ output }) {
434 yield* rds
435 .deleteDBParameterGroup({
436 DBParameterGroupName: output.dbParameterGroupName,
437 })
438 .pipe(Effect.catchTag("DBParameterGroupNotFoundFault", () => Effect.void));
439 }),
440 };
441 }),
442 );