| 1 | import * as efs from "@distilled.cloud/aws/efs"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | |
| 11 | export 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 | |
| 39 | export 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 | */ |
| 95 | export 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 | */ |
| 101 | export 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 | */ |
| 109 | export 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 | */ |
| 118 | const 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 | */ |
| 146 | const 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 | */ |
| 159 | const 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 | |
| 168 | export 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 | ); |