1import * as AWS from "@/AWS";
2import { Budget, Farm, Fleet, Queue, StorageProfile } from "@/AWS/Deadline";
3import { Role } from "@/AWS/IAM/Role.ts";
4import * as Test from "@/Test/Alchemy";
5import * as deadline from "@distilled.cloud/aws/deadline";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: AWS.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Ungated typed-error probes: prove the distilled error union carries the
20// not-found tag every provider read/delete path in this service depends on.
21test.provider(
22 "getFarm on a nonexistent farm fails with ResourceNotFoundException",
23 () =>
24 Effect.gen(function* () {
25 const error = yield* Effect.flip(
26 deadline.getFarm({
27 farmId: "farm-00000000000000000000000000000000",
28 }),
29 );
30 expect(error._tag).toBe("ResourceNotFoundException");
31 }),
32 { tags: ["provider:aws", "provider:aws:deadline", "live"] },
33);
34
35test.provider(
36 "getMonitor on a nonexistent monitor fails with ResourceNotFoundException",
37 () =>
38 Effect.gen(function* () {
39 const error = yield* Effect.flip(
40 deadline.getMonitor({
41 monitorId: "monitor-00000000000000000000000000000000",
42 }),
43 );
44 expect(error._tag).toBe("ResourceNotFoundException");
45 }),
46 { tags: ["provider:aws", "provider:aws:deadline", "live"] },
47);
48
49class FarmStillExists extends Data.TaggedError("FarmStillExists") {}
50
51const assertFarmDeleted = Effect.fn(function* (farmId: string) {
52 yield* deadline.getFarm({ farmId }).pipe(
53 Effect.flatMap(() => Effect.fail(new FarmStillExists())),
54 Effect.retry({
55 while: (e) => e._tag === "FarmStillExists",
56 schedule: Schedule.max([
57 Schedule.spaced("6 seconds"),
58 Schedule.recurs(9),
59 ]),
60 }),
61 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
62 );
63});
64
65// Farms, queues, storage profiles, and budgets are free configuration —
66// exercised ungated.
67test.provider(
68 "create, update, delete farm + queue + storage profile + budget",
69 (stack) =>
70 Effect.gen(function* () {
71 yield* stack.destroy();
72
73 // Deadline rejects budget windows over 120 days, and clamps a past
74 // startTime to the creation instant. Compute one window per run and
75 // reuse it across both deploy phases so the schedule never drifts.
76 const schedule = yield* Effect.sync(() => {
77 const now = Date.now();
78 return {
79 fixed: {
80 startTime: new Date(now - 24 * 3600 * 1000).toISOString(),
81 endTime: new Date(now + 90 * 24 * 3600 * 1000).toISOString(),
82 },
83 };
84 });
85
86 // Phase 1 — create.
87 const created = yield* stack.deploy(
88 Effect.gen(function* () {
89 const farm = yield* Farm("TestFarm", {
90 description: "alchemy deadline test farm",
91 tags: { fixture: "deadline" },
92 });
93 const profile = yield* StorageProfile("LinuxHosts", {
94 farmId: farm.farmId,
95 osFamily: "LINUX",
96 fileSystemLocations: [
97 { name: "Assets", path: "/mnt/assets", type: "SHARED" },
98 ],
99 });
100 const queue = yield* Queue("RenderQueue", {
101 farmId: farm.farmId,
102 description: "alchemy deadline test queue",
103 tags: { fixture: "deadline" },
104 });
105 const budget = yield* Budget("QueueBudget", {
106 farmId: farm.farmId,
107 queueId: queue.queueId,
108 approximateDollarLimit: 1,
109 actions: [
110 {
111 type: "STOP_SCHEDULING_AND_COMPLETE_TASKS",
112 thresholdPercentage: 100,
113 },
114 ],
115 schedule,
116 });
117 return { farm, profile, queue, budget };
118 }),
119 );
120
121 expect(created.farm.farmId).toMatch(/^farm-/);
122 expect(created.farm.farmArn).toContain(":farm/");
123 expect(created.farm.costScaleFactor).toBe(1);
124 expect(created.queue.queueId).toMatch(/^queue-/);
125 expect(created.queue.farmId).toBe(created.farm.farmId);
126 expect(created.profile.storageProfileId).toMatch(/^sp-/);
127 expect(created.profile.osFamily).toBe("LINUX");
128 expect(created.budget.budgetId).toMatch(/^budget-/);
129 expect(created.budget.queueId).toBe(created.queue.queueId);
130 expect(created.budget.approximateDollarLimit).toBe(1);
131
132 // Verify out-of-band via distilled.
133 const farm = yield* deadline.getFarm({ farmId: created.farm.farmId });
134 expect(farm.displayName).toBe(created.farm.displayName);
135 const queue = yield* deadline.getQueue({
136 farmId: created.farm.farmId,
137 queueId: created.queue.queueId,
138 });
139 expect(queue.displayName).toBe(created.queue.displayName);
140 const tags = yield* deadline.listTagsForResource({
141 resourceArn: created.farm.farmArn,
142 });
143 expect(tags.tags?.fixture).toBe("deadline");
144 expect(tags.tags?.["alchemy::id"]).toBe("TestFarm");
145
146 // Phase 2 — update mutable settings across all four resources.
147 const updated = yield* stack.deploy(
148 Effect.gen(function* () {
149 const farm = yield* Farm("TestFarm", {
150 description: "alchemy deadline test farm (updated)",
151 costScaleFactor: 2,
152 tags: { fixture: "deadline", phase: "two" },
153 });
154 const profile = yield* StorageProfile("LinuxHosts", {
155 farmId: farm.farmId,
156 osFamily: "LINUX",
157 fileSystemLocations: [
158 { name: "Assets", path: "/mnt/assets", type: "SHARED" },
159 { name: "Cache", path: "/mnt/cache", type: "LOCAL" },
160 ],
161 });
162 const queue = yield* Queue("RenderQueue", {
163 farmId: farm.farmId,
164 description: "alchemy deadline test queue",
165 allowedStorageProfileIds: [profile.storageProfileId],
166 tags: { fixture: "deadline" },
167 });
168 const budget = yield* Budget("QueueBudget", {
169 farmId: farm.farmId,
170 queueId: queue.queueId,
171 approximateDollarLimit: 2,
172 actions: [
173 {
174 type: "STOP_SCHEDULING_AND_CANCEL_TASKS",
175 thresholdPercentage: 90,
176 },
177 ],
178 schedule,
179 });
180 return { farm, profile, queue, budget };
181 }),
182 );
183
184 // Stable identifiers survive the in-place update.
185 expect(updated.farm.farmId).toBe(created.farm.farmId);
186 expect(updated.queue.queueId).toBe(created.queue.queueId);
187 expect(updated.profile.storageProfileId).toBe(
188 created.profile.storageProfileId,
189 );
190 expect(updated.budget.budgetId).toBe(created.budget.budgetId);
191 expect(updated.farm.costScaleFactor).toBe(2);
192 expect(updated.budget.approximateDollarLimit).toBe(2);
193 expect(updated.profile.fileSystemLocations).toHaveLength(2);
194
195 // Verify the update landed out-of-band.
196 const updatedQueue = yield* deadline.getQueue({
197 farmId: created.farm.farmId,
198 queueId: created.queue.queueId,
199 });
200 expect(updatedQueue.allowedStorageProfileIds).toEqual([
201 created.profile.storageProfileId,
202 ]);
203 const updatedBudget = yield* deadline.getBudget({
204 farmId: created.farm.farmId,
205 budgetId: created.budget.budgetId,
206 });
207 expect(updatedBudget.approximateDollarLimit).toBe(2);
208 expect(updatedBudget.actions).toHaveLength(1);
209 expect(updatedBudget.actions[0]?.type).toBe(
210 "STOP_SCHEDULING_AND_CANCEL_TASKS",
211 );
212 const updatedFarmTags = yield* deadline.listTagsForResource({
213 resourceArn: created.farm.farmArn,
214 });
215 expect(updatedFarmTags.tags?.phase).toBe("two");
216
217 yield* stack.destroy();
218 yield* assertFarmDeleted(created.farm.farmId);
219 }).pipe(logLevel),
220 { tags: ["provider:aws", "provider:aws:deadline", "live"], timeout: 180_000 },
221);
222
223// A customer-managed fleet provisions no EC2 capacity, but fleet lifecycle
224// is gated per the service plan (service-managed fleets can provision
225// costly workers; entitlement/limits vary by account).
226test.provider.skipIf(!process.env.AWS_TEST_DEADLINE)(
227 "create and destroy a customer-managed fleet (AWS_TEST_DEADLINE=1)",
228 (stack) =>
229 Effect.gen(function* () {
230 yield* stack.destroy();
231
232 const { farm, fleet } = yield* stack.deploy(
233 Effect.gen(function* () {
234 const farm = yield* Farm("FleetFarm", {
235 description: "alchemy deadline fleet test farm",
236 });
237 const role = yield* Role("FleetWorkerRole", {
238 assumeRolePolicyDocument: {
239 Version: "2012-10-17",
240 Statement: [
241 {
242 Effect: "Allow",
243 Principal: { Service: "credentials.deadline.amazonaws.com" },
244 Action: ["sts:AssumeRole"],
245 },
246 ],
247 },
248 managedPolicyArns: [
249 "arn:aws:iam::aws:policy/AWSDeadlineCloud-FleetWorker",
250 ],
251 });
252 const fleet = yield* Fleet("Workers", {
253 farmId: farm.farmId,
254 roleArn: role.roleArn,
255 maxWorkerCount: 1,
256 configuration: {
257 customerManaged: {
258 mode: "NO_SCALING",
259 workerCapabilities: {
260 vCpuCount: { min: 1 },
261 memoryMiB: { min: 1024 },
262 osFamily: "LINUX",
263 cpuArchitectureType: "x86_64",
264 },
265 },
266 },
267 tags: { fixture: "deadline-fleet" },
268 });
269 return { farm, fleet };
270 }),
271 );
272
273 expect(fleet.fleetId).toMatch(/^fleet-/);
274 expect(fleet.status).toBe("ACTIVE");
275 expect(fleet.workerCount).toBe(0);
276 expect(fleet.maxWorkerCount).toBe(1);
277
278 // Verify out-of-band via distilled.
279 const described = yield* deadline.getFleet({
280 farmId: fleet.farmId,
281 fleetId: fleet.fleetId,
282 });
283 expect(described.status).toBe("ACTIVE");
284 expect(described.configuration.customerManaged?.mode).toBe("NO_SCALING");
285
286 yield* stack.destroy();
287 yield* assertFarmDeleted(farm.farmId);
288 }).pipe(logLevel),
289 {
290 tags: ["provider:aws", "provider:aws:deadline", "provider:aws:iam", "live"],
291 timeout: 300_000,
292 },
293);