1import * as AWS from "@/AWS";
2import { Cluster } from "@/AWS/ECS/Cluster.ts";
3import * as Test from "@/Test/Alchemy";
4import * as ecs from "@distilled.cloud/aws/ecs";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
9import InlineDockerfileTaskLive, {
10 InlineDockerfileTask,
11} from "./fixtures/inline-dockerfile-task.ts";
12
13const { test } = Test.make({ providers: AWS.providers() });
14
15// Live proof of the `main` + `Dockerfile.inline` environment composition: the
16// fixture's inline content carries a `RUN` that bakes a marker file into an
17// image layer, and its bundled `{ run }` program reads the marker back at
18// container runtime, exiting non-zero if absent. Exit code 0 proves the
19// inline preamble replaced the generated `FROM` (the RUN executed at build
20// time) AND the bundle was layered on top of that environment.
21//
22// Docker + ECR + Fargate placement is minutes of wall clock, so like the
23// other Task e2e tests this is gated out of the default sweep: run it
24// explicitly with `AWS_TEST_SLOW=1`.
25test.provider.skipIf(!process.env.AWS_TEST_SLOW || !!process.env.FAST)(
26 "inline-dockerfile environment bakes RUN artifact into the bundled image",
27 (stack) =>
28 Effect.gen(function* () {
29 yield* stack.destroy();
30
31 // Reuse the standing default VPC: a public subnet + public IP is
32 // required for the task ENI to pull the image from ECR.
33 const { subnetIds } = yield* getDefaultVpcNetwork;
34 const subnetId = subnetIds[0];
35 expect(subnetId).toBeTruthy();
36
37 const { clusterArn, taskDefinitionArn } = yield* stack.deploy(
38 Effect.gen(function* () {
39 const cluster = yield* Cluster("InlineDockerfileCluster", {
40 clusterName: "alchemy-test-inline-dockerfile",
41 });
42 const task = yield* InlineDockerfileTask;
43 return {
44 clusterArn: cluster.clusterArn,
45 taskDefinitionArn: task.taskDefinitionArn,
46 };
47 }).pipe(Effect.provide(InlineDockerfileTaskLive)),
48 );
49
50 expect(taskDefinitionArn).toBeTruthy();
51
52 // Launch the one-shot task once, out-of-band. The task/execution
53 // roles were created seconds ago: ECS rejects `runTask` with
54 // "unable to assume the role" until IAM propagates, so retry that
55 // one typed error (bounded).
56 const started = yield* ecs
57 .runTask({
58 cluster: clusterArn,
59 taskDefinition: taskDefinitionArn!,
60 launchType: "FARGATE",
61 count: 1,
62 startedBy: "alchemy-inline-dockerfile-test",
63 networkConfiguration: {
64 awsvpcConfiguration: {
65 subnets: [subnetId!],
66 assignPublicIp: "ENABLED",
67 },
68 },
69 })
70 .pipe(
71 Effect.retry({
72 while: (e) =>
73 e._tag === "ClientException" &&
74 (e.message ?? "").includes("unable to assume the role"),
75 schedule: Schedule.spaced("5 seconds"),
76 times: 12,
77 }),
78 );
79 expect(started.failures ?? []).toEqual([]);
80 const taskArn = started.tasks?.[0]?.taskArn;
81 expect(taskArn).toBeTruthy();
82
83 // Wait for the task to stop: image pull + container boot + the
84 // one-shot program running to completion (~1-3 minutes cold).
85 const stopped = yield* ecs
86 .describeTasks({ cluster: clusterArn, tasks: [taskArn!] })
87 .pipe(
88 Effect.flatMap((result) => {
89 const task = result.tasks?.[0];
90 return task?.lastStatus === "STOPPED"
91 ? Effect.succeed(task)
92 : Effect.fail(
93 new Error(`task not stopped yet: ${task?.lastStatus}`),
94 );
95 }),
96 Effect.tapError((error) => Effect.logInfo(String(error))),
97 Effect.retry({ schedule: Schedule.spaced("6 seconds"), times: 50 }),
98 );
99
100 // Exit 0 requires the artifact to have been baked by the inline RUN.
101 expect(stopped.stoppedReason ?? "").not.toContain("CannotPullContainer");
102 expect(stopped.containers?.[0]?.exitCode).toBe(0);
103
104 yield* stack.destroy();
105 }),
106 {
107 tags: [
108 "provider:aws",
109 "provider:aws:ec2",
110 "provider:aws:ecs",
111 "provider:docker",
112 "provider:docker:dockerfile",
113 "live",
114 ],
115 timeout: 900_000,
116 },
117);