1import * as fsx from "@distilled.cloud/aws/fsx";
2import * as Effect from "effect/Effect";
3import * as Stream 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, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import { AWSEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface FileSystemProps {
14 /**
15 * The type of file system to create. Cannot be changed after creation
16 * (replacement). `LUSTRE` is the cheapest/fastest scratch storage;
17 * `WINDOWS`, `ONTAP`, and `OPENZFS` are the other managed engines.
18 */
19 fileSystemType: fsx.FileSystemType;
20 /**
21 * Storage capacity in GiB. Minimums depend on the engine and deployment
22 * type (e.g. Lustre `SCRATCH_2` starts at 1200 GiB). Can be increased in
23 * place; decreasing requires replacement.
24 */
25 storageCapacity?: number;
26 /**
27 * Storage type. `SSD` (default) or `HDD` (Lustre `PERSISTENT_1` / Windows
28 * only), or `INTELLIGENT_TIERING` (OpenZFS). Cannot be changed after
29 * creation (replacement).
30 */
31 storageType?: fsx.StorageType;
32 /**
33 * Subnet IDs the file system is deployed into. Single-AZ engines take one
34 * subnet; multi-AZ ONTAP/OpenZFS take two. Cannot be changed after
35 * creation (replacement).
36 */
37 subnetIds: string[];
38 /**
39 * Security group IDs to associate with the file system's network
40 * interfaces.
41 */
42 securityGroupIds?: string[];
43 /**
44 * The KMS key used to encrypt the file system at rest. Cannot be changed
45 * after creation (replacement).
46 * @default the AWS-managed FSx key
47 */
48 kmsKeyId?: string;
49 /**
50 * Engine version string (e.g. Lustre `"2.15"`, ONTAP `"9.13"`). Cannot be
51 * changed after creation (replacement).
52 */
53 fileSystemTypeVersion?: string;
54 /**
55 * Lustre-specific configuration (deployment type, per-unit throughput,
56 * data-repository import/export, compression, etc.). Passed straight
57 * through to the FSx API.
58 */
59 lustreConfiguration?: fsx.CreateFileSystemLustreConfiguration;
60 /**
61 * Windows-specific configuration (Active Directory, throughput capacity,
62 * deployment type). Passed straight through to the FSx API.
63 */
64 windowsConfiguration?: fsx.CreateFileSystemWindowsConfiguration;
65 /**
66 * NetApp ONTAP-specific configuration. Passed straight through to the FSx
67 * API.
68 */
69 ontapConfiguration?: fsx.CreateFileSystemOntapConfiguration;
70 /**
71 * OpenZFS-specific configuration. Passed straight through to the FSx API.
72 */
73 openZFSConfiguration?: fsx.CreateFileSystemOpenZFSConfiguration;
74 /**
75 * Tags to apply to the file system. Merged with internal Alchemy tags.
76 */
77 tags?: Record<string, string>;
78}
79
80export interface FileSystem extends Resource<
81 "AWS.FSx.FileSystem",
82 FileSystemProps,
83 {
84 /** The generated ID of the file system, e.g. `fs-0123456789abcdef0`. */
85 fileSystemId: string;
86 /** The ARN of the file system. */
87 fileSystemArn: string;
88 /** The engine of the file system: `LUSTRE`, `WINDOWS`, `ONTAP`, or `OPENZFS`. */
89 fileSystemType: fsx.FileSystemType;
90 /** The DNS name clients mount, e.g. `fs-....fsx.us-west-2.amazonaws.com`. */
91 dnsName: string | undefined;
92 /** The VPC the file system's network interfaces live in. */
93 vpcId: string | undefined;
94 },
95 {},
96 Providers
97> {}
98
99/**
100 * An Amazon FSx file system — fully-managed shared storage for Lustre,
101 * Windows File Server, NetApp ONTAP, or OpenZFS.
102 *
103 * FSx file systems take several minutes to provision. The file system id,
104 * ARN, and DNS name are returned as soon as `CreateFileSystem` accepts the
105 * request; the file system then transitions `CREATING` → `AVAILABLE`
106 * asynchronously. Create is idempotent on a deterministic client request
107 * token derived from the app, stage, and logical id; if state is lost, the
108 * file system is re-discovered by its internal Alchemy tags.
109 *
110 * ### Creating File Systems
111 * **Example:** Lustre scratch file system (cheapest / fastest)
112 * ```typescript
113 * import * as AWS from "alchemy/AWS";
114 *
115 * const scratch = yield* AWS.FSx.FileSystem("Scratch", {
116 * fileSystemType: "LUSTRE",
117 * storageCapacity: 1200,
118 * subnetIds: [subnetId],
119 * lustreConfiguration: { DeploymentType: "SCRATCH_2" },
120 * });
121 * ```
122 *
123 * **Example:** Persistent Lustre with S3 data repository
124 * ```typescript
125 * const files = yield* AWS.FSx.FileSystem("Files", {
126 * fileSystemType: "LUSTRE",
127 * storageCapacity: 1200,
128 * subnetIds: [subnetId],
129 * lustreConfiguration: {
130 * DeploymentType: "PERSISTENT_2",
131 * PerUnitStorageThroughput: 125,
132 * DataCompressionType: "LZ4",
133 * },
134 * });
135 * ```
136 *
137 * **Example:** OpenZFS file system
138 * ```typescript
139 * const zfs = yield* AWS.FSx.FileSystem("Zfs", {
140 * fileSystemType: "OPENZFS",
141 * storageCapacity: 64,
142 * subnetIds: [subnetId],
143 * openZFSConfiguration: {
144 * DeploymentType: "SINGLE_AZ_1",
145 * ThroughputCapacity: 64,
146 * },
147 * });
148 * ```
149 *
150 * @resource
151 */
152export const FileSystem = Resource<FileSystem>("AWS.FSx.FileSystem");
153
154const fsxTagsToRecord = (
155 tags: readonly fsx.Tag[] | undefined,
156): Record<string, string> =>
157 Object.fromEntries((tags ?? []).map((t) => [t.Key, t.Value]));
158
159const deleteConfigFor = (
160 type: fsx.FileSystemType,
161): Partial<fsx.DeleteFileSystemRequest> => {
162 switch (type) {
163 case "LUSTRE":
164 return { LustreConfiguration: { SkipFinalBackup: true } };
165 case "WINDOWS":
166 return { WindowsConfiguration: { SkipFinalBackup: true } };
167 case "OPENZFS":
168 return {
169 OpenZFSConfiguration: {
170 SkipFinalBackup: true,
171 Options: ["DELETE_CHILD_VOLUMES_AND_SNAPSHOTS"],
172 },
173 };
174 default:
175 // ONTAP has no file-system-level skip-final-backup option; volumes and
176 // SVMs must be torn down first.
177 return {};
178 }
179};
180
181export const FileSystemProvider = () =>
182 Provider.effect(
183 FileSystem,
184 Effect.gen(function* () {
185 const createToken = Effect.fn(function* (id: string) {
186 return yield* createPhysicalName({ id, maxLength: 63 });
187 });
188
189 const fileSystemArnOf = Effect.fn(function* (fileSystemId: string) {
190 const { accountId, region } = yield* AWSEnvironment.current;
191 return `arn:aws:fsx:${region}:${accountId}:file-system/${fileSystemId}`;
192 });
193
194 const findById = Effect.fn(function* (fileSystemId: string) {
195 return yield* fsx
196 .describeFileSystems({ FileSystemIds: [fileSystemId] })
197 .pipe(
198 Effect.map((r) => r.FileSystems?.[0]),
199 Effect.catchTag("FileSystemNotFound", () =>
200 Effect.succeed(undefined),
201 ),
202 );
203 });
204
205 const listAll = fsx.describeFileSystems.pages({}).pipe(
206 Stream.runCollect,
207 Effect.map((chunk) =>
208 Array.from(chunk).flatMap((page) => page.FileSystems ?? []),
209 ),
210 );
211
212 // FSx has no creation-token lookup, so recover lost state by scanning
213 // every file system and matching our internal Alchemy tags.
214 const findByTags = Effect.fn(function* (id: string) {
215 const internalTags = yield* createInternalTags(id);
216 const all = yield* listAll;
217 return all.find((fs) => {
218 const tags = fsxTagsToRecord(fs.Tags);
219 return Object.entries(internalTags).every(([k, v]) => tags[k] === v);
220 });
221 });
222
223 return FileSystem.Provider.of({
224 stables: ["fileSystemId", "fileSystemArn", "fileSystemType"],
225
226 list: () =>
227 Effect.gen(function* () {
228 const systems = yield* listAll;
229 return yield* Effect.forEach(systems, (fs) =>
230 Effect.gen(function* () {
231 return {
232 fileSystemId: fs.FileSystemId!,
233 fileSystemArn:
234 fs.ResourceARN ??
235 (yield* fileSystemArnOf(fs.FileSystemId!)),
236 fileSystemType: fs.FileSystemType ?? "LUSTRE",
237 dnsName: fs.DNSName,
238 vpcId: fs.VpcId,
239 };
240 }),
241 );
242 }),
243
244 read: Effect.fn(function* ({ id, output }) {
245 const fs = output?.fileSystemId
246 ? yield* findById(output.fileSystemId)
247 : yield* findByTags(id);
248 if (fs === undefined || fs.Lifecycle === "DELETING") {
249 return undefined;
250 }
251 const attrs = {
252 fileSystemId: fs.FileSystemId!,
253 fileSystemArn:
254 fs.ResourceARN ?? (yield* fileSystemArnOf(fs.FileSystemId!)),
255 fileSystemType: fs.FileSystemType ?? "LUSTRE",
256 dnsName: fs.DNSName,
257 vpcId: fs.VpcId,
258 };
259 return (yield* hasAlchemyTags(id, fsxTagsToRecord(fs.Tags)))
260 ? attrs
261 : Unowned(attrs);
262 }),
263
264 diff: Effect.fn(function* ({ news, olds }) {
265 if (!isResolved(news)) return undefined;
266 const replaced =
267 news.fileSystemType !== olds.fileSystemType ||
268 news.kmsKeyId !== olds.kmsKeyId ||
269 (news.storageType ?? "SSD") !== (olds.storageType ?? "SSD") ||
270 news.fileSystemTypeVersion !== olds.fileSystemTypeVersion ||
271 JSON.stringify(news.subnetIds) !== JSON.stringify(olds.subnetIds) ||
272 // storage capacity can only be increased in place
273 (news.storageCapacity !== undefined &&
274 olds.storageCapacity !== undefined &&
275 news.storageCapacity < olds.storageCapacity);
276 if (replaced) {
277 return { action: "replace" } as const;
278 }
279 // undefined → default update path (storage-capacity increase, tags)
280 }),
281
282 reconcile: Effect.fn(function* ({ id, news, output, session }) {
283 const internalTags = yield* createInternalTags(id);
284
285 // 1. OBSERVE — cloud state is authoritative; output is only a cache.
286 let fs = output?.fileSystemId
287 ? yield* findById(output.fileSystemId)
288 : yield* findByTags(id);
289 if (fs?.Lifecycle === "DELETING") {
290 fs = undefined;
291 }
292
293 // 2. ENSURE — create if missing. CreateFileSystem is idempotent on
294 // the deterministic client request token.
295 if (fs === undefined) {
296 const token = yield* createToken(id);
297 fs = yield* fsx
298 .createFileSystem({
299 ClientRequestToken: token,
300 FileSystemType: news.fileSystemType,
301 StorageCapacity: news.storageCapacity,
302 StorageType: news.storageType,
303 SubnetIds: news.subnetIds,
304 SecurityGroupIds: news.securityGroupIds,
305 KmsKeyId: news.kmsKeyId,
306 FileSystemTypeVersion: news.fileSystemTypeVersion,
307 LustreConfiguration: news.lustreConfiguration,
308 WindowsConfiguration: news.windowsConfiguration,
309 OntapConfiguration: news.ontapConfiguration,
310 OpenZFSConfiguration: news.openZFSConfiguration,
311 Tags: Object.entries({ ...news.tags, ...internalTags }).map(
312 ([Key, Value]) => ({ Key, Value }),
313 ),
314 })
315 .pipe(Effect.map((r) => r.FileSystem!));
316 }
317
318 const fileSystemId = fs.FileSystemId!;
319
320 // 3a. SYNC storage capacity — only an increase is valid, and only
321 // while the file system is settled. Skip during CREATING.
322 if (
323 fs.Lifecycle === "AVAILABLE" &&
324 news.storageCapacity !== undefined &&
325 fs.StorageCapacity !== undefined &&
326 news.storageCapacity > fs.StorageCapacity
327 ) {
328 yield* fsx.updateFileSystem({
329 FileSystemId: fileSystemId,
330 StorageCapacity: news.storageCapacity,
331 });
332 }
333
334 // 3b. SYNC tags — diff against OBSERVED cloud tags so adoption
335 // converges.
336 const observedTags = fsxTagsToRecord(fs.Tags);
337 const { upsert, removed } = diffTags(observedTags, {
338 ...news.tags,
339 ...internalTags,
340 });
341 if (upsert.length > 0) {
342 yield* fsx.tagResource({
343 ResourceARN: fs.ResourceARN,
344 Tags: upsert,
345 });
346 }
347 if (removed.length > 0) {
348 yield* fsx.untagResource({
349 ResourceARN: fs.ResourceARN,
350 TagKeys: removed,
351 });
352 }
353
354 yield* session.note(fileSystemId);
355 return {
356 fileSystemId,
357 fileSystemArn:
358 fs.ResourceARN ?? (yield* fileSystemArnOf(fileSystemId)),
359 fileSystemType: fs.FileSystemType ?? news.fileSystemType,
360 dnsName: fs.DNSName,
361 vpcId: fs.VpcId,
362 };
363 }),
364
365 delete: Effect.fn(function* ({ output }) {
366 yield* fsx
367 .deleteFileSystem({
368 FileSystemId: output.fileSystemId,
369 ...deleteConfigFor(output.fileSystemType),
370 })
371 .pipe(Effect.catchTag("FileSystemNotFound", () => Effect.void));
372 }),
373 });
374 }),
375 );