1import * as efs from "@distilled.cloud/aws/efs";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { deepEqual, isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13
14export interface AccessPointPosixUser {
15 /** POSIX user ID applied to all file-system requests through this access point. */
16 uid: number;
17 /** POSIX group ID applied to all file-system requests through this access point. */
18 gid: number;
19 /** Secondary POSIX group IDs. */
20 secondaryGids?: number[];
21}
22
23export interface AccessPointRootDirectory {
24 /**
25 * Path on the file system to expose as the access point's root.
26 * @default "/"
27 */
28 path?: string;
29 /**
30 * Ownership and permissions EFS applies when it creates the root directory
31 * on first mount. Required if `path` does not already exist on the file
32 * system (which is always the case for a freshly created file system).
33 */
34 creationInfo?: {
35 /** POSIX user ID that owns the created root directory. */
36 ownerUid: number;
37 /** POSIX group ID that owns the created root directory. */
38 ownerGid: number;
39 /** Octal permissions of the created root directory, e.g. `"750"`. */
40 permissions: string;
41 };
42}
43
44export interface AccessPointProps {
45 /**
46 * ID of the EFS file system the access point exposes
47 * (e.g. `fileSystem.fileSystemId`). Cannot be changed after creation
48 * (replacement).
49 */
50 fileSystemId: string;
51 /**
52 * POSIX identity enforced for all requests through this access point.
53 * Cannot be changed after creation (replacement).
54 */
55 posixUser?: AccessPointPosixUser;
56 /**
57 * Directory on the file system exposed as the access point's root,
58 * optionally created on first mount with the given ownership/permissions.
59 * Cannot be changed after creation (replacement).
60 */
61 rootDirectory?: AccessPointRootDirectory;
62 /**
63 * Tags to apply to the access point. Merged with internal Alchemy tags.
64 */
65 tags?: Record<string, string>;
66}
67
68export interface AccessPoint extends Resource<
69 "AWS.EFS.AccessPoint",
70 AccessPointProps,
71 {
72 /** The ID of the access point (e.g. `fsap-0123456789abcdef0`). */
73 accessPointId: string;
74 /** The ARN of the access point. */
75 accessPointArn: string;
76 /** The ID of the EFS file system the access point belongs to. */
77 fileSystemId: string;
78 },
79 {},
80 Providers
81> {}
82
83/**
84 * An Amazon EFS access point — an application-specific entry point into a
85 * file system that enforces a POSIX identity and a root directory.
86 *
87 * Access points are how Lambda (and other serverless compute) mounts EFS:
88 * pass `accessPoint.accessPointArn` to a Lambda Function's
89 * `fileSystemConfigs`. The POSIX user and root directory are immutable —
90 * changing them replaces the access point.
91 * ### Creating Access Points
92 * **Example:** Access point with a POSIX identity and auto-created root
93 * ```typescript
94 * import * as AWS from "alchemy/AWS";
95 *
96 * const files = yield* AWS.EFS.FileSystem("Files");
97 * const accessPoint = yield* AWS.EFS.AccessPoint("FilesAccess", {
98 * fileSystemId: files.fileSystemId,
99 * posixUser: { uid: 1000, gid: 1000 },
100 * rootDirectory: {
101 * path: "/app",
102 * creationInfo: { ownerUid: 1000, ownerGid: 1000, permissions: "750" },
103 * },
104 * });
105 * ```
106 *
107 * ### Mounting into Lambda
108 * **Example:** Mount at /mnt/files
109 * ```typescript
110 * const fn = yield* AWS.Lambda.Function("Api", {
111 * main: "./src/handler.ts",
112 * vpc: { subnetIds: [subnetId], securityGroupIds: [securityGroupId] },
113 * fileSystemConfigs: [
114 * { arn: accessPoint.accessPointArn, localMountPath: "/mnt/files" },
115 * ],
116 * });
117 * ```
118 *
119 * @resource
120 */
121export const AccessPoint = Resource<AccessPoint>("AWS.EFS.AccessPoint");
122
123/**
124 * Internal marker error used to drive the bounded wait for an access point
125 * to reach the `available` lifecycle state.
126 */
127export class AccessPointNotAvailable extends Data.TaggedError(
128 "AccessPointNotAvailable",
129)<{ accessPointId: string; state: string }> {}
130
131/**
132 * Access points settle to `available` in seconds. Bounded poll (~60s),
133 * explicitly typed so declaration emit never widens the provider layer
134 * (see PATTERNS §7).
135 */
136const retryUntilAccessPointAvailable = <E extends { _tag: string }, R>(
137 self: Effect.Effect<efs.AccessPointDescription, E, R>,
138): Effect.Effect<efs.AccessPointDescription, E | AccessPointNotAvailable, R> =>
139 Effect.retry(
140 Effect.flatMap(self, (ap) =>
141 ap.LifeCycleState === "available"
142 ? Effect.succeed(ap)
143 : Effect.fail(
144 new AccessPointNotAvailable({
145 accessPointId: ap.AccessPointId ?? "",
146 state: ap.LifeCycleState ?? "unknown",
147 }),
148 ),
149 ),
150 {
151 while: (e) => e._tag === "AccessPointNotAvailable",
152 schedule: Schedule.max([
153 Schedule.fixed("2 seconds"),
154 Schedule.recurs(30),
155 ]),
156 },
157 );
158
159const efsTagsToRecord = (
160 tags: readonly efs.Tag[] | undefined,
161): Record<string, string> =>
162 Object.fromEntries((tags ?? []).map((t) => [t.Key, t.Value]));
163
164const toWirePosixUser = (
165 posixUser: AccessPointPosixUser | undefined,
166): efs.PosixUser | undefined =>
167 posixUser === undefined
168 ? undefined
169 : {
170 Uid: posixUser.uid,
171 Gid: posixUser.gid,
172 ...(posixUser.secondaryGids !== undefined
173 ? { SecondaryGids: posixUser.secondaryGids }
174 : {}),
175 };
176
177const toWireRootDirectory = (
178 rootDirectory: AccessPointRootDirectory | undefined,
179): efs.RootDirectory | undefined =>
180 rootDirectory === undefined
181 ? undefined
182 : {
183 ...(rootDirectory.path !== undefined
184 ? { Path: rootDirectory.path }
185 : {}),
186 ...(rootDirectory.creationInfo !== undefined
187 ? {
188 CreationInfo: {
189 OwnerUid: rootDirectory.creationInfo.ownerUid,
190 OwnerGid: rootDirectory.creationInfo.ownerGid,
191 Permissions: rootDirectory.creationInfo.permissions,
192 },
193 }
194 : {}),
195 };
196
197export const AccessPointProvider = () =>
198 Provider.effect(
199 AccessPoint,
200 Effect.gen(function* () {
201 const createToken = Effect.fn(function* (id: string) {
202 return yield* createPhysicalName({ id, maxLength: 64 });
203 });
204
205 const findById = Effect.fn(function* (accessPointId: string) {
206 return yield* efs
207 .describeAccessPoints({ AccessPointId: accessPointId })
208 .pipe(
209 Effect.map((r) => r.AccessPoints?.[0]),
210 Effect.catchTag("AccessPointNotFound", () =>
211 Effect.succeed(undefined),
212 ),
213 );
214 });
215
216 const findByToken = Effect.fn(function* (
217 fileSystemId: string,
218 token: string,
219 ) {
220 return yield* efs
221 .describeAccessPoints({ FileSystemId: fileSystemId })
222 .pipe(
223 Effect.map((r) =>
224 r.AccessPoints?.find((ap) => ap.ClientToken === token),
225 ),
226 Effect.catchTag("FileSystemNotFound", () =>
227 Effect.succeed(undefined),
228 ),
229 );
230 });
231
232 return AccessPoint.Provider.of({
233 stables: ["accessPointId", "accessPointArn", "fileSystemId"],
234
235 // Access points are only enumerable per file system, so walk every
236 // file system in the account/region.
237 list: () =>
238 Effect.gen(function* () {
239 const systems = yield* efs.describeFileSystems.items({}).pipe(
240 Stream.runCollect,
241 Effect.map((chunk) => Array.from(chunk)),
242 );
243 const perSystem = yield* Effect.forEach(
244 systems,
245 (fs) =>
246 efs
247 .describeAccessPoints({ FileSystemId: fs.FileSystemId })
248 .pipe(
249 Effect.map((r) =>
250 (r.AccessPoints ?? [])
251 .filter(
252 (ap) =>
253 ap.AccessPointId !== undefined &&
254 ap.LifeCycleState !== "deleted",
255 )
256 .map((ap) => ({
257 accessPointId: ap.AccessPointId!,
258 accessPointArn: ap.AccessPointArn!,
259 fileSystemId: ap.FileSystemId!,
260 })),
261 ),
262 Effect.catchTag("FileSystemNotFound", () =>
263 Effect.succeed([]),
264 ),
265 ),
266 { concurrency: 5 },
267 );
268 return perSystem.flat();
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const ap = output?.accessPointId
273 ? yield* findById(output.accessPointId)
274 : olds?.fileSystemId
275 ? yield* findByToken(olds.fileSystemId, yield* createToken(id))
276 : undefined;
277 if (
278 ap?.AccessPointId === undefined ||
279 ap.LifeCycleState === "deleted"
280 ) {
281 return undefined;
282 }
283 const attrs = {
284 accessPointId: ap.AccessPointId,
285 accessPointArn: ap.AccessPointArn!,
286 fileSystemId: ap.FileSystemId!,
287 };
288 return (yield* hasAlchemyTags(id, efsTagsToRecord(ap.Tags)))
289 ? attrs
290 : Unowned(attrs);
291 }),
292
293 // posixUser, rootDirectory, and the file system are all immutable on
294 // an access point — any change replaces it. Only tags update in
295 // place.
296 diff: Effect.fn(function* ({ news, olds }) {
297 if (!isResolved(news) || olds === undefined) return undefined;
298 if (
299 news.fileSystemId !== olds.fileSystemId ||
300 !deepEqual(news.posixUser, olds.posixUser) ||
301 !deepEqual(news.rootDirectory, olds.rootDirectory)
302 ) {
303 return { action: "replace" } as const;
304 }
305 // undefined → default update path (tag sync)
306 }),
307
308 reconcile: Effect.fn(function* ({ id, news, output, session }) {
309 const token = yield* createToken(id);
310 const internalTags = yield* createInternalTags(id);
311
312 // 1. OBSERVE — output.accessPointId is only a cache; the client
313 // token is the deterministic identity within the file system.
314 let ap = output?.accessPointId
315 ? yield* findById(output.accessPointId)
316 : yield* findByToken(news.fileSystemId, token);
317 if (ap?.LifeCycleState === "deleted") {
318 ap = undefined;
319 }
320
321 // 2. ENSURE — create if missing. CreateAccessPoint is idempotent
322 // on the client token; a concurrent identical create surfaces
323 // the typed AccessPointAlreadyExists which we resolve by
324 // describing the winner.
325 if (ap === undefined) {
326 ap = yield* efs
327 .createAccessPoint({
328 ClientToken: token,
329 FileSystemId: news.fileSystemId,
330 PosixUser: toWirePosixUser(news.posixUser),
331 RootDirectory: toWireRootDirectory(news.rootDirectory),
332 Tags: Object.entries({ ...news.tags, ...internalTags }).map(
333 ([Key, Value]) => ({ Key, Value }),
334 ),
335 })
336 .pipe(
337 Effect.catchTag("AccessPointAlreadyExists", (e) =>
338 efs
339 .describeAccessPoints({ AccessPointId: e.AccessPointId })
340 .pipe(Effect.map((r) => r.AccessPoints![0])),
341 ),
342 );
343 }
344
345 const accessPointId = ap.AccessPointId!;
346
347 ap = yield* retryUntilAccessPointAvailable(
348 efs
349 .describeAccessPoints({ AccessPointId: accessPointId })
350 .pipe(Effect.map((r) => r.AccessPoints![0])),
351 );
352
353 // 3. SYNC tags — diff against OBSERVED cloud tags so adoption
354 // converges.
355 const observedTags = efsTagsToRecord(ap.Tags);
356 const { upsert, removed } = diffTags(observedTags, {
357 ...news.tags,
358 ...internalTags,
359 });
360 if (upsert.length > 0) {
361 yield* efs.tagResource({ ResourceId: accessPointId, Tags: upsert });
362 }
363 if (removed.length > 0) {
364 yield* efs.untagResource({
365 ResourceId: accessPointId,
366 TagKeys: removed,
367 });
368 }
369
370 yield* session.note(accessPointId);
371 return {
372 accessPointId,
373 accessPointArn: ap.AccessPointArn!,
374 fileSystemId: ap.FileSystemId!,
375 };
376 }),
377
378 delete: Effect.fn(function* ({ output }) {
379 yield* efs
380 .deleteAccessPoint({ AccessPointId: output.accessPointId })
381 .pipe(Effect.catchTag("AccessPointNotFound", () => Effect.void));
382 }),
383 });
384 }),
385 );