1import * as AWS from "@/AWS";
2import { Cluster } from "@/AWS/EKS";
3import { VirtualCluster } from "@/AWS/EMRContainers";
4import { Role } from "@/AWS/IAM";
5import * as Provider from "@/Provider";
6import * as Test from "@/Test/Alchemy";
7import * as ec2 from "@distilled.cloud/aws/ec2";
8import * as emrc from "@distilled.cloud/aws/emr-containers";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Deterministic names — same on every run of the test case.
16const EKS_CLUSTER_NAME = "alchemy-test-emrc-eks";
17const VC_NAME = "alchemy-test-emrc-vc";
18
19// Ungated typed-error probe: proves the tags the observe/read/delete paths
20// depend on. describeVirtualCluster on a well-formed nonexistent id must
21// surface the typed ResourceNotFoundException; deleteVirtualCluster on the
22// same id must surface the typed ValidationException the idempotent delete
23// path swallows (its typed union has no not-found tag — the API reports
24// missing/terminated ids as validation errors).
25test.provider(
26 "typed error semantics on a nonexistent virtual cluster",
27 () =>
28 Effect.gen(function* () {
29 const id = "abcdefabcdefabcdefabcdef01";
30
31 const describeError = yield* Effect.flip(
32 emrc.describeVirtualCluster({ id }),
33 );
34 expect(describeError._tag).toBe("ResourceNotFoundException");
35
36 const deleteError = yield* Effect.flip(emrc.deleteVirtualCluster({ id }));
37 expect(deleteError._tag).toBe("ValidationException");
38 }),
39 { tags: ["provider:aws", "provider:aws:emrcontainers", "live"] },
40);
41
42// Ungated typed-error probe for the job-run data plane the VC-scoped
43// bindings (DescribeJobRun / CancelJobRun / ListJobRuns) call: each op on a
44// well-formed nonexistent virtual cluster id must surface a typed tag from
45// its inferred error union — never a catch-all. Observed live: the API
46// reports job-run-addressed ops on a nonexistent virtual cluster as
47// validation errors, and ListJobRuns succeeds with an empty page.
48// (StartJobRun's tag depends on the caller's IAM scope — AccessDenied for
49// unauthorized arbitrary cluster ARNs — so it is not asserted here; the
50// binding grants on the bound cluster's ARN.)
51test.provider(
52 "typed error semantics for job run ops on a nonexistent vc",
53 () =>
54 Effect.gen(function* () {
55 const virtualClusterId = "abcdefabcdefabcdefabcdef01";
56 const jobRunId = "abcdefabcdefabcdefa";
57
58 const describeError = yield* Effect.flip(
59 emrc.describeJobRun({ id: jobRunId, virtualClusterId }),
60 );
61 expect(describeError._tag).toBe("ValidationException");
62
63 const cancelError = yield* Effect.flip(
64 emrc.cancelJobRun({ id: jobRunId, virtualClusterId }),
65 );
66 expect(cancelError._tag).toBe("ValidationException");
67
68 const list = yield* emrc.listJobRuns({ virtualClusterId });
69 expect(list.jobRuns ?? []).toHaveLength(0);
70 }),
71 { tags: ["provider:aws", "provider:aws:emrcontainers", "live"] },
72);
73
74// Ungated list() probe: enumerates live virtual clusters in the ambient
75// account/region — proves the pagination + attribute mapping wiring without
76// needing an EKS cluster.
77test.provider(
78 "list returns a well-formed array of virtual clusters",
79 () =>
80 Effect.gen(function* () {
81 const provider = yield* Provider.findProvider(VirtualCluster);
82 const all = yield* provider.list();
83
84 expect(Array.isArray(all)).toBe(true);
85 for (const vc of all) {
86 expect(typeof vc.virtualClusterId).toBe("string");
87 expect(typeof vc.virtualClusterName).toBe("string");
88 expect(vc.virtualClusterArn).toContain(":/virtualclusters/");
89 }
90 }),
91 { tags: ["provider:aws", "provider:aws:emrcontainers", "live"] },
92);
93
94// Full lifecycle: a virtual cluster itself is free and provisions instantly,
95// but it requires a live EKS cluster (~10 min control-plane create + ~5 min
96// delete), so the whole path is gated behind AWS_TEST_SLOW=1. The EKS cluster
97// uses API authentication mode so EMR on EKS registers its access entries
98// automatically — no kubectl/aws-auth step.
99test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
100 "virtual cluster lifecycle on a live EKS cluster (AWS_TEST_SLOW=1)",
101 (stack) =>
102 Effect.gen(function* () {
103 // Clean slate in case a previous run died mid-flight.
104 yield* stack.destroy();
105
106 // Default-VPC subnets in >= 2 AZs (never create NAT gateways).
107 const subnetsResult = yield* ec2.describeSubnets({
108 Filters: [{ Name: "default-for-az", Values: ["true"] }],
109 });
110 const byAz = new Map<string, string>();
111 for (const s of subnetsResult.Subnets ?? []) {
112 if (s.State === "available" && s.SubnetId && s.AvailabilityZone) {
113 if (!byAz.has(s.AvailabilityZone)) {
114 byAz.set(s.AvailabilityZone, s.SubnetId);
115 }
116 }
117 }
118 const subnetIds = Array.from(byAz.values()).slice(0, 3);
119 expect(subnetIds.length).toBeGreaterThanOrEqual(2);
120
121 const vc = yield* stack.deploy(
122 Effect.gen(function* () {
123 const clusterRole = yield* Role("EksRole", {
124 assumeRolePolicyDocument: {
125 Version: "2012-10-17",
126 Statement: [
127 {
128 Effect: "Allow",
129 Principal: { Service: "eks.amazonaws.com" },
130 Action: ["sts:AssumeRole"],
131 },
132 ],
133 },
134 managedPolicyArns: [
135 "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy",
136 ],
137 });
138 const cluster = yield* Cluster("Cluster", {
139 clusterName: EKS_CLUSTER_NAME,
140 roleArn: clusterRole.roleArn,
141 resourcesVpcConfig: { subnetIds },
142 accessConfig: { authenticationMode: "API_AND_CONFIG_MAP" },
143 });
144 return yield* VirtualCluster("VC", {
145 virtualClusterName: VC_NAME,
146 containerProvider: {
147 id: cluster.clusterName,
148 info: { eksInfo: { namespace: "default" } },
149 },
150 });
151 }),
152 );
153
154 expect(vc.virtualClusterName).toBe(VC_NAME);
155 expect(vc.eksClusterName).toBe(EKS_CLUSTER_NAME);
156 expect(vc.state).toBe("RUNNING");
157
158 // Out-of-band verification via distilled.
159 const observed = yield* emrc.describeVirtualCluster({
160 id: vc.virtualClusterId,
161 });
162 expect(observed.virtualCluster?.name).toBe(VC_NAME);
163 expect(observed.virtualCluster?.containerProvider?.id).toBe(
164 EKS_CLUSTER_NAME,
165 );
166
167 // Destroy immediately and verify the virtual cluster terminated.
168 yield* stack.destroy();
169 yield* assertVirtualClusterGone(vc.virtualClusterId);
170 }),
171 {
172 tags: [
173 "provider:aws",
174 "provider:aws:ec2",
175 "provider:aws:eks",
176 "provider:aws:emrcontainers",
177 "provider:aws:iam",
178 "live",
179 ],
180 timeout: 1_800_000,
181 },
182);
183
184const assertVirtualClusterGone = (id: string) =>
185 Effect.gen(function* () {
186 const state = yield* emrc.describeVirtualCluster({ id }).pipe(
187 Effect.map((response) => response.virtualCluster?.state ?? "gone"),
188 Effect.catchTag("ResourceNotFoundException", () =>
189 Effect.succeed("gone"),
190 ),
191 );
192 if (state !== "gone" && state !== "TERMINATED" && state !== "TERMINATING") {
193 return yield* Effect.fail(
194 new Error(`virtual cluster ${id} still exists (${state})`),
195 );
196 }
197 }).pipe(
198 Effect.retry({
199 schedule: Schedule.max([
200 Schedule.fixed("5 seconds"),
201 Schedule.recurs(12),
202 ]),
203 }),
204 );