1import * as AWS from "@/AWS";
2import { Studio } from "@/AWS/EMR/Studio.ts";
3import * as Output from "@/Output";
4import * as Test from "@/Test/Alchemy";
5import * as EC2 from "@distilled.cloud/aws/ec2";
6import * as emr from "@distilled.cloud/aws/emr";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { getDefaultVpc } from "../DefaultVpc.ts";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// Ungated typed-error probe: proves the distilled emr error union carries the
14// StudioNotFound tag this provider's read/delete paths depend on (EMR
15// overloads InvalidRequestException with "Studio does not exist.").
16test.provider(
17 "describeStudio on a nonexistent id fails with StudioNotFound",
18 () =>
19 Effect.gen(function* () {
20 const error = yield* Effect.flip(
21 emr.describeStudio({ StudioId: "es-AAAAAAAAAAAAAAAAAAAAAAAAA" }),
22 );
23 expect(error._tag).toBe("StudioNotFound");
24 }),
25 { tags: ["provider:aws", "provider:aws:emr", "live"] },
26);
27
28// Resolve a subnet of the account's default VPC.
29const resolveSubnets = Effect.gen(function* () {
30 const vpc = yield* getDefaultVpc;
31 const subnets = yield* EC2.describeSubnets({
32 Filters: [
33 { Name: "vpc-id", Values: [vpc.vpcId] },
34 { Name: "default-for-az", Values: ["true"] },
35 ],
36 });
37 const subnetIds = (subnets.Subnets ?? [])
38 .flatMap((s) => (s.SubnetId ? [s.SubnetId] : []))
39 .slice(0, 2);
40 return { vpcId: vpc.vpcId, subnetIds };
41});
42
43test.provider(
44 "lifecycle: create IAM studio, update in place, destroy",
45 (stack) =>
46 Effect.gen(function* () {
47 yield* stack.destroy();
48
49 const { vpcId, subnetIds } = yield* resolveSubnets;
50 expect(subnetIds.length).toBeGreaterThan(0);
51
52 const deploy = (description: string, s3Suffix: string) =>
53 stack.deploy(
54 Effect.gen(function* () {
55 const bucket = yield* AWS.S3.Bucket("StudioBackup", {
56 forceDestroy: true,
57 });
58 const role = yield* AWS.IAM.Role("StudioServiceRole", {
59 assumeRolePolicyDocument: {
60 Version: "2012-10-17",
61 Statement: [
62 {
63 Effect: "Allow",
64 Principal: { Service: ["elasticmapreduce.amazonaws.com"] },
65 Action: ["sts:AssumeRole"],
66 },
67 ],
68 },
69 inlinePolicies: {
70 "studio-service": {
71 Version: "2012-10-17",
72 Statement: [
73 {
74 // CreateStudio validates the service role can access
75 // the default S3 location (incl. GetEncryptionConfiguration).
76 Effect: "Allow",
77 Action: ["s3:*"],
78 Resource: [
79 bucket.bucketArn,
80 Output.interpolate`${bucket.bucketArn}/*`,
81 ],
82 },
83 {
84 Effect: "Allow",
85 Action: [
86 "ec2:CreateNetworkInterface",
87 "ec2:CreateNetworkInterfacePermission",
88 "ec2:DeleteNetworkInterface",
89 "ec2:DeleteNetworkInterfacePermission",
90 "ec2:DescribeNetworkInterfaces",
91 "ec2:ModifyNetworkInterfaceAttribute",
92 "ec2:DescribeSecurityGroups",
93 "ec2:DescribeSubnets",
94 "ec2:DescribeVpcs",
95 "ec2:DescribeTags",
96 "ec2:CreateTags",
97 ],
98 Resource: ["*"],
99 },
100 ],
101 },
102 },
103 });
104 const workspaceSg = yield* AWS.EC2.SecurityGroup("WorkspaceSg", {
105 vpcId: vpcId,
106 description: "EMR Studio workspace",
107 });
108 const engineSg = yield* AWS.EC2.SecurityGroup("EngineSg", {
109 vpcId: vpcId,
110 description: "EMR Studio engine",
111 });
112 // Engine must accept TCP 18888 from the workspace security group.
113 yield* AWS.EC2.SecurityGroupRule("EngineIngress", {
114 groupId: engineSg.groupId,
115 type: "ingress",
116 ipProtocol: "tcp",
117 fromPort: 18888,
118 toPort: 18888,
119 referencedGroupId: workspaceSg.groupId,
120 });
121 const studio = yield* Studio("Studio", {
122 authMode: "IAM",
123 vpcId: vpcId,
124 subnetIds,
125 serviceRole: role.roleArn,
126 workspaceSecurityGroupId: workspaceSg.groupId,
127 engineSecurityGroupId: engineSg.groupId,
128 defaultS3Location: Output.interpolate`s3://${bucket.bucketName}/${s3Suffix}/`,
129 description,
130 tags: { fixture: "emr-studio" },
131 });
132 return { studio };
133 }),
134 );
135
136 // Create.
137 const { studio } = yield* deploy("alchemy test studio", "studio");
138 expect(studio.studioId).toMatch(/^es-/);
139 expect(studio.studioArn).toContain(":studio/");
140 expect(studio.url).toBeDefined();
141
142 // Out-of-band verification via distilled.
143 const created = yield* emr.describeStudio({
144 StudioId: studio.studioId,
145 });
146 expect(created.Studio?.AuthMode).toBe("IAM");
147 expect(created.Studio?.Description).toBe("alchemy test studio");
148 expect(created.Studio?.DefaultS3Location).toContain("/studio/");
149 const createdTags = Object.fromEntries(
150 (created.Studio?.Tags ?? []).map((t) => [t.Key, t.Value]),
151 );
152 expect(createdTags.fixture).toBe("emr-studio");
153
154 // Update — description and default S3 location sync in place (same
155 // StudioId, no replacement).
156 const { studio: updated } = yield* deploy(
157 "alchemy test studio v2",
158 "studio-v2",
159 );
160 expect(updated.studioId).toBe(studio.studioId);
161 const afterUpdate = yield* emr.describeStudio({
162 StudioId: studio.studioId,
163 });
164 expect(afterUpdate.Studio?.Description).toBe("alchemy test studio v2");
165 expect(afterUpdate.Studio?.DefaultS3Location).toContain("/studio-v2/");
166
167 // Destroy — verify gone out of band.
168 yield* stack.destroy();
169 const afterDestroy = yield* Effect.flip(
170 emr.describeStudio({ StudioId: studio.studioId }),
171 );
172 expect(afterDestroy._tag).toBe("StudioNotFound");
173 }),
174 {
175 tags: [
176 "provider:aws",
177 "provider:aws:ec2",
178 "provider:aws:emr",
179 "provider:aws:iam",
180 "provider:aws:s3",
181 "live",
182 ],
183 timeout: 300_000,
184 },
185);