| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Provider from "@/Provider"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as efs from "@distilled.cloud/aws/efs"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | |
| 9 | const { test } = Test.make({ providers: AWS.providers() }); |
| 10 | |
| 11 | const 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. |
| 19 | const 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 | |
| 32 | test.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 | |
| 245 | test.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 | ); |