1import * as AWS from "@/AWS";
2import { Network } from "@/AWS/EC2/Network";
3import { DBCluster } from "@/AWS/RDS/DBCluster.ts";
4import type { DBClusterProps } from "@/AWS/RDS/DBCluster.ts";
5import { DBSubnetGroup } from "@/AWS/RDS/DBSubnetGroup.ts";
6import * as Provider from "@/Provider";
7import * as Test from "@/Test/Alchemy";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import * as Redacted from "effect/Redacted";
11import * as Result from "effect/Result";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Fast, unconditional `diff` checks for the replacement-set logic. No deploy.
16const callDiff = (olds: DBClusterProps, news: DBClusterProps) =>
17 Effect.gen(function* () {
18 const provider = yield* Provider.findProvider(DBCluster);
19 return yield* provider.diff!({
20 id: "TestCluster",
21 fqn: "TestCluster",
22 instanceId: "test-cluster",
23 olds,
24 news,
25 oldBindings: undefined as never,
26 newBindings: undefined as never,
27 output: undefined,
28 });
29 });
30
31const base: DBClusterProps = {
32 dbClusterIdentifier: "alchemy-rds-cluster-diff",
33 engine: "aurora-postgresql",
34};
35
36test.provider(
37 "diff: backup retention is an in-place update",
38 () =>
39 Effect.gen(function* () {
40 const result = yield* callDiff(
41 { ...base, backupRetentionPeriod: "1 day" },
42 { ...base, backupRetentionPeriod: "7 days" },
43 );
44 expect(result).toBeUndefined();
45 }),
46 { tags: ["provider:aws", "provider:aws:rds", "live"] },
47);
48
49test.provider(
50 "diff: changing databaseName forces replacement",
51 () =>
52 Effect.gen(function* () {
53 const result = yield* callDiff(
54 { ...base, databaseName: "app" },
55 { ...base, databaseName: "other" },
56 );
57 expect(result).toEqual({ action: "replace" });
58 }),
59 { tags: ["provider:aws", "provider:aws:rds", "live"] },
60);
61
62test.provider(
63 "diff: changing kmsKeyId forces replacement",
64 () =>
65 Effect.gen(function* () {
66 const result = yield* callDiff(
67 { ...base, kmsKeyId: "key-a" },
68 { ...base, kmsKeyId: "key-b" },
69 );
70 expect(result).toEqual({ action: "replace" });
71 }),
72 { tags: ["provider:aws", "provider:aws:rds", "live"] },
73);
74
75test.provider(
76 "diff: changing engineMode forces replacement",
77 () =>
78 Effect.gen(function* () {
79 const result = yield* callDiff(
80 { ...base, engineMode: "provisioned" },
81 { ...base, engineMode: "serverless" },
82 );
83 expect(result).toEqual({ action: "replace" });
84 }),
85 { tags: ["provider:aws", "provider:aws:rds", "live"] },
86);
87
88// Render a deploy failure (whatever engine wrapper it arrives in) to a string
89// we can assert AWS's parameter-validation message against.
90const renderFailure = (attempt: Result.Result<unknown, unknown>): string => {
91 if (!Result.isFailure(attempt)) {
92 return "";
93 }
94 const failure = attempt.failure;
95 const json = (() => {
96 try {
97 return JSON.stringify(failure);
98 } catch {
99 return "";
100 }
101 })();
102 return `${String(failure)} ${json}`;
103};
104
105// Live wire probes for this PR's Redacted/Duration prop conversions. Both
106// drive the full engine + provider `reconcile` path into a real
107// `createDBCluster` call that AWS rejects at parameter-validation time, so
108// nothing is ever provisioned and the probe completes in seconds.
109//
110// Probe 1 proves `masterUserPassword: Redacted.Redacted<string>` is
111// serialized to the actual secret characters on the wire — AWS's validator
112// can only reject the password if it saw the real '@'/' ' characters, not a
113// "<redacted>" placeholder.
114//
115// Probe 2 proves `backupRetentionPeriod: Duration.Input` ("60 days") reaches
116// the wire as integer days — AWS can only reject 60 > 35 if the converted
117// number arrived.
118//
119// (The in-range live round-trip — create with "1 day", read back 1, modify to
120// "3 days", read back 3 — is covered by the RDS_TEST_LIFECYCLE-gated test
121// below, which needs ~15+ minutes of Aurora provisioning.)
122test.provider(
123 "wire probe: Redacted password + Duration retention reach createDBCluster",
124 (stack) =>
125 Effect.gen(function* () {
126 yield* stack.destroy();
127
128 const badPassword = yield* Effect.result(
129 stack.deploy(
130 Effect.gen(function* () {
131 return yield* DBCluster("AuditProbeCluster", {
132 dbClusterIdentifier: "alchemy-audit-probe",
133 engine: "aurora-postgresql",
134 masterUsername: "alchemy",
135 // '@' and ' ' are forbidden password characters — AWS rejects
136 // the create before provisioning anything.
137 masterUserPassword: Redacted.make("bad@pass word1"),
138 backupRetentionPeriod: "3 days",
139 });
140 }),
141 ),
142 );
143 expect(Result.isFailure(badPassword)).toBe(true);
144 // Typed tag (patched into distilled rds createDBCluster) + AWS's own
145 // validation text naming the password parameter.
146 expect(renderFailure(badPassword)).toContain("InvalidParameterValue");
147 expect(renderFailure(badPassword)).toContain("MasterUserPassword");
148
149 const badRetention = yield* Effect.result(
150 stack.deploy(
151 Effect.gen(function* () {
152 return yield* DBCluster("AuditProbeCluster", {
153 dbClusterIdentifier: "alchemy-audit-probe",
154 engine: "aurora-postgresql",
155 masterUsername: "alchemy",
156 masterUserPassword: Redacted.make("ValidPassw0rd"),
157 // 60 days is above the 1-35 day API maximum — AWS echoes the
158 // converted integer back in the validation error.
159 backupRetentionPeriod: "60 days",
160 });
161 }),
162 ),
163 );
164 expect(Result.isFailure(badRetention)).toBe(true);
165 expect(renderFailure(badRetention)).toContain("InvalidParameterValue");
166 expect(renderFailure(badRetention)).toMatch(/retention/i);
167
168 yield* stack.destroy();
169 }),
170 { tags: ["provider:aws", "provider:aws:rds", "live"], timeout: 120_000 },
171);
172
173// Read-only `list()` test (no deploy). An Aurora DB cluster takes MANY minutes
174// to create *and* delete — far beyond the 240s test budget — so we exercise the
175// enumeration path without provisioning. We resolve the provider via the typed
176// `Provider.findProvider(DBCluster)` so `list()`'s element type is the exact
177// `DBCluster["Attributes"]` shape, call it, and assert it returns a well-typed
178// array (likely empty in a clean test account). This proves the paginated
179// `describeDBClusters` -> Attributes mapping compiles and runs.
180test.provider(
181 "list returns a typed DBCluster Attributes array",
182 () =>
183 Effect.gen(function* () {
184 const provider = yield* Provider.findProvider(DBCluster);
185 const all = yield* provider.list();
186
187 expect(Array.isArray(all)).toBe(true);
188 for (const cluster of all) {
189 expect(typeof cluster.dbClusterIdentifier).toBe("string");
190 expect(typeof cluster.dbClusterArn).toBe("string");
191 expect(typeof cluster.engine).toBe("string");
192 expect(typeof cluster.tags).toBe("object");
193 expect(Array.isArray(cluster.vpcSecurityGroupIds)).toBe(true);
194 }
195 }),
196 { tags: ["provider:aws", "provider:aws:rds", "live"] },
197);
198
199// Full deploy-based `list()` test, gated behind AWS_TEST_RDS_DBCLUSTER=1.
200// Reason: an Aurora cluster create + delete spans many minutes, blowing past
201// the 240s budget, so it is opt-in only. When enabled, it deploys a real
202// cluster and asserts the deployed identifier appears in the exhaustively
203// paginated `list()` result.
204test.provider.skipIf(!process.env.AWS_TEST_RDS_DBCLUSTER)(
205 "list enumerates the deployed DB cluster",
206 (stack) =>
207 Effect.gen(function* () {
208 yield* stack.destroy();
209
210 const cluster = yield* stack.deploy(
211 Effect.gen(function* () {
212 return yield* DBCluster("ListCluster", {
213 engine: "aurora-postgresql",
214 engineMode: "provisioned",
215 serverlessV2ScalingConfiguration: {
216 MinCapacity: 0.5,
217 MaxCapacity: 1,
218 },
219 manageMasterUserPassword: true,
220 masterUsername: "alchemy",
221 });
222 }),
223 );
224
225 const provider = yield* Provider.findProvider(DBCluster);
226 const all = yield* provider.list();
227
228 expect(
229 all.some((c) => c.dbClusterIdentifier === cluster.dbClusterIdentifier),
230 ).toBe(true);
231
232 yield* stack.destroy();
233 }),
234 { tags: ["provider:aws", "provider:aws:rds", "live"], timeout: 1_800_000 },
235);
236
237// Full cluster lifecycle gated behind RDS_TEST_LIFECYCLE=1. Creates a
238// serverless-v2 Aurora cluster with backup/log/deletion-protection knobs, then
239// does an in-place modify (backup retention, toggle deletionProtection — a
240// regression for the previously-missing `modifyDBCluster` deletion-protection
241// sync — and scaling min/max), asserting no replacement (same ARN).
242test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
243 "cluster: create with knobs, then in-place modify (deletionProtection toggle)",
244 (stack) =>
245 Effect.gen(function* () {
246 yield* stack.destroy();
247
248 // No default VPC/subnets in the testing account — provision a
249 // production-shaped network (VPC + subnets across 2 AZs) + a DB subnet
250 // group for the cluster.
251 const network = Effect.gen(function* () {
252 const net = yield* Network("ClusterNet", { cidrBlock: "10.42.0.0/16" });
253 // No fixed name — let the engine generate a unique physical name so a
254 // leftover group from an interrupted run can't force a cross-VPC
255 // ModifyDBSubnetGroup ("new Subnets are not in the same Vpc").
256 const subnetGroup = yield* DBSubnetGroup("ClusterSubnetGroup", {
257 description: "alchemy cluster lifecycle",
258 subnetIds: net.privateSubnetIds,
259 });
260 return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName };
261 });
262
263 const created = yield* stack.deploy(
264 Effect.gen(function* () {
265 const { dbSubnetGroupName } = yield* network;
266 return yield* DBCluster("LifecycleCluster", {
267 dbClusterIdentifier: "alchemy-rds-lifecycle",
268 engine: "aurora-postgresql",
269 engineMode: "provisioned",
270 dbSubnetGroupName,
271 serverlessV2ScalingConfiguration: {
272 MinCapacity: 0.5,
273 MaxCapacity: 1,
274 },
275 manageMasterUserPassword: true,
276 masterUsername: "alchemy",
277 backupRetentionPeriod: "1 day",
278 enableCloudwatchLogsExports: ["postgresql"],
279 deletionProtection: false,
280 });
281 }),
282 );
283
284 expect(created.backupRetentionPeriod).toBe(1);
285 expect(created.enabledCloudwatchLogsExports).toContain("postgresql");
286 expect(created.deletionProtection).toBe(false);
287
288 const updated = yield* stack.deploy(
289 Effect.gen(function* () {
290 const { dbSubnetGroupName } = yield* network;
291 return yield* DBCluster("LifecycleCluster", {
292 dbClusterIdentifier: "alchemy-rds-lifecycle",
293 engine: "aurora-postgresql",
294 engineMode: "provisioned",
295 dbSubnetGroupName,
296 serverlessV2ScalingConfiguration: {
297 MinCapacity: 1,
298 MaxCapacity: 2,
299 },
300 manageMasterUserPassword: true,
301 masterUsername: "alchemy",
302 backupRetentionPeriod: "3 days",
303 enableCloudwatchLogsExports: ["postgresql"],
304 deletionProtection: true,
305 });
306 }),
307 );
308
309 expect(updated.dbClusterArn).toBe(created.dbClusterArn);
310 expect(updated.backupRetentionPeriod).toBe(3);
311 expect(updated.deletionProtection).toBe(true);
312
313 // Re-disable protection so the trailing destroy can delete the cluster.
314 yield* stack.deploy(
315 Effect.gen(function* () {
316 const { dbSubnetGroupName } = yield* network;
317 return yield* DBCluster("LifecycleCluster", {
318 dbClusterIdentifier: "alchemy-rds-lifecycle",
319 engine: "aurora-postgresql",
320 engineMode: "provisioned",
321 dbSubnetGroupName,
322 serverlessV2ScalingConfiguration: {
323 MinCapacity: 1,
324 MaxCapacity: 2,
325 },
326 manageMasterUserPassword: true,
327 masterUsername: "alchemy",
328 backupRetentionPeriod: "3 days",
329 enableCloudwatchLogsExports: ["postgresql"],
330 deletionProtection: false,
331 });
332 }),
333 );
334
335 yield* stack.destroy();
336 }),
337 {
338 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"],
339 timeout: 2_400_000,
340 },
341);