1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import {
4 normalizePolicyDocument,
5 type PolicyDocument,
6} from "@/AWS/IAM/Policy.ts";
7import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
8import * as ec2 from "@distilled.cloud/aws/ec2";
9import { SecurityGroup } from "@/AWS/EC2/SecurityGroup";
10import { DBParameterGroup } from "@/AWS/RDS/DBParameterGroup";
11import { Network } from "@/AWS/EC2/Network";
12import { DBCluster, DBInstance, type DBInstanceProps } from "@/AWS/RDS";
13import * as Drift from "@/Drift";
14import { State } from "@/State";
15import * as HttpClient from "effect/http/HttpClient";
16import * as HttpClientResponse from "effect/http/HttpClientResponse";
17import { DBSubnetGroup } from "@/AWS/RDS/DBSubnetGroup.ts";
18import * as Provider from "@/Provider";
19import * as Test from "@/Test/Alchemy";
20import * as rds from "@distilled.cloud/aws/rds";
21import { expect } from "alchemy-test";
22import * as Effect from "effect/Effect";
23import * as Duration from "effect/Duration";
24import * as Redacted from "effect/Redacted";
25import * as Result from "effect/Result";
26import * as Schedule from "effect/Schedule";
27
28const { test } = Test.make({ providers: AWS.providers() });
29
30// Render a deploy failure (whatever engine wrapper it arrives in) to a string
31// we can assert AWS's parameter-validation message against.
32const renderFailure = (attempt: Result.Result<unknown, unknown>): string => {
33 if (!Result.isFailure(attempt)) {
34 return "";
35 }
36 const failure = attempt.failure;
37 const json = (() => {
38 try {
39 return JSON.stringify(failure);
40 } catch {
41 return "";
42 }
43 })();
44 return `${String(failure)} ${json}`;
45};
46
47// Live wire probes for this PR's Redacted/Duration prop conversions on
48// DBInstance (the instance reconcile has its own conversion code, separate
49// from DBCluster's). Both drive the full engine + provider `reconcile` path
50// into a real `createDBInstance` call that AWS rejects at
51// parameter-validation time — nothing is provisioned and the probe completes
52// in seconds. Probe 1 proves `masterUserPassword: Redacted.Redacted<string>`
53// serializes to the actual secret characters on the wire; probe 2 proves
54// `backupRetentionPeriod: Duration.Input` ("60 days") arrives as integer days
55// (rejected as > the 35-day maximum). These rejected requests do not verify
56// the RDS_TEST_LIFECYCLE-gated storage lifecycle below.
57test.provider(
58 "wire probe: Redacted password + Duration retention reach createDBInstance",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 const network = Effect.gen(function* () {
64 const net = yield* Network("ProbeNet", { cidrBlock: "10.47.0.0/16" });
65 return yield* DBSubnetGroup("ProbeSubnetGroup", {
66 description: "RDS serialization probe network",
67 subnetIds: net.privateSubnetIds,
68 });
69 });
70 const badPassword = yield* Effect.result(
71 stack.deploy(
72 Effect.gen(function* () {
73 const subnetGroup = yield* network;
74 return yield* DBInstance("AuditProbeInstance", {
75 dbInstanceIdentifier: "alchemy-audit-probe-instance",
76 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
77 engine: "postgres",
78 dbInstanceClass: "db.t3.micro",
79 allocatedStorage: 20,
80 masterUsername: "alchemy",
81 // '@' and ' ' are forbidden password characters — AWS rejects
82 // the create before provisioning anything.
83 masterUserPassword: Redacted.make("bad@pass word1"),
84 backupRetentionPeriod: "3 days",
85 });
86 }),
87 ),
88 );
89 expect(Result.isFailure(badPassword)).toBe(true);
90 expect(renderFailure(badPassword)).toContain("InvalidParameterValue");
91 expect(renderFailure(badPassword)).toContain("MasterUserPassword");
92
93 const badRetention = yield* Effect.result(
94 stack.deploy(
95 Effect.gen(function* () {
96 const subnetGroup = yield* network;
97 return yield* DBInstance("AuditProbeInstance", {
98 dbInstanceIdentifier: "alchemy-audit-probe-instance",
99 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
100 engine: "postgres",
101 dbInstanceClass: "db.t3.micro",
102 allocatedStorage: 20,
103 masterUsername: "alchemy",
104 masterUserPassword: Redacted.make("ValidPassw0rd"),
105 // 60 days is above the 1-35 day API maximum — AWS can only
106 // reject it if the converted integer arrived on the wire.
107 backupRetentionPeriod: "60 days",
108 });
109 }),
110 ),
111 );
112 expect(Result.isFailure(badRetention)).toBe(true);
113 expect(renderFailure(badRetention)).toContain("InvalidParameterValue");
114 expect(renderFailure(badRetention)).toMatch(/retention/i);
115
116 const invalidFlags = yield* rds
117 .describeDBEngineVersions({
118 Engine: "postgres",
119 IncludeAll: true,
120 DefaultOnly: true,
121 })
122 .pipe(Effect.result);
123 expect(Result.isFailure(invalidFlags)).toBe(true);
124 if (Result.isFailure(invalidFlags)) {
125 expect(invalidFlags.failure._tag).toBe("InvalidParameterCombination");
126 }
127 const emptyGroups = yield* stack
128 .deploy(
129 Effect.gen(function* () {
130 const subnetGroup = yield* network;
131 return yield* DBInstance("AuditProbeInstance", {
132 dbInstanceIdentifier: "alchemy-audit-probe-instance",
133 engine: "postgres",
134 dbInstanceClass: "db.t3.micro",
135 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
136 vpcSecurityGroupIds: [],
137 masterUsername: "alchemy",
138 masterUserPassword: Redacted.make("bad@pass word1"),
139 });
140 }),
141 )
142 .pipe(Effect.result);
143 expect(Result.isFailure(emptyGroups)).toBe(true);
144 expect(renderFailure(emptyGroups)).toContain(
145 "InvalidDBInstanceAssociations",
146 );
147
148 yield* stack.destroy();
149 }),
150 {
151 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"],
152 timeout: 120_000,
153 },
154);
155
156// Default (read-only) path: an RDS instance takes many minutes to create and
157// delete — far beyond the 240s test budget — so the canonical `list()` test
158// here does NOT deploy. It resolves the provider via the typed
159// `Provider.findProvider(DBInstance)` helper and calls `list()` directly,
160// asserting it returns a well-typed `DBInstance["Attributes"][]`. On a fresh
161// account this is typically empty; either way every element must conform to
162// the exact `read` shape.
163test.provider(
164 "list returns well-typed DB instance attributes",
165 () =>
166 Effect.gen(function* () {
167 const provider = yield* Provider.findProvider(DBInstance);
168 const all = yield* provider.list();
169
170 expect(Array.isArray(all)).toBe(true);
171
172 // Every element must match the exact `Attributes` shape `read` produces.
173 for (const instance of all) {
174 expect(typeof instance.dbInstanceIdentifier).toBe("string");
175 expect(typeof instance.dbInstanceArn).toBe("string");
176 expect(Array.isArray(instance.dbParameterGroupNames)).toBe(true);
177 expect(typeof instance.tags).toBe("object");
178 }
179 }),
180 { tags: ["provider:aws", "provider:aws:rds", "live"] },
181);
182
183// Full lifecycle is gated: provisioning an Aurora cluster + instance and then
184// tearing it down takes many minutes, exceeding the 240s budget. Set
185// AWS_TEST_RDS_DBINSTANCE=1 on an account that can afford the wait to run it.
186// It deploys a serverless-v2 Aurora cluster + instance and asserts the
187// instance appears in the exhaustively-paginated `list()` result.
188test.provider.skipIf(!process.env.AWS_TEST_RDS_DBINSTANCE)(
189 "list enumerates the deployed DB instance",
190 (stack) =>
191 Effect.gen(function* () {
192 yield* stack.destroy();
193
194 const { client, requests } = yield* observeInstanceRequests;
195 const program = (round: string) =>
196 Effect.gen(function* () {
197 const network = yield* Network("ListNet", {
198 cidrBlock: "10.43.0.0/16",
199 });
200 const subnetGroup = yield* DBSubnetGroup("ListSubnetGroup", {
201 description: "alchemy instance list lifecycle",
202 subnetIds: network.privateSubnetIds,
203 });
204 const clusterGroup = yield* SecurityGroup("ListClusterGroup", {
205 vpcId: network.vpcId,
206 description: "Aurora cluster group",
207 });
208 const ignoredInstanceGroup = yield* SecurityGroup(
209 "ListIgnoredInstanceGroup",
210 { vpcId: network.vpcId, description: "Ignored instance group" },
211 );
212 const cluster = yield* DBCluster("ListCluster", {
213 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
214 vpcSecurityGroupIds: [clusterGroup.groupId],
215 engine: "aurora-postgresql",
216 engineMode: "provisioned",
217 port: 5434,
218 serverlessV2ScalingConfiguration: {
219 MinCapacity: 0.5,
220 MaxCapacity: 1,
221 },
222 manageMasterUserPassword: true,
223 masterUsername: "alchemy",
224 });
225
226 return yield* DBInstance("ListInstance", {
227 dbClusterIdentifier: cluster.dbClusterIdentifier,
228 dbInstanceClass: "db.serverless",
229 engine: "aurora-postgresql",
230 port: 5435,
231 vpcSecurityGroupIds: [ignoredInstanceGroup.groupId],
232 tags: { round },
233 });
234 });
235 const instance = yield* stack
236 .deploy(program("created"))
237 .pipe(Effect.provideService(HttpClient.HttpClient, client));
238 expect(instance.endpointPort).toBe(5434);
239 const updated = yield* stack
240 .deploy(program("updated"))
241 .pipe(Effect.provideService(HttpClient.HttpClient, client));
242 expect(updated.endpointPort).toBe(5434);
243 expect(requests.filter((request) => request.port !== null)).toEqual([]);
244 expect(
245 requests.filter((request) => request.securityGroups.length > 0),
246 ).toEqual([]);
247 const cluster = (yield* rds.describeDBClusters({
248 DBClusterIdentifier: instance.dbClusterIdentifier!,
249 })).DBClusters![0]!;
250 expect([...updated.vpcSecurityGroupIds].sort()).toEqual(
251 cluster
252 .VpcSecurityGroups!.map((group) => group.VpcSecurityGroupId!)
253 .sort(),
254 );
255 const family = (yield* rds.describeDBEngineVersions({
256 Engine: "aurora-postgresql",
257 EngineVersion: cluster.EngineVersion,
258 })).DBEngineVersions![0]!.DBParameterGroupFamily!;
259 expect(updated.dbParameterGroupNames).toEqual([`default.${family}`]);
260 expect(
261 requests.filter(
262 (request) =>
263 request.action === "ModifyDBInstance" &&
264 request.parameterGroup !== null,
265 ),
266 ).toEqual([]);
267 expect(
268 requests.some((request) => request.action === "CreateDBInstance"),
269 ).toBe(true);
270 yield* assertPort(instance.dbInstanceIdentifier, 5434);
271 expect(
272 (yield* stack.plan(program("updated"))).resources.ListInstance,
273 ).toMatchObject({ action: "noop" });
274
275 const provider = yield* Provider.findProvider(DBInstance);
276 const all = yield* provider.list();
277
278 expect(
279 all.some(
280 (i) => i.dbInstanceIdentifier === instance.dbInstanceIdentifier,
281 ),
282 ).toBe(true);
283
284 yield* stack.destroy();
285 }),
286 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
287);
288
289// RDS provisioning and storage optimization exceed the default test budget.
290test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
291 "standalone instance: autoscaling defaults, drift, and allocation floor",
292 (stack) =>
293 Effect.gen(function* () {
294 yield* stack.destroy();
295
296 // The testing account has no default VPC/subnets, so provision a
297 // production-shaped network (VPC + subnets across 2 AZs) and a DB subnet
298 // group for the instance to live in.
299 const network = Effect.gen(function* () {
300 const net = yield* Network("RdsNet", { cidrBlock: "10.41.0.0/16" });
301 // No fixed name — let the engine generate a unique physical name so a
302 // leftover group from an interrupted run can't force a cross-VPC
303 // ModifyDBSubnetGroup ("new Subnets are not in the same Vpc").
304 const subnetGroup = yield* DBSubnetGroup("RdsSubnetGroup", {
305 description: "alchemy standalone instance lifecycle",
306 subnetIds: net.privateSubnetIds,
307 });
308 return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName };
309 });
310
311 const program = (
312 allocatedStorage: number,
313 maxAllocatedStorage?: number,
314 backupRetentionPeriod: "1 day" | "3 days" = "1 day",
315 enablePerformanceInsights = false,
316 ) =>
317 Effect.gen(function* () {
318 const { dbSubnetGroupName } = yield* network;
319 return yield* DBInstance("StandaloneInstance", {
320 engine: "postgres",
321 dbInstanceClass: "db.t3.micro",
322 allocatedStorage,
323 ...(maxAllocatedStorage === undefined
324 ? {}
325 : { maxAllocatedStorage }),
326 storageType: "gp2",
327 masterUsername: "alchemy",
328 manageMasterUserPassword: true,
329 backupRetentionPeriod,
330 enablePerformanceInsights,
331 deletionProtection: false,
332 dbSubnetGroupName,
333 publiclyAccessible: false,
334 });
335 });
336 const created = yield* stack.deploy(program(20));
337 expect(created.allocatedStorage).toBe(20);
338 expect(created.storageType).toBe("gp2");
339 expect(created.backupRetentionPeriod).toBe(1);
340 expect([0, 20]).toContain(created.maxAllocatedStorage ?? 0);
341 const describe = rds.describeDBInstances({
342 DBInstanceIdentifier: created.dbInstanceIdentifier,
343 });
344 expect([0, 20]).toContain(
345 (yield* describe).DBInstances?.[0]?.MaxAllocatedStorage ?? 0,
346 );
347
348 const enabled = yield* stack.deploy(program(20, 40));
349 expect(enabled.maxAllocatedStorage).toBe(40);
350 expect((yield* describe).DBInstances?.[0]?.MaxAllocatedStorage).toBe(40);
351 // Grow beyond the old ceiling while disabling autoscaling in the same request.
352 const updated = yield* stack.deploy(
353 program(50, undefined, "3 days", true),
354 );
355 expect(updated.dbInstanceArn).toBe(created.dbInstanceArn);
356 expect(updated.backupRetentionPeriod).toBe(3);
357 expect(updated.allocatedStorage).toBe(50);
358 expect([0, 50]).toContain(updated.maxAllocatedStorage ?? 0);
359 const grown = (yield* describe).DBInstances?.[0];
360 expect(grown?.AllocatedStorage).toBe(50);
361 expect([0, 50]).toContain(grown?.MaxAllocatedStorage ?? 0);
362 expect(grown?.PendingModifiedValues?.AllocatedStorage).toBeUndefined();
363
364 const explicit = program(20, 100, "3 days", true);
365 const reenabled = yield* stack.deploy(explicit);
366 expect(reenabled.allocatedStorage).toBe(50);
367 expect(reenabled.maxAllocatedStorage).toBe(100);
368 expect((yield* describe).DBInstances?.[0]?.MaxAllocatedStorage).toBe(100);
369
370 const injectCeiling = Effect.fn(function* (ceiling: number) {
371 yield* rds.modifyDBInstance({
372 DBInstanceIdentifier: created.dbInstanceIdentifier,
373 MaxAllocatedStorage: ceiling,
374 ApplyImmediately: true,
375 });
376 const injected = yield* describe.pipe(
377 Effect.repeat({
378 schedule: Schedule.spaced("5 seconds"),
379 times: 8,
380 until: (response) =>
381 response.DBInstances?.[0]?.MaxAllocatedStorage === ceiling,
382 }),
383 );
384 expect(injected.DBInstances?.[0]?.MaxAllocatedStorage).toBe(ceiling);
385 });
386 yield* injectCeiling(120);
387 expect(
388 (yield* stack.plan(explicit)).resources.StandaloneInstance,
389 ).toMatchObject({
390 action: "update",
391 });
392 const repairedExplicit = yield* stack.deploy(explicit);
393 expect(repairedExplicit.maxAllocatedStorage).toBe(100);
394 expect((yield* describe).DBInstances?.[0]?.MaxAllocatedStorage).toBe(100);
395
396 // Lowering the minimum cannot shrink capacity, but omission disables autoscaling.
397 const lowerMinimum = program(20);
398 expect(
399 (yield* stack.plan(lowerMinimum)).resources.StandaloneInstance,
400 ).toMatchObject({
401 action: "update",
402 });
403 const preserved = yield* stack.deploy(lowerMinimum);
404 expect(preserved.dbInstanceArn).toBe(created.dbInstanceArn);
405 expect(preserved.allocatedStorage).toBe(50);
406 expect([0, 50]).toContain(preserved.maxAllocatedStorage ?? 0);
407 const observed = (yield* describe).DBInstances?.[0];
408 expect(observed?.AllocatedStorage).toBe(50);
409 expect([0, 50]).toContain(observed?.MaxAllocatedStorage ?? 0);
410 expect(observed?.PendingModifiedValues?.AllocatedStorage).toBeUndefined();
411
412 yield* injectCeiling(80);
413 const drift = yield* Drift.detect({
414 name: stack.name,
415 stage: stack.stage,
416 });
417 expect(drift.resources.StandaloneInstance?.action).toBe("drifted");
418 expect(
419 (yield* stack.plan(lowerMinimum)).resources.StandaloneInstance,
420 ).toMatchObject({
421 action: "update",
422 });
423 const repaired = yield* stack.deploy(lowerMinimum);
424 expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn);
425 expect(repaired.allocatedStorage).toBe(50);
426 expect([0, 50]).toContain(repaired.maxAllocatedStorage ?? 0);
427 const settled = (yield* describe).DBInstances?.[0];
428 expect([0, 50]).toContain(settled?.MaxAllocatedStorage ?? 0);
429 expect(settled?.AllocatedStorage).toBe(50);
430 expect(
431 (yield* stack.plan(lowerMinimum)).resources.StandaloneInstance,
432 ).toMatchObject({ action: "noop" });
433
434 const disabled = program(20, 0);
435 const explicitZero = yield* stack.deploy(disabled);
436 expect([0, 50]).toContain(explicitZero.maxAllocatedStorage ?? 0);
437 expect(explicitZero.allocatedStorage).toBe(50);
438 expect(
439 (yield* stack.plan(disabled)).resources.StandaloneInstance,
440 ).toMatchObject({ action: "noop" });
441
442 yield* stack.destroy();
443 const gone = yield* describe.pipe(
444 Effect.as(false),
445 Effect.catchTag("DBInstanceNotFoundFault", () => Effect.succeed(true)),
446 Effect.repeat({
447 schedule: Schedule.spaced("5 seconds"),
448 times: 8,
449 until: (absent) => absent,
450 }),
451 );
452 expect(gone).toBe(true);
453 }),
454 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
455);
456
457type StorageProps = Pick<
458 DBInstanceProps,
459 | "allocatedStorage"
460 | "storageType"
461 | "iops"
462 | "storageThroughput"
463 | "maxAllocatedStorage"
464>;
465
466type StorageState = readonly [
467 allocatedStorage: number,
468 storageType: string,
469 iops: number,
470 storageThroughput: number,
471];
472
473const storageProgram = (props: StorageProps) =>
474 Effect.gen(function* () {
475 const network = yield* Network("StorageNet", { cidrBlock: "10.44.0.0/16" });
476 const subnetGroup = yield* DBSubnetGroup("StorageSubnetGroup", {
477 description: "alchemy coupled storage lifecycle",
478 subnetIds: network.privateSubnetIds,
479 });
480 return yield* DBInstance("StorageInstance", {
481 engine: "postgres",
482 dbInstanceClass: "db.t3.micro",
483 masterUsername: "alchemy",
484 masterUserPassword: Redacted.make("StorageCouplingPass123"),
485 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
486 backupRetentionPeriod: "0 days",
487 deletionProtection: false,
488 skipFinalSnapshot: true,
489 publiclyAccessible: false,
490 ...props,
491 });
492 });
493
494const assertStorageState = Effect.fn(function* (
495 identifier: string,
496 expected: StorageState,
497 maximum = 0,
498) {
499 const observed = (yield* rds.describeDBInstances({
500 DBInstanceIdentifier: identifier,
501 })).DBInstances?.[0];
502 expect([
503 observed?.AllocatedStorage,
504 observed?.StorageType,
505 observed?.Iops ?? 0,
506 observed?.StorageThroughput ?? 0,
507 ]).toEqual(expected);
508 if (maximum === 0) {
509 expect([0, expected[0]]).toContain(observed?.MaxAllocatedStorage ?? 0);
510 } else {
511 expect(observed?.MaxAllocatedStorage).toBe(maximum);
512 }
513 expect(observed?.PendingModifiedValues?.AllocatedStorage).toBeUndefined();
514 expect(observed?.PendingModifiedValues?.StorageType).toBeUndefined();
515 expect(observed?.PendingModifiedValues?.Iops).toBeUndefined();
516 expect(observed?.PendingModifiedValues?.StorageThroughput).toBeUndefined();
517});
518
519const assertInstanceGone = Effect.fn(function* (identifier: string) {
520 const gone = yield* rds
521 .describeDBInstances({ DBInstanceIdentifier: identifier })
522 .pipe(
523 Effect.as(false),
524 Effect.catchTag("DBInstanceNotFoundFault", () => Effect.succeed(true)),
525 Effect.repeat({
526 schedule: Schedule.spaced("5 seconds"),
527 times: 8,
528 until: (gone) => gone,
529 }),
530 );
531 expect(gone).toBe(true);
532});
533
534interface StorageCase {
535 name: string;
536 initial: StorageProps;
537 desired: StorageProps;
538 before: StorageState;
539 after: StorageState;
540 equivalent?: StorageProps;
541}
542
543// Each database receives at most one storage modification; optimization can take hours.
544const storageCases: StorageCase[] = [
545 {
546 name: "small gp3 defaults and resize",
547 initial: {},
548 desired: {
549 allocatedStorage: 25,
550 storageType: "gp3",
551 iops: 3000,
552 storageThroughput: 125,
553 },
554 before: [20, "gp3", 3000, 125],
555 after: [25, "gp3", 3000, 125],
556 equivalent: {},
557 },
558 {
559 name: "gp3 resize retains provisioned performance",
560 initial: { allocatedStorage: 400 },
561 desired: { allocatedStorage: 500 },
562 before: [400, "gp3", 12000, 500],
563 after: [500, "gp3", 12000, 500],
564 },
565 {
566 name: "gp3 IOPS update includes allocation",
567 initial: { allocatedStorage: 400 },
568 desired: { allocatedStorage: 400, iops: 16000 },
569 before: [400, "gp3", 12000, 500],
570 after: [400, "gp3", 16000, 500],
571 },
572 {
573 name: "gp3 throughput update includes IOPS",
574 initial: { allocatedStorage: 400 },
575 desired: { allocatedStorage: 400, storageThroughput: 750 },
576 before: [400, "gp3", 12000, 500],
577 after: [400, "gp3", 12000, 750],
578 },
579 {
580 name: "gp3 performance removal restores defaults",
581 initial: { allocatedStorage: 400, iops: 16000, storageThroughput: 750 },
582 desired: { allocatedStorage: 400 },
583 before: [400, "gp3", 16000, 750],
584 after: [400, "gp3", 12000, 500],
585 },
586 {
587 name: "storage type removal restores gp3",
588 initial: { allocatedStorage: 25, storageType: "gp2" },
589 desired: {},
590 before: [25, "gp2", 0, 0],
591 after: [25, "gp3", 3000, 125],
592 },
593 ...(["io1", "io2"] as const).flatMap((storageType): StorageCase[] => [
594 {
595 name: `${storageType} resize includes unchanged IOPS`,
596 initial: { storageType },
597 desired: { storageType, allocatedStorage: 120 },
598 before: [100, storageType, 1000, 0],
599 after: [120, storageType, 1000, 0],
600 },
601 {
602 name: `${storageType} performance and autoscaling removal are atomic`,
603 initial: { storageType, iops: 3000, maxAllocatedStorage: 6000 },
604 desired: { storageType },
605 before: [100, storageType, 3000, 0],
606 after: [100, storageType, 1000, 0],
607 equivalent: { storageType, maxAllocatedStorage: 0 },
608 },
609 ]),
610];
611
612for (const scenario of storageCases) {
613 test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
614 `storage coupling: ${scenario.name}`,
615 (stack) =>
616 Effect.gen(function* () {
617 yield* stack.destroy();
618 const created = yield* stack.deploy(storageProgram(scenario.initial));
619 yield* assertStorageState(
620 created.dbInstanceIdentifier,
621 scenario.before,
622 scenario.initial.maxAllocatedStorage,
623 );
624 const updated = yield* stack.deploy(storageProgram(scenario.desired));
625 expect(updated.dbInstanceArn).toBe(created.dbInstanceArn);
626 yield* assertStorageState(
627 updated.dbInstanceIdentifier,
628 scenario.after,
629 scenario.desired.maxAllocatedStorage,
630 );
631 const finalProps = scenario.equivalent ?? scenario.desired;
632 if (scenario.equivalent) {
633 const equivalent = yield* stack.deploy(storageProgram(finalProps));
634 expect(equivalent.dbInstanceArn).toBe(created.dbInstanceArn);
635 yield* assertStorageState(
636 equivalent.dbInstanceIdentifier,
637 scenario.after,
638 finalProps.maxAllocatedStorage,
639 );
640 }
641 expect(
642 (yield* stack.plan(storageProgram(finalProps))).resources
643 .StorageInstance,
644 ).toMatchObject({ action: "noop" });
645 yield* stack.destroy();
646 yield* assertInstanceGone(created.dbInstanceIdentifier);
647 }),
648 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
649 );
650}
651
652test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
653 "storage coupling: plans correction for external storage drift",
654 (stack) =>
655 Effect.gen(function* () {
656 yield* stack.destroy();
657 const desired = storageProgram({});
658 const created = yield* stack.deploy(desired);
659 yield* assertStorageState(created.dbInstanceIdentifier, [
660 20,
661 "gp3",
662 3000,
663 125,
664 ]);
665 yield* rds.modifyDBInstance({
666 DBInstanceIdentifier: created.dbInstanceIdentifier,
667 StorageType: "gp2",
668 ApplyImmediately: true,
669 });
670 yield* rds
671 .describeDBInstances({
672 DBInstanceIdentifier: created.dbInstanceIdentifier,
673 })
674 .pipe(
675 Effect.repeat({
676 schedule: Schedule.min([
677 Schedule.exponential("5 seconds"),
678 Schedule.spaced("1 minute"),
679 ]),
680 times: 10,
681 until: (response) => {
682 const instance = response.DBInstances?.[0];
683 return (
684 instance?.StorageType === "gp2" &&
685 (instance.Iops ?? 0) === 0 &&
686 (instance.StorageThroughput ?? 0) === 0 &&
687 instance.PendingModifiedValues?.StorageType === undefined
688 );
689 },
690 }),
691 );
692 yield* assertStorageState(created.dbInstanceIdentifier, [
693 20,
694 "gp2",
695 0,
696 0,
697 ]);
698 const drift = yield* Drift.detect({
699 name: stack.name,
700 stage: stack.stage,
701 });
702 expect(drift.resources.StorageInstance?.action).toBe("drifted");
703 expect(
704 (yield* stack.plan(desired)).resources.StorageInstance,
705 ).toMatchObject({ action: "update" });
706 // A second storage modification must wait for AWS's optimization cooldown.
707 yield* stack.destroy();
708 yield* assertInstanceGone(created.dbInstanceIdentifier);
709 }),
710 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
711);
712
713const observeInstanceRequests = Effect.gen(function* () {
714 const client = yield* HttpClient.HttpClient;
715 const requests: Array<{
716 action: string;
717 port: string | null;
718 parameterGroup: string | null;
719 securityGroups: string[];
720 iamAuthentication: string | null;
721 publiclyAccessible: string | null;
722 deletionProtection: string | null;
723 networkType: string | null;
724 applyImmediately: string | null;
725 enabledLogs: string[];
726 disabledLogs: string[];
727 }> = [];
728 const observedClient = client.pipe(
729 HttpClient.tapRequest((request) =>
730 Effect.sync(() => {
731 if (request.body._tag !== "Uint8Array") return;
732 const parameters = new URLSearchParams(
733 new TextDecoder().decode(request.body.body),
734 );
735 const action = parameters.get("Action");
736 if (
737 action === "CreateDBInstance" ||
738 action === "ModifyDBInstance" ||
739 action === "DescribeDBInstances"
740 ) {
741 requests.push({
742 action,
743 iamAuthentication: parameters.get(
744 "EnableIAMDatabaseAuthentication",
745 ),
746 publiclyAccessible: parameters.get("PubliclyAccessible"),
747 deletionProtection: parameters.get("DeletionProtection"),
748 networkType: parameters.get("NetworkType"),
749 applyImmediately: parameters.get("ApplyImmediately"),
750 enabledLogs: [...parameters.entries()]
751 .filter(
752 ([key]) =>
753 key.startsWith("EnableCloudwatchLogsExports.") ||
754 key.startsWith(
755 "CloudwatchLogsExportConfiguration.EnableLogTypes.",
756 ),
757 )
758 .map(([, value]) => value)
759 .sort(),
760 disabledLogs: [...parameters.entries()]
761 .filter(([key]) =>
762 key.startsWith(
763 "CloudwatchLogsExportConfiguration.DisableLogTypes.",
764 ),
765 )
766 .map(([, value]) => value)
767 .sort(),
768 parameterGroup: parameters.get("DBParameterGroupName"),
769 securityGroups: [...parameters.entries()]
770 .filter(([key]) => key.startsWith("VpcSecurityGroupIds."))
771 .map(([, value]) => value)
772 .sort(),
773 port: parameters.get(
774 action === "CreateDBInstance" ? "Port" : "DBPortNumber",
775 ),
776 });
777 }
778 }),
779 ),
780 );
781 return { client: observedClient, requests };
782});
783
784const portProgram = (port?: number, round = "ports", identifier?: string) =>
785 Effect.gen(function* () {
786 const network = yield* Network("PortNet", { cidrBlock: "10.45.0.0/16" });
787 const subnetGroup = yield* DBSubnetGroup("PortSubnetGroup", {
788 description: "alchemy listener port lifecycle",
789 subnetIds: network.privateSubnetIds,
790 });
791 return yield* DBInstance("PortInstance", {
792 dbInstanceIdentifier: identifier,
793 engine: "postgres",
794 dbInstanceClass: "db.t3.micro",
795 masterUsername: "alchemy",
796 manageMasterUserPassword: true,
797 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
798 backupRetentionPeriod: "0 days",
799 deletionProtection: false,
800 skipFinalSnapshot: true,
801 publiclyAccessible: false,
802 ...(port === undefined ? {} : { port }),
803 tags: { round },
804 });
805 });
806
807const assertPort = Effect.fn(function* (identifier: string, port: number) {
808 const instance = (yield* rds.describeDBInstances({
809 DBInstanceIdentifier: identifier,
810 })).DBInstances?.[0];
811 expect(instance?.Endpoint?.Port).toBe(port);
812 expect(instance?.PendingModifiedValues?.Port).toBeUndefined();
813 expect(["available", "storage-optimization"]).toContain(
814 instance?.DBInstanceStatus,
815 );
816 return instance;
817});
818
819const injectPort = Effect.fn(function* (identifier: string, port: number) {
820 yield* rds.modifyDBInstance({
821 DBInstanceIdentifier: identifier,
822 DBPortNumber: port,
823 ApplyImmediately: true,
824 });
825 yield* rds.describeDBInstances({ DBInstanceIdentifier: identifier }).pipe(
826 Effect.repeat({
827 schedule: Schedule.min([
828 Schedule.exponential("5 seconds"),
829 Schedule.spaced("1 minute"),
830 ]),
831 times: 10,
832 until: (response) => {
833 const instance = response.DBInstances?.[0];
834 return (
835 instance?.DBInstanceStatus === "available" &&
836 instance.Endpoint?.Port === port &&
837 instance.PendingModifiedValues?.Port === undefined
838 );
839 },
840 }),
841 );
842 yield* assertPort(identifier, port);
843});
844
845test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
846 "listener port: defaults, updates, removal, and no redundant writes",
847 (stack) =>
848 Effect.gen(function* () {
849 yield* stack.destroy();
850 const { client, requests } = yield* observeInstanceRequests;
851 const deploy = (port?: number, round?: string) =>
852 stack
853 .deploy(portProgram(port, round))
854 .pipe(Effect.provideService(HttpClient.HttpClient, client));
855 const writes = () =>
856 requests
857 .filter(
858 (request) =>
859 request.action === "ModifyDBInstance" && request.port !== null,
860 )
861 .map((request) => request.port);
862 const created = yield* deploy();
863 expect(created.endpointPort).toBe(5432);
864 expect(
865 requests
866 .filter((request) => request.action === "CreateDBInstance")
867 .map((request) => request.port),
868 ).toEqual(["5432"]);
869 expect(
870 (yield* assertPort(created.dbInstanceIdentifier, 5432))?.DbInstancePort,
871 ).toBe(0);
872
873 requests.length = 0;
874 yield* deploy(5432, "same-port");
875 expect(
876 requests.some((request) => request.action === "DescribeDBInstances"),
877 ).toBe(true);
878 expect(writes()).toEqual([]);
879
880 requests.length = 0;
881 const changed = yield* deploy(5433);
882 expect(changed.dbInstanceArn).toBe(created.dbInstanceArn);
883 expect(changed.endpointPort).toBe(5433);
884 expect(writes()).toEqual(["5433"]);
885 yield* assertPort(created.dbInstanceIdentifier, 5433);
886
887 requests.length = 0;
888 yield* deploy(5433, "same-custom-port");
889 expect(writes()).toEqual([]);
890 yield* assertPort(created.dbInstanceIdentifier, 5433);
891
892 requests.length = 0;
893 const restored = yield* deploy();
894 expect(restored.dbInstanceArn).toBe(created.dbInstanceArn);
895 expect(restored.endpointPort).toBe(5432);
896 expect(writes()).toEqual(["5432"]);
897 yield* assertPort(created.dbInstanceIdentifier, 5432);
898 expect(
899 (yield* stack.plan(portProgram())).resources.PortInstance,
900 ).toMatchObject({ action: "noop" });
901 yield* stack.destroy();
902 yield* assertInstanceGone(created.dbInstanceIdentifier);
903 }),
904 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
905);
906
907test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
908 "listener port: unchanged-input drift repair and adoption defaults",
909 (stack) =>
910 Effect.gen(function* () {
911 yield* stack.destroy();
912 const identifier = "alchemy-rds-port-adoption";
913 const program = (port?: number) =>
914 portProgram(port, "adoption", identifier);
915 const created = yield* stack.deploy(program(5433));
916 yield* assertPort(identifier, 5433);
917 yield* injectPort(identifier, 5434);
918 expect(
919 (yield* stack.plan(program(5433))).resources.PortInstance,
920 ).toMatchObject({ action: "update" });
921 const drift = yield* Drift.detect({
922 name: stack.name,
923 stage: stack.stage,
924 });
925 expect(drift.resources.PortInstance?.action).toBe("drifted");
926 const repaired = yield* stack.deploy(program(5433));
927 expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn);
928 expect(repaired.endpointPort).toBe(5433);
929 yield* assertPort(identifier, 5433);
930 expect(
931 (yield* stack.plan(program(5433))).resources.PortInstance,
932 ).toMatchObject({ action: "noop" });
933
934 // Removing the saved row exercises discovery with no previous props or attributes.
935 yield* Effect.gen(function* () {
936 const state = yield* yield* State;
937 yield* state.delete({
938 stack: stack.name,
939 stage: stack.stage,
940 fqn: "PortInstance",
941 });
942 }).pipe(Effect.provide(stack.state));
943 const adopted = yield* stack.deploy(program());
944 expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn);
945 expect(adopted.endpointPort).toBe(5432);
946 yield* assertPort(identifier, 5432);
947 expect(
948 (yield* stack.plan(program())).resources.PortInstance,
949 ).toMatchObject({ action: "noop" });
950
951 yield* injectPort(identifier, 5434);
952 expect(
953 (yield* stack.plan(program())).resources.PortInstance,
954 ).toMatchObject({ action: "update" });
955 const defaultRepaired = yield* stack.deploy(program());
956 expect(defaultRepaired.endpointPort).toBe(5432);
957 yield* assertPort(identifier, 5432);
958 expect(
959 (yield* stack.plan(program())).resources.PortInstance,
960 ).toMatchObject({ action: "noop" });
961 yield* stack.destroy();
962 yield* assertInstanceGone(identifier);
963 }),
964 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
965);
966
967test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
968 "listener port: waits for an accepted change without resubmitting",
969 (stack) =>
970 Effect.gen(function* () {
971 yield* stack.destroy();
972 const created = yield* stack.deploy(portProgram());
973 yield* assertPort(created.dbInstanceIdentifier, 5432);
974 yield* rds.modifyDBInstance({
975 DBInstanceIdentifier: created.dbInstanceIdentifier,
976 DBPortNumber: 5433,
977 ApplyImmediately: false,
978 });
979 const { client, requests } = yield* observeInstanceRequests;
980 const settled = yield* stack
981 .deploy(portProgram(5433))
982 .pipe(Effect.provideService(HttpClient.HttpClient, client));
983 expect(settled.dbInstanceArn).toBe(created.dbInstanceArn);
984 expect(settled.endpointPort).toBe(5433);
985 expect(
986 requests.some((request) => request.action === "DescribeDBInstances"),
987 ).toBe(true);
988 expect(
989 requests.filter(
990 (request) =>
991 request.action === "ModifyDBInstance" && request.port !== null,
992 ),
993 ).toEqual([]);
994 yield* assertPort(created.dbInstanceIdentifier, 5433);
995 expect(
996 (yield* stack.plan(portProgram(5433))).resources.PortInstance,
997 ).toMatchObject({ action: "noop" });
998 yield* stack.destroy();
999 yield* assertInstanceGone(created.dbInstanceIdentifier);
1000 }),
1001 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
1002);
1003
1004type SecurityProps = Pick<
1005 DBInstanceProps,
1006 | "enableIAMDatabaseAuthentication"
1007 | "publiclyAccessible"
1008 | "deletionProtection"
1009 | "networkType"
1010 | "enableCloudwatchLogsExports"
1011>;
1012
1013const securityProgram = (
1014 security: SecurityProps = {},
1015 round = "security",
1016 identifier?: string,
1017 preferredMaintenanceWindow?: string,
1018) =>
1019 Effect.gen(function* () {
1020 const network = yield* Network("SecurityNet", {
1021 cidrBlock: "10.51.0.0/16",
1022 });
1023 const subnetGroup = yield* DBSubnetGroup("SecuritySubnetGroup", {
1024 description: "RDS security desired-state lifecycle",
1025 subnetIds: network.publicSubnetIds,
1026 });
1027 return yield* DBInstance("SecurityInstance", {
1028 dbInstanceIdentifier: identifier,
1029 engine: "postgres",
1030 dbInstanceClass: "db.t3.micro",
1031 masterUsername: "alchemy",
1032 manageMasterUserPassword: true,
1033 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
1034 backupRetentionPeriod: "0 days",
1035 preferredMaintenanceWindow,
1036 skipFinalSnapshot: true,
1037 ...security,
1038 tags: { round },
1039 });
1040 });
1041
1042const assertSecurity = Effect.fn(function* (
1043 identifier: string,
1044 security: SecurityProps = {},
1045) {
1046 const instance = (yield* rds.describeDBInstances({
1047 DBInstanceIdentifier: identifier,
1048 })).DBInstances?.[0];
1049 expect(["available", "storage-optimization"]).toContain(
1050 instance?.DBInstanceStatus,
1051 );
1052 expect(instance?.IAMDatabaseAuthenticationEnabled).toBe(
1053 security.enableIAMDatabaseAuthentication ?? false,
1054 );
1055 expect(
1056 instance?.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled,
1057 ).toBeUndefined();
1058 expect(instance?.PubliclyAccessible).toBe(
1059 security.publiclyAccessible ?? false,
1060 );
1061 expect(instance?.DeletionProtection).toBe(
1062 security.deletionProtection ?? false,
1063 );
1064 expect(instance?.NetworkType).toBe(security.networkType ?? "IPV4");
1065 expect([...(instance?.EnabledCloudwatchLogsExports ?? [])].sort()).toEqual(
1066 [...new Set(security.enableCloudwatchLogsExports ?? [])].sort(),
1067 );
1068 expect(
1069 instance?.PendingModifiedValues?.PendingCloudwatchLogsExports
1070 ?.LogTypesToEnable ?? [],
1071 ).toEqual([]);
1072 expect(
1073 instance?.PendingModifiedValues?.PendingCloudwatchLogsExports
1074 ?.LogTypesToDisable ?? [],
1075 ).toEqual([]);
1076 return instance;
1077});
1078
1079const waitForSecurityObservation = (
1080 identifier: string,
1081 until: (instance: rds.DBInstance) => boolean,
1082) =>
1083 rds.describeDBInstances({ DBInstanceIdentifier: identifier }).pipe(
1084 Effect.map((response) => response.DBInstances?.[0]),
1085 Effect.repeat({
1086 schedule: Schedule.min([
1087 Schedule.exponential("5 seconds"),
1088 Schedule.spaced("1 minute"),
1089 ]),
1090 times: 10,
1091 until: (instance) =>
1092 instance !== undefined &&
1093 ["available", "storage-optimization"].includes(
1094 instance.DBInstanceStatus ?? "",
1095 ) &&
1096 until(instance),
1097 }),
1098 Effect.tap((instance) =>
1099 Effect.sync(() => {
1100 expect(instance).toBeDefined();
1101 expect(["available", "storage-optimization"]).toContain(
1102 instance?.DBInstanceStatus,
1103 );
1104 expect(instance !== undefined && until(instance)).toBe(true);
1105 }),
1106 ),
1107 );
1108
1109const enabledSecurity: SecurityProps = {
1110 enableIAMDatabaseAuthentication: true,
1111 publiclyAccessible: true,
1112 deletionProtection: true,
1113 networkType: "IPV4",
1114 enableCloudwatchLogsExports: ["postgresql", "upgrade"],
1115};
1116
1117test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
1118 "security: defaults, removal, drift, adoption, and no redundant writes",
1119 (stack) =>
1120 Effect.gen(function* () {
1121 yield* stack.destroy();
1122 const identifier = "alchemy-rds-security-defaults";
1123 const { client, requests } = yield* observeInstanceRequests;
1124 const program = (security: SecurityProps = {}, round = "security") =>
1125 securityProgram(security, round, identifier);
1126 const deploy = (security: SecurityProps = {}, round = "security") =>
1127 stack
1128 .deploy(program(security, round))
1129 .pipe(Effect.provideService(HttpClient.HttpClient, client));
1130 const created = yield* deploy();
1131 yield* assertSecurity(identifier);
1132 expect(created.iamDatabaseAuthenticationEnabled).toBe(false);
1133 expect(created.pendingIamDatabaseAuthenticationEnabled).toBeUndefined();
1134 expect(created.publiclyAccessible).toBe(false);
1135 expect(created.deletionProtection).toBe(false);
1136 expect(created.networkType).toBe("IPV4");
1137 expect(created.enabledCloudwatchLogsExports).toEqual([]);
1138 expect(Object.keys(created.vpcSecurityGroupStatuses).sort()).toEqual(
1139 [...created.vpcSecurityGroupIds].sort(),
1140 );
1141 expect(Object.values(created.vpcSecurityGroupStatuses)).toEqual([
1142 "active",
1143 ]);
1144 expect(
1145 requests.filter((request) => request.action === "CreateDBInstance"),
1146 ).toMatchObject([
1147 {
1148 iamAuthentication: "false",
1149 publiclyAccessible: "false",
1150 deletionProtection: "false",
1151 networkType: "IPV4",
1152 enabledLogs: [],
1153 },
1154 ]);
1155
1156 const enabled = yield* deploy(enabledSecurity);
1157 expect(enabled.dbInstanceArn).toBe(created.dbInstanceArn);
1158 expect(enabled.iamDatabaseAuthenticationEnabled).toBe(true);
1159 expect(enabled.pendingIamDatabaseAuthenticationEnabled).toBeUndefined();
1160 yield* assertSecurity(identifier, enabledSecurity);
1161 requests.length = 0;
1162 yield* deploy(
1163 {
1164 ...enabledSecurity,
1165 enableCloudwatchLogsExports: ["upgrade", "postgresql", "postgresql"],
1166 },
1167 "set-equivalent",
1168 );
1169 expect(
1170 requests.filter((request) => request.action === "ModifyDBInstance"),
1171 ).toEqual([]);
1172
1173 requests.length = 0;
1174 const removed = yield* deploy();
1175 expect(removed.dbInstanceArn).toBe(created.dbInstanceArn);
1176 yield* assertSecurity(identifier);
1177 expect(
1178 requests.filter((request) => request.disabledLogs.length > 0),
1179 ).toMatchObject([
1180 {
1181 disabledLogs: ["postgresql", "upgrade"],
1182 applyImmediately: "false",
1183 },
1184 ]);
1185 expect(
1186 (yield* stack.plan(program())).resources.SecurityInstance,
1187 ).toMatchObject({ action: "noop" });
1188
1189 const inject = Effect.gen(function* () {
1190 yield* rds.modifyDBInstance({
1191 DBInstanceIdentifier: identifier,
1192 EnableIAMDatabaseAuthentication: true,
1193 PubliclyAccessible: true,
1194 DeletionProtection: true,
1195 CloudwatchLogsExportConfiguration: {
1196 EnableLogTypes: ["postgresql", "upgrade"],
1197 },
1198 ApplyImmediately: true,
1199 });
1200 yield* waitForSecurityObservation(
1201 identifier,
1202 (instance) =>
1203 instance.IAMDatabaseAuthenticationEnabled === true &&
1204 instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled ===
1205 undefined &&
1206 instance.PubliclyAccessible === true &&
1207 instance.DeletionProtection === true &&
1208 (instance.EnabledCloudwatchLogsExports ?? []).length === 2 &&
1209 (instance.PendingModifiedValues?.PendingCloudwatchLogsExports
1210 ?.LogTypesToEnable?.length ?? 0) === 0 &&
1211 (instance.PendingModifiedValues?.PendingCloudwatchLogsExports
1212 ?.LogTypesToDisable?.length ?? 0) === 0,
1213 );
1214 yield* assertSecurity(identifier, enabledSecurity);
1215 });
1216 yield* inject;
1217 const drift = yield* Drift.detect({
1218 name: stack.name,
1219 stage: stack.stage,
1220 });
1221 expect(drift.resources.SecurityInstance?.action).toBe("drifted");
1222 expect(
1223 (yield* stack.plan(program())).resources.SecurityInstance,
1224 ).toMatchObject({ action: "update" });
1225 const repaired = yield* deploy();
1226 expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn);
1227 yield* assertSecurity(identifier);
1228
1229 yield* inject;
1230 yield* Effect.gen(function* () {
1231 const state = yield* yield* State;
1232 yield* state.delete({
1233 stack: stack.name,
1234 stage: stack.stage,
1235 fqn: "SecurityInstance",
1236 });
1237 }).pipe(Effect.provide(stack.state));
1238 const adopted = yield* deploy();
1239 expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn);
1240 yield* assertSecurity(identifier);
1241 requests.length = 0;
1242 yield* deploy({}, "tag-only");
1243 expect(
1244 requests.filter((request) => request.action === "ModifyDBInstance"),
1245 ).toEqual([]);
1246 expect(
1247 (yield* stack.plan(program({}, "tag-only"))).resources.SecurityInstance,
1248 ).toMatchObject({ action: "noop" });
1249 yield* stack.destroy();
1250 yield* assertInstanceGone(identifier);
1251 }),
1252 {
1253 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"],
1254 timeout: 1_800_000,
1255 },
1256);
1257
1258test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
1259 "security: maintenance-queued IAM convergence and immediate-only queue isolation",
1260 (stack) =>
1261 Effect.gen(function* () {
1262 yield* stack.destroy();
1263 const maintenance = yield* Effect.sync(() => {
1264 const day = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"][
1265 (new Date().getUTCDay() + 2) % 7
1266 ];
1267 return `${day}:03:00-${day}:04:00`;
1268 });
1269 const { client, requests } = yield* observeInstanceRequests;
1270 const program = (security: SecurityProps = {}, round = "security") =>
1271 securityProgram(security, round, undefined, maintenance);
1272 const deploy = (security: SecurityProps = {}, round = "security") =>
1273 stack
1274 .deploy(program(security, round))
1275 .pipe(Effect.provideService(HttpClient.HttpClient, client));
1276 const created = yield* deploy();
1277 const identifier = created.dbInstanceIdentifier;
1278 yield* rds.modifyDBInstance({
1279 DBInstanceIdentifier: identifier,
1280 BackupRetentionPeriod: 1,
1281 ApplyImmediately: false,
1282 });
1283 yield* waitForSecurityObservation(
1284 identifier,
1285 (instance) =>
1286 instance.PendingModifiedValues?.BackupRetentionPeriod === 1,
1287 );
1288 requests.length = 0;
1289 const immediateOnly: SecurityProps = {
1290 publiclyAccessible: true,
1291 deletionProtection: true,
1292 enableCloudwatchLogsExports: ["postgresql"],
1293 };
1294 const changed = yield* deploy(immediateOnly);
1295 expect(changed.dbInstanceArn).toBe(created.dbInstanceArn);
1296 expect(
1297 (yield* assertSecurity(identifier, immediateOnly))
1298 ?.PendingModifiedValues?.BackupRetentionPeriod,
1299 ).toBe(1);
1300 expect(
1301 requests.filter((request) => request.action === "ModifyDBInstance"),
1302 ).toMatchObject([{ applyImmediately: "false", iamAuthentication: null }]);
1303 expect(
1304 (yield* stack.plan(program(immediateOnly))).resources.SecurityInstance,
1305 ).toMatchObject({ action: "noop" });
1306 yield* deploy();
1307 expect(
1308 (yield* assertSecurity(identifier))?.PendingModifiedValues
1309 ?.BackupRetentionPeriod,
1310 ).toBe(1);
1311 // Cancel the test's unrelated queued change before exercising queue promotion.
1312 yield* rds.modifyDBInstance({
1313 DBInstanceIdentifier: identifier,
1314 BackupRetentionPeriod: 0,
1315 ApplyImmediately: true,
1316 });
1317 yield* waitForSecurityObservation(
1318 identifier,
1319 (instance) =>
1320 instance.BackupRetentionPeriod === 0 &&
1321 instance.PendingModifiedValues?.BackupRetentionPeriod === undefined,
1322 );
1323
1324 yield* rds.modifyDBInstance({
1325 DBInstanceIdentifier: identifier,
1326 EnableIAMDatabaseAuthentication: true,
1327 ApplyImmediately: false,
1328 });
1329 yield* waitForSecurityObservation(
1330 identifier,
1331 (instance) =>
1332 instance.IAMDatabaseAuthenticationEnabled === false &&
1333 instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled ===
1334 true,
1335 );
1336 const pendingDrift = yield* Drift.detect({
1337 name: stack.name,
1338 stage: stack.stage,
1339 });
1340 expect(pendingDrift.resources.SecurityInstance).toMatchObject({
1341 action: "drifted",
1342 attr: {
1343 iamDatabaseAuthenticationEnabled: false,
1344 pendingIamDatabaseAuthenticationEnabled: true,
1345 },
1346 });
1347 requests.length = 0;
1348 const desired = { enableIAMDatabaseAuthentication: true };
1349 const promoted = yield* deploy(desired);
1350 expect(promoted.iamDatabaseAuthenticationEnabled).toBe(true);
1351 expect(promoted.pendingIamDatabaseAuthenticationEnabled).toBeUndefined();
1352 yield* assertSecurity(identifier, desired);
1353 // Matching pending IAM needs queue promotion, not another IAM assignment.
1354 expect(
1355 requests.filter((request) => request.action === "ModifyDBInstance"),
1356 ).toMatchObject([{ applyImmediately: "true", iamAuthentication: null }]);
1357
1358 yield* rds.modifyDBInstance({
1359 DBInstanceIdentifier: identifier,
1360 EnableIAMDatabaseAuthentication: false,
1361 ApplyImmediately: false,
1362 });
1363 yield* waitForSecurityObservation(
1364 identifier,
1365 (instance) =>
1366 instance.IAMDatabaseAuthenticationEnabled === true &&
1367 instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled ===
1368 false,
1369 );
1370 expect(
1371 (yield* stack.plan(program(desired))).resources.SecurityInstance,
1372 ).toMatchObject({ action: "update" });
1373 requests.length = 0;
1374 const repaired = yield* deploy(desired);
1375 expect(repaired.dbInstanceArn).toBe(created.dbInstanceArn);
1376 yield* assertSecurity(identifier, desired);
1377 expect(
1378 requests.filter((request) => request.action === "ModifyDBInstance"),
1379 ).toMatchObject([
1380 { applyImmediately: "true", iamAuthentication: "true" },
1381 ]);
1382 requests.length = 0;
1383 yield* deploy(desired, "tag-only");
1384 expect(
1385 requests.filter((request) => request.action === "ModifyDBInstance"),
1386 ).toEqual([]);
1387 yield* rds.modifyDBInstance({
1388 DBInstanceIdentifier: identifier,
1389 EnableIAMDatabaseAuthentication: false,
1390 ApplyImmediately: false,
1391 });
1392 yield* waitForSecurityObservation(
1393 identifier,
1394 (instance) =>
1395 instance.IAMDatabaseAuthenticationEnabled === true &&
1396 instance.PendingModifiedValues?.IAMDatabaseAuthenticationEnabled ===
1397 false,
1398 );
1399 requests.length = 0;
1400 yield* deploy();
1401 yield* assertSecurity(identifier);
1402 expect(
1403 requests.filter((request) => request.action === "ModifyDBInstance"),
1404 ).toMatchObject([{ applyImmediately: "true", iamAuthentication: null }]);
1405 expect(
1406 (yield* stack.plan(program())).resources.SecurityInstance,
1407 ).toMatchObject({ action: "noop" });
1408 yield* stack.destroy();
1409 yield* assertInstanceGone(identifier);
1410 }),
1411 {
1412 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"],
1413 timeout: 1_800_000,
1414 },
1415);
1416
1417test.provider.skipIf(!process.env.AWS_TEST_RDS_DBINSTANCE)(
1418 "security: Aurora cluster ownership survives omitted membership and adoption",
1419 (stack) =>
1420 Effect.gen(function* () {
1421 yield* stack.destroy();
1422 const identifier = "alchemy-rds-security-aurora";
1423 const { client, requests } = yield* observeInstanceRequests;
1424 const program = (membership = true, explicit = true, round = "created") =>
1425 Effect.gen(function* () {
1426 const network = yield* Network("AuroraSecurityNet", {
1427 cidrBlock: "10.52.0.0/16",
1428 });
1429 const subnetGroup = yield* DBSubnetGroup(
1430 "AuroraSecuritySubnetGroup",
1431 {
1432 description: "Aurora instance security ownership",
1433 subnetIds: network.privateSubnetIds,
1434 },
1435 );
1436 const cluster = yield* DBCluster("AuroraSecurityCluster", {
1437 engine: "aurora-postgresql",
1438 engineMode: "provisioned",
1439 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
1440 masterUsername: "alchemy",
1441 manageMasterUserPassword: true,
1442 enableIAMDatabaseAuthentication: true,
1443 enableCloudwatchLogsExports: ["postgresql"],
1444 networkType: "IPV4",
1445 deletionProtection: false,
1446 serverlessV2ScalingConfiguration: {
1447 MinCapacity: 0.5,
1448 MaxCapacity: 1,
1449 },
1450 });
1451 return yield* DBInstance("AuroraSecurityInstance", {
1452 dbInstanceIdentifier: identifier,
1453 dbClusterIdentifier: membership
1454 ? cluster.dbClusterIdentifier
1455 : undefined,
1456 engine: "aurora-postgresql",
1457 dbInstanceClass: "db.serverless",
1458 ...(explicit
1459 ? {
1460 enableIAMDatabaseAuthentication: false,
1461 deletionProtection: true,
1462 networkType: "DUAL",
1463 enableCloudwatchLogsExports: ["not-an-instance-log"],
1464 }
1465 : {}),
1466 tags: { round, cluster: cluster.dbClusterIdentifier },
1467 });
1468 });
1469 const deploy = (membership = true, explicit = true, round = "created") =>
1470 stack
1471 .deploy(program(membership, explicit, round))
1472 .pipe(Effect.provideService(HttpClient.HttpClient, client));
1473 const created = yield* deploy();
1474 expect(created.publiclyAccessible).toBe(false);
1475 const check = Effect.gen(function* () {
1476 const instance = (yield* rds.describeDBInstances({
1477 DBInstanceIdentifier: identifier,
1478 })).DBInstances![0]!;
1479 const cluster = (yield* rds.describeDBClusters({
1480 DBClusterIdentifier: instance.DBClusterIdentifier!,
1481 })).DBClusters![0]!;
1482 expect(cluster.IAMDatabaseAuthenticationEnabled).toBe(true);
1483 expect(cluster.EnabledCloudwatchLogsExports).toEqual(["postgresql"]);
1484 expect(cluster.DeletionProtection).toBe(false);
1485 expect(cluster.NetworkType).toBe("IPV4");
1486 expect(instance.PubliclyAccessible).toBe(false);
1487 expect(
1488 requests
1489 .filter(
1490 (request) =>
1491 request.action === "CreateDBInstance" ||
1492 request.action === "ModifyDBInstance",
1493 )
1494 .every(
1495 (request) =>
1496 request.iamAuthentication === null &&
1497 request.deletionProtection === null &&
1498 request.networkType === null &&
1499 request.enabledLogs.length === 0 &&
1500 request.disabledLogs.length === 0,
1501 ),
1502 ).toBe(true);
1503 return instance;
1504 });
1505 yield* check;
1506 const omitted = yield* deploy(true, false, "omitted");
1507 expect(omitted.dbInstanceArn).toBe(created.dbInstanceArn);
1508 yield* check;
1509 yield* Effect.gen(function* () {
1510 const state = yield* yield* State;
1511 yield* state.delete({
1512 stack: stack.name,
1513 stage: stack.stage,
1514 fqn: "AuroraSecurityInstance",
1515 });
1516 }).pipe(Effect.provide(stack.state));
1517 const adopted = yield* deploy(false, true, "adopted");
1518 expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn);
1519 const observed = yield* check;
1520 expect(adopted.iamDatabaseAuthenticationEnabled).toBe(
1521 observed.IAMDatabaseAuthenticationEnabled,
1522 );
1523 expect(adopted.enabledCloudwatchLogsExports).toEqual(
1524 observed.EnabledCloudwatchLogsExports ?? [],
1525 );
1526 expect(
1527 (yield* stack.plan(program(false, true, "adopted"))).resources
1528 .AuroraSecurityInstance,
1529 ).toMatchObject({ action: "noop" });
1530 yield* stack.destroy();
1531 yield* assertInstanceGone(identifier);
1532 }),
1533 {
1534 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"],
1535 timeout: 1_800_000,
1536 },
1537);
1538
1539const associationProgram = (
1540 options: {
1541 explicit?: boolean;
1542 reverse?: boolean;
1543 version?: string;
1544 identifier?: string;
1545 round?: string;
1546 omitSubnet?: boolean;
1547 } = {},
1548) =>
1549 Effect.gen(function* () {
1550 const network = yield* Network("AssociationNet", {
1551 cidrBlock: "10.46.0.0/16",
1552 });
1553 const subnetGroup = yield* DBSubnetGroup("AssociationSubnetGroup", {
1554 description: "RDS association lifecycle",
1555 subnetIds: network.privateSubnetIds,
1556 });
1557 const groupA = yield* SecurityGroup("AssociationGroupA", {
1558 vpcId: network.vpcId,
1559 description: "RDS association A",
1560 });
1561 const groupB = yield* SecurityGroup("AssociationGroupB", {
1562 vpcId: network.vpcId,
1563 description: "RDS association B",
1564 });
1565 const parameterGroup = yield* DBParameterGroup("AssociationParameters", {
1566 family: "postgres16",
1567 parameters: { max_connections: "120" },
1568 });
1569 const groups = options.reverse
1570 ? [groupB.groupId, groupA.groupId, groupA.groupId]
1571 : [groupA.groupId, groupB.groupId];
1572 const instance = yield* DBInstance("AssociationInstance", {
1573 dbInstanceIdentifier: options.identifier,
1574 engine: "postgres",
1575 engineVersion: options.version,
1576 dbInstanceClass: "db.t3.micro",
1577 masterUsername: "alchemy",
1578 manageMasterUserPassword: true,
1579 ...(options.omitSubnet
1580 ? {}
1581 : { dbSubnetGroupName: subnetGroup.dbSubnetGroupName }),
1582 ...(options.explicit
1583 ? {
1584 dbParameterGroupName: parameterGroup.dbParameterGroupName,
1585 vpcSecurityGroupIds: groups,
1586 }
1587 : {}),
1588 backupRetentionPeriod: "0 days",
1589 deletionProtection: false,
1590 skipFinalSnapshot: true,
1591 publiclyAccessible: false,
1592 // Keep teardown ordered when adoption omits the subnet input.
1593 tags: {
1594 round: options.round ?? "associations",
1595 subnetGroup: subnetGroup.dbSubnetGroupName,
1596 },
1597 });
1598 return {
1599 instance,
1600 parameterGroupName: parameterGroup.dbParameterGroupName,
1601 groupIds: [groupA.groupId, groupB.groupId],
1602 vpcId: network.vpcId,
1603 };
1604 });
1605
1606const defaultGroupForVpc = Effect.fn(function* (vpcId: string) {
1607 const groups = yield* ec2.describeSecurityGroups({
1608 Filters: [
1609 { Name: "vpc-id", Values: [vpcId] },
1610 { Name: "group-name", Values: ["default"] },
1611 ],
1612 });
1613 expect(groups.SecurityGroups).toHaveLength(1);
1614 return groups.SecurityGroups![0]!.GroupId!;
1615});
1616
1617const assertAssociations = Effect.fn(function* (
1618 identifier: string,
1619 parameterGroup: string,
1620 securityGroups: string[],
1621) {
1622 const instance = (yield* rds.describeDBInstances({
1623 DBInstanceIdentifier: identifier,
1624 })).DBInstances?.[0];
1625 expect(["available", "storage-optimization"]).toContain(
1626 instance?.DBInstanceStatus,
1627 );
1628 expect(
1629 instance?.DBParameterGroups?.map((group) => group.DBParameterGroupName),
1630 ).toEqual([parameterGroup]);
1631 expect(["in-sync", "pending-reboot"]).toContain(
1632 instance?.DBParameterGroups?.[0]?.ParameterApplyStatus,
1633 );
1634 expect(
1635 instance?.VpcSecurityGroups?.map(
1636 (group) => group.VpcSecurityGroupId,
1637 ).sort(),
1638 ).toEqual([...securityGroups].sort());
1639 expect(
1640 instance?.VpcSecurityGroups?.every((group) => group.Status === "active"),
1641 ).toBe(true);
1642 return instance;
1643});
1644
1645const injectAssociations = Effect.fn(function* (
1646 identifier: string,
1647 parameterGroup: string,
1648 securityGroups: string[],
1649) {
1650 yield* rds.modifyDBInstance({
1651 DBInstanceIdentifier: identifier,
1652 DBParameterGroupName: parameterGroup,
1653 VpcSecurityGroupIds: securityGroups,
1654 ApplyImmediately: true,
1655 });
1656 yield* rds.describeDBInstances({ DBInstanceIdentifier: identifier }).pipe(
1657 Effect.repeat({
1658 schedule: Schedule.min([
1659 Schedule.exponential("5 seconds"),
1660 Schedule.spaced("1 minute"),
1661 ]),
1662 times: 10,
1663 until: (response) => {
1664 const db = response.DBInstances?.[0];
1665 return (
1666 db?.DBInstanceStatus === "available" &&
1667 db.DBParameterGroups?.[0]?.DBParameterGroupName === parameterGroup &&
1668 ["in-sync", "pending-reboot"].includes(
1669 db.DBParameterGroups?.[0]?.ParameterApplyStatus ?? "",
1670 ) &&
1671 db.VpcSecurityGroups?.every((group) => group.Status === "active") ===
1672 true &&
1673 JSON.stringify(
1674 db.VpcSecurityGroups.map(
1675 (group) => group.VpcSecurityGroupId,
1676 ).sort(),
1677 ) === JSON.stringify([...securityGroups].sort())
1678 );
1679 },
1680 }),
1681 );
1682 yield* assertAssociations(identifier, parameterGroup, securityGroups);
1683});
1684
1685test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
1686 "associations: defaults, explicit attachments, and order-independent no-op writes",
1687 (stack) =>
1688 Effect.gen(function* () {
1689 yield* stack.destroy();
1690 const { client, requests } = yield* observeInstanceRequests;
1691 const options = { version: "16.13" };
1692 const deploy = (extra: Parameters<typeof associationProgram>[0] = {}) =>
1693 stack
1694 .deploy(associationProgram({ ...options, ...extra }))
1695 .pipe(Effect.provideService(HttpClient.HttpClient, client));
1696 const created = yield* deploy();
1697 const defaultGroup = yield* defaultGroupForVpc(created.vpcId);
1698 yield* assertAssociations(
1699 created.instance.dbInstanceIdentifier,
1700 "default.postgres16",
1701 [defaultGroup],
1702 );
1703 expect(created.instance.vpcSecurityGroupIds).toEqual([defaultGroup]);
1704 expect(
1705 requests.filter((request) => request.action === "CreateDBInstance"),
1706 ).toMatchObject([
1707 {
1708 parameterGroup: "default.postgres16",
1709 securityGroups: [defaultGroup],
1710 },
1711 ]);
1712
1713 requests.length = 0;
1714 const attached = yield* deploy({ explicit: true });
1715 expect(attached.instance.dbInstanceArn).toBe(
1716 created.instance.dbInstanceArn,
1717 );
1718 yield* assertAssociations(
1719 created.instance.dbInstanceIdentifier,
1720 created.parameterGroupName,
1721 created.groupIds,
1722 );
1723 expect(
1724 requests.filter(
1725 (request) =>
1726 request.action === "ModifyDBInstance" &&
1727 (request.parameterGroup !== null ||
1728 request.securityGroups.length > 0),
1729 ),
1730 ).toHaveLength(1);
1731 expect(["in-sync", "pending-reboot"]).toContain(
1732 attached.instance.dbParameterGroupApplyStatuses[
1733 created.parameterGroupName
1734 ],
1735 );
1736
1737 requests.length = 0;
1738 yield* deploy({ explicit: true, reverse: true, round: "reordered" });
1739 expect(
1740 requests.some((request) => request.action === "DescribeDBInstances"),
1741 ).toBe(true);
1742 expect(
1743 requests.filter(
1744 (request) =>
1745 request.action === "ModifyDBInstance" &&
1746 (request.parameterGroup !== null ||
1747 request.securityGroups.length > 0),
1748 ),
1749 ).toEqual([]);
1750 yield* assertAssociations(
1751 created.instance.dbInstanceIdentifier,
1752 created.parameterGroupName,
1753 created.groupIds,
1754 );
1755 expect(
1756 (yield* stack.plan(
1757 associationProgram({
1758 ...options,
1759 explicit: true,
1760 reverse: true,
1761 round: "reordered",
1762 }),
1763 )).resources.AssociationInstance,
1764 ).toMatchObject({ action: "noop" });
1765 yield* stack.destroy();
1766 yield* assertInstanceGone(created.instance.dbInstanceIdentifier);
1767 }),
1768 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
1769);
1770
1771test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
1772 "associations: removal, older-engine defaults, drift, and nondefault-VPC adoption",
1773 (stack) =>
1774 Effect.gen(function* () {
1775 yield* stack.destroy();
1776 const identifier = "alchemy-rds-association-defaults";
1777 const program = (extra: Parameters<typeof associationProgram>[0] = {}) =>
1778 associationProgram({ identifier, ...extra });
1779 const created = yield* stack.deploy(
1780 program({ version: "16.13", explicit: true }),
1781 );
1782 const defaultGroup = yield* defaultGroupForVpc(created.vpcId);
1783 yield* assertAssociations(
1784 identifier,
1785 created.parameterGroupName,
1786 created.groupIds,
1787 );
1788
1789 const removed = yield* stack.deploy(program());
1790 expect(removed.instance.dbInstanceArn).toBe(
1791 created.instance.dbInstanceArn,
1792 );
1793 expect(removed.instance.engineVersion).toBe("16.13");
1794 yield* assertAssociations(identifier, "default.postgres16", [
1795 defaultGroup,
1796 ]);
1797 expect(
1798 (yield* ec2.describeSecurityGroups({ GroupIds: created.groupIds }))
1799 .SecurityGroups,
1800 ).toHaveLength(2);
1801 expect(
1802 (yield* rds.describeDBParameterGroups({
1803 DBParameterGroupName: created.parameterGroupName,
1804 })).DBParameterGroups,
1805 ).toHaveLength(1);
1806
1807 yield* injectAssociations(
1808 identifier,
1809 created.parameterGroupName,
1810 created.groupIds,
1811 );
1812 const drift = yield* Drift.detect({
1813 name: stack.name,
1814 stage: stack.stage,
1815 });
1816 expect(drift.resources.AssociationInstance?.action).toBe("drifted");
1817 expect(
1818 (yield* stack.plan(program())).resources.AssociationInstance,
1819 ).toMatchObject({ action: "update" });
1820 yield* stack.deploy(program());
1821 yield* assertAssociations(identifier, "default.postgres16", [
1822 defaultGroup,
1823 ]);
1824 expect(
1825 (yield* stack.plan(program())).resources.AssociationInstance,
1826 ).toMatchObject({ action: "noop" });
1827
1828 yield* injectAssociations(
1829 identifier,
1830 created.parameterGroupName,
1831 created.groupIds,
1832 );
1833 yield* Effect.gen(function* () {
1834 const state = yield* yield* State;
1835 yield* state.delete({
1836 stack: stack.name,
1837 stage: stack.stage,
1838 fqn: "AssociationInstance",
1839 });
1840 }).pipe(Effect.provide(stack.state));
1841 const adopted = yield* stack.deploy(program({ omitSubnet: true }));
1842 expect(adopted.instance.dbInstanceArn).toBe(
1843 created.instance.dbInstanceArn,
1844 );
1845 expect(adopted.instance.engineVersion).toBe("16.13");
1846 yield* assertAssociations(identifier, "default.postgres16", [
1847 defaultGroup,
1848 ]);
1849 expect(
1850 (yield* stack.plan(program({ omitSubnet: true }))).resources
1851 .AssociationInstance,
1852 ).toMatchObject({ action: "noop" });
1853 yield* stack.destroy();
1854 yield* assertInstanceGone(identifier);
1855 }),
1856 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
1857);
1858
1859test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
1860 "managed secret policy: create, update, removal, drift, adoption, and public rejection",
1861 (stack) =>
1862 Effect.gen(function* () {
1863 yield* stack.destroy();
1864 const { accountId } = yield* AWSEnvironment.current;
1865 const policy = (actions: string[]): PolicyDocument => ({
1866 Version: "2012-10-17",
1867 Statement: [
1868 {
1869 Effect: "Allow",
1870 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
1871 Action: actions,
1872 Resource: "*",
1873 },
1874 ],
1875 });
1876 const initialPolicy = policy(["secretsmanager:DescribeSecret"]);
1877 const updatedPolicy = policy([
1878 "secretsmanager:DescribeSecret",
1879 "secretsmanager:GetResourcePolicy",
1880 ]);
1881 const client = yield* HttpClient.HttpClient;
1882 const requests: Array<{
1883 action: string;
1884 blockPublicPolicy: boolean | undefined;
1885 }> = [];
1886 const observedClient = client.pipe(
1887 HttpClient.tapRequest((request) =>
1888 Effect.sync(() => {
1889 const target = request.headers["x-amz-target"];
1890 if (!target?.startsWith("secretsmanager.")) return;
1891 const body =
1892 request.body._tag === "Uint8Array"
1893 ? JSON.parse(new TextDecoder().decode(request.body.body))
1894 : {};
1895 requests.push({
1896 action: target.split(".").at(-1)!,
1897 blockPublicPolicy: body.BlockPublicPolicy,
1898 });
1899 }),
1900 ),
1901 );
1902 const identifier = "alchemy-rds-secret-policy";
1903 const program = (
1904 masterUserSecretResourcePolicy: PolicyDocument | undefined,
1905 round = "default",
1906 ) =>
1907 Effect.gen(function* () {
1908 const net = yield* Network("SecretPolicyNet", {
1909 cidrBlock: "10.49.0.0/16",
1910 });
1911 const subnetGroup = yield* DBSubnetGroup("SecretPolicySubnetGroup", {
1912 description: "RDS managed secret policy lifecycle",
1913 subnetIds: net.privateSubnetIds,
1914 });
1915 return yield* DBInstance("SecretPolicyInstance", {
1916 dbInstanceIdentifier: identifier,
1917 engine: "postgres",
1918 dbInstanceClass: "db.t3.micro",
1919 masterUsername: "alchemy",
1920 manageMasterUserPassword: true,
1921 masterUserSecretResourcePolicy,
1922 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
1923 backupRetentionPeriod: "0 days",
1924 deletionProtection: false,
1925 skipFinalSnapshot: true,
1926 publiclyAccessible: false,
1927 tags: { round },
1928 });
1929 });
1930 const deploy = (desired: PolicyDocument | undefined, round = "default") =>
1931 stack
1932 .deploy(program(desired, round))
1933 .pipe(Effect.provideService(HttpClient.HttpClient, observedClient));
1934 const created = yield* deploy(initialPolicy);
1935 const secretArn = created.masterUserSecretArn;
1936 if (!secretArn)
1937 return yield* Effect.fail(
1938 new Error("RDS did not return its managed secret ARN"),
1939 );
1940 const readPolicy = secretsmanager
1941 .getResourcePolicy({ SecretId: secretArn })
1942 .pipe(
1943 Effect.map((response) =>
1944 response.ResourcePolicy === undefined
1945 ? undefined
1946 : normalizePolicyDocument(response.ResourcePolicy),
1947 ),
1948 );
1949 const waitPolicy = (desired: PolicyDocument | undefined) =>
1950 readPolicy.pipe(
1951 Effect.repeat({
1952 schedule: Schedule.spaced("5 seconds"),
1953 times: 8,
1954 until: (actual) =>
1955 actual ===
1956 (desired === undefined
1957 ? undefined
1958 : normalizePolicyDocument(desired)),
1959 }),
1960 );
1961 const injectPolicy = (desired: PolicyDocument) =>
1962 Effect.gen(function* () {
1963 yield* secretsmanager.putResourcePolicy({
1964 SecretId: secretArn,
1965 ResourcePolicy: JSON.stringify(desired),
1966 BlockPublicPolicy: true,
1967 });
1968 expect(yield* waitPolicy(desired)).toBe(
1969 normalizePolicyDocument(desired),
1970 );
1971 });
1972 const metadata = yield* secretsmanager.describeSecret({
1973 SecretId: secretArn,
1974 });
1975 expect(metadata.OwningService).toBe("rds");
1976 expect(created.masterUserSecretResourcePolicy).toBe(
1977 normalizePolicyDocument(initialPolicy),
1978 );
1979 expect(yield* readPolicy).toBe(normalizePolicyDocument(initialPolicy));
1980 expect(
1981 requests.filter((request) => request.action === "PutResourcePolicy"),
1982 ).toEqual([{ action: "PutResourcePolicy", blockPublicPolicy: true }]);
1983 const beforePlan = requests.length;
1984 expect(
1985 (yield* stack
1986 .plan(program(initialPolicy))
1987 .pipe(Effect.provideService(HttpClient.HttpClient, observedClient)))
1988 .resources.SecretPolicyInstance,
1989 ).toMatchObject({ action: "noop" });
1990 expect(
1991 requests
1992 .slice(beforePlan)
1993 .some((request) => request.action === "ValidateResourcePolicy"),
1994 ).toBe(true);
1995
1996 const beforeNoop = requests.length;
1997 const unchanged = yield* deploy(initialPolicy, "force-reconcile");
1998 expect(unchanged.dbInstanceArn).toBe(created.dbInstanceArn);
1999 expect(
2000 requests
2001 .slice(beforeNoop)
2002 .some((request) => request.action === "ValidateResourcePolicy"),
2003 ).toBe(true);
2004 expect(
2005 requests
2006 .slice(beforeNoop)
2007 .filter((request) =>
2008 ["PutResourcePolicy", "DeleteResourcePolicy"].includes(
2009 request.action,
2010 ),
2011 ),
2012 ).toEqual([]);
2013
2014 const updated = yield* deploy(updatedPolicy);
2015 expect(updated.masterUserSecretArn).toBe(secretArn);
2016 expect(updated.masterUserSecretResourcePolicy).toBe(
2017 normalizePolicyDocument(updatedPolicy),
2018 );
2019 expect(yield* readPolicy).toBe(normalizePolicyDocument(updatedPolicy));
2020
2021 yield* secretsmanager.deleteResourcePolicy({ SecretId: secretArn });
2022 expect(yield* waitPolicy(undefined)).toBeUndefined();
2023 expect(
2024 (yield* stack.plan(program(updatedPolicy))).resources
2025 .SecretPolicyInstance,
2026 ).toMatchObject({ action: "update" });
2027 yield* deploy(updatedPolicy);
2028 expect(yield* readPolicy).toBe(normalizePolicyDocument(updatedPolicy));
2029
2030 const removed = yield* deploy(undefined);
2031 expect(removed.masterUserSecretArn).toBe(secretArn);
2032 expect(removed.masterUserSecretResourcePolicy).toBeUndefined();
2033 expect(yield* readPolicy).toBeUndefined();
2034 expect(
2035 (yield* stack.plan(program(undefined))).resources.SecretPolicyInstance,
2036 ).toMatchObject({ action: "noop" });
2037
2038 yield* injectPolicy(initialPolicy);
2039 const drift = yield* Drift.detect({
2040 name: stack.name,
2041 stage: stack.stage,
2042 });
2043 expect(drift.resources.SecretPolicyInstance?.action).toBe("drifted");
2044 expect(
2045 (yield* stack.plan(program(undefined))).resources.SecretPolicyInstance,
2046 ).toMatchObject({ action: "update" });
2047 yield* deploy(undefined);
2048 expect(yield* readPolicy).toBeUndefined();
2049
2050 yield* injectPolicy(updatedPolicy);
2051 yield* Effect.gen(function* () {
2052 const state = yield* yield* State;
2053 yield* state.delete({
2054 stack: stack.name,
2055 stage: stack.stage,
2056 fqn: "SecretPolicyInstance",
2057 });
2058 }).pipe(Effect.provide(stack.state));
2059 const adopted = yield* deploy(undefined);
2060 expect(adopted.dbInstanceArn).toBe(created.dbInstanceArn);
2061 expect(adopted.masterUserSecretArn).toBe(secretArn);
2062 expect(yield* readPolicy).toBeUndefined();
2063
2064 const publicPolicy: PolicyDocument = {
2065 Version: "2012-10-17",
2066 Statement: [
2067 {
2068 Effect: "Allow",
2069 Principal: { AWS: "*" },
2070 Action: ["secretsmanager:GetSecretValue"],
2071 Resource: "*",
2072 },
2073 ],
2074 };
2075 const rejected = yield* deploy(publicPolicy).pipe(Effect.result);
2076 expect(Result.isFailure(rejected)).toBe(true);
2077 expect(renderFailure(rejected)).toContain(
2078 "InvalidDBInstanceSecretPolicy",
2079 );
2080 expect(yield* readPolicy).toBeUndefined();
2081 yield* deploy(undefined);
2082 const finalMetadata = yield* secretsmanager.describeSecret({
2083 SecretId: secretArn,
2084 });
2085 expect(finalMetadata.ARN).toBe(metadata.ARN);
2086 expect(finalMetadata.RotationEnabled).toBe(metadata.RotationEnabled);
2087 expect(finalMetadata.VersionIdsToStages).toEqual(
2088 metadata.VersionIdsToStages,
2089 );
2090 expect(
2091 requests.some((request) =>
2092 [
2093 "GetSecretValue",
2094 "BatchGetSecretValue",
2095 "PutSecretValue",
2096 "UpdateSecret",
2097 "RotateSecret",
2098 "DeleteSecret",
2099 ].includes(request.action),
2100 ),
2101 ).toBe(false);
2102 expect(
2103 requests
2104 .filter((request) => request.action === "PutResourcePolicy")
2105 .every((request) => request.blockPublicPolicy),
2106 ).toBe(true);
2107 expect(
2108 (yield* stack.plan(program(undefined))).resources.SecretPolicyInstance,
2109 ).toMatchObject({ action: "noop" });
2110 yield* stack.destroy();
2111 yield* assertInstanceGone(identifier);
2112 expect(
2113 yield* secretsmanager.describeSecret({ SecretId: secretArn }).pipe(
2114 Effect.as(false),
2115 Effect.catchTag("ResourceNotFoundException", () =>
2116 Effect.succeed(true),
2117 ),
2118 Effect.repeat({
2119 schedule: Schedule.spaced("5 seconds"),
2120 times: 8,
2121 until: (gone) => gone,
2122 }),
2123 ),
2124 ).toBe(true);
2125 }),
2126 {
2127 tags: [
2128 "provider:aws",
2129 "provider:aws:ec2",
2130 "provider:aws:iam",
2131 "provider:aws:rds",
2132 "provider:aws:secretsmanager",
2133 "live",
2134 ],
2135 },
2136);
2137
2138test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
2139 "managed secret policy: enable management on an existing instance",
2140 (stack) =>
2141 Effect.gen(function* () {
2142 yield* stack.destroy();
2143 const { accountId } = yield* AWSEnvironment.current;
2144 const policy: PolicyDocument = {
2145 Version: "2012-10-17",
2146 Statement: [
2147 {
2148 Effect: "Allow",
2149 Principal: { AWS: `arn:aws:iam::${accountId}:root` },
2150 Action: ["secretsmanager:DescribeSecret"],
2151 Resource: "*",
2152 },
2153 ],
2154 };
2155 const program = (managed: boolean, resourcePolicy?: PolicyDocument) =>
2156 Effect.gen(function* () {
2157 const net = yield* Network("EnableSecretNet", {
2158 cidrBlock: "10.50.0.0/16",
2159 });
2160 const subnetGroup = yield* DBSubnetGroup("EnableSecretSubnetGroup", {
2161 description: "Enable RDS managed credentials",
2162 subnetIds: net.privateSubnetIds,
2163 });
2164 return yield* DBInstance("EnableSecretInstance", {
2165 engine: "postgres",
2166 dbInstanceClass: "db.t3.micro",
2167 masterUsername: "alchemy",
2168 masterUserPassword: managed
2169 ? undefined
2170 : Redacted.make("Alchemy-secret-policy-42!"),
2171 manageMasterUserPassword: managed,
2172 masterUserSecretResourcePolicy: resourcePolicy,
2173 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
2174 backupRetentionPeriod: "0 days",
2175 deletionProtection: false,
2176 skipFinalSnapshot: true,
2177 publiclyAccessible: false,
2178 });
2179 });
2180 const invalid = yield* stack
2181 .deploy(program(false, policy))
2182 .pipe(Effect.result);
2183 expect(renderFailure(invalid)).toContain("InvalidDBInstanceSecretPolicy");
2184 const created = yield* stack.deploy(program(false));
2185 expect(created.masterUserSecretArn).toBeUndefined();
2186 expect(created.masterUserSecretResourcePolicy).toBeUndefined();
2187 const enabled = yield* stack.deploy(program(true, policy));
2188 expect(enabled.dbInstanceArn).toBe(created.dbInstanceArn);
2189 const secretArn = enabled.masterUserSecretArn;
2190 if (!secretArn)
2191 return yield* Effect.fail(
2192 new Error("RDS did not enable its managed secret"),
2193 );
2194 expect(enabled.masterUserSecretResourcePolicy).toBe(
2195 normalizePolicyDocument(policy),
2196 );
2197 expect(
2198 normalizePolicyDocument(
2199 (yield* secretsmanager.getResourcePolicy({ SecretId: secretArn }))
2200 .ResourcePolicy!,
2201 ),
2202 ).toBe(normalizePolicyDocument(policy));
2203 const removed = yield* stack.deploy(program(true));
2204 expect(removed.masterUserSecretArn).toBe(secretArn);
2205 expect(
2206 (yield* secretsmanager.getResourcePolicy({ SecretId: secretArn }))
2207 .ResourcePolicy,
2208 ).toBeUndefined();
2209 expect(
2210 (yield* stack.plan(program(true))).resources.EnableSecretInstance,
2211 ).toMatchObject({ action: "noop" });
2212 yield* stack.destroy();
2213 yield* assertInstanceGone(created.dbInstanceIdentifier);
2214 }),
2215 {
2216 tags: [
2217 "provider:aws",
2218 "provider:aws:ec2",
2219 "provider:aws:iam",
2220 "provider:aws:rds",
2221 "provider:aws:secretsmanager",
2222 "live",
2223 ],
2224 },
2225);
2226
2227test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
2228 "readiness: pending creation, blocked stop, and restart recovery",
2229 (stack) =>
2230 Effect.gen(function* () {
2231 yield* stack.destroy();
2232 const client = yield* HttpClient.HttpClient;
2233 const observations: Array<{ action: string; statuses: string[] }> = [];
2234 const observedClient = client.pipe(
2235 HttpClient.transformResponse(
2236 Effect.flatMap((response) =>
2237 Effect.gen(function* () {
2238 const body = response.request.body;
2239 if (body._tag !== "Uint8Array") return response;
2240 const action = yield* Effect.sync(() =>
2241 new URLSearchParams(new TextDecoder().decode(body.body)).get(
2242 "Action",
2243 ),
2244 );
2245 if (action !== "DescribeDBInstances") return response;
2246 const bytes = yield* response.arrayBuffer;
2247 return yield* Effect.sync(() => {
2248 const text = new TextDecoder().decode(bytes);
2249 observations.push({
2250 action,
2251 statuses: [
2252 ...text.matchAll(
2253 /<DBInstanceStatus>([^<]+)<\/DBInstanceStatus>/g,
2254 ),
2255 ].map((match) => match[1]!),
2256 });
2257 // Forward the real response bytes unchanged to the SDK parser.
2258 return HttpClientResponse.fromWeb(
2259 response.request,
2260 new Response(bytes, {
2261 status: response.status,
2262 headers: response.headers,
2263 }),
2264 );
2265 });
2266 }),
2267 ),
2268 ),
2269 );
2270 const program = Effect.gen(function* () {
2271 const network = yield* Network("ReadinessNet", {
2272 cidrBlock: "10.48.0.0/16",
2273 });
2274 const subnetGroup = yield* DBSubnetGroup("ReadinessSubnetGroup", {
2275 description: "RDS readiness lifecycle",
2276 subnetIds: network.privateSubnetIds,
2277 });
2278 return yield* DBInstance("ReadinessInstance", {
2279 engine: "postgres",
2280 dbInstanceClass: "db.t3.micro",
2281 dbSubnetGroupName: subnetGroup.dbSubnetGroupName,
2282 masterUsername: "alchemy",
2283 manageMasterUserPassword: true,
2284 backupRetentionPeriod: "0 days",
2285 deletionProtection: false,
2286 skipFinalSnapshot: true,
2287 publiclyAccessible: false,
2288 });
2289 });
2290 const deploy = stack
2291 .deploy(program)
2292 .pipe(Effect.provideService(HttpClient.HttpClient, observedClient));
2293 const created = yield* deploy;
2294 const statuses = () =>
2295 observations.flatMap((observation) => observation.statuses);
2296 expect(statuses()).toContain("creating");
2297 expect(statuses().at(-1)).toBe("available");
2298 expect(created.status).toBe("available");
2299 const identifier = created.dbInstanceIdentifier;
2300 const describe = rds.describeDBInstances({
2301 DBInstanceIdentifier: identifier,
2302 });
2303 const waitForStatus = (status: string) =>
2304 describe.pipe(
2305 Effect.repeat({
2306 schedule: Schedule.min([
2307 Schedule.exponential("5 seconds"),
2308 Schedule.spaced("1 minute"),
2309 ]),
2310 times: 10,
2311 until: (response) =>
2312 response.DBInstances?.[0]?.DBInstanceStatus === status,
2313 }),
2314 );
2315 expect((yield* describe).DBInstances?.[0]?.DBInstanceStatus).toBe(
2316 "available",
2317 );
2318
2319 yield* rds.stopDBInstance({ DBInstanceIdentifier: identifier });
2320 const stopped = yield* waitForStatus("stopped");
2321 expect(stopped.DBInstances?.[0]?.DBInstanceStatus).toBe("stopped");
2322 expect(
2323 (yield* stack.plan(program)).resources.ReadinessInstance,
2324 ).toMatchObject({ action: "update" });
2325 const [elapsed, blocked] = yield* deploy.pipe(
2326 Effect.result,
2327 Effect.timed,
2328 );
2329 expect(Result.isFailure(blocked)).toBe(true);
2330 expect(renderFailure(blocked)).toContain("DBInstanceReadinessBlocked");
2331 expect(renderFailure(blocked)).toContain("stopped");
2332 expect(Duration.toMillis(elapsed)).toBeLessThan(60_000);
2333
2334 yield* rds.startDBInstance({ DBInstanceIdentifier: identifier });
2335 const starting = yield* describe.pipe(
2336 Effect.repeat({
2337 schedule: Schedule.spaced("5 seconds"),
2338 times: 8,
2339 until: (response) =>
2340 ["starting", "available"].includes(
2341 response.DBInstances?.[0]?.DBInstanceStatus ?? "",
2342 ),
2343 }),
2344 );
2345 expect(["starting", "available"]).toContain(
2346 starting.DBInstances?.[0]?.DBInstanceStatus,
2347 );
2348 observations.length = 0;
2349 const started = yield* deploy;
2350 expect(statuses().at(-1)).toBe("available");
2351 expect(started.dbInstanceArn).toBe(created.dbInstanceArn);
2352 expect(started.status).toBe("available");
2353 expect((yield* describe).DBInstances?.[0]?.DBInstanceStatus).toBe(
2354 "available",
2355 );
2356 expect(
2357 (yield* stack.plan(program)).resources.ReadinessInstance,
2358 ).toMatchObject({ action: "noop" });
2359 yield* stack.destroy();
2360 yield* assertInstanceGone(identifier);
2361 }),
2362 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
2363);
2364
2365// Fingerprint-guarded master password lifecycle (#876), gated behind
2366// RDS_TEST_LIFECYCLE=1 (real db.t3.micro, ~15-25 min).
2367//
2368// AWS never returns the master password, so the provider fingerprints the
2369// configured value (identifier-salted sha256, persisted `Redacted`) and only
2370// sends `MasterUserPassword` on modify when the fingerprint changed. RDS
2371// durably records a "Reset master credentials" event whenever a password
2372// modify actually applies, which makes the guard observable out-of-band:
2373//
2374// 1. create with password P1 — set at create time, no reset event
2375// 2. redeploy P1 with a tag change (forces reconcile) — fingerprint stable
2376// 3. redeploy P2 — fingerprint changes; RDS records the credentials reset
2377//
2378// After step 3's event is observed, the reset-event count over the whole run
2379// must be exactly 1 — the anchored positive event proves step 2's reconcile
2380// did not re-send the unchanged password (pre-#876 every reconcile did,
2381// putting the instance through a live `resetting-master-credentials` cycle).
2382test.provider.skipIf(!process.env.RDS_TEST_LIFECYCLE)(
2383 "master password: fingerprint guard skips unchanged, applies rotation",
2384 (stack) =>
2385 Effect.gen(function* () {
2386 yield* stack.destroy();
2387
2388 const identifier = "alchemy-rds-fingerprint";
2389 const startedAt = yield* Effect.sync(() => new Date());
2390
2391 // The testing account has no default VPC/subnets — provision a network
2392 // and DB subnet group like the standalone lifecycle test above.
2393 const network = Effect.gen(function* () {
2394 const net = yield* Network("FingerprintNet", {
2395 cidrBlock: "10.42.0.0/16",
2396 });
2397 const subnetGroup = yield* DBSubnetGroup("FingerprintSubnetGroup", {
2398 description: "alchemy master-password fingerprint lifecycle",
2399 subnetIds: net.privateSubnetIds,
2400 });
2401 return { dbSubnetGroupName: subnetGroup.dbSubnetGroupName };
2402 });
2403
2404 const deployInstance = (password: string, round: string) =>
2405 stack.deploy(
2406 Effect.gen(function* () {
2407 const { dbSubnetGroupName } = yield* network;
2408 return yield* DBInstance("FingerprintInstance", {
2409 dbInstanceIdentifier: identifier,
2410 engine: "postgres",
2411 dbInstanceClass: "db.t3.micro",
2412 allocatedStorage: 20,
2413 masterUsername: "alchemy",
2414 masterUserPassword: Redacted.make(password),
2415 deletionProtection: false,
2416 dbSubnetGroupName,
2417 publiclyAccessible: false,
2418 // A changed tag guarantees the engine sees a props diff and
2419 // runs `reconcile` — the exact path that used to re-send the
2420 // unchanged password.
2421 tags: { round },
2422 });
2423 }),
2424 );
2425
2426 const resetEvents = rds
2427 .describeEvents({
2428 SourceIdentifier: identifier,
2429 SourceType: "db-instance",
2430 StartTime: startedAt,
2431 })
2432 .pipe(
2433 Effect.map((response) =>
2434 (response.Events ?? []).filter((event) =>
2435 /reset master credentials/i.test(event.Message ?? ""),
2436 ),
2437 ),
2438 );
2439
2440 const created = yield* deployInstance("FingerprintPass1", "one");
2441 const createdFingerprint = created.masterUserPasswordFingerprint;
2442 expect(createdFingerprint).toBeDefined();
2443 // sha256 hex digest — never the password itself.
2444 expect(Redacted.value(createdFingerprint!)).toMatch(/^[0-9a-f]{64}$/);
2445
2446 // Same password, tag-only change → reconcile runs but must skip the
2447 // `MasterUserPassword` modify (same fingerprint).
2448 const unchanged = yield* deployInstance("FingerprintPass1", "two");
2449 expect(unchanged.dbInstanceArn).toBe(created.dbInstanceArn);
2450 expect(Redacted.value(unchanged.masterUserPasswordFingerprint!)).toBe(
2451 Redacted.value(createdFingerprint!),
2452 );
2453
2454 // Rotation: new password → new fingerprint, and RDS applies a real
2455 // master-credentials reset.
2456 const rotated = yield* deployInstance("FingerprintPass2", "three");
2457 expect(rotated.dbInstanceArn).toBe(created.dbInstanceArn);
2458 expect(Redacted.value(rotated.masterUserPasswordFingerprint!)).not.toBe(
2459 Redacted.value(createdFingerprint!),
2460 );
2461
2462 // The reset event lands when the modify applies; poll bounded for it,
2463 // then assert the count over the whole run is exactly 1 — proving
2464 // round "two" (unchanged password) never triggered a reset.
2465 const events = yield* resetEvents.pipe(
2466 Effect.repeat({
2467 schedule: Schedule.min([
2468 Schedule.exponential("5 seconds"),
2469 Schedule.spaced("1 minute"),
2470 ]),
2471 until: (found) => found.length > 0,
2472 times: 10,
2473 }),
2474 );
2475 expect(events).toHaveLength(1);
2476
2477 yield* stack.destroy();
2478 }),
2479 { tags: ["provider:aws", "provider:aws:ec2", "provider:aws:rds", "live"] },
2480);