| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import * as ecr from "@distilled.cloud/aws/ecr"; |
| 4 | import * as Lambda from "@distilled.cloud/aws/lambda"; |
| 5 | import { expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | |
| 13 | const { test } = Test.make({ providers: AWS.providers() }); |
| 14 | |
| 15 | const fixture = `${import.meta.dirname}/fixtures/image-function`; |
| 16 | const coreFunctionName = "alchemy-test-lambda-container-image"; |
| 17 | const armFunctionName = "alchemy-test-lambda-container-image-arm64"; |
| 18 | const zipFunctionName = "alchemy-test-lambda-container-image-zip"; |
| 19 | const externalFunctionName = "alchemy-test-lambda-container-image-external"; |
| 20 | const externalRepositoryName = "alchemy-test-lambda-external-image"; |
| 21 | const skipLive = !process.env.AWS_TEST_SLOW || !!process.env.FAST; |
| 22 | const liveTestTimeout = 240_000; |
| 23 | |
| 24 | const imageFunction = ( |
| 25 | context: string, |
| 26 | functionName: string, |
| 27 | architecture: AWS.Lambda.FunctionArchitecture, |
| 28 | ) => |
| 29 | AWS.Lambda.Function("ContainerFunction", { |
| 30 | functionName, |
| 31 | image: { context, dockerfile: "Dockerfile" }, |
| 32 | architecture, |
| 33 | env: { |
| 34 | IMAGE_FUNCTION_ENV: "bound", |
| 35 | }, |
| 36 | functionUrl: false, |
| 37 | }); |
| 38 | |
| 39 | const zipFunction = (functionName: string) => |
| 40 | AWS.Lambda.Function("ContainerFunction", { |
| 41 | functionName, |
| 42 | main: `${import.meta.dirname}/timeout-handler.ts`, |
| 43 | handler: "handler", |
| 44 | isExternal: true, |
| 45 | functionUrl: false, |
| 46 | }); |
| 47 | |
| 48 | // Docker builds, ECR pushes, Lambda propagation, and the provider's bounded |
| 49 | // CloudWatch Logs cleanup make these unsuitable for the default fast sweep. |
| 50 | // Each test is kept within one deletion window and runs only when explicitly |
| 51 | // requested with `AWS_TEST_SLOW=1`. |
| 52 | test.provider.skipIf(skipLive)( |
| 53 | "builds, invokes, updates, and destroys an image function", |
| 54 | (stack) => |
| 55 | Effect.gen(function* () { |
| 56 | const fs = yield* FileSystem.FileSystem; |
| 57 | const path = yield* Path.Path; |
| 58 | yield* stack.destroy(); |
| 59 | |
| 60 | const context = yield* fs.makeTempDirectoryScoped({ |
| 61 | prefix: "alchemy-lambda-image-", |
| 62 | }); |
| 63 | yield* fs.copy(fixture, context, { overwrite: true }); |
| 64 | |
| 65 | const first = yield* stack.deploy( |
| 66 | imageFunction(context, coreFunctionName, "x86_64"), |
| 67 | ); |
| 68 | expect(first.functionName).toBe(coreFunctionName); |
| 69 | expect(first.code.image?.digest).toMatch(/^sha256:/); |
| 70 | expect(first.code.image?.imageUri).toContain(`:${first.code.hash}`); |
| 71 | expect(first.code.image!.resolvedImageUri).toBe( |
| 72 | `${first.code.image!.repositoryUri}@${first.code.image!.digest}`, |
| 73 | ); |
| 74 | yield* assertRepositoryImmutable(first.code.image!.repositoryName); |
| 75 | yield* assertFunctionImage(first.functionName, "x86_64"); |
| 76 | expect(yield* invoke(first.functionName, "one")).toEqual({ |
| 77 | marker: "one", |
| 78 | environment: "bound", |
| 79 | event: { hello: "image" }, |
| 80 | }); |
| 81 | |
| 82 | // Unchanged context: the engine and ECR tag are both a no-op. |
| 83 | const unchanged = yield* stack.deploy( |
| 84 | imageFunction(context, coreFunctionName, "x86_64"), |
| 85 | ); |
| 86 | expect(unchanged.code.hash).toBe(first.code.hash); |
| 87 | expect(unchanged.code.image?.digest).toBe(first.code.image?.digest); |
| 88 | |
| 89 | // Repository drift alone must plan an update so reconcile can restore |
| 90 | // the content-addressing and Lambda pull contracts. |
| 91 | yield* ecr.putImageTagMutability({ |
| 92 | repositoryName: unchanged.code.image!.repositoryName, |
| 93 | imageTagMutability: "MUTABLE", |
| 94 | }); |
| 95 | yield* ecr.deleteRepositoryPolicy({ |
| 96 | repositoryName: unchanged.code.image!.repositoryName, |
| 97 | }); |
| 98 | const repositoryDriftPlan = yield* stack.plan( |
| 99 | imageFunction(context, coreFunctionName, "x86_64"), |
| 100 | ); |
| 101 | expect(repositoryDriftPlan.resources["ContainerFunction"]).toMatchObject({ |
| 102 | action: "update", |
| 103 | }); |
| 104 | const repaired = yield* stack.deploy( |
| 105 | imageFunction(context, coreFunctionName, "x86_64"), |
| 106 | ); |
| 107 | yield* assertRepositoryImmutable(repaired.code.image!.repositoryName); |
| 108 | yield* assertRepositoryPolicy(repaired.code.image!.repositoryName); |
| 109 | |
| 110 | // A context change creates a new content-addressed tag and updates the |
| 111 | // existing Lambda in place. |
| 112 | yield* fs.writeFileString(path.join(context, "marker.txt"), "two\n"); |
| 113 | const updated = yield* stack.deploy( |
| 114 | imageFunction(context, coreFunctionName, "x86_64"), |
| 115 | ); |
| 116 | expect(updated.functionName).toBe(first.functionName); |
| 117 | expect(updated.code.hash).not.toBe(first.code.hash); |
| 118 | expect(updated.code.image?.digest).not.toBe(first.code.image?.digest); |
| 119 | yield* assertRepositoryImmutable(updated.code.image!.repositoryName); |
| 120 | expect((yield* invoke(updated.functionName, "two")).marker).toBe("two"); |
| 121 | |
| 122 | // Architecture changes update the image function. The separate arm64 |
| 123 | // smoke test below performs the platform-specific build and invocation. |
| 124 | const armPlan = yield* stack.plan( |
| 125 | imageFunction(context, coreFunctionName, "arm64"), |
| 126 | ); |
| 127 | expect(armPlan.resources["ContainerFunction"]).toMatchObject({ |
| 128 | action: "update", |
| 129 | }); |
| 130 | |
| 131 | // Package type is immutable. A fixed function name must be deleted |
| 132 | // before the Zip replacement can reuse it. |
| 133 | const zipPlan = yield* stack.plan(zipFunction(coreFunctionName)); |
| 134 | expect(zipPlan.resources["ContainerFunction"]).toMatchObject({ |
| 135 | action: "replace", |
| 136 | deleteFirst: true, |
| 137 | }); |
| 138 | |
| 139 | const repositoryName = updated.code.image!.repositoryName; |
| 140 | yield* stack.destroy(); |
| 141 | yield* assertFunctionDeleted(coreFunctionName); |
| 142 | yield* assertRepositoryDeleted(repositoryName); |
| 143 | }).pipe( |
| 144 | Effect.tap(() => stack.destroy()), |
| 145 | Effect.onError(() => stack.destroy().pipe(Effect.ignore)), |
| 146 | ), |
| 147 | { |
| 148 | tags: ["provider:aws", "provider:aws:ecr", "provider:aws:lambda", "live"], |
| 149 | timeout: liveTestTimeout, |
| 150 | }, |
| 151 | ); |
| 152 | |
| 153 | test.provider.skipIf(skipLive)( |
| 154 | "deploys an existing ECR tag, detects retagging, and applies image overrides", |
| 155 | (stack) => |
| 156 | Effect.gen(function* () { |
| 157 | const fs = yield* FileSystem.FileSystem; |
| 158 | const path = yield* Path.Path; |
| 159 | yield* stack.destroy(); |
| 160 | |
| 161 | const context = yield* fs.makeTempDirectoryScoped({ |
| 162 | prefix: "alchemy-lambda-external-image-", |
| 163 | }); |
| 164 | yield* fs.copy(fixture, context, { overwrite: true }); |
| 165 | |
| 166 | const program = ( |
| 167 | uri?: string, |
| 168 | imageConfig?: AWS.Lambda.FunctionImageConfig, |
| 169 | ) => |
| 170 | Effect.gen(function* () { |
| 171 | const repository = yield* AWS.ECR.Repository("ExternalRepository", { |
| 172 | repositoryName: externalRepositoryName, |
| 173 | }); |
| 174 | const image = yield* AWS.ECR.Image("ExternalImage", { |
| 175 | repositoryUri: repository.repositoryUri, |
| 176 | context, |
| 177 | platform: "linux/amd64", |
| 178 | }); |
| 179 | const func = |
| 180 | uri === undefined |
| 181 | ? undefined |
| 182 | : yield* AWS.Lambda.Function("ExternalFunction", { |
| 183 | functionName: externalFunctionName, |
| 184 | image: { uri, ...imageConfig }, |
| 185 | architecture: "x86_64", |
| 186 | env: { IMAGE_FUNCTION_ENV: "external" }, |
| 187 | functionUrl: false, |
| 188 | }); |
| 189 | return { image, func }; |
| 190 | }); |
| 191 | |
| 192 | const aliasTag = "lambda"; |
| 193 | const pointAliasAt = Effect.fn(function* ( |
| 194 | repositoryName: string, |
| 195 | imageTag: string, |
| 196 | ) { |
| 197 | const response = yield* ecr.batchGetImage({ |
| 198 | repositoryName, |
| 199 | imageIds: [{ imageTag }], |
| 200 | }); |
| 201 | const image = response.images?.[0]; |
| 202 | if (image?.imageManifest === undefined) { |
| 203 | return yield* Effect.fail( |
| 204 | new Error( |
| 205 | `ECR image ${repositoryName}:${imageTag} has no manifest`, |
| 206 | ), |
| 207 | ); |
| 208 | } |
| 209 | yield* ecr |
| 210 | .putImage({ |
| 211 | repositoryName, |
| 212 | imageManifest: image.imageManifest, |
| 213 | imageManifestMediaType: image.imageManifestMediaType, |
| 214 | imageTag: aliasTag, |
| 215 | }) |
| 216 | .pipe( |
| 217 | Effect.catchTag("ImageAlreadyExistsException", () => Effect.void), |
| 218 | ); |
| 219 | }); |
| 220 | |
| 221 | // Materialize a private ECR image independently of the Lambda function, |
| 222 | // then pass its now-literal URI through the external-image create path. |
| 223 | const source = yield* stack.deploy(program()); |
| 224 | yield* pointAliasAt(source.image.repositoryName, source.image.imageTag); |
| 225 | const aliasUri = `${source.image.repositoryUri}:${aliasTag}`; |
| 226 | const tagged = yield* stack.deploy( |
| 227 | program(aliasUri, { |
| 228 | command: ["index.alternate"], |
| 229 | entryPoint: ["/lambda-entrypoint.sh"], |
| 230 | workingDirectory: "/var/task", |
| 231 | }), |
| 232 | ); |
| 233 | if (tagged.func === undefined) { |
| 234 | return yield* Effect.fail( |
| 235 | new Error("External function was not created"), |
| 236 | ); |
| 237 | } |
| 238 | expect(tagged.func.code.image).toMatchObject({ |
| 239 | source: "uri", |
| 240 | imageUri: aliasUri, |
| 241 | ownsRepository: false, |
| 242 | }); |
| 243 | yield* assertFunctionImage(tagged.func.functionName, "x86_64"); |
| 244 | yield* assertFunctionImageConfig(tagged.func.functionName, { |
| 245 | Command: ["index.alternate"], |
| 246 | EntryPoint: ["/lambda-entrypoint.sh"], |
| 247 | WorkingDirectory: "/var/task", |
| 248 | }); |
| 249 | expect(yield* invokeAlternate(tagged.func.functionName)).toBe( |
| 250 | "alternate", |
| 251 | ); |
| 252 | |
| 253 | // Rebuild an independently-managed image, then repoint the same alias. |
| 254 | // The URI string is unchanged, so only digest resolution can detect this. |
| 255 | yield* fs.writeFileString(path.join(context, "marker.txt"), "two\n"); |
| 256 | const rebuilt = yield* stack.deploy( |
| 257 | program(aliasUri, { |
| 258 | command: ["index.alternate"], |
| 259 | entryPoint: ["/lambda-entrypoint.sh"], |
| 260 | workingDirectory: "/var/task", |
| 261 | }), |
| 262 | ); |
| 263 | yield* pointAliasAt(rebuilt.image.repositoryName, rebuilt.image.imageTag); |
| 264 | |
| 265 | // Omitting the overrides while updating the digest clears every override. |
| 266 | const retaggedPlan = yield* stack.plan(program(aliasUri)); |
| 267 | expect(retaggedPlan.resources.ExternalFunction).toMatchObject({ |
| 268 | action: "update", |
| 269 | }); |
| 270 | const retagged = yield* stack.deploy(program(aliasUri)); |
| 271 | if (retagged.func === undefined) { |
| 272 | return yield* Effect.fail( |
| 273 | new Error("External function was not updated"), |
| 274 | ); |
| 275 | } |
| 276 | expect(retagged.func.code.image).toMatchObject({ |
| 277 | source: "uri", |
| 278 | imageUri: aliasUri, |
| 279 | digest: rebuilt.image.digest, |
| 280 | ownsRepository: false, |
| 281 | }); |
| 282 | yield* assertFunctionImageConfig(retagged.func.functionName, undefined); |
| 283 | expect((yield* invoke(retagged.func.functionName, "two")).marker).toBe( |
| 284 | "two", |
| 285 | ); |
| 286 | |
| 287 | yield* stack.destroy(); |
| 288 | yield* assertFunctionDeleted(externalFunctionName); |
| 289 | yield* assertRepositoryDeleted(externalRepositoryName); |
| 290 | }).pipe( |
| 291 | Effect.tap(() => stack.destroy()), |
| 292 | Effect.onError(() => stack.destroy().pipe(Effect.ignore)), |
| 293 | ), |
| 294 | { |
| 295 | tags: ["provider:aws", "provider:aws:ecr", "provider:aws:lambda", "live"], |
| 296 | timeout: liveTestTimeout, |
| 297 | }, |
| 298 | ); |
| 299 | |
| 300 | test.provider.skipIf(skipLive)( |
| 301 | "builds and invokes an arm64 image function", |
| 302 | (stack) => |
| 303 | Effect.gen(function* () { |
| 304 | const fs = yield* FileSystem.FileSystem; |
| 305 | yield* stack.destroy(); |
| 306 | |
| 307 | const context = yield* fs.makeTempDirectoryScoped({ |
| 308 | prefix: "alchemy-lambda-image-arm64-", |
| 309 | }); |
| 310 | yield* fs.copy(fixture, context, { overwrite: true }); |
| 311 | |
| 312 | const deployed = yield* stack.deploy( |
| 313 | imageFunction(context, armFunctionName, "arm64"), |
| 314 | ); |
| 315 | expect(deployed.functionName).toBe(armFunctionName); |
| 316 | expect(deployed.code.image?.digest).toMatch(/^sha256:/); |
| 317 | yield* assertRepositoryImmutable(deployed.code.image!.repositoryName); |
| 318 | yield* assertFunctionImage(deployed.functionName, "arm64"); |
| 319 | expect(yield* invoke(deployed.functionName, "one")).toEqual({ |
| 320 | marker: "one", |
| 321 | environment: "bound", |
| 322 | event: { hello: "image" }, |
| 323 | }); |
| 324 | |
| 325 | const repositoryName = deployed.code.image!.repositoryName; |
| 326 | yield* stack.destroy(); |
| 327 | yield* assertFunctionDeleted(armFunctionName); |
| 328 | yield* assertRepositoryDeleted(repositoryName); |
| 329 | }).pipe( |
| 330 | Effect.tap(() => stack.destroy()), |
| 331 | Effect.onError(() => stack.destroy().pipe(Effect.ignore)), |
| 332 | ), |
| 333 | { |
| 334 | tags: ["provider:aws", "provider:aws:ecr", "provider:aws:lambda", "live"], |
| 335 | timeout: liveTestTimeout, |
| 336 | }, |
| 337 | ); |
| 338 | |
| 339 | test.provider.skipIf(skipLive)( |
| 340 | "plans a Zip function replacement with an image function", |
| 341 | (stack) => |
| 342 | Effect.gen(function* () { |
| 343 | const fs = yield* FileSystem.FileSystem; |
| 344 | yield* stack.destroy(); |
| 345 | |
| 346 | const context = yield* fs.makeTempDirectoryScoped({ |
| 347 | prefix: "alchemy-lambda-image-replacement-", |
| 348 | }); |
| 349 | yield* fs.copy(fixture, context, { overwrite: true }); |
| 350 | |
| 351 | const deployed = yield* stack.deploy(zipFunction(zipFunctionName)); |
| 352 | expect(deployed.functionName).toBe(zipFunctionName); |
| 353 | yield* assertFunctionPackageType(deployed.functionName, "Zip"); |
| 354 | |
| 355 | const renamedPlan = yield* stack.plan( |
| 356 | zipFunction(`${zipFunctionName}-renamed`), |
| 357 | ); |
| 358 | expect(renamedPlan.resources["ContainerFunction"]).toMatchObject({ |
| 359 | action: "replace", |
| 360 | deleteFirst: false, |
| 361 | }); |
| 362 | |
| 363 | const imagePlan = yield* stack.plan( |
| 364 | imageFunction(context, zipFunctionName, "x86_64"), |
| 365 | ); |
| 366 | expect(imagePlan.resources["ContainerFunction"]).toMatchObject({ |
| 367 | action: "replace", |
| 368 | deleteFirst: true, |
| 369 | }); |
| 370 | |
| 371 | yield* stack.destroy(); |
| 372 | yield* assertFunctionDeleted(zipFunctionName); |
| 373 | }).pipe( |
| 374 | Effect.tap(() => stack.destroy()), |
| 375 | Effect.onError(() => stack.destroy().pipe(Effect.ignore)), |
| 376 | ), |
| 377 | { |
| 378 | tags: ["provider:aws", "provider:aws:lambda", "live"], |
| 379 | timeout: liveTestTimeout, |
| 380 | }, |
| 381 | ); |
| 382 | |
| 383 | const ImageInvocationResponse = Schema.Struct({ |
| 384 | marker: Schema.optionalKey(Schema.String), |
| 385 | environment: Schema.optionalKey(Schema.String), |
| 386 | event: Schema.Struct({ hello: Schema.String }), |
| 387 | handler: Schema.optionalKey(Schema.String), |
| 388 | }); |
| 389 | |
| 390 | const invokePayload = Effect.fn(function* (deployedFunctionName: string) { |
| 391 | return yield* Lambda.invoke({ |
| 392 | FunctionName: deployedFunctionName, |
| 393 | Payload: JSON.stringify({ hello: "image" }), |
| 394 | }).pipe( |
| 395 | Effect.flatMap((response) => |
| 396 | Effect.gen(function* () { |
| 397 | if (response.FunctionError) { |
| 398 | return yield* Effect.fail( |
| 399 | new Error(`Lambda invocation failed: ${response.FunctionError}`), |
| 400 | ); |
| 401 | } |
| 402 | const payload = response.Payload |
| 403 | ? yield* response.Payload.pipe(Stream.decodeText(), Stream.mkString) |
| 404 | : ""; |
| 405 | const json = yield* Effect.try({ |
| 406 | try: () => JSON.parse(payload) as unknown, |
| 407 | catch: (cause) => |
| 408 | new Error("Lambda returned an invalid JSON payload", { cause }), |
| 409 | }); |
| 410 | return yield* Schema.decodeUnknownEffect(ImageInvocationResponse)(json); |
| 411 | }), |
| 412 | ), |
| 413 | ); |
| 414 | }); |
| 415 | |
| 416 | const invoke = Effect.fn(function* ( |
| 417 | deployedFunctionName: string, |
| 418 | expectedMarker: string, |
| 419 | ) { |
| 420 | return yield* invokePayload(deployedFunctionName).pipe( |
| 421 | Effect.filterOrFail( |
| 422 | (response) => response.marker === expectedMarker, |
| 423 | () => new Error(`Lambda is not serving marker ${expectedMarker} yet`), |
| 424 | ), |
| 425 | Effect.retry({ |
| 426 | schedule: Schedule.spaced("2 seconds"), |
| 427 | times: 10, |
| 428 | }), |
| 429 | ); |
| 430 | }); |
| 431 | |
| 432 | const invokeAlternate = Effect.fn(function* (deployedFunctionName: string) { |
| 433 | return yield* invokePayload(deployedFunctionName).pipe( |
| 434 | Effect.filterOrFail( |
| 435 | (response) => response.handler === "alternate", |
| 436 | () => new Error("Lambda is not serving the alternate image command yet"), |
| 437 | ), |
| 438 | Effect.map((response) => response.handler), |
| 439 | Effect.retry({ |
| 440 | schedule: Schedule.spaced("2 seconds"), |
| 441 | times: 10, |
| 442 | }), |
| 443 | ); |
| 444 | }); |
| 445 | |
| 446 | const assertFunctionImage = Effect.fn(function* ( |
| 447 | deployedFunctionName: string, |
| 448 | architecture: AWS.Lambda.FunctionArchitecture, |
| 449 | ) { |
| 450 | yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe( |
| 451 | Effect.filterOrFail( |
| 452 | (response) => |
| 453 | response.Configuration?.State === "Active" && |
| 454 | response.Configuration.LastUpdateStatus === "Successful" && |
| 455 | response.Configuration?.PackageType === "Image" && |
| 456 | response.Configuration.Architectures?.[0] === architecture && |
| 457 | response.Code?.ResolvedImageUri?.includes("@sha256:") === true, |
| 458 | () => new Error("Lambda image configuration has not propagated yet"), |
| 459 | ), |
| 460 | Effect.retry({ |
| 461 | schedule: Schedule.spaced("2 seconds"), |
| 462 | times: 10, |
| 463 | }), |
| 464 | ); |
| 465 | }); |
| 466 | |
| 467 | const assertFunctionImageConfig = Effect.fn(function* ( |
| 468 | deployedFunctionName: string, |
| 469 | expected: Lambda.ImageConfig | undefined, |
| 470 | ) { |
| 471 | yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe( |
| 472 | Effect.filterOrFail( |
| 473 | (response) => { |
| 474 | const observed = |
| 475 | response.Configuration?.ImageConfigResponse?.ImageConfig; |
| 476 | if (expected === undefined) { |
| 477 | return ( |
| 478 | observed?.Command === undefined && |
| 479 | observed?.EntryPoint === undefined && |
| 480 | observed?.WorkingDirectory === undefined |
| 481 | ); |
| 482 | } |
| 483 | return ( |
| 484 | JSON.stringify(observed?.Command) === |
| 485 | JSON.stringify(expected.Command) && |
| 486 | JSON.stringify(observed?.EntryPoint) === |
| 487 | JSON.stringify(expected.EntryPoint) && |
| 488 | observed?.WorkingDirectory === expected.WorkingDirectory |
| 489 | ); |
| 490 | }, |
| 491 | () => new Error("Lambda image overrides have not propagated yet"), |
| 492 | ), |
| 493 | Effect.retry({ |
| 494 | schedule: Schedule.spaced("2 seconds"), |
| 495 | times: 10, |
| 496 | }), |
| 497 | ); |
| 498 | }); |
| 499 | |
| 500 | const assertFunctionPackageType = Effect.fn(function* ( |
| 501 | deployedFunctionName: string, |
| 502 | packageType: Lambda.PackageType, |
| 503 | ) { |
| 504 | yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe( |
| 505 | Effect.filterOrFail( |
| 506 | (response) => response.Configuration?.PackageType === packageType, |
| 507 | () => new Error(`Lambda package type is not ${packageType} yet`), |
| 508 | ), |
| 509 | Effect.retry({ |
| 510 | schedule: Schedule.spaced("2 seconds"), |
| 511 | times: 10, |
| 512 | }), |
| 513 | ); |
| 514 | }); |
| 515 | |
| 516 | const assertFunctionDeleted = Effect.fn(function* ( |
| 517 | deployedFunctionName: string, |
| 518 | ) { |
| 519 | yield* Lambda.getFunction({ FunctionName: deployedFunctionName }).pipe( |
| 520 | Effect.flatMap(() => |
| 521 | Effect.fail(new Error(`Function ${deployedFunctionName} still exists`)), |
| 522 | ), |
| 523 | Effect.catchTag("ResourceNotFoundException", () => Effect.void), |
| 524 | Effect.retry({ |
| 525 | schedule: Schedule.spaced("1 second"), |
| 526 | times: 8, |
| 527 | }), |
| 528 | ); |
| 529 | }); |
| 530 | |
| 531 | const assertRepositoryDeleted = Effect.fn(function* (repositoryName: string) { |
| 532 | yield* ecr.describeRepositories({ repositoryNames: [repositoryName] }).pipe( |
| 533 | Effect.flatMap(() => |
| 534 | Effect.fail(new Error(`Repository ${repositoryName} still exists`)), |
| 535 | ), |
| 536 | Effect.catchTag("RepositoryNotFoundException", () => Effect.void), |
| 537 | Effect.retry({ |
| 538 | schedule: Schedule.spaced("1 second"), |
| 539 | times: 8, |
| 540 | }), |
| 541 | ); |
| 542 | }); |
| 543 | |
| 544 | const assertRepositoryImmutable = Effect.fn(function* (repositoryName: string) { |
| 545 | yield* ecr.describeRepositories({ repositoryNames: [repositoryName] }).pipe( |
| 546 | Effect.filterOrFail( |
| 547 | (response) => |
| 548 | response.repositories?.[0]?.imageTagMutability === "IMMUTABLE", |
| 549 | () => new Error(`Repository ${repositoryName} is not immutable`), |
| 550 | ), |
| 551 | ); |
| 552 | }); |
| 553 | |
| 554 | const assertRepositoryPolicy = Effect.fn(function* (repositoryName: string) { |
| 555 | yield* ecr.getRepositoryPolicy({ repositoryName }).pipe( |
| 556 | Effect.filterOrFail( |
| 557 | (response) => |
| 558 | response.policyText?.includes("LambdaECRImageRetrievalPolicy") === true, |
| 559 | () => new Error(`Repository ${repositoryName} has no Lambda pull policy`), |
| 560 | ), |
| 561 | ); |
| 562 | }); |