| 1 | import * as AWS from "@/AWS"; |
| 2 | import { AWSEnvironment } from "@/AWS/Environment.ts"; |
| 3 | import { InstanceProfile } from "@/AWS/IAM/InstanceProfile.ts"; |
| 4 | import { Role } from "@/AWS/IAM/Role.ts"; |
| 5 | import { |
| 6 | Component, |
| 7 | DistributionConfiguration, |
| 8 | ImagePipeline, |
| 9 | ImageRecipe, |
| 10 | InfrastructureConfiguration, |
| 11 | } from "@/AWS/ImageBuilder"; |
| 12 | import * as Test from "@/Test/Alchemy"; |
| 13 | import * as imagebuilder from "@distilled.cloud/aws/imagebuilder"; |
| 14 | import { expect } from "alchemy-test"; |
| 15 | import * as Effect from "effect/Effect"; |
| 16 | import * as Result from "effect/Result"; |
| 17 | import * as Schedule from "effect/Schedule"; |
| 18 | |
| 19 | const { test } = Test.make({ providers: AWS.providers() }); |
| 20 | |
| 21 | // Ungated typed-error probe: prove the distilled error union carries the |
| 22 | // not-found tag every read/delete path in this service depends on. Runs in |
| 23 | // every CI pass at near-zero cost. |
| 24 | test.provider( |
| 25 | "getComponent on a nonexistent ARN fails with ResourceNotFoundException", |
| 26 | () => |
| 27 | Effect.gen(function* () { |
| 28 | const { accountId, region } = yield* AWSEnvironment.current; |
| 29 | const error = yield* Effect.flip( |
| 30 | imagebuilder.getComponent({ |
| 31 | componentBuildVersionArn: `arn:aws:imagebuilder:${region}:${accountId}:component/alchemy-nonexistent-probe/1.0.0/1`, |
| 32 | }), |
| 33 | ); |
| 34 | expect(error._tag).toBe("ResourceNotFoundException"); |
| 35 | }), |
| 36 | { tags: ["provider:aws", "provider:aws:imagebuilder", "live"] }, |
| 37 | ); |
| 38 | |
| 39 | // Ungated typed-error probe: the Smithy model omits ResourceNotFoundException |
| 40 | // from GetImage / DeleteImage / CancelImageCreation even though the wire |
| 41 | // returns it — patched in distilled (patches/imagebuilder.json). This pins |
| 42 | // the patch so the image data-plane bindings can rely on the typed tag. |
| 43 | test.provider( |
| 44 | "getImage/deleteImage on a nonexistent ARN fail with ResourceNotFoundException", |
| 45 | () => |
| 46 | Effect.gen(function* () { |
| 47 | const { accountId, region } = yield* AWSEnvironment.current; |
| 48 | const arn = `arn:aws:imagebuilder:${region}:${accountId}:image/alchemy-nonexistent-probe/1.0.0/1`; |
| 49 | const getError = yield* Effect.flip( |
| 50 | imagebuilder.getImage({ imageBuildVersionArn: arn }), |
| 51 | ); |
| 52 | expect(getError._tag).toBe("ResourceNotFoundException"); |
| 53 | const deleteError = yield* Effect.flip( |
| 54 | imagebuilder.deleteImage({ imageBuildVersionArn: arn }), |
| 55 | ); |
| 56 | expect(deleteError._tag).toBe("ResourceNotFoundException"); |
| 57 | }), |
| 58 | { tags: ["provider:aws", "provider:aws:imagebuilder", "live"] }, |
| 59 | ); |
| 60 | |
| 61 | const componentData = (marker: string) => |
| 62 | [ |
| 63 | "name: alchemy-test-component", |
| 64 | "description: no-op component used by alchemy live tests", |
| 65 | "schemaVersion: 1.0", |
| 66 | "phases:", |
| 67 | " - name: build", |
| 68 | " steps:", |
| 69 | " - name: hello", |
| 70 | " action: ExecuteBash", |
| 71 | " inputs:", |
| 72 | " commands:", |
| 73 | ` - echo ${marker}`, |
| 74 | ].join("\n"); |
| 75 | |
| 76 | /** Poll until an Image Builder resource is gone (typed NotFound). */ |
| 77 | const untilGone = <A, E extends { readonly _tag: string }, R>( |
| 78 | get: Effect.Effect<A, E, R>, |
| 79 | ) => |
| 80 | Effect.gen(function* () { |
| 81 | const result = yield* Effect.result(get); |
| 82 | if (Result.isSuccess(result)) { |
| 83 | return yield* Effect.fail(new Error("resource still exists")); |
| 84 | } |
| 85 | if (result.failure._tag !== "ResourceNotFoundException") { |
| 86 | return yield* Effect.fail( |
| 87 | new Error(`unexpected error: ${result.failure._tag}`), |
| 88 | ); |
| 89 | } |
| 90 | }).pipe( |
| 91 | Effect.retry({ |
| 92 | schedule: Schedule.max([ |
| 93 | Schedule.fixed("3 seconds"), |
| 94 | Schedule.recurs(10), |
| 95 | ]), |
| 96 | }), |
| 97 | ); |
| 98 | |
| 99 | test.provider( |
| 100 | "image builder config lifecycle: create, update, replace, destroy", |
| 101 | (stack) => |
| 102 | Effect.gen(function* () { |
| 103 | yield* stack.destroy(); |
| 104 | |
| 105 | const { region } = yield* AWSEnvironment.current; |
| 106 | const parentImage = `arn:aws:imagebuilder:${region}:aws:image/amazon-linux-2023-x86/x.x.x`; |
| 107 | |
| 108 | const program = (options: { |
| 109 | componentVersion: string; |
| 110 | distributionDescription: string; |
| 111 | pipelineDescription: string; |
| 112 | }) => |
| 113 | Effect.gen(function* () { |
| 114 | const role = yield* Role("BuilderRole", { |
| 115 | assumeRolePolicyDocument: { |
| 116 | Version: "2012-10-17", |
| 117 | Statement: [ |
| 118 | { |
| 119 | Effect: "Allow", |
| 120 | Principal: { Service: "ec2.amazonaws.com" }, |
| 121 | Action: ["sts:AssumeRole"], |
| 122 | }, |
| 123 | ], |
| 124 | }, |
| 125 | managedPolicyArns: [ |
| 126 | "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder", |
| 127 | "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", |
| 128 | ], |
| 129 | }); |
| 130 | const profile = yield* InstanceProfile("BuilderProfile", { |
| 131 | roleName: role.roleName, |
| 132 | }); |
| 133 | const component = yield* Component("Setup", { |
| 134 | platform: "Linux", |
| 135 | semanticVersion: options.componentVersion, |
| 136 | data: componentData("hello-from-alchemy"), |
| 137 | tags: { fixture: "imagebuilder" }, |
| 138 | }); |
| 139 | const recipe = yield* ImageRecipe("Recipe", { |
| 140 | parentImage, |
| 141 | semanticVersion: options.componentVersion, |
| 142 | components: [{ componentArn: component.componentBuildVersionArn }], |
| 143 | tags: { fixture: "imagebuilder" }, |
| 144 | }); |
| 145 | const infra = yield* InfrastructureConfiguration("Infra", { |
| 146 | instanceProfileName: profile.instanceProfileName, |
| 147 | instanceTypes: ["t3.micro"], |
| 148 | terminateInstanceOnFailure: true, |
| 149 | tags: { fixture: "imagebuilder" }, |
| 150 | }); |
| 151 | const distribution = yield* DistributionConfiguration("Dist", { |
| 152 | description: options.distributionDescription, |
| 153 | distributions: [ |
| 154 | { |
| 155 | region, |
| 156 | amiDistributionConfiguration: { |
| 157 | name: "alchemy-imagebuilder-test-{{ imagebuilder:buildDate }}", |
| 158 | amiTags: { fixture: "imagebuilder" }, |
| 159 | }, |
| 160 | }, |
| 161 | ], |
| 162 | tags: { fixture: "imagebuilder" }, |
| 163 | }); |
| 164 | const pipeline = yield* ImagePipeline("Pipeline", { |
| 165 | imageRecipeArn: recipe.imageRecipeArn, |
| 166 | infrastructureConfigurationArn: |
| 167 | infra.infrastructureConfigurationArn, |
| 168 | distributionConfigurationArn: |
| 169 | distribution.distributionConfigurationArn, |
| 170 | description: options.pipelineDescription, |
| 171 | status: "DISABLED", |
| 172 | // Duration-typed test timeout — converted to wire minutes. |
| 173 | imageTestsConfiguration: { |
| 174 | imageTestsEnabled: false, |
| 175 | timeout: "2 hours", |
| 176 | }, |
| 177 | tags: { fixture: "imagebuilder" }, |
| 178 | }); |
| 179 | return { component, recipe, infra, distribution, pipeline }; |
| 180 | }); |
| 181 | |
| 182 | // 1. Create. |
| 183 | const created = yield* stack.deploy( |
| 184 | program({ |
| 185 | componentVersion: "1.0.0", |
| 186 | distributionDescription: "alchemy imagebuilder test", |
| 187 | pipelineDescription: "alchemy imagebuilder pipeline", |
| 188 | }), |
| 189 | ); |
| 190 | |
| 191 | expect(created.component.componentBuildVersionArn).toContain( |
| 192 | ":component/", |
| 193 | ); |
| 194 | expect(created.component.componentBuildVersionArn).toContain("/1.0.0/1"); |
| 195 | expect(created.component.platform).toBe("Linux"); |
| 196 | expect(created.recipe.imageRecipeArn).toContain(":image-recipe/"); |
| 197 | expect(created.infra.infrastructureConfigurationArn).toContain( |
| 198 | ":infrastructure-configuration/", |
| 199 | ); |
| 200 | expect(created.distribution.distributionConfigurationArn).toContain( |
| 201 | ":distribution-configuration/", |
| 202 | ); |
| 203 | expect(created.pipeline.imagePipelineArn).toContain(":image-pipeline/"); |
| 204 | expect(created.pipeline.status).toBe("DISABLED"); |
| 205 | |
| 206 | // Out-of-band verification via distilled. |
| 207 | const observedPipeline = yield* imagebuilder.getImagePipeline({ |
| 208 | imagePipelineArn: created.pipeline.imagePipelineArn, |
| 209 | }); |
| 210 | expect(observedPipeline.imagePipeline?.imageRecipeArn).toBe( |
| 211 | created.recipe.imageRecipeArn, |
| 212 | ); |
| 213 | expect( |
| 214 | observedPipeline.imagePipeline?.infrastructureConfigurationArn, |
| 215 | ).toBe(created.infra.infrastructureConfigurationArn); |
| 216 | expect(observedPipeline.imagePipeline?.status).toBe("DISABLED"); |
| 217 | // The Duration-typed test timeout landed as wire minutes. |
| 218 | expect( |
| 219 | observedPipeline.imagePipeline?.imageTestsConfiguration?.timeoutMinutes, |
| 220 | ).toBe(120); |
| 221 | expect(observedPipeline.imagePipeline?.tags?.["alchemy::id"]).toBe( |
| 222 | "Pipeline", |
| 223 | ); |
| 224 | |
| 225 | const observedRecipe = yield* imagebuilder.getImageRecipe({ |
| 226 | imageRecipeArn: created.recipe.imageRecipeArn, |
| 227 | }); |
| 228 | expect(observedRecipe.imageRecipe?.components?.[0]?.componentArn).toBe( |
| 229 | created.component.componentBuildVersionArn, |
| 230 | ); |
| 231 | expect(observedRecipe.imageRecipe?.parentImage).toBe(parentImage); |
| 232 | |
| 233 | const observedInfra = yield* imagebuilder.getInfrastructureConfiguration({ |
| 234 | infrastructureConfigurationArn: |
| 235 | created.infra.infrastructureConfigurationArn, |
| 236 | }); |
| 237 | expect( |
| 238 | observedInfra.infrastructureConfiguration?.instanceProfileName, |
| 239 | ).toBe(created.infra.instanceProfileName); |
| 240 | expect(observedInfra.infrastructureConfiguration?.instanceTypes).toEqual([ |
| 241 | "t3.micro", |
| 242 | ]); |
| 243 | |
| 244 | // 2. Update mutable configs in place (same ARNs). |
| 245 | const updated = yield* stack.deploy( |
| 246 | program({ |
| 247 | componentVersion: "1.0.0", |
| 248 | distributionDescription: "alchemy imagebuilder test (updated)", |
| 249 | pipelineDescription: "alchemy imagebuilder pipeline (updated)", |
| 250 | }), |
| 251 | ); |
| 252 | expect(updated.distribution.distributionConfigurationArn).toBe( |
| 253 | created.distribution.distributionConfigurationArn, |
| 254 | ); |
| 255 | expect(updated.pipeline.imagePipelineArn).toBe( |
| 256 | created.pipeline.imagePipelineArn, |
| 257 | ); |
| 258 | const updatedDistribution = |
| 259 | yield* imagebuilder.getDistributionConfiguration({ |
| 260 | distributionConfigurationArn: |
| 261 | updated.distribution.distributionConfigurationArn, |
| 262 | }); |
| 263 | expect(updatedDistribution.distributionConfiguration?.description).toBe( |
| 264 | "alchemy imagebuilder test (updated)", |
| 265 | ); |
| 266 | const updatedPipeline = yield* imagebuilder.getImagePipeline({ |
| 267 | imagePipelineArn: updated.pipeline.imagePipelineArn, |
| 268 | }); |
| 269 | expect(updatedPipeline.imagePipeline?.description).toBe( |
| 270 | "alchemy imagebuilder pipeline (updated)", |
| 271 | ); |
| 272 | |
| 273 | // 3. Replace immutable versions: bumping the component version |
| 274 | // replaces component + recipe; the pipeline updates in place to |
| 275 | // the new recipe ARN. |
| 276 | const replaced = yield* stack.deploy( |
| 277 | program({ |
| 278 | componentVersion: "1.0.1", |
| 279 | distributionDescription: "alchemy imagebuilder test (updated)", |
| 280 | pipelineDescription: "alchemy imagebuilder pipeline (updated)", |
| 281 | }), |
| 282 | ); |
| 283 | expect(replaced.component.componentBuildVersionArn).not.toBe( |
| 284 | created.component.componentBuildVersionArn, |
| 285 | ); |
| 286 | expect(replaced.component.componentBuildVersionArn).toContain("/1.0.1/1"); |
| 287 | expect(replaced.recipe.imageRecipeArn).not.toBe( |
| 288 | created.recipe.imageRecipeArn, |
| 289 | ); |
| 290 | expect(replaced.pipeline.imagePipelineArn).toBe( |
| 291 | created.pipeline.imagePipelineArn, |
| 292 | ); |
| 293 | const replacedPipeline = yield* imagebuilder.getImagePipeline({ |
| 294 | imagePipelineArn: replaced.pipeline.imagePipelineArn, |
| 295 | }); |
| 296 | expect(replacedPipeline.imagePipeline?.imageRecipeArn).toBe( |
| 297 | replaced.recipe.imageRecipeArn, |
| 298 | ); |
| 299 | |
| 300 | // 4. Destroy and verify everything is gone out-of-band. |
| 301 | yield* stack.destroy(); |
| 302 | yield* untilGone( |
| 303 | imagebuilder.getImagePipeline({ |
| 304 | imagePipelineArn: replaced.pipeline.imagePipelineArn, |
| 305 | }), |
| 306 | ); |
| 307 | yield* untilGone( |
| 308 | imagebuilder.getImageRecipe({ |
| 309 | imageRecipeArn: replaced.recipe.imageRecipeArn, |
| 310 | }), |
| 311 | ); |
| 312 | yield* untilGone( |
| 313 | imagebuilder.getComponent({ |
| 314 | componentBuildVersionArn: replaced.component.componentBuildVersionArn, |
| 315 | }), |
| 316 | ); |
| 317 | yield* untilGone( |
| 318 | imagebuilder.getInfrastructureConfiguration({ |
| 319 | infrastructureConfigurationArn: |
| 320 | replaced.infra.infrastructureConfigurationArn, |
| 321 | }), |
| 322 | ); |
| 323 | yield* untilGone( |
| 324 | imagebuilder.getDistributionConfiguration({ |
| 325 | distributionConfigurationArn: |
| 326 | replaced.distribution.distributionConfigurationArn, |
| 327 | }), |
| 328 | ); |
| 329 | }), |
| 330 | { |
| 331 | tags: [ |
| 332 | "provider:aws", |
| 333 | "provider:aws:iam", |
| 334 | "provider:aws:imagebuilder", |
| 335 | "live", |
| 336 | ], |
| 337 | timeout: 180_000, |
| 338 | }, |
| 339 | ); |