1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Binding from "../../Binding.ts";
4import { isTask } from "../ECS/Task.ts";
5import type { Function } from "../Lambda/Function.ts";
6import type { AccessPoint } from "./AccessPoint.ts";
7import type { FileSystem } from "./FileSystem.ts";
8
9/**
10 * Options for {@link Mount}.
11 */
12export interface MountOptions {
13 /**
14 * Local path the file system is mounted at inside the compute environment.
15 * On Lambda the path must begin with `/mnt/` (e.g. `/mnt/data`); on ECS any
16 * absolute container path works.
17 */
18 path: string;
19 /**
20 * Mount read-only: the role is granted `elasticfilesystem:ClientMount`
21 * only (no `ClientWrite`), and on ECS the container mount point is marked
22 * `readOnly`.
23 * @default false
24 */
25 readOnly?: boolean;
26}
27
28/**
29 * The runtime view of a mounted EFS file system: the local path it is
30 * available at inside the running Function/Task.
31 */
32export interface MountedFileSystem {
33 /** The local mount path (same value as {@link MountOptions.path}). */
34 path: string;
35}
36
37/**
38 * Host-agnostic EFS mount binding.
39 *
40 * `yield* EFS.mount(accessPoint, { path: "/mnt/data" })` inside a compute
41 * body wires the file system into whatever host the code deploys to:
42 *
43 * - **Lambda** — injects a `FileSystemConfigs` entry (the access point ARN +
44 * local mount path) through the Function's binding channel and grants the
45 * execution role `elasticfilesystem:ClientMount`/`ClientWrite` scoped to
46 * the access point. Lambda requires an `AWS.EFS.AccessPoint` (not a bare
47 * file system) and a `/mnt/…` path, and the Function must have `vpc` set
48 * to subnets that can reach an EFS mount target.
49 * - **ECS Task** — injects a task-level EFS volume (transit encryption on,
50 * IAM auth on, access-point scoped when one is given) plus a container
51 * mount point, and grants the task role the matching client actions.
52 *
53 * Provide the `EFS.MountLive` layer on the Function/Task Effect to satisfy
54 * the binding. Mount targets for the file system's VPC/subnets must already
55 * exist (`AWS.EFS.MountTarget`).
56 *
57 * @binding
58 */
59export interface Mount extends Binding.Service<
60 Mount,
61 "AWS.EFS.Mount",
62 (
63 target: AccessPoint | FileSystem,
64 options: MountOptions,
65 ) => Effect.Effect<MountedFileSystem>
66> {}
67export const Mount = Binding.Service<Mount>("AWS.EFS.Mount");
68
69/**
70 * Ergonomic alias of {@link Mount} — `yield* EFS.mount(accessPoint, { path })`.
71 */
72export const mount = Mount;
73
74const isAccessPoint = (
75 target: AccessPoint | FileSystem,
76): target is AccessPoint =>
77 (target as { Type?: string }).Type === "AWS.EFS.AccessPoint";
78
79const isLambdaFunction = (value: unknown): value is Function =>
80 typeof value === "object" &&
81 value !== null &&
82 (value as { Type?: string }).Type === "AWS.Lambda.Function";
83
84/**
85 * Host-agnostic implementation of the {@link Mount} binding. Detects the
86 * host (Lambda Function vs ECS Task) at deploy time and registers the
87 * host-appropriate mount config + IAM through the binding channel; at
88 * runtime it is a no-op that returns the mount path.
89 */
90export const MountLive = Layer.effect(
91 Mount,
92 Effect.gen(function* () {
93 return Effect.fn(function* (
94 target: AccessPoint | FileSystem,
95 options: MountOptions,
96 ) {
97 if (!globalThis.__ALCHEMY_RUNTIME__) {
98 const host = yield* Binding.Host;
99 const actions = options.readOnly
100 ? ["elasticfilesystem:ClientMount"]
101 : ["elasticfilesystem:ClientMount", "elasticfilesystem:ClientWrite"];
102
103 if (isTask(host)) {
104 // ECS: task-level EFS volume + container mount point. IAM auth +
105 // transit encryption are always on so the task-role grant below is
106 // what authorizes the mount.
107 const volumeName = `efs-${target.LogicalId.replace(/[^A-Za-z0-9_-]+/g, "-")}`;
108 yield* host.bind`Allow(${host}, AWS.EFS.Mount(${target}))`({
109 policyStatements: [
110 isAccessPoint(target)
111 ? {
112 Effect: "Allow" as const,
113 Action: actions,
114 Resource: ["*"],
115 Condition: {
116 StringEquals: {
117 "elasticfilesystem:AccessPointArn":
118 target.accessPointArn,
119 },
120 },
121 }
122 : {
123 Effect: "Allow" as const,
124 Action: actions,
125 Resource: [target.fileSystemArn],
126 },
127 ],
128 volumes: [
129 {
130 name: volumeName,
131 efsVolumeConfiguration: {
132 fileSystemId: target.fileSystemId,
133 transitEncryption: "ENABLED",
134 authorizationConfig: isAccessPoint(target)
135 ? { accessPointId: target.accessPointId, iam: "ENABLED" }
136 : { iam: "ENABLED" },
137 },
138 },
139 ],
140 mountPoints: [
141 {
142 sourceVolume: volumeName,
143 containerPath: options.path,
144 readOnly: options.readOnly,
145 },
146 ],
147 });
148 } else if (isLambdaFunction(host)) {
149 // Lambda: FileSystemConfigs entry + access-point-scoped client
150 // access on the execution role. Lambda only mounts access points.
151 if (!isAccessPoint(target)) {
152 return yield* Effect.die(
153 new Error(
154 `AWS.EFS.Mount(${target.LogicalId}): Lambda can only mount an EFS access point — create an AWS.EFS.AccessPoint for the file system and mount that instead`,
155 ),
156 );
157 }
158 if (!options.path.startsWith("/mnt/")) {
159 return yield* Effect.die(
160 new Error(
161 `AWS.EFS.Mount(${target.LogicalId}): Lambda mount paths must begin with /mnt/ (got ${options.path})`,
162 ),
163 );
164 }
165 yield* host.bind`Allow(${host}, AWS.EFS.Mount(${target}))`({
166 policyStatements: [
167 {
168 Effect: "Allow" as const,
169 Action: actions,
170 Resource: ["*"],
171 Condition: {
172 StringEquals: {
173 "elasticfilesystem:AccessPointArn": target.accessPointArn,
174 },
175 },
176 },
177 ],
178 fileSystemConfigs: [
179 {
180 arn: target.accessPointArn,
181 localMountPath: options.path,
182 },
183 ],
184 });
185 } else {
186 return yield* Effect.die(
187 new Error(
188 `AWS.EFS.Mount(${target.LogicalId}): unsupported host ${(host as { Type?: string }).Type} — EFS mounts are supported on AWS.Lambda.Function and AWS.ECS.Task`,
189 ),
190 );
191 }
192 }
193 return { path: options.path };
194 });
195 }),
196);