1import * as AWS from "@/AWS";
2import type { SubnetId } from "@/AWS/EC2/Subnet.ts";
3import { Cluster, ClusterSubnetGroup } from "@/AWS/Redshift";
4import * as Test from "@/Test/Alchemy";
5import * as EC2 from "@distilled.cloud/aws/ec2";
6import * as redshift from "@distilled.cloud/aws/redshift";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import * as Schedule from "effect/Schedule";
11import { getDefaultVpc } from "../DefaultVpc.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Ungated typed-error probe: proves the distilled error union carries the
16// not-found tag this provider's read/delete paths depend on.
17test.provider(
18 "describeClusters on a nonexistent cluster fails with ClusterNotFoundFault",
19 () =>
20 Effect.gen(function* () {
21 const error = yield* Effect.flip(
22 redshift.describeClusters({
23 ClusterIdentifier: "alchemy-nonexistent-redshift-probe",
24 }),
25 );
26 expect(error._tag).toBe("ClusterNotFoundFault");
27 }),
28 { tags: ["provider:aws", "provider:aws:redshift", "live"] },
29);
30
31// Resolve two default-for-AZ subnets in the default VPC.
32const defaultSubnets = Effect.gen(function* () {
33 const vpc = yield* getDefaultVpc;
34 const subnets = yield* EC2.describeSubnets({
35 Filters: [
36 { Name: "vpc-id", Values: [vpc.vpcId] },
37 { Name: "default-for-az", Values: ["true"] },
38 ],
39 });
40 const subnetIds = (subnets.Subnets ?? [])
41 .map((s) => s.SubnetId)
42 .filter((id): id is string => id !== undefined)
43 .sort()
44 .slice(0, 2);
45 if (subnetIds.length < 2) {
46 return yield* Effect.die(
47 new Error("default VPC has fewer than 2 default-for-az subnets"),
48 );
49 }
50 return subnetIds as SubnetId[];
51});
52
53// Deletion is verified as INITIATED (status `deleting`, irreversible) or
54// fully gone. Full disappearance takes several more minutes server-side;
55// waiting for it would push the test into its timeout.
56const assertClusterDeleting = (identifier: string) =>
57 Effect.gen(function* () {
58 const status = yield* redshift
59 .describeClusters({ ClusterIdentifier: identifier })
60 .pipe(
61 Effect.map((r) => r.Clusters?.[0]?.ClusterStatus ?? "gone"),
62 Effect.catchTag("ClusterNotFoundFault", () =>
63 Effect.succeed("gone" as const),
64 ),
65 );
66 if (status !== "gone" && status !== "deleting") {
67 return yield* Effect.fail(
68 new Error(`cluster '${identifier}' still exists (status: ${status})`),
69 );
70 }
71 }).pipe(
72 Effect.retry({
73 schedule: Schedule.max([
74 Schedule.fixed("10 seconds"),
75 Schedule.recurs(18),
76 ]),
77 }),
78 );
79
80// Provisioned Redshift clusters take ~5-10 minutes to reach `available` and
81// bill hourly per node while they exist. The full lifecycle is gated behind
82// AWS_TEST_SLOW=1 and always destroys what it created.
83test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
84 "create single-node Redshift cluster, verify, destroy",
85 (stack) =>
86 Effect.gen(function* () {
87 yield* stack.destroy();
88
89 const subnetIds = yield* defaultSubnets;
90
91 const { cluster } = yield* stack.deploy(
92 Effect.gen(function* () {
93 const subnetGroup = yield* ClusterSubnetGroup("WarehouseSubnets", {
94 description: "alchemy redshift cluster subnets",
95 subnetIds,
96 });
97 const cluster = yield* Cluster("Warehouse", {
98 nodeType: "ra3.large",
99 numberOfNodes: 1,
100 masterUsername: "alchemyadmin",
101 masterUserPassword: Redacted.make("AlchemyRedshiftTest1"),
102 dbName: "analytics",
103 clusterSubnetGroupName: subnetGroup.clusterSubnetGroupName,
104 publiclyAccessible: false,
105 encrypted: true,
106 tags: { fixture: "redshift-cluster" },
107 });
108 return { cluster };
109 }),
110 );
111
112 expect(cluster.clusterIdentifier).toBeDefined();
113 expect(cluster.clusterArn).toContain(":cluster:");
114 expect(cluster.clusterStatus).toBe("available");
115 expect(cluster.nodeType).toBe("ra3.large");
116 expect(cluster.numberOfNodes).toBe(1);
117 expect(cluster.dbName).toBe("analytics");
118 expect(cluster.masterUsername).toBe("alchemyadmin");
119 expect(cluster.endpointAddress).toContain("redshift");
120 expect(cluster.endpointPort).toBe(5439);
121 expect(cluster.publiclyAccessible).toBe(false);
122 expect(cluster.encrypted).toBe(true);
123
124 // Out-of-band verification via distilled, tags included.
125 const described = yield* redshift.describeClusters({
126 ClusterIdentifier: cluster.clusterIdentifier,
127 });
128 const observed = described.Clusters?.[0];
129 expect(observed?.ClusterStatus).toBe("available");
130 expect(observed?.NodeType).toBe("ra3.large");
131 expect(observed?.Encrypted).toBe(true);
132 expect(
133 observed?.Tags?.some(
134 (t) => t.Key === "fixture" && t.Value === "redshift-cluster",
135 ),
136 ).toBe(true);
137
138 // Destroy immediately — clusters bill while they exist — and verify
139 // deletion was initiated out-of-band.
140 yield* stack.destroy();
141 yield* assertClusterDeleting(cluster.clusterIdentifier);
142 }),
143 // create (~5-10 min) + delete initiation, one test.
144 {
145 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:redshift", "live"],
146 timeout: 1_500_000,
147 },
148);