1import * as s3files from "@distilled.cloud/aws/s3files";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
9import type { PolicyDocument } from "../IAM/Policy.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 retryWhileConflict,
13 S3FilesNotConverged,
14 toTagList,
15 toTagRecord,
16 untilSettled,
17} from "./internal.ts";
18
19export interface FileSystemProps {
20 /**
21 * ARN of the S3 general purpose bucket the file system exposes as POSIX
22 * storage (`arn:aws:s3:::my-bucket`). The bucket must have versioning
23 * enabled. Changing it replaces the file system.
24 */
25 bucket: string;
26 /**
27 * Optional key prefix that scopes the file system to a portion of the
28 * bucket. Changing it replaces the file system.
29 */
30 prefix?: string;
31 /**
32 * ARN of the IAM role that grants the S3 Files service permission to read
33 * and write the bucket on the file system's behalf. S3 Files runs on EFS
34 * infrastructure, so the role must trust the
35 * `elasticfilesystem.amazonaws.com` service principal. Changing it
36 * replaces the file system.
37 */
38 roleArn: string;
39 /**
40 * KMS key used to encrypt file system data. Changing it replaces the file
41 * system.
42 * @default AWS-managed encryption
43 */
44 kmsKeyId?: string;
45 /**
46 * Acknowledge the service warning that applies when creating a file system
47 * over a bucket that already contains data.
48 * @default false
49 */
50 acceptBucketWarning?: boolean;
51 /**
52 * IAM resource policy controlling access to the file system. Omitting it
53 * removes any existing policy.
54 */
55 policy?: PolicyDocument;
56 /**
57 * When true, deletion force-deletes the file system even if it still has
58 * pending export data.
59 * @default false
60 */
61 forceDestroy?: boolean;
62 /**
63 * Tags to apply to the file system. Merged with internal Alchemy tags.
64 */
65 tags?: Record<string, string>;
66}
67
68export interface FileSystem extends Resource<
69 "AWS.S3Files.FileSystem",
70 FileSystemProps,
71 {
72 /**
73 * Unique ID of the file system.
74 */
75 fileSystemId: string;
76 /**
77 * ARN of the file system.
78 */
79 fileSystemArn: string;
80 /**
81 * Name of the S3 general purpose bucket backing the file system.
82 */
83 bucket: string;
84 /**
85 * ARN of the IAM role the file system uses to access the backing bucket.
86 */
87 roleArn: string;
88 /**
89 * Current lifecycle status of the file system (e.g. `AVAILABLE`).
90 */
91 status: string;
92 /**
93 * Key prefix within the backing bucket that scopes the file system, if
94 * one was configured.
95 */
96 prefix?: string;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An Amazon S3 File System — POSIX-style file system access over an S3
104 * general purpose bucket (mountable from EC2/ECS via mount targets, with
105 * application-scoped access via {@link AccessPoint}s).
106 *
107 * S3 Files is a newer service; availability varies by region and account.
108 *
109 * ### Creating a File System
110 * **Example:** Basic File System
111 * ```typescript
112 * import * as AWS from "alchemy/AWS";
113 *
114 * // S3 Files requires versioning on the source bucket.
115 * const bucket = yield* AWS.S3.Bucket("Data", { versioning: "Enabled" });
116 * const role = yield* AWS.IAM.Role("FilesRole", {
117 * assumeRolePolicyDocument: {
118 * Version: "2012-10-17",
119 * Statement: [
120 * {
121 * Effect: "Allow",
122 * // S3 Files runs on EFS infrastructure and assumes the role as
123 * // the elasticfilesystem service principal.
124 * Principal: { Service: "elasticfilesystem.amazonaws.com" },
125 * Action: ["sts:AssumeRole"],
126 * },
127 * ],
128 * },
129 * inlinePolicies: {
130 * bucket: {
131 * Version: "2012-10-17",
132 * Statement: [
133 * {
134 * Effect: "Allow",
135 * Action: ["s3:ListBucket", "s3:ListBucketVersions"],
136 * Resource: [bucket.bucketArn],
137 * },
138 * {
139 * Effect: "Allow",
140 * Action: ["s3:GetObject*", "s3:PutObject*", "s3:DeleteObject*", "s3:List*", "s3:AbortMultipartUpload"],
141 * Resource: [AWS.Output.interpolate`${bucket.bucketArn}/*`],
142 * },
143 * ],
144 * },
145 * },
146 * });
147 *
148 * const fs = yield* AWS.S3Files.FileSystem("Files", {
149 * bucket: bucket.bucketArn,
150 * roleArn: role.roleArn,
151 * });
152 * ```
153 *
154 * **Example:** Prefix-Scoped File System
155 * ```typescript
156 * const fs = yield* AWS.S3Files.FileSystem("Files", {
157 * bucket: bucket.bucketArn,
158 * prefix: "shared/",
159 * roleArn: role.roleArn,
160 * });
161 * ```
162 *
163 * @resource
164 */
165export const FileSystem = Resource<FileSystem>("AWS.S3Files.FileSystem");
166
167const replacementKey = (props: Partial<FileSystemProps>) =>
168 JSON.stringify({
169 bucket: props.bucket,
170 prefix: props.prefix,
171 roleArn: props.roleArn,
172 kmsKeyId: props.kmsKeyId,
173 });
174
175const normalizePolicy = (policy: string | undefined) => {
176 if (policy === undefined) return undefined;
177 try {
178 return JSON.stringify(JSON.parse(policy));
179 } catch {
180 return policy;
181 }
182};
183
184export const FileSystemProvider = () =>
185 Provider.effect(
186 FileSystem,
187 Effect.gen(function* () {
188 const observe = (fileSystemId: string) =>
189 s3files
190 .getFileSystem({ fileSystemId })
191 .pipe(
192 Effect.catchTag("ResourceNotFoundException", () =>
193 Effect.succeed(undefined),
194 ),
195 );
196
197 const observedTags = (fileSystemId: string) =>
198 s3files.listTagsForResource.items({ resourceId: fileSystemId }).pipe(
199 Stream.runCollect,
200 Effect.map((tags) => toTagRecord(Array.from(tags))),
201 Effect.catchTag("ResourceNotFoundException", () =>
202 Effect.succeed({} as Record<string, string>),
203 ),
204 );
205
206 const toAttributes = Effect.fn(function* (
207 fs: s3files.GetFileSystemResponse,
208 ) {
209 if (
210 fs.fileSystemId === undefined ||
211 fs.fileSystemArn === undefined ||
212 fs.bucket === undefined ||
213 fs.roleArn === undefined ||
214 fs.status === undefined
215 ) {
216 return yield* Effect.fail(
217 new S3FilesNotConverged({
218 resource: fs.fileSystemId ?? "unknown file system",
219 status: fs.status,
220 }),
221 );
222 }
223 return {
224 fileSystemId: fs.fileSystemId,
225 fileSystemArn: fs.fileSystemArn,
226 bucket: fs.bucket,
227 roleArn: fs.roleArn,
228 status: fs.status,
229 ...(fs.prefix !== undefined ? { prefix: fs.prefix } : {}),
230 };
231 });
232
233 // State-loss / conflict fallback: find the file system carrying this
234 // logical id's Alchemy tags (optionally narrowed to a bucket).
235 const findByAlchemyTags = Effect.fn(function* (
236 id: string,
237 bucket: string | undefined,
238 ) {
239 const fileSystems = yield* s3files.listFileSystems
240 .items(bucket !== undefined ? { bucket } : {})
241 .pipe(
242 Stream.runCollect,
243 Effect.map((c) => Array.from(c)),
244 );
245 for (const fs of fileSystems) {
246 const tags = yield* observedTags(fs.fileSystemId);
247 if (yield* hasAlchemyTags(id, tags)) {
248 return yield* observe(fs.fileSystemId);
249 }
250 }
251 return undefined;
252 });
253
254 const syncTags = Effect.fn(function* (
255 fileSystemId: string,
256 desired: Record<string, string>,
257 ) {
258 const current = yield* observedTags(fileSystemId);
259 const { upsert, removed } = diffTags(current, desired);
260 if (upsert.length > 0) {
261 yield* s3files.tagResource({
262 resourceId: fileSystemId,
263 tags: upsert.map((t) => ({ key: t.Key, value: t.Value })),
264 });
265 }
266 if (removed.length > 0) {
267 yield* s3files.untagResource({
268 resourceId: fileSystemId,
269 tagKeys: removed,
270 });
271 }
272 });
273
274 const syncPolicy = Effect.fn(function* (
275 fileSystemId: string,
276 desired: PolicyDocument | undefined,
277 ) {
278 const current = yield* s3files
279 .getFileSystemPolicy({ fileSystemId })
280 .pipe(
281 Effect.map((r) => r.policy),
282 Effect.catchTag("ResourceNotFoundException", () =>
283 Effect.succeed(undefined),
284 ),
285 );
286 const desiredJson =
287 desired !== undefined ? JSON.stringify(desired) : undefined;
288 if (desiredJson !== undefined) {
289 if (normalizePolicy(current) !== normalizePolicy(desiredJson)) {
290 yield* s3files.putFileSystemPolicy({
291 fileSystemId,
292 policy: desiredJson,
293 });
294 }
295 } else if (current !== undefined) {
296 yield* s3files
297 .deleteFileSystemPolicy({ fileSystemId })
298 .pipe(
299 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
300 );
301 }
302 });
303
304 return FileSystem.Provider.of({
305 stables: ["fileSystemId", "fileSystemArn", "bucket"],
306 list: () =>
307 Effect.gen(function* () {
308 const fileSystems = yield* s3files.listFileSystems.items({}).pipe(
309 Stream.runCollect,
310 Effect.map((c) => Array.from(c)),
311 );
312 return fileSystems.map((fs) => ({
313 fileSystemId: fs.fileSystemId,
314 fileSystemArn: fs.fileSystemArn,
315 bucket: fs.bucket,
316 roleArn: fs.roleArn,
317 status: fs.status,
318 }));
319 }),
320 read: Effect.fn(function* ({ id, olds, output }) {
321 const found =
322 output?.fileSystemId !== undefined
323 ? yield* observe(output.fileSystemId)
324 : yield* findByAlchemyTags(id, olds?.bucket);
325 if (found === undefined) return undefined;
326 const attrs = yield* toAttributes(found);
327 const tags = toTagRecord(found.tags);
328 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
329 }),
330 diff: Effect.fn(function* ({ news, olds }) {
331 if (!isResolved(news)) return undefined;
332 // There is no UpdateFileSystem API — bucket, prefix, role, and
333 // encryption are all fixed at create time. Only tags and the
334 // resource policy are mutable in place.
335 if (replacementKey(olds ?? {}) !== replacementKey(news ?? {})) {
336 return { action: "replace" } as const;
337 }
338 // fall through: engine default update (tags + policy sync)
339 }),
340 reconcile: Effect.fn(function* ({ id, news, output, session }) {
341 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
342 const internalTags = yield* createInternalTags(id);
343 const desiredTags = { ...news.tags, ...internalTags };
344
345 // 1. OBSERVE — cloud state is authoritative; output only caches
346 // the server-assigned file system id.
347 let live =
348 output?.fileSystemId !== undefined
349 ? yield* observe(output.fileSystemId)
350 : yield* findByAlchemyTags(id, news.bucket);
351
352 // 2. ENSURE — create when missing. A ConflictException means a
353 // file system already exists for this bucket/prefix (possibly a
354 // concurrent create) — re-find it by our tags and only fail if
355 // it is not ours.
356 if (live === undefined) {
357 const created = yield* s3files
358 .createFileSystem({
359 bucket: news.bucket,
360 prefix: news.prefix,
361 roleArn: news.roleArn,
362 kmsKeyId: news.kmsKeyId,
363 acceptBucketWarning: news.acceptBucketWarning,
364 clientToken,
365 tags: toTagList(desiredTags),
366 })
367 .pipe(
368 Effect.catchTag("ConflictException", () =>
369 Effect.succeed(undefined),
370 ),
371 );
372 live =
373 created?.fileSystemId !== undefined
374 ? yield* observe(created.fileSystemId)
375 : yield* findByAlchemyTags(id, news.bucket);
376 }
377 if (live?.fileSystemId === undefined) {
378 return yield* Effect.fail(
379 new S3FilesNotConverged({
380 resource: `file system for bucket ${news.bucket}`,
381 status: live?.status,
382 }),
383 );
384 }
385 const fileSystemId = live.fileSystemId;
386
387 // Wait (bounded) for the file system to leave `creating`.
388 const settled = yield* untilSettled(
389 observe(fileSystemId),
390 (fs) => fs === undefined || fs.status !== "creating",
391 );
392 if (settled === undefined || settled.status !== "available") {
393 return yield* Effect.fail(
394 new S3FilesNotConverged({
395 resource: fileSystemId,
396 status: settled?.status,
397 }),
398 );
399 }
400
401 // 3. SYNC — tags against observed cloud tags; policy against the
402 // observed resource policy.
403 yield* syncTags(fileSystemId, desiredTags);
404 yield* syncPolicy(fileSystemId, news.policy);
405
406 // 4. RETURN fresh attributes.
407 yield* session.note(fileSystemId);
408 return yield* toAttributes(settled);
409 }),
410 delete: Effect.fn(function* ({ olds, output }) {
411 // Idempotent: initiate deletion unless it is already underway.
412 const live = yield* observe(output.fileSystemId);
413 if (live === undefined) return;
414 if (live.status !== "deleting" && live.status !== "deleted") {
415 yield* retryWhileConflict(
416 s3files.deleteFileSystem({
417 fileSystemId: output.fileSystemId,
418 forceDelete: olds.forceDestroy,
419 }),
420 ).pipe(
421 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
422 );
423 }
424 // Wait until the file system is fully gone: the source bucket
425 // rejects DeleteBucket with BucketHasS3FileSystemAttached while
426 // the file system still exists, and the engine deletes the bucket
427 // immediately after this resource.
428 const remaining = yield* untilSettled(
429 observe(output.fileSystemId),
430 (fs) => fs === undefined,
431 );
432 if (remaining !== undefined) {
433 return yield* Effect.fail(
434 new S3FilesNotConverged({
435 resource: output.fileSystemId,
436 status: remaining.status,
437 }),
438 );
439 }
440 }),
441 });
442 }),
443 );