| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Cluster } from "@/AWS/EKS"; |
| 3 | import { VirtualCluster } from "@/AWS/EMRContainers"; |
| 4 | import { Role } from "@/AWS/IAM"; |
| 5 | import * as Provider from "@/Provider"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 8 | import * as emrc from "@distilled.cloud/aws/emr-containers"; |
| 9 | import { expect } from "alchemy-test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | |
| 13 | const { test } = Test.make({ providers: AWS.providers() }); |
| 14 | |
| 15 | // Deterministic names — same on every run of the test case. |
| 16 | const EKS_CLUSTER_NAME = "alchemy-test-emrc-eks"; |
| 17 | const 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). |
| 25 | test.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.) |
| 51 | test.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. |
| 77 | test.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. |
| 99 | test.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 | |
| 184 | const 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 | ); |