1import * as AWS from "@/AWS";
2import * as Provider from "@/Provider";
3import * as Test from "@/Test/Alchemy";
4import * as efs from "@distilled.cloud/aws/efs";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8
9const { test } = Test.make({ providers: AWS.providers() });
10
11const efsTagsToRecord = (
12 tags: readonly efs.Tag[] | undefined,
13): Record<string, string> =>
14 Object.fromEntries((tags ?? []).map((t) => [t.Key, t.Value]));
15
16// Typed wait-until-gone: DescribeFileSystems surfaces the typed
17// FileSystemNotFound once the deletion finishes; a still-visible file system
18// in the `deleted` state also counts as gone.
19const waitUntilFileSystemGone = (fileSystemId: string) =>
20 efs.describeFileSystems({ FileSystemId: fileSystemId }).pipe(
21 Effect.map((r) =>
22 (r.FileSystems ?? []).every((f) => f.LifeCycleState === "deleted"),
23 ),
24 Effect.catchTag("FileSystemNotFound", () => Effect.succeed(true)),
25 Effect.repeat({
26 schedule: Schedule.fixed("2 seconds"),
27 until: (gone) => gone,
28 times: 30,
29 }),
30 );
31
32test.provider(
33 "create, update, list, delete file system",
34 (stack) =>
35 Effect.gen(function* () {
36 yield* stack.destroy();
37
38 // --- create: encrypted by default, elastic throughput ---
39 const created = yield* stack.deploy(
40 Effect.gen(function* () {
41 const files = yield* AWS.EFS.FileSystem("Files", {
42 throughputMode: "elastic",
43 tags: { purpose: "alchemy-efs-test" },
44 });
45 return { files };
46 }),
47 );
48 expect(created.files.fileSystemId).toMatch(/^fs-/);
49 expect(created.files.fileSystemArn).toContain(":file-system/");
50
51 const observed = yield* efs
52 .describeFileSystems({ FileSystemId: created.files.fileSystemId })
53 .pipe(Effect.map((r) => r.FileSystems![0]));
54 expect(observed.Encrypted).toBe(true);
55 expect(observed.PerformanceMode).toBe("generalPurpose");
56 expect(observed.ThroughputMode).toBe("elastic");
57 const observedTags = efsTagsToRecord(observed.Tags);
58 expect(observedTags.purpose).toBe("alchemy-efs-test");
59 expect(observedTags["alchemy::id"]).toBe("Files");
60
61 // --- update: lifecycle policies + typed file system policy + backup
62 // policy + replication overwrite protection + tags, same file
63 // system ---
64 const updated = yield* stack.deploy(
65 Effect.gen(function* () {
66 const files = yield* AWS.EFS.FileSystem("Files", {
67 throughputMode: "elastic",
68 backup: true,
69 replicationOverwriteProtection: "DISABLED",
70 lifecyclePolicies: [
71 { transitionToIA: "AFTER_30_DAYS" },
72 { transitionToPrimaryStorageClass: "AFTER_1_ACCESS" },
73 ],
74 policy: {
75 Version: "2012-10-17",
76 Statement: [
77 {
78 Sid: "AllowMountViaMountTarget",
79 Effect: "Allow",
80 Principal: { AWS: "*" },
81 Action: [
82 "elasticfilesystem:ClientMount",
83 "elasticfilesystem:ClientWrite",
84 ],
85 Condition: {
86 Bool: {
87 "elasticfilesystem:AccessedViaMountTarget": "true",
88 },
89 },
90 },
91 ],
92 },
93 tags: { purpose: "alchemy-efs-test-updated" },
94 });
95 return { files };
96 }),
97 );
98 expect(updated.files.fileSystemId).toBe(created.files.fileSystemId);
99
100 const lifecycle = yield* efs.describeLifecycleConfiguration({
101 FileSystemId: created.files.fileSystemId,
102 });
103 expect(lifecycle.LifecyclePolicies ?? []).toHaveLength(2);
104
105 // the PolicyDocument round-trips as the file system policy
106 const storedPolicy = yield* efs
107 .describeFileSystemPolicy({
108 FileSystemId: created.files.fileSystemId,
109 })
110 .pipe(Effect.map((r) => r.Policy ?? ""));
111 expect(storedPolicy).toContain("AllowMountViaMountTarget");
112
113 const retagged = yield* efs
114 .describeFileSystems({ FileSystemId: created.files.fileSystemId })
115 .pipe(Effect.map((r) => efsTagsToRecord(r.FileSystems![0].Tags)));
116 expect(retagged.purpose).toBe("alchemy-efs-test-updated");
117
118 // backup: true converged (ENABLING settles to ENABLED asynchronously)
119 const backupStatus = yield* efs
120 .describeBackupPolicy({ FileSystemId: created.files.fileSystemId })
121 .pipe(Effect.map((r) => r.BackupPolicy?.Status));
122 expect(["ENABLED", "ENABLING"]).toContain(backupStatus);
123
124 // replication overwrite protection converged to DISABLED
125 const protection = yield* efs
126 .describeFileSystems({ FileSystemId: created.files.fileSystemId })
127 .pipe(
128 Effect.map(
129 (r) =>
130 r.FileSystems![0].FileSystemProtection
131 ?.ReplicationOverwriteProtection,
132 ),
133 );
134 expect(protection).toBe("DISABLED");
135
136 // --- re-deploy: an equivalent PolicyDocument (statement keys in a
137 // different order) is a no-op — the normalized comparison skips
138 // the put and the stored policy is untouched. The tag change
139 // forces the reconcile to actually run.
140 yield* stack.deploy(
141 Effect.gen(function* () {
142 const files = yield* AWS.EFS.FileSystem("Files", {
143 throughputMode: "elastic",
144 lifecyclePolicies: [
145 { transitionToIA: "AFTER_30_DAYS" },
146 { transitionToPrimaryStorageClass: "AFTER_1_ACCESS" },
147 ],
148 policy: {
149 Version: "2012-10-17",
150 Statement: [
151 {
152 Condition: {
153 Bool: {
154 "elasticfilesystem:AccessedViaMountTarget": "true",
155 },
156 },
157 Action: [
158 "elasticfilesystem:ClientMount",
159 "elasticfilesystem:ClientWrite",
160 ],
161 Principal: { AWS: "*" },
162 Effect: "Allow",
163 Sid: "AllowMountViaMountTarget",
164 },
165 ],
166 },
167 tags: { purpose: "alchemy-efs-test-redeploy" },
168 });
169 return { files };
170 }),
171 );
172 const redeployedPolicy = yield* efs
173 .describeFileSystemPolicy({
174 FileSystemId: created.files.fileSystemId,
175 })
176 .pipe(Effect.map((r) => r.Policy ?? ""));
177 expect(redeployedPolicy).toBe(storedPolicy);
178
179 // --- update: clearing lifecycle policies + policy converges to
180 // empty / the default policy; backup + protection revert ---
181 yield* stack.deploy(
182 Effect.gen(function* () {
183 const files = yield* AWS.EFS.FileSystem("Files", {
184 throughputMode: "elastic",
185 backup: false,
186 replicationOverwriteProtection: "ENABLED",
187 tags: { purpose: "alchemy-efs-test-updated" },
188 });
189 return { files };
190 }),
191 );
192 const cleared = yield* efs.describeLifecycleConfiguration({
193 FileSystemId: created.files.fileSystemId,
194 });
195 expect(cleared.LifecyclePolicies ?? []).toHaveLength(0);
196
197 // backup: false converged (DISABLING settles asynchronously)
198 const backupDisabled = yield* efs
199 .describeBackupPolicy({ FileSystemId: created.files.fileSystemId })
200 .pipe(
201 Effect.map((r) => r.BackupPolicy?.Status ?? "DISABLED"),
202 Effect.catchTag("PolicyNotFound", () => Effect.succeed("DISABLED")),
203 );
204 expect(["DISABLED", "DISABLING"]).toContain(backupDisabled);
205
206 // protection reverted to ENABLED
207 const protectionReverted = yield* efs
208 .describeFileSystems({ FileSystemId: created.files.fileSystemId })
209 .pipe(
210 Effect.map(
211 (r) =>
212 r.FileSystems![0].FileSystemProtection
213 ?.ReplicationOverwriteProtection ?? "ENABLED",
214 ),
215 );
216 expect(protectionReverted).toBe("ENABLED");
217
218 // removing the policy prop reverts to the default policy — the
219 // explicit policy is gone (typed PolicyNotFound)
220 const policyGone = yield* efs
221 .describeFileSystemPolicy({
222 FileSystemId: created.files.fileSystemId,
223 })
224 .pipe(
225 Effect.map(() => false),
226 Effect.catchTag("PolicyNotFound", () => Effect.succeed(true)),
227 );
228 expect(policyGone).toBe(true);
229
230 // --- list ---
231 const provider = yield* Provider.findProvider(AWS.EFS.FileSystem);
232 const all = yield* provider.list();
233 expect(
234 all.some((f) => f.fileSystemId === created.files.fileSystemId),
235 ).toBe(true);
236
237 // --- delete + typed wait-until-gone ---
238 yield* stack.destroy();
239 const gone = yield* waitUntilFileSystemGone(created.files.fileSystemId);
240 expect(gone).toBe(true);
241 }),
242 { tags: ["provider:aws", "provider:aws:efs", "live"], timeout: 180_000 },
243);
244
245test.provider(
246 "changing encryption replaces the file system",
247 (stack) =>
248 Effect.gen(function* () {
249 yield* stack.destroy();
250
251 const first = yield* stack.deploy(
252 Effect.gen(function* () {
253 const files = yield* AWS.EFS.FileSystem("ReplaceFiles", {
254 encrypted: false,
255 });
256 return { files };
257 }),
258 );
259
260 const second = yield* stack.deploy(
261 Effect.gen(function* () {
262 const files = yield* AWS.EFS.FileSystem("ReplaceFiles", {
263 encrypted: true,
264 });
265 return { files };
266 }),
267 );
268
269 expect(second.files.fileSystemId).not.toBe(first.files.fileSystemId);
270
271 const observed = yield* efs
272 .describeFileSystems({ FileSystemId: second.files.fileSystemId })
273 .pipe(Effect.map((r) => r.FileSystems![0]));
274 expect(observed.Encrypted).toBe(true);
275
276 // the replaced (unencrypted) file system must be gone
277 const oldGone = yield* waitUntilFileSystemGone(first.files.fileSystemId);
278 expect(oldGone).toBe(true);
279
280 yield* stack.destroy();
281 const gone = yield* waitUntilFileSystemGone(second.files.fileSystemId);
282 expect(gone).toBe(true);
283 }).pipe(
284 Effect.tap(() => stack.destroy()),
285 Effect.onError(() => stack.destroy().pipe(Effect.ignore)),
286 ),
287 { tags: ["provider:aws", "provider:aws:efs", "live"], timeout: 120_000 },
288);