1import * as deadline from "@distilled.cloud/aws/deadline";
2import * as Effect from "effect/Effect";
3import * as EffectStream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9import { retryWhileFarmSettling } from "./internal.ts";
10
11export type StorageProfileOperatingSystemFamily =
12 deadline.StorageProfileOperatingSystemFamily;
13export type FileSystemLocation = deadline.FileSystemLocation;
14
15export interface StorageProfileProps {
16 /**
17 * The identifier of the farm the storage profile belongs to. Changing it
18 * replaces the storage profile.
19 */
20 farmId: string;
21 /**
22 * Display name of the storage profile.
23 * @default ${app}-${stage}-${id}
24 */
25 displayName?: string;
26 /**
27 * Operating system family of the hosts the profile describes
28 * (`WINDOWS`, `LINUX`, `MACOS`).
29 */
30 osFamily: StorageProfileOperatingSystemFamily;
31 /**
32 * Shared or local file system locations available on hosts using this
33 * profile.
34 */
35 fileSystemLocations?: FileSystemLocation[];
36}
37
38export interface StorageProfile extends Resource<
39 "AWS.Deadline.StorageProfile",
40 StorageProfileProps,
41 {
42 /**
43 * The identifier of the farm the storage profile belongs to.
44 */
45 farmId: string;
46 /**
47 * Service-assigned unique identifier of the storage profile (`sp-...`).
48 */
49 storageProfileId: string;
50 /**
51 * The storage profile's display name.
52 */
53 displayName: string;
54 /**
55 * The configured operating system family.
56 */
57 osFamily: StorageProfileOperatingSystemFamily;
58 /**
59 * The configured file system locations.
60 */
61 fileSystemLocations: FileSystemLocation[];
62 },
63 never,
64 Providers
65> {}
66
67/**
68 * An AWS Deadline Cloud storage profile — describes the operating system
69 * and file system locations of the hosts in a farm so path mapping works
70 * across mixed environments.
71 *
72 * ### Creating Storage Profiles
73 * **Example:** Linux Storage Profile
74 * ```typescript
75 * import * as AWS from "alchemy/AWS";
76 *
77 * const profile = yield* AWS.Deadline.StorageProfile("LinuxHosts", {
78 * farmId: farm.farmId,
79 * osFamily: "LINUX",
80 * fileSystemLocations: [
81 * { name: "Assets", path: "/mnt/assets", type: "SHARED" },
82 * ],
83 * });
84 * ```
85 *
86 * **Example:** Cross-Platform Path Mapping
87 * ```typescript
88 * // A second profile in the same farm maps the same shared location to its
89 * // Windows drive path, so jobs submitted from either OS resolve `Assets`.
90 * const windows = yield* AWS.Deadline.StorageProfile("WindowsHosts", {
91 * farmId: farm.farmId,
92 * osFamily: "WINDOWS",
93 * fileSystemLocations: [
94 * { name: "Assets", path: "Z:\\assets", type: "SHARED" },
95 * ],
96 * });
97 * ```
98 *
99 * @resource
100 */
101export const StorageProfile = Resource<StorageProfile>(
102 "AWS.Deadline.StorageProfile",
103);
104
105const createStorageProfileName = (
106 id: string,
107 props: { displayName?: string | undefined },
108) =>
109 props.displayName
110 ? Effect.succeed(props.displayName)
111 : createPhysicalName({ id, maxLength: 64 });
112
113interface StorageProfileState {
114 attrs: StorageProfile["Attributes"];
115 described: deadline.GetStorageProfileResponse;
116}
117
118const readStorageProfileById = Effect.fn(function* (
119 farmId: string,
120 storageProfileId: string,
121) {
122 const described = yield* deadline
123 .getStorageProfile({ farmId, storageProfileId })
124 .pipe(
125 Effect.catchTag("ResourceNotFoundException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129 if (!described) return undefined;
130 const state: StorageProfileState = {
131 described,
132 attrs: {
133 farmId,
134 storageProfileId: described.storageProfileId,
135 displayName: described.displayName,
136 osFamily: described.osFamily,
137 fileSystemLocations: [...(described.fileSystemLocations ?? [])],
138 },
139 };
140 return state;
141});
142
143const findStorageProfileByDisplayName = Effect.fn(function* (
144 farmId: string,
145 displayName: string,
146) {
147 const summaries = yield* deadline.listStorageProfiles.items({ farmId }).pipe(
148 EffectStream.runCollect,
149 Effect.map((chunk) => Array.from(chunk)),
150 // The parent farm may itself be gone.
151 Effect.catchTag("ResourceNotFoundException", () =>
152 Effect.succeed([] as deadline.StorageProfileSummary[]),
153 ),
154 );
155 const match = summaries.find(
156 (summary) => summary.displayName === displayName,
157 );
158 if (!match) return undefined;
159 return yield* readStorageProfileById(farmId, match.storageProfileId);
160});
161
162const locationKey = (location: FileSystemLocation) =>
163 `${location.name}${location.path}${location.type}`;
164
165export const StorageProfileProvider = () =>
166 Provider.effect(
167 StorageProfile,
168 Effect.gen(function* () {
169 return {
170 stables: ["farmId", "storageProfileId"],
171 // Keyed by a parent farm — sub-resource list() convention.
172 list: () => Effect.succeed([]),
173 // Storage profiles have no tag support, so ownership cannot be
174 // verified out of band — a display-name match is treated as ours.
175 read: Effect.fn(function* ({ id, olds, output }) {
176 const farmId = output?.farmId ?? olds?.farmId;
177 if (farmId === undefined) return undefined;
178 const state = output?.storageProfileId
179 ? yield* readStorageProfileById(farmId, output.storageProfileId)
180 : yield* findStorageProfileByDisplayName(
181 farmId,
182 yield* createStorageProfileName(id, olds ?? {}),
183 );
184 return state?.attrs;
185 }),
186 diff: Effect.fn(function* ({ news, olds }) {
187 if (!isResolved(news)) return;
188 if (olds === undefined) return;
189 // The parent farm is fixed at creation.
190 if (olds.farmId !== news.farmId) {
191 return { action: "replace" } as const;
192 }
193 }),
194 reconcile: Effect.fn(function* ({ id, news, output, session }) {
195 if (news === undefined) {
196 return yield* Effect.fail(
197 new Error("AWS.Deadline.StorageProfile requires props"),
198 );
199 }
200 const farmId = news.farmId;
201 const displayName = yield* createStorageProfileName(id, news);
202
203 // Observe.
204 let state = output?.storageProfileId
205 ? yield* readStorageProfileById(farmId, output.storageProfileId)
206 : yield* findStorageProfileByDisplayName(farmId, displayName);
207
208 // Ensure.
209 if (state === undefined) {
210 const created = yield* retryWhileFarmSettling(
211 deadline.createStorageProfile({
212 farmId,
213 displayName,
214 osFamily: news.osFamily,
215 fileSystemLocations: news.fileSystemLocations,
216 }),
217 );
218 yield* session.note(
219 `Created storage profile ${displayName} (${created.storageProfileId})`,
220 );
221 state = yield* readStorageProfileById(
222 farmId,
223 created.storageProfileId,
224 );
225 if (state === undefined) {
226 return yield* Effect.fail(
227 new Error(
228 `failed to read created storage profile ${displayName}`,
229 ),
230 );
231 }
232 }
233
234 // Sync — compute the file-system-location delta from OBSERVED state.
235 const observedLocations = state.described.fileSystemLocations ?? [];
236 const desiredLocations = news.fileSystemLocations ?? [];
237 const observedKeys = new Set(observedLocations.map(locationKey));
238 const desiredKeys = new Set(desiredLocations.map(locationKey));
239 const toAdd = desiredLocations.filter(
240 (location) => !observedKeys.has(locationKey(location)),
241 );
242 const toRemove = observedLocations.filter(
243 (location) => !desiredKeys.has(locationKey(location)),
244 );
245 const needsUpdate =
246 displayName !== state.described.displayName ||
247 news.osFamily !== state.described.osFamily ||
248 toAdd.length > 0 ||
249 toRemove.length > 0;
250 if (needsUpdate) {
251 yield* retryWhileFarmSettling(
252 deadline.updateStorageProfile({
253 farmId,
254 storageProfileId: state.attrs.storageProfileId,
255 displayName,
256 osFamily: news.osFamily,
257 fileSystemLocationsToAdd: toAdd.length > 0 ? toAdd : undefined,
258 fileSystemLocationsToRemove:
259 toRemove.length > 0 ? toRemove : undefined,
260 }),
261 );
262 yield* session.note(`Updated storage profile ${displayName}`);
263 }
264
265 yield* session.note(state.attrs.storageProfileId);
266 const final = yield* readStorageProfileById(
267 farmId,
268 state.attrs.storageProfileId,
269 );
270 if (!final) {
271 return yield* Effect.fail(
272 new Error(
273 `failed to read reconciled storage profile ${displayName}`,
274 ),
275 );
276 }
277 return final.attrs;
278 }),
279 delete: Effect.fn(function* ({ output }) {
280 yield* deadline
281 .deleteStorageProfile({
282 farmId: output.farmId,
283 storageProfileId: output.storageProfileId,
284 })
285 .pipe(
286 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
287 );
288 }),
289 };
290 }),
291 );