1import * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { deepEqual } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 deleteImageBuilderLogGroup,
14 imageBuilderArn,
15 immutableVersionKeysChanged,
16 retryWhileDependedOn,
17 syncImageBuilderTags,
18 toTagRecord,
19} from "./internal.ts";
20
21/**
22 * Image Builder image recipe versions are immutable — the deployed recipe
23 * differs from the desired one but the semantic version was not bumped, so
24 * the cloud state cannot be converged.
25 */
26export class ImageRecipeVersionImmutable extends Data.TaggedError(
27 "AWS::ImageBuilder::ImageRecipeVersionImmutable",
28)<{
29 message: string;
30}> {}
31
32export interface RecipeComponentParameter {
33 /** Name of the component parameter to set. */
34 name: string;
35 /** Value(s) to assign to the parameter. */
36 value: string[];
37}
38
39export interface RecipeComponent {
40 /**
41 * Build-version ARN of the component to apply
42 * (e.g. `component.componentBuildVersionArn`).
43 */
44 componentArn: string;
45 /** Parameter overrides for parameterized components. */
46 parameters?: RecipeComponentParameter[];
47}
48
49export interface ImageRecipeProps {
50 /**
51 * Name of the image recipe. If omitted, a deterministic physical name is
52 * generated. Changing the name replaces the recipe.
53 */
54 imageRecipeName?: string;
55 /**
56 * Semantic version of the recipe (`major.minor.patch`). Recipes are
57 * immutable — bump the version when changing any property. Changing the
58 * version replaces the recipe.
59 * @default "1.0.0"
60 */
61 semanticVersion?: string;
62 /**
63 * Base image of the recipe: an Image Builder image ARN (e.g. the
64 * AWS-managed `arn:aws:imagebuilder:us-west-2:aws:image/amazon-linux-2023-x86/x.x.x`),
65 * an AMI ID, or an SSM parameter reference (`ssm:/path/to/parameter`).
66 * Changing the parent image replaces the recipe.
67 */
68 parentImage: string;
69 /**
70 * Components applied to the image, in order. Changing the component list
71 * replaces the recipe.
72 */
73 components: RecipeComponent[];
74 /**
75 * Description of the recipe. Changing it replaces the recipe.
76 */
77 description?: string;
78 /**
79 * Block device mappings for instances built from the recipe. Changing
80 * them replaces the recipe.
81 */
82 blockDeviceMappings?: imagebuilder.InstanceBlockDeviceMapping[];
83 /**
84 * Working directory used during build and test workflows.
85 * Changing it replaces the recipe.
86 * @default "/tmp"
87 */
88 workingDirectory?: string;
89 /**
90 * Additional instance configuration (SSM agent removal, user data
91 * override). Changing it replaces the recipe.
92 */
93 additionalInstanceConfiguration?: imagebuilder.AdditionalInstanceConfiguration;
94 /**
95 * Tags applied to output AMIs created from the recipe. Changing them
96 * replaces the recipe.
97 */
98 amiTags?: Record<string, string>;
99 /**
100 * User-defined tags for the recipe.
101 */
102 tags?: Record<string, string>;
103}
104
105export interface ImageRecipe extends Resource<
106 "AWS.ImageBuilder.ImageRecipe",
107 ImageRecipeProps,
108 {
109 /** The name of the image recipe. */
110 imageRecipeName: string;
111 /** The ARN of the image recipe. */
112 imageRecipeArn: string;
113 /** The semantic version of the recipe. */
114 semanticVersion: string;
115 /** The OS platform of the recipe (`Linux` / `Windows`). */
116 platform: string | undefined;
117 /** The base image the recipe builds on. */
118 parentImage: string | undefined;
119 /** When the recipe was created. */
120 dateCreated: string | undefined;
121 },
122 never,
123 Providers
124> {}
125
126/**
127 * An EC2 Image Builder image recipe — the blueprint that combines a parent
128 * image with an ordered list of components to produce a new AMI.
129 *
130 * Recipes are immutable versions: every property except `tags` replaces the
131 * recipe. Bump `semanticVersion` when changing the definition.
132 * ### Creating an Image Recipe
133 * **Example:** Recipe from an AWS-Managed Parent Image
134 * ```typescript
135 * const recipe = yield* ImageBuilder.ImageRecipe("Recipe", {
136 * parentImage: "arn:aws:imagebuilder:us-west-2:aws:image/amazon-linux-2023-x86/x.x.x",
137 * semanticVersion: "1.0.0",
138 * components: [{ componentArn: component.componentBuildVersionArn }],
139 * });
140 * ```
141 *
142 * ### Using in a Pipeline
143 * **Example:** Wire into an Image Pipeline
144 * ```typescript
145 * const pipeline = yield* ImageBuilder.ImagePipeline("Pipeline", {
146 * imageRecipeArn: recipe.imageRecipeArn,
147 * infrastructureConfigurationArn: infra.infrastructureConfigurationArn,
148 * });
149 * ```
150 *
151 * @resource
152 */
153export const ImageRecipe = Resource<ImageRecipe>(
154 "AWS.ImageBuilder.ImageRecipe",
155);
156
157export const ImageRecipeProvider = () =>
158 Provider.effect(
159 ImageRecipe,
160 Effect.gen(function* () {
161 const toName = (id: string, props: ImageRecipeProps) =>
162 props.imageRecipeName
163 ? Effect.succeed(props.imageRecipeName)
164 : createPhysicalName({ id, maxLength: 126 });
165
166 const toVersion = (props: ImageRecipeProps) =>
167 props.semanticVersion ?? "1.0.0";
168
169 const toArn = (name: string, version: string) =>
170 imageBuilderArn("image-recipe", `${name}/${version}`);
171
172 const getRecipe = Effect.fn(function* (arn: string) {
173 const response = yield* imagebuilder
174 .getImageRecipe({ imageRecipeArn: arn })
175 .pipe(
176 Effect.catchTag("ResourceNotFoundException", () =>
177 Effect.succeed(undefined),
178 ),
179 );
180 return response?.imageRecipe;
181 });
182
183 const toAttrs = Effect.fn(function* (recipe: imagebuilder.ImageRecipe) {
184 if (!recipe.arn || !recipe.name || !recipe.version) {
185 return yield* Effect.fail(
186 new Error(
187 "Image Builder image recipe is missing its ARN, name, or version",
188 ),
189 );
190 }
191 return {
192 imageRecipeName: recipe.name,
193 imageRecipeArn: recipe.arn,
194 semanticVersion: recipe.version,
195 platform: recipe.platform,
196 parentImage: recipe.parentImage,
197 dateCreated: recipe.dateCreated,
198 };
199 });
200
201 return {
202 stables: ["imageRecipeName", "semanticVersion"],
203
204 // Recipes are immutable — any changed property except tags
205 // replaces. Individual keys may still hold unresolved Outputs at
206 // plan time (e.g. the ARN of a component being replaced in the
207 // same deploy); compare per key so a resolved change (like a
208 // semanticVersion bump) still plans a replacement, and let the
209 // reconcile immutability guard catch drift hidden behind an
210 // unresolved key.
211 diff: Effect.fn(function* ({ olds, news }) {
212 const immutableKeys = [
213 "imageRecipeName",
214 "semanticVersion",
215 "parentImage",
216 "components",
217 "description",
218 "blockDeviceMappings",
219 "workingDirectory",
220 "additionalInstanceConfiguration",
221 "amiTags",
222 ] as const;
223 if (immutableVersionKeysChanged(olds, news, immutableKeys)) {
224 return { action: "replace" } as const;
225 }
226 }),
227
228 read: Effect.fn(function* ({ id, olds, output }) {
229 const arn =
230 output?.imageRecipeArn ??
231 (yield* toArn(yield* toName(id, olds), toVersion(olds)));
232 const recipe = yield* getRecipe(arn);
233 if (recipe === undefined) return undefined;
234 const attrs = yield* toAttrs(recipe);
235 const tags = toTagRecord(recipe.tags);
236 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
237 }),
238
239 reconcile: Effect.fn(function* ({ id, news, output, session }) {
240 // One idempotency token per reconcile — retries within this run
241 // are deduplicated by the API.
242 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
243 const name = output?.imageRecipeName ?? (yield* toName(id, news));
244 const version = output?.semanticVersion ?? toVersion(news);
245 const internalTags = yield* createInternalTags(id);
246 const desiredTags = { ...internalTags, ...news.tags };
247
248 // 1. Observe — the recipe ARN is deterministic from name+version.
249 const arn = output?.imageRecipeArn ?? (yield* toArn(name, version));
250 let observed = yield* getRecipe(arn);
251
252 // Immutability guard — the version already exists in the cloud
253 // with a different definition (e.g. a component was replaced
254 // without bumping the recipe version). It cannot be converged in
255 // place; the user must bump `semanticVersion`.
256 if (observed !== undefined) {
257 const observedComponents = (observed.components ?? []).map(
258 (component) => component.componentArn,
259 );
260 const desiredComponents = news.components.map(
261 (component) => component.componentArn,
262 );
263 if (
264 !deepEqual(observedComponents, desiredComponents) ||
265 observed.parentImage !== news.parentImage
266 ) {
267 return yield* new ImageRecipeVersionImmutable({
268 message: `Image Builder recipe '${name}' version '${version}' already exists with a different definition — bump semanticVersion to publish the change`,
269 });
270 }
271 }
272
273 // 2. Ensure — recipes are immutable; missing is the only create
274 // path.
275 if (observed === undefined) {
276 const created = yield* imagebuilder.createImageRecipe({
277 name,
278 semanticVersion: version,
279 parentImage: news.parentImage,
280 components: news.components,
281 description: news.description,
282 blockDeviceMappings: news.blockDeviceMappings,
283 workingDirectory: news.workingDirectory,
284 additionalInstanceConfiguration:
285 news.additionalInstanceConfiguration,
286 amiTags: news.amiTags,
287 tags: desiredTags,
288 clientToken,
289 });
290 if (!created.imageRecipeArn) {
291 return yield* Effect.fail(
292 new Error("CreateImageRecipe returned no imageRecipeArn"),
293 );
294 }
295 observed = yield* getRecipe(created.imageRecipeArn);
296 if (observed === undefined) {
297 return yield* Effect.fail(
298 new Error(
299 `created Image Builder recipe '${name}' is not readable`,
300 ),
301 );
302 }
303 }
304
305 // 3. Sync — tags are the only mutable aspect.
306 if (observed.arn) {
307 yield* syncImageBuilderTags(observed.arn, desiredTags);
308 }
309
310 // 4. Return fresh attributes.
311 yield* session.note(name);
312 return yield* toAttrs(observed);
313 }),
314
315 delete: Effect.fn(function* ({ output }) {
316 yield* retryWhileDependedOn(
317 imagebuilder.deleteImageRecipe({
318 imageRecipeArn: output.imageRecipeArn,
319 }),
320 ).pipe(
321 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
322 );
323
324 // Do not discard state until Image Builder confirms the owned
325 // version is gone. The auto-created recipe log group is shared by
326 // every version with the same name, so delete it only when no live
327 // same-name recipe remains (including a replacement version).
328 for (let attempt = 0; attempt < 30; attempt++) {
329 if ((yield* getRecipe(output.imageRecipeArn)) === undefined) break;
330 if (attempt === 29) {
331 return yield* Effect.die(
332 new Error(
333 `Image Builder recipe ${output.imageRecipeArn} remained observable 30 seconds after delete`,
334 ),
335 );
336 }
337 yield* Effect.sleep("1 second");
338 }
339
340 const summaries = yield* imagebuilder.listImageRecipes
341 .pages({ owner: "Self" })
342 .pipe(
343 Stream.runCollect,
344 Effect.map((pages) =>
345 Array.from(pages).flatMap(
346 (page) => page.imageRecipeSummaryList ?? [],
347 ),
348 ),
349 );
350 const sameName = summaries.filter(
351 (summary) =>
352 summary.name === output.imageRecipeName &&
353 summary.arn !== undefined,
354 );
355 const liveSameName = yield* Effect.forEach(
356 sameName,
357 (summary) => getRecipe(summary.arn!),
358 { concurrency: 4 },
359 );
360 if (liveSameName.every((recipe) => recipe === undefined)) {
361 yield* deleteImageBuilderLogGroup(
362 `/aws/imagebuilder/${output.imageRecipeName}`,
363 );
364 }
365 }),
366
367 list: () =>
368 imagebuilder.listImageRecipes.pages({ owner: "Self" }).pipe(
369 Stream.runCollect,
370 Effect.map((chunk) =>
371 Array.from(chunk).flatMap((page) =>
372 (page.imageRecipeSummaryList ?? []).filter(
373 (
374 summary,
375 ): summary is imagebuilder.ImageRecipeSummary & {
376 arn: string;
377 } => summary.arn !== undefined,
378 ),
379 ),
380 ),
381 Effect.flatMap(
382 Effect.forEach(
383 (summary) =>
384 getRecipe(summary.arn).pipe(
385 Effect.flatMap((recipe) =>
386 recipe === undefined
387 ? Effect.succeed(undefined)
388 : toAttrs(recipe),
389 ),
390 ),
391 { concurrency: 4 },
392 ),
393 ),
394 Effect.map((items) => items.filter((item) => item !== undefined)),
395 ),
396 };
397 }),
398 );