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 { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface MountTargetProps {
12 /**
13 * ID of the EFS file system to expose through this mount target
14 * (e.g. `fileSystem.fileSystemId`). Cannot be changed after creation
15 * (replacement).
16 */
17 fileSystemId: string;
18 /**
19 * ID of the subnet to create the mount target in. Determines the VPC and
20 * Availability Zone; one mount target is allowed per AZ. Cannot be changed
21 * after creation (replacement).
22 */
23 subnetId: string;
24 /**
25 * Static private IPv4 address for the mount target within the subnet's
26 * range. Cannot be changed after creation (replacement).
27 * @default an address assigned by EFS
28 */
29 ipAddress?: string;
30 /**
31 * Security groups attached to the mount target's network interface (up to
32 * five). NFS clients must be allowed to reach TCP port 2049 through these
33 * groups. Updatable in place.
34 * @default the VPC's default security group
35 */
36 securityGroups?: string[];
37}
38
39export interface MountTarget extends Resource<
40 "AWS.EFS.MountTarget",
41 MountTargetProps,
42 {
43 /** The ID of the mount target (e.g. `fsmt-0123456789abcdef0`). */
44 mountTargetId: string;
45 /** The ID of the EFS file system the mount target belongs to. */
46 fileSystemId: string;
47 /** The ID of the subnet the mount target was created in. */
48 subnetId: string;
49 /** The IPv4 address at which the file system is reachable in the subnet. */
50 ipAddress: string | undefined;
51 /** The ID of the network interface created for the mount target. */
52 networkInterfaceId: string | undefined;
53 /** The name of the Availability Zone the mount target resides in. */
54 availabilityZoneName: string | undefined;
55 },
56 {},
57 Providers
58> {}
59
60/**
61 * An Amazon EFS mount target — the per-subnet network endpoint (an ENI
62 * serving NFS on TCP 2049) that compute in a VPC uses to reach a file
63 * system.
64 *
65 * Create one mount target per Availability Zone you run compute in. The
66 * reconciler waits for the mount target to reach the `available` state
67 * (typically 1–2 minutes), so downstream resources that depend on its
68 * attributes deploy only once the endpoint is usable. Deletion likewise
69 * waits until the mount target is fully gone, because its ENI must be
70 * released before the subnet, security groups, or file system can be
71 * deleted.
72 * ### Creating Mount Targets
73 * **Example:** Mount target in a subnet
74 * ```typescript
75 * import * as AWS from "alchemy/AWS";
76 *
77 * const files = yield* AWS.EFS.FileSystem("Files");
78 * const target = yield* AWS.EFS.MountTarget("FilesTarget", {
79 * fileSystemId: files.fileSystemId,
80 * subnetId,
81 * });
82 * ```
83 *
84 * **Example:** Mount target with explicit security groups
85 * ```typescript
86 * const target = yield* AWS.EFS.MountTarget("FilesTarget", {
87 * fileSystemId: files.fileSystemId,
88 * subnetId,
89 * securityGroups: [nfsSecurityGroupId],
90 * });
91 * ```
92 *
93 * @resource
94 */
95export const MountTarget = Resource<MountTarget>("AWS.EFS.MountTarget");
96
97/**
98 * Internal marker error used to drive the bounded wait for a mount target to
99 * reach the `available` lifecycle state.
100 */
101export class MountTargetNotAvailable extends Data.TaggedError(
102 "MountTargetNotAvailable",
103)<{ mountTargetId: string; state: string }> {}
104
105/**
106 * Internal marker error used to drive the bounded wait for a mount target to
107 * disappear after deletion (its ENI releases asynchronously).
108 */
109export class MountTargetStillDeleting extends Data.TaggedError(
110 "MountTargetStillDeleting",
111)<{ mountTargetId: string; state: string }> {}
112
113/**
114 * Mount-target provisioning takes 1–3 minutes. Bounded poll (5s × 36 ≈
115 * 180s), explicitly typed so declaration emit never widens the provider
116 * layer (see PATTERNS §7).
117 */
118const retryUntilMountTargetAvailable = <E extends { _tag: string }, R>(
119 self: Effect.Effect<efs.MountTargetDescription, E, R>,
120): Effect.Effect<efs.MountTargetDescription, E | MountTargetNotAvailable, R> =>
121 Effect.retry(
122 Effect.flatMap(self, (mt) =>
123 mt.LifeCycleState === "available"
124 ? Effect.succeed(mt)
125 : Effect.fail(
126 new MountTargetNotAvailable({
127 mountTargetId: mt.MountTargetId,
128 state: mt.LifeCycleState,
129 }),
130 ),
131 ),
132 {
133 while: (e) => e._tag === "MountTargetNotAvailable",
134 schedule: Schedule.max([
135 Schedule.fixed("5 seconds"),
136 Schedule.recurs(36),
137 ]),
138 },
139 );
140
141/**
142 * Deletion releases the mount target's ENI asynchronously (1–3 minutes).
143 * Bounded poll (5s × 36 ≈ 180s) until the mount target is observed gone,
144 * explicitly typed (PATTERNS §7).
145 */
146const retryUntilMountTargetGone = <A, E extends { _tag: string }, R>(
147 self: Effect.Effect<A, E, R>,
148): Effect.Effect<A, E, R> =>
149 Effect.retry(self, {
150 while: (e) => e._tag === "MountTargetStillDeleting",
151 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(36)]),
152 });
153
154/**
155 * Security-group modifications race the mount target's own state machine;
156 * `IncorrectMountTargetState` while it is still creating is transient.
157 * Bounded retry (~60s), explicitly typed (PATTERNS §7).
158 */
159const retryWhileMountTargetSettling = <A, E extends { _tag: string }, R>(
160 self: Effect.Effect<A, E, R>,
161): Effect.Effect<A, E, R> =>
162 Effect.retry(self, {
163 while: (e) =>
164 e._tag === "IncorrectMountTargetState" || e._tag === "DependencyTimeout",
165 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
166 });
167
168export const MountTargetProvider = () =>
169 Provider.effect(
170 MountTarget,
171 Effect.gen(function* () {
172 const findById = Effect.fn(function* (mountTargetId: string) {
173 return yield* efs
174 .describeMountTargets({ MountTargetId: mountTargetId })
175 .pipe(
176 Effect.map((r) => r.MountTargets?.[0]),
177 Effect.catchTag("MountTargetNotFound", () =>
178 Effect.succeed(undefined),
179 ),
180 );
181 });
182
183 const findBySubnet = Effect.fn(function* (
184 fileSystemId: string,
185 subnetId: string,
186 ) {
187 return yield* efs
188 .describeMountTargets({ FileSystemId: fileSystemId })
189 .pipe(
190 Effect.map((r) =>
191 r.MountTargets?.find((mt) => mt.SubnetId === subnetId),
192 ),
193 Effect.catchTag("FileSystemNotFound", () =>
194 Effect.succeed(undefined),
195 ),
196 );
197 });
198
199 const toAttributes = (mt: efs.MountTargetDescription) => ({
200 mountTargetId: mt.MountTargetId,
201 fileSystemId: mt.FileSystemId,
202 subnetId: mt.SubnetId,
203 ipAddress: mt.IpAddress,
204 networkInterfaceId: mt.NetworkInterfaceId,
205 availabilityZoneName: mt.AvailabilityZoneName,
206 });
207
208 return MountTarget.Provider.of({
209 stables: [
210 "mountTargetId",
211 "fileSystemId",
212 "subnetId",
213 "ipAddress",
214 "availabilityZoneName",
215 ],
216
217 // Mount targets are only enumerable per file system, so walk every
218 // file system in the account/region. A file system can vanish between
219 // the enumeration and the per-FS describe — drop it.
220 list: () =>
221 Effect.gen(function* () {
222 const systems = yield* efs.describeFileSystems.items({}).pipe(
223 Stream.runCollect,
224 Effect.map((chunk) => Array.from(chunk)),
225 );
226 const perSystem = yield* Effect.forEach(
227 systems,
228 (fs) =>
229 efs
230 .describeMountTargets({ FileSystemId: fs.FileSystemId })
231 .pipe(
232 Effect.map((r) =>
233 (r.MountTargets ?? [])
234 .filter((mt) => mt.LifeCycleState !== "deleted")
235 .map(toAttributes),
236 ),
237 Effect.catchTag("FileSystemNotFound", () =>
238 Effect.succeed([]),
239 ),
240 ),
241 { concurrency: 5 },
242 );
243 return perSystem.flat();
244 }),
245
246 // Mount targets are untaggable, so there is no ownership marker to
247 // check: identity is the (fileSystemId, subnetId) pair, matching the
248 // one-mount-target-per-AZ constraint.
249 read: Effect.fn(function* ({ olds, output }) {
250 const mt = output?.mountTargetId
251 ? yield* findById(output.mountTargetId)
252 : olds?.fileSystemId && olds.subnetId
253 ? yield* findBySubnet(olds.fileSystemId, olds.subnetId)
254 : undefined;
255 if (mt === undefined || mt.LifeCycleState === "deleted") {
256 return undefined;
257 }
258 return toAttributes(mt);
259 }),
260
261 diff: Effect.fn(function* ({ news, olds }) {
262 if (!isResolved(news) || olds === undefined) return undefined;
263 if (
264 news.fileSystemId !== olds.fileSystemId ||
265 news.subnetId !== olds.subnetId ||
266 news.ipAddress !== olds.ipAddress
267 ) {
268 return { action: "replace" } as const;
269 }
270 // undefined → default update path (security-group sync)
271 }),
272
273 reconcile: Effect.fn(function* ({ news, output, session }) {
274 // 1. OBSERVE — output.mountTargetId is only a cache; fall through
275 // to the (fileSystemId, subnetId) identity if it is stale.
276 let mt = output?.mountTargetId
277 ? yield* findById(output.mountTargetId)
278 : undefined;
279 if (mt === undefined || mt.LifeCycleState === "deleted") {
280 mt = yield* findBySubnet(news.fileSystemId, news.subnetId);
281 }
282 if (mt?.LifeCycleState === "deleted") {
283 mt = undefined;
284 }
285
286 // 2. ENSURE — create if missing. `MountTargetConflict` means a peer
287 // created it in the same subnet/AZ concurrently — re-observe.
288 if (mt === undefined) {
289 mt = yield* efs
290 .createMountTarget({
291 FileSystemId: news.fileSystemId,
292 SubnetId: news.subnetId,
293 IpAddress: news.ipAddress,
294 SecurityGroups: news.securityGroups,
295 })
296 .pipe(
297 Effect.catchTag("MountTargetConflict", () =>
298 findBySubnet(news.fileSystemId, news.subnetId).pipe(
299 Effect.flatMap((existing) =>
300 existing !== undefined
301 ? Effect.succeed(existing)
302 : efs.createMountTarget({
303 FileSystemId: news.fileSystemId,
304 SubnetId: news.subnetId,
305 IpAddress: news.ipAddress,
306 SecurityGroups: news.securityGroups,
307 }),
308 ),
309 ),
310 ),
311 );
312 }
313
314 const mountTargetId = mt.MountTargetId;
315
316 // Wait for the network endpoint to become usable — dependents
317 // (e.g. a Lambda with fileSystemConfigs) fail to create against a
318 // mount target that is still `creating`.
319 yield* session.note(`waiting for ${mountTargetId} to be available`);
320 mt = yield* retryUntilMountTargetAvailable(
321 efs
322 .describeMountTargets({ MountTargetId: mountTargetId })
323 .pipe(Effect.map((r) => r.MountTargets![0])),
324 );
325
326 // 3. SYNC security groups — diff the OBSERVED set against the
327 // desired one; only call the API on a real delta. When the prop
328 // is omitted the observed groups (VPC default) are left alone.
329 if (news.securityGroups !== undefined) {
330 const observed = yield* efs
331 .describeMountTargetSecurityGroups({
332 MountTargetId: mountTargetId,
333 })
334 .pipe(Effect.map((r) => [...r.SecurityGroups].sort()));
335 const desired = [...news.securityGroups].sort();
336 if (JSON.stringify(observed) !== JSON.stringify(desired)) {
337 yield* retryWhileMountTargetSettling(
338 efs.modifyMountTargetSecurityGroups({
339 MountTargetId: mountTargetId,
340 SecurityGroups: news.securityGroups,
341 }),
342 );
343 }
344 }
345
346 yield* session.note(mountTargetId);
347 return toAttributes(mt);
348 }),
349
350 // Deletion must observe the mount target actually GONE before
351 // returning: its ENI is released asynchronously and the subnet,
352 // security groups, and file system cannot be deleted until then.
353 delete: Effect.fn(function* ({ output, session }) {
354 yield* efs
355 .deleteMountTarget({ MountTargetId: output.mountTargetId })
356 .pipe(Effect.catchTag("MountTargetNotFound", () => Effect.void));
357
358 yield* session.note(
359 `waiting for ${output.mountTargetId} to be deleted`,
360 );
361 yield* retryUntilMountTargetGone(
362 efs
363 .describeMountTargets({ MountTargetId: output.mountTargetId })
364 .pipe(
365 Effect.flatMap((r) => {
366 const state = r.MountTargets?.[0]?.LifeCycleState;
367 return state === undefined || state === "deleted"
368 ? Effect.void
369 : Effect.fail(
370 new MountTargetStillDeleting({
371 mountTargetId: output.mountTargetId,
372 state,
373 }),
374 );
375 }),
376 Effect.catchTag("MountTargetNotFound", () => Effect.void),
377 ),
378 );
379 }),
380 });
381 }),
382 );