1import * as AWS from "@/AWS";
2import * as Test from "@/Test/Alchemy";
3import * as batch from "@distilled.cloud/aws/batch";
4import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
5import * as ecr from "@distilled.cloud/aws/ecr";
6import * as iam from "@distilled.cloud/aws/iam";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import NightlyJob, { MARKER } from "./fixtures/nightly-job.ts";
11import { BatchTestNetwork } from "./TestNetwork.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Full end-to-end for the Effect-native `Batch.JobDefinition` form: bundle
16// the inline run-to-completion Effect, build + push the container image to
17// the managed ECR repository (Docker build, linux/amd64), provision the
18// job/execution IAM roles, register the job definition — then submit a job
19// against it and prove the Effect actually ran inside Batch: the job lands
20// in SUCCEEDED (container exit 0) and the marker appears in its
21// `/aws/batch/job` log stream.
22//
23// It is heavy (Docker build + ECR push + Fargate provisioning of the job,
24// several minutes end to end), so it is gated behind AWS_TEST_SLOW=1 and
25// always destroys what it created.
26test.provider.skipIf(!process.env.AWS_TEST_SLOW)(
27 "deploys an Effect-native Batch job definition and runs it to SUCCEEDED",
28 (stack) =>
29 Effect.gen(function* () {
30 // Clean slate in case a previous run died mid-flight.
31 yield* stack.destroy();
32
33 const outputs = yield* stack.deploy(
34 Effect.gen(function* () {
35 const network = yield* BatchTestNetwork;
36 const computeEnvironment = yield* AWS.Batch.ComputeEnvironment(
37 "PlatformCE",
38 {
39 subnets: network.subnetIds,
40 securityGroupIds: network.securityGroupIds,
41 },
42 );
43 const queue = yield* AWS.Batch.JobQueue("PlatformQueue", {
44 computeEnvironments: [computeEnvironment.computeEnvironmentArn],
45 });
46 const job = yield* NightlyJob;
47 return {
48 jobQueueArn: queue.jobQueueArn,
49 jobDefinitionName: job.jobDefinitionName,
50 jobDefinitionArn: job.jobDefinitionArn,
51 imageUri: job.imageUri,
52 repositoryName: job.repositoryName,
53 repositoryUri: job.repositoryUri,
54 codeHash: job.codeHash,
55 jobRoleArn: job.jobRoleArn,
56 jobRoleName: job.jobRoleName,
57 executionRoleName: job.executionRoleName,
58 };
59 }),
60 );
61
62 // Effect-native attributes: managed repo, roles, and image.
63 expect(outputs.jobDefinitionName).toBe("alchemy-test-batch-platform-e2e");
64 expect(outputs.repositoryUri).toBeTruthy();
65 expect(outputs.imageUri).toBe(
66 `${outputs.repositoryUri}:${outputs.codeHash}`,
67 );
68 expect(outputs.jobRoleArn).toContain(":role/");
69
70 // Out-of-band verification via distilled: the registered revision runs
71 // the built image with the managed roles.
72 const described = yield* batch.describeJobDefinitions({
73 jobDefinitions: [outputs.jobDefinitionArn!],
74 });
75 const container = described.jobDefinitions?.[0]?.containerProperties;
76 expect(container?.image).toBe(outputs.imageUri);
77 expect(container?.jobRoleArn).toBe(outputs.jobRoleArn);
78
79 // Submit a job against the platform definition and wait for the
80 // run-to-completion Effect to finish (SUCCEEDED = container exit 0).
81 const submitted = yield* batch.submitJob({
82 jobName: "alchemy-e2e-platform-run",
83 jobQueue: outputs.jobQueueArn,
84 jobDefinition: outputs.jobDefinitionArn!,
85 });
86 const job = yield* batch.describeJobs({ jobs: [submitted.jobId] }).pipe(
87 Effect.map((result) => result.jobs?.[0]),
88 Effect.repeat({
89 schedule: Schedule.spaced("10 seconds"),
90 until: (j): boolean =>
91 j?.status === "SUCCEEDED" || j?.status === "FAILED",
92 times: 60,
93 }),
94 );
95 // Include the reason so a FAILED run shows why (placement, pull, or a
96 // bootstrap crash all land here with different statusReasons).
97 expect(`${job?.status}: ${job?.statusReason ?? ""}`).toMatch(
98 /^SUCCEEDED:/,
99 );
100
101 // The bundled Effect actually executed: its marker is in the job's
102 // log stream (log delivery can lag the state change slightly).
103 const logStreamName = job?.container?.logStreamName;
104 expect(logStreamName).toBeTruthy();
105 const events = yield* logs
106 .getLogEvents({
107 logGroupName: "/aws/batch/job",
108 logStreamName: logStreamName!,
109 })
110 .pipe(
111 Effect.flatMap((result) =>
112 (result.events ?? []).some((e) => e.message?.includes(MARKER))
113 ? Effect.succeed(result.events ?? [])
114 : Effect.fail(new Error("marker not in log stream yet")),
115 ),
116 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 12 }),
117 );
118 expect(events.some((e) => e.message?.includes(MARKER))).toBe(true);
119
120 // Destroy — and verify zero leftovers: job definition revisions, the
121 // managed repository, and both managed roles.
122 const {
123 jobDefinitionName,
124 repositoryName,
125 jobRoleName,
126 executionRoleName,
127 } = outputs;
128 yield* stack.destroy();
129
130 const revisions = yield* batch.describeJobDefinitions({
131 jobDefinitionName,
132 status: "ACTIVE",
133 });
134 expect(revisions.jobDefinitions ?? []).toHaveLength(0);
135
136 const repoError = yield* Effect.flip(
137 ecr.describeRepositories({ repositoryNames: [repositoryName!] }),
138 );
139 expect(repoError._tag).toBe("RepositoryNotFoundException");
140
141 for (const roleName of [jobRoleName!, executionRoleName!]) {
142 const roleError = yield* Effect.flip(
143 iam.getRole({ RoleName: roleName }),
144 );
145 expect(roleError._tag).toBe("NoSuchEntityException");
146 }
147 }),
148 // Docker build + push (~2-4 min) + CE/queue (~1-2 min) + Fargate job run
149 // (~2-4 min) + destroy (~2-3 min).
150 {
151 tags: [
152 "provider:aws",
153 "provider:aws:batch",
154 "provider:aws:ec2",
155 "provider:aws:ecr",
156 "provider:aws:iam",
157 "live",
158 ],
159 timeout: 1_200_000,
160 },
161);