1import * as AWS from "@/AWS";
2import { JobTemplate } from "@/AWS/EMRContainers";
3import { Role } from "@/AWS/IAM";
4import * as Provider from "@/Provider";
5import * as Test from "@/Test/Alchemy";
6import * as emrc from "@distilled.cloud/aws/emr-containers";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Ungated typed-error probe: proves the tags the observe/read/delete paths
13// depend on. describeJobTemplate on a well-formed nonexistent id must
14// surface the typed ResourceNotFoundException; deleteJobTemplate on the
15// same id must surface the typed ValidationException the idempotent delete
16// path swallows (its typed union has no not-found tag).
17test.provider(
18 "typed error semantics on a nonexistent job template",
19 () =>
20 Effect.gen(function* () {
21 const id = "abcdefabcdefabcdefabcdef01";
22
23 const describeError = yield* Effect.flip(
24 emrc.describeJobTemplate({ id }),
25 );
26 expect(describeError._tag).toBe("ResourceNotFoundException");
27
28 const deleteError = yield* Effect.flip(emrc.deleteJobTemplate({ id }));
29 expect(deleteError._tag).toBe("ValidationException");
30 }),
31 { tags: ["provider:aws", "provider:aws:emrcontainers", "live"] },
32);
33
34// Ungated list() probe: proves the pagination + attribute mapping wiring.
35test.provider(
36 "list returns a well-formed array of job templates",
37 () =>
38 Effect.gen(function* () {
39 const provider = yield* Provider.findProvider(JobTemplate);
40 const all = yield* provider.list();
41
42 expect(Array.isArray(all)).toBe(true);
43 for (const jt of all) {
44 expect(typeof jt.jobTemplateId).toBe("string");
45 expect(typeof jt.jobTemplateName).toBe("string");
46 expect(jt.jobTemplateArn).toContain(":/jobtemplates/");
47 }
48 }),
49 { tags: ["provider:aws", "provider:aws:emrcontainers", "live"] },
50);
51
52// Full lifecycle — job templates are account-level, free, and provision
53// synchronously (no EKS cluster required), so this runs ungated.
54test.provider(
55 "job template lifecycle: create, tag replace, data replace, destroy",
56 (stack) =>
57 Effect.gen(function* () {
58 // Clean slate in case a previous run died mid-flight.
59 yield* stack.destroy();
60
61 const deployTemplate = (
62 releaseLabel: string,
63 tags: Record<string, string>,
64 ) =>
65 stack.deploy(
66 Effect.gen(function* () {
67 const role = yield* Role("JobRole", {
68 assumeRolePolicyDocument: {
69 Version: "2012-10-17",
70 Statement: [
71 {
72 Effect: "Allow",
73 Principal: { Service: "pods.eks.amazonaws.com" },
74 Action: ["sts:AssumeRole", "sts:TagSession"],
75 },
76 ],
77 },
78 });
79 return yield* JobTemplate("Template", {
80 jobTemplateData: {
81 executionRoleArn: role.roleArn,
82 releaseLabel,
83 jobDriver: {
84 sparkSubmitJobDriver: {
85 entryPoint: "s3://alchemy-test-emrc/scripts/etl.py",
86 sparkSubmitParameters: "--conf spark.executor.instances=1",
87 },
88 },
89 jobTags: { Origin: "alchemy-test" },
90 },
91 tags,
92 });
93 }),
94 );
95
96 // 1. CREATE
97 const first = yield* deployTemplate("emr-7.5.0-latest", {
98 Purpose: "alchemy-emrc-test",
99 });
100 expect(first.jobTemplateArn).toContain(":/jobtemplates/");
101
102 // Out-of-band verification via distilled.
103 const observed = yield* emrc.describeJobTemplate({
104 id: first.jobTemplateId,
105 });
106 expect(observed.jobTemplate?.jobTemplateData.releaseLabel).toBe(
107 "emr-7.5.0-latest",
108 );
109 expect(observed.jobTemplate?.tags?.Purpose).toBe("alchemy-emrc-test");
110
111 // 2. TAG CHANGE — replaces (EMR containers' TagResource rejects job
112 // template ARNs with the typed InvalidResourceArn, so tags are
113 // create-only and any tag change replaces the template)
114 const retagged = yield* deployTemplate("emr-7.5.0-latest", {
115 Purpose: "alchemy-emrc-test-updated",
116 });
117 expect(retagged.jobTemplateId).not.toBe(first.jobTemplateId);
118 const retaggedObserved = yield* emrc.describeJobTemplate({
119 id: retagged.jobTemplateId,
120 });
121 expect(retaggedObserved.jobTemplate?.tags?.Purpose).toBe(
122 "alchemy-emrc-test-updated",
123 );
124
125 // 3. REPLACE — jobTemplateData is immutable, changing it replaces
126 const replaced = yield* deployTemplate("emr-7.2.0-latest", {
127 Purpose: "alchemy-emrc-test-updated",
128 });
129 expect(replaced.jobTemplateId).not.toBe(retagged.jobTemplateId);
130
131 // The replaced (old) template is deleted by the engine.
132 const oldGone = yield* Effect.flip(
133 emrc.describeJobTemplate({ id: retagged.jobTemplateId }),
134 );
135 expect(oldGone._tag).toBe("ResourceNotFoundException");
136
137 // 4. DESTROY
138 yield* stack.destroy();
139 const gone = yield* Effect.flip(
140 emrc.describeJobTemplate({ id: replaced.jobTemplateId }),
141 );
142 expect(gone._tag).toBe("ResourceNotFoundException");
143 }),
144 {
145 tags: [
146 "provider:aws",
147 "provider:aws:emrcontainers",
148 "provider:aws:iam",
149 "live",
150 ],
151 timeout: 240_000,
152 },
153);