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 { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 imageBuilderArn,
13 immutableVersionKeysChanged,
14 retryWhileDependedOn,
15 syncImageBuilderTags,
16 toTagRecord,
17} from "./internal.ts";
18
19/**
20 * A Component must define its document inline (`data`) or point at an S3
21 * object (`uri`) — exactly one of the two.
22 */
23export class ComponentSourceInvalid extends Data.TaggedError(
24 "AWS::ImageBuilder::ComponentSourceInvalid",
25)<{
26 message: string;
27}> {}
28
29/**
30 * Image Builder component versions are immutable — the deployed document
31 * differs from the desired one but the semantic version was not bumped, so
32 * the cloud state cannot be converged.
33 */
34export class ComponentVersionImmutable extends Data.TaggedError(
35 "AWS::ImageBuilder::ComponentVersionImmutable",
36)<{
37 message: string;
38}> {}
39
40export interface ComponentProps {
41 /**
42 * Name of the component. If omitted, a deterministic physical name is
43 * generated. Changing the name replaces the component.
44 */
45 componentName?: string;
46 /**
47 * Semantic version of the component (`major.minor.patch`). Components
48 * are immutable — bump the version when changing `data`/`uri`. Changing
49 * the version replaces the component.
50 * @default "1.0.0"
51 */
52 semanticVersion?: string;
53 /**
54 * Operating system platform of the component. Changing the platform
55 * replaces the component.
56 */
57 platform: "Linux" | "Windows" | "macOS";
58 /**
59 * Inline YAML component document (AWSTOMC schema) defining the build /
60 * validate / test phases. Mutually exclusive with `uri`. Changing the
61 * data replaces the component.
62 */
63 data?: string;
64 /**
65 * S3 URI of a YAML component document. Use for documents larger than
66 * 64 KB. Mutually exclusive with `data`. Changing the URI replaces the
67 * component.
68 */
69 uri?: string;
70 /**
71 * Description of the component. Changing the description replaces the
72 * component (component versions are immutable).
73 */
74 description?: string;
75 /**
76 * Description of the change in this version compared to the previous
77 * one. Changing it replaces the component.
78 */
79 changeDescription?: string;
80 /**
81 * Operating system versions supported by the component
82 * (e.g. `["Amazon Linux 2023"]`). Changing them replaces the component.
83 */
84 supportedOsVersions?: string[];
85 /**
86 * KMS key used to encrypt the component. Changing it replaces the
87 * component.
88 */
89 kmsKeyId?: string;
90 /**
91 * User-defined tags for the component.
92 */
93 tags?: Record<string, string>;
94}
95
96export interface Component extends Resource<
97 "AWS.ImageBuilder.Component",
98 ComponentProps,
99 {
100 /** The name of the component. */
101 componentName: string;
102 /** The ARN of this component build version. */
103 componentBuildVersionArn: string;
104 /** The semantic version of the component. */
105 semanticVersion: string;
106 /** The OS platform of the component (`Linux` / `Windows`). */
107 platform: string;
108 /** Whether the component is a `BUILD` or `TEST` component. */
109 type: string | undefined;
110 /** When the component was created. */
111 dateCreated: string | undefined;
112 },
113 never,
114 Providers
115> {}
116
117/**
118 * An EC2 Image Builder component — a YAML document that defines the build,
119 * validation, and test steps applied to an instance during image creation.
120 *
121 * Components are immutable versions: every property except `tags` replaces
122 * the component. Bump `semanticVersion` when changing the document.
123 * ### Creating a Component
124 * **Example:** Inline Build Component
125 * ```typescript
126 * const component = yield* ImageBuilder.Component("Setup", {
127 * platform: "Linux",
128 * semanticVersion: "1.0.0",
129 * data: [
130 * "name: setup",
131 * "description: install packages",
132 * "schemaVersion: 1.2",
133 * "phases:",
134 * " - name: build",
135 * " steps:",
136 * " - name: install",
137 * " action: ExecuteBash",
138 * " inputs:",
139 * " commands:",
140 * " - dnf install -y htop",
141 * ].join("\n"),
142 * });
143 * ```
144 *
145 * ### Using in an Image Recipe
146 * **Example:** Reference from a Recipe
147 * ```typescript
148 * const recipe = yield* ImageBuilder.ImageRecipe("Recipe", {
149 * parentImage: "arn:aws:imagebuilder:us-west-2:aws:image/amazon-linux-2023-x86/x.x.x",
150 * components: [{ componentArn: component.componentBuildVersionArn }],
151 * });
152 * ```
153 *
154 * @resource
155 */
156export const Component = Resource<Component>("AWS.ImageBuilder.Component");
157
158export const ComponentProvider = () =>
159 Provider.effect(
160 Component,
161 Effect.gen(function* () {
162 const toName = (id: string, props: ComponentProps) =>
163 props.componentName
164 ? Effect.succeed(props.componentName)
165 : createPhysicalName({ id, maxLength: 126 });
166
167 const toVersion = (props: ComponentProps) =>
168 props.semanticVersion ?? "1.0.0";
169
170 /**
171 * CreateComponent always mints build version 1 for a fresh
172 * name/version pair (subsequent creates of the same pair are
173 * rejected), so the build-version ARN is deterministic.
174 */
175 const toBuildVersionArn = (name: string, version: string) =>
176 imageBuilderArn("component", `${name}/${version}/1`);
177
178 const getComponent = Effect.fn(function* (arn: string) {
179 const response = yield* imagebuilder
180 .getComponent({ componentBuildVersionArn: arn })
181 .pipe(
182 Effect.catchTag("ResourceNotFoundException", () =>
183 Effect.succeed(undefined),
184 ),
185 );
186 return response?.component;
187 });
188
189 const toAttrs = Effect.fn(function* (component: imagebuilder.Component) {
190 if (!component.arn || !component.name || !component.version) {
191 return yield* Effect.fail(
192 new Error(
193 "Image Builder component is missing its ARN, name, or version",
194 ),
195 );
196 }
197 return {
198 componentName: component.name,
199 componentBuildVersionArn: component.arn,
200 semanticVersion: component.version,
201 platform: component.platform ?? "",
202 type: component.type,
203 dateCreated: component.dateCreated,
204 };
205 });
206
207 return {
208 stables: ["componentName", "semanticVersion"],
209
210 // Components are immutable — any changed property except tags
211 // replaces. Individual keys may still hold unresolved Outputs at
212 // plan time; compare per key so a resolved change still plans a
213 // replacement, and let the reconcile immutability guard catch
214 // drift hidden behind an unresolved key.
215 diff: Effect.fn(function* ({ olds, news }) {
216 const immutableKeys = [
217 "componentName",
218 "semanticVersion",
219 "platform",
220 "data",
221 "uri",
222 "description",
223 "changeDescription",
224 "supportedOsVersions",
225 "kmsKeyId",
226 ] as const;
227 if (immutableVersionKeysChanged(olds, news, immutableKeys)) {
228 return { action: "replace" } as const;
229 }
230 }),
231
232 read: Effect.fn(function* ({ id, olds, output }) {
233 const arn =
234 output?.componentBuildVersionArn ??
235 (yield* toBuildVersionArn(
236 yield* toName(id, olds),
237 toVersion(olds),
238 ));
239 const component = yield* getComponent(arn);
240 if (component === undefined) return undefined;
241 const attrs = yield* toAttrs(component);
242 const tags = toTagRecord(component.tags);
243 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
244 }),
245
246 reconcile: Effect.fn(function* ({ id, news, output, session }) {
247 // One idempotency token per reconcile — retries within this run
248 // are deduplicated by the API.
249 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
250 if ((news.data === undefined) === (news.uri === undefined)) {
251 return yield* new ComponentSourceInvalid({
252 message:
253 "Specify exactly one of `data` (inline YAML) or `uri` (S3 object) for an Image Builder component",
254 });
255 }
256 const name = output?.componentName ?? (yield* toName(id, news));
257 const version = output?.semanticVersion ?? toVersion(news);
258 const internalTags = yield* createInternalTags(id);
259 const desiredTags = { ...internalTags, ...news.tags };
260
261 // 1. Observe — cloud state is authoritative; output only caches
262 // the build-version ARN.
263 const arn =
264 output?.componentBuildVersionArn ??
265 (yield* toBuildVersionArn(name, version));
266 let observed = yield* getComponent(arn);
267
268 // Immutability guard — the version already exists in the cloud
269 // with a different document. It cannot be converged in place; the
270 // user must bump `semanticVersion` (which replaces the component).
271 if (
272 observed !== undefined &&
273 news.data !== undefined &&
274 observed.data !== news.data
275 ) {
276 return yield* new ComponentVersionImmutable({
277 message: `Image Builder component '${name}' version '${version}' already exists with a different document — bump semanticVersion to publish the change`,
278 });
279 }
280
281 // 2. Ensure — components are immutable, so a missing component is
282 // the only create path. A same-name/version conflict (race or
283 // an unbumped document change on a fixed name) surfaces as the
284 // API's typed error.
285 if (observed === undefined) {
286 const created = yield* imagebuilder.createComponent({
287 name,
288 semanticVersion: version,
289 platform: news.platform,
290 data: news.data,
291 uri: news.uri,
292 description: news.description,
293 changeDescription: news.changeDescription,
294 supportedOsVersions: news.supportedOsVersions,
295 kmsKeyId: news.kmsKeyId,
296 tags: desiredTags,
297 clientToken,
298 });
299 if (!created.componentBuildVersionArn) {
300 return yield* Effect.fail(
301 new Error(
302 "CreateComponent returned no componentBuildVersionArn",
303 ),
304 );
305 }
306 observed = yield* getComponent(created.componentBuildVersionArn);
307 if (observed === undefined) {
308 return yield* Effect.fail(
309 new Error(
310 `created Image Builder component '${name}' is not readable`,
311 ),
312 );
313 }
314 }
315
316 // 3. Sync — tags are the only mutable aspect; diff against
317 // OBSERVED cloud tags.
318 if (observed.arn) {
319 yield* syncImageBuilderTags(observed.arn, desiredTags);
320 }
321
322 // 4. Return fresh attributes.
323 yield* session.note(name);
324 return yield* toAttrs(observed);
325 }),
326
327 delete: Effect.fn(function* ({ output }) {
328 yield* retryWhileDependedOn(
329 imagebuilder.deleteComponent({
330 componentBuildVersionArn: output.componentBuildVersionArn,
331 }),
332 ).pipe(
333 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
334 );
335 }),
336
337 list: () =>
338 imagebuilder.listComponents.pages({ owner: "Self" }).pipe(
339 Stream.runCollect,
340 Effect.map((chunk) =>
341 Array.from(chunk).flatMap((page) =>
342 (page.componentVersionList ?? []).filter(
343 (
344 version,
345 ): version is imagebuilder.ComponentVersion & {
346 arn: string;
347 } => version.arn !== undefined,
348 ),
349 ),
350 ),
351 Effect.flatMap(
352 Effect.forEach(
353 (version) =>
354 imagebuilder
355 .listComponentBuildVersions({
356 componentVersionArn: version.arn,
357 })
358 .pipe(
359 Effect.map((response) =>
360 (response.componentSummaryList ?? []).filter(
361 (
362 summary,
363 ): summary is imagebuilder.ComponentSummary & {
364 arn: string;
365 name: string;
366 version: string;
367 } =>
368 summary.arn !== undefined &&
369 summary.name !== undefined &&
370 summary.version !== undefined,
371 ),
372 ),
373 // Tolerate a delete race — drop the version.
374 Effect.catchTag("ResourceNotFoundException", () =>
375 Effect.succeed([]),
376 ),
377 ),
378 { concurrency: 4 },
379 ),
380 ),
381 Effect.map((groups) =>
382 groups.flat().map((summary) => ({
383 componentName: summary.name,
384 componentBuildVersionArn: summary.arn,
385 semanticVersion: summary.version,
386 platform: summary.platform ?? "",
387 type: summary.type,
388 dateCreated: summary.dateCreated,
389 })),
390 ),
391 ),
392 };
393 }),
394 );