1import * as deadline from "@distilled.cloud/aws/deadline";
2import * as Effect from "effect/Effect";
3import * as EffectStream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags, type Tags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 asPlain,
13 deadlineArnOf,
14 fetchDeadlineTags,
15 reapDeadlineLogGroups,
16 reapFarmChildren,
17 retryWhileConflict,
18 syncDeadlineTags,
19} from "./internal.ts";
20
21export interface FarmProps {
22 /**
23 * Display name of the farm.
24 * @default ${app}-${stage}-${id}
25 */
26 displayName?: string;
27 /**
28 * A description of the farm.
29 */
30 description?: string;
31 /**
32 * ARN of the KMS key used to encrypt farm data. Changing it replaces the
33 * farm.
34 * @default an AWS-owned key
35 */
36 kmsKeyArn?: string;
37 /**
38 * Multiplier applied to the cost of renders on this farm when computing
39 * budget usage.
40 * @default 1
41 */
42 costScaleFactor?: number;
43 /**
44 * Tags to associate with the farm.
45 */
46 tags?: Record<string, string>;
47}
48
49export interface Farm extends Resource<
50 "AWS.Deadline.Farm",
51 FarmProps,
52 {
53 /**
54 * Service-assigned unique identifier of the farm (`farm-...`).
55 */
56 farmId: string;
57 /**
58 * ARN of the farm.
59 */
60 farmArn: string;
61 /**
62 * The farm's display name.
63 */
64 displayName: string;
65 /**
66 * ARN of the KMS key encrypting farm data, when customer-managed.
67 */
68 kmsKeyArn: string | undefined;
69 /**
70 * The configured cost scale factor.
71 */
72 costScaleFactor: number;
73 /**
74 * Current tags reported for the farm.
75 */
76 tags: Record<string, string>;
77 },
78 never,
79 Providers
80> {}
81
82/**
83 * An AWS Deadline Cloud farm — the top-level container for render-farm
84 * queues, fleets, storage profiles, and budgets.
85 *
86 * ### Creating Farms
87 * **Example:** Basic Farm
88 * ```typescript
89 * import * as AWS from "alchemy/AWS";
90 *
91 * const farm = yield* AWS.Deadline.Farm("RenderFarm", {});
92 * ```
93 *
94 * **Example:** Farm with Description and Cost Scaling
95 * ```typescript
96 * const farm = yield* AWS.Deadline.Farm("RenderFarm", {
97 * displayName: "studio-renders",
98 * description: "Production render farm",
99 * costScaleFactor: 1.5,
100 * tags: { team: "vfx" },
101 * });
102 * ```
103 *
104 * @resource
105 */
106export const Farm = Resource<Farm>("AWS.Deadline.Farm");
107
108const createFarmName = (
109 id: string,
110 props: { displayName?: string | undefined },
111) =>
112 props.displayName
113 ? Effect.succeed(props.displayName)
114 : createPhysicalName({ id, maxLength: 100 });
115
116interface FarmState {
117 attrs: Farm["Attributes"];
118 described: deadline.GetFarmResponse;
119}
120
121const readFarmById = Effect.fn(function* (
122 farmId: string,
123 arnOf: (path: string) => string,
124) {
125 const described = yield* deadline
126 .getFarm({ farmId })
127 .pipe(
128 Effect.catchTag("ResourceNotFoundException", () =>
129 Effect.succeed(undefined),
130 ),
131 );
132 if (!described) return undefined;
133 const farmArn = arnOf(`farm/${described.farmId}`);
134 const state: FarmState = {
135 described,
136 attrs: {
137 farmId: described.farmId,
138 farmArn,
139 displayName: described.displayName,
140 kmsKeyArn: described.kmsKeyArn,
141 costScaleFactor: described.costScaleFactor,
142 tags: yield* fetchDeadlineTags(farmArn),
143 },
144 };
145 return state;
146});
147
148const findFarmByDisplayName = Effect.fn(function* (
149 displayName: string,
150 arnOf: (path: string) => string,
151) {
152 const summaries = yield* deadline.listFarms.items({}).pipe(
153 EffectStream.runCollect,
154 Effect.map((chunk) => Array.from(chunk)),
155 );
156 const match = summaries.find(
157 (summary) => summary.displayName === displayName,
158 );
159 if (!match) return undefined;
160 return yield* readFarmById(match.farmId, arnOf);
161});
162
163export const FarmProvider = () =>
164 Provider.effect(
165 Farm,
166 Effect.gen(function* () {
167 return {
168 stables: ["farmId", "farmArn"],
169 list: () =>
170 Effect.gen(function* () {
171 const arnOf = yield* deadlineArnOf;
172 const summaries = yield* deadline.listFarms.items({}).pipe(
173 EffectStream.runCollect,
174 Effect.map((chunk) => Array.from(chunk)),
175 );
176 const states = yield* Effect.forEach(
177 summaries,
178 (summary) => readFarmById(summary.farmId, arnOf),
179 { concurrency: 4 },
180 );
181 return states
182 .filter((state): state is FarmState => state !== undefined)
183 .map((state) => state.attrs);
184 }),
185 read: Effect.fn(function* ({ id, olds, output }) {
186 const arnOf = yield* deadlineArnOf;
187 const state = output?.farmId
188 ? yield* readFarmById(output.farmId, arnOf)
189 : yield* findFarmByDisplayName(
190 yield* createFarmName(id, olds ?? {}),
191 arnOf,
192 );
193 if (!state) return undefined;
194 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
195 ? state.attrs
196 : Unowned(state.attrs);
197 }),
198 diff: Effect.fn(function* ({ news, olds }) {
199 if (!isResolved(news)) return;
200 if (olds === undefined) return;
201 // The KMS key is fixed at creation.
202 if ((olds.kmsKeyArn ?? undefined) !== (news.kmsKeyArn ?? undefined)) {
203 return { action: "replace" } as const;
204 }
205 }),
206 reconcile: Effect.fn(function* ({ id, news, output, session }) {
207 if (news === undefined) {
208 return yield* Effect.fail(
209 new Error("AWS.Deadline.Farm requires props"),
210 );
211 }
212 const arnOf = yield* deadlineArnOf;
213 const displayName = yield* createFarmName(id, news);
214 const internalTags = yield* createInternalTags(id);
215 const desiredTags = { ...internalTags, ...news.tags };
216
217 // Observe — prefer the cached farmId; fall back to a display-name
218 // lookup so a create whose state failed to persist is adopted.
219 let state = output?.farmId
220 ? yield* readFarmById(output.farmId, arnOf)
221 : yield* findFarmByDisplayName(displayName, arnOf);
222
223 // Ensure — create if missing.
224 if (state === undefined) {
225 const created = yield* deadline.createFarm({
226 displayName,
227 description: news.description,
228 kmsKeyArn: news.kmsKeyArn,
229 costScaleFactor: news.costScaleFactor,
230 tags: desiredTags,
231 });
232 yield* session.note(
233 `Created farm ${displayName} (${created.farmId})`,
234 );
235 state = yield* readFarmById(created.farmId, arnOf);
236 if (state === undefined) {
237 return yield* Effect.fail(
238 new Error(`failed to read created farm ${displayName}`),
239 );
240 }
241 }
242
243 // Sync mutable settings — only when drifted from OBSERVED state.
244 const described = state.described;
245 const needsUpdate =
246 displayName !== described.displayName ||
247 (news.description !== undefined &&
248 news.description !== (asPlain(described.description) ?? "")) ||
249 (news.costScaleFactor !== undefined &&
250 news.costScaleFactor !== described.costScaleFactor);
251 if (needsUpdate) {
252 yield* deadline.updateFarm({
253 farmId: state.attrs.farmId,
254 displayName,
255 description: news.description,
256 costScaleFactor: news.costScaleFactor,
257 });
258 yield* session.note(`Updated farm ${displayName}`);
259 }
260
261 // Sync tags — diff against observed cloud tags.
262 yield* syncDeadlineTags(state.attrs.farmArn, desiredTags);
263
264 yield* session.note(state.attrs.farmArn);
265 const final = yield* readFarmById(state.attrs.farmId, arnOf);
266 if (!final) {
267 return yield* Effect.fail(
268 new Error(`failed to read reconciled farm ${displayName}`),
269 );
270 }
271 return final.attrs;
272 }),
273 delete: Effect.fn(function* ({ output }) {
274 // A farm refuses deletion while ANY child resource exists (storage
275 // profiles, queues, fleets, budgets, limits, associations) — and
276 // unlike async sub-resource drain, those conflicts never resolve
277 // by waiting. A normal stack destroy deletes children first, so
278 // this reap observes nothing; an orphan sweep (nuke) or a mid-run
279 // crash targets a farm whose children were never enumerated, and
280 // without the reap deleteFarm conflicts until the retry budget
281 // runs out and the farm leaks.
282 yield* reapFarmChildren(output.farmId);
283 // Child deletion (queues, fleets) finishes asynchronously; the
284 // farm rejects deletion with ConflictException until it settles.
285 yield* retryWhileConflict(
286 deadline.deleteFarm({ farmId: output.farmId }),
287 ).pipe(
288 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
289 );
290 // Deadline auto-creates log groups under /aws/deadline/{farmId}/
291 // (queue job/session logs, fleet worker logs) and deleteFarm does
292 // NOT remove them. The farm only deletes once every sub-resource
293 // is fully gone, so by this point all of its log groups exist if
294 // they ever will — sweep the whole prefix.
295 yield* reapDeadlineLogGroups(`/aws/deadline/${output.farmId}`);
296 }),
297 };
298 }),
299 );