1import * as AWS from "@/AWS";
2import { AWSEnvironment } from "@/AWS/Environment.ts";
3import { InstanceProfile } from "@/AWS/IAM/InstanceProfile.ts";
4import { Role } from "@/AWS/IAM/Role.ts";
5import {
6 Component,
7 DistributionConfiguration,
8 ImagePipeline,
9 ImageRecipe,
10 InfrastructureConfiguration,
11} from "@/AWS/ImageBuilder";
12import * as Test from "@/Test/Alchemy";
13import * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
14import { expect } from "alchemy-test";
15import * as Effect from "effect/Effect";
16import * as Result from "effect/Result";
17import * as Schedule from "effect/Schedule";
18
19const { 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.
24test.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.
43test.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
61const 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). */
77const 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
99test.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);