1import * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } 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 { toWireMinutes } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 deleteImageBuilderLogGroup,
15 driftedFrom,
16 imageBuilderArn,
17 retryWhileDependedOn,
18 syncImageBuilderTags,
19 toTagRecord,
20} from "./internal.ts";
21
22/**
23 * Image test settings applied to builds of the pipeline. Mirrors the wire
24 * `ImageTestsConfiguration`, with the timeout expressed as a
25 * {@link Duration.Input} instead of raw minutes.
26 */
27export interface ImageTestsConfiguration {
28 /**
29 * Whether to run tests on the output image.
30 * @default true
31 */
32 imageTestsEnabled?: boolean;
33 /**
34 * Maximum time tests may run before they are considered failed
35 * (e.g. `"90 minutes"`). AWS accepts 1 hour to 24 hours, in whole
36 * minutes on the wire.
37 * @default "12 hours"
38 */
39 timeout?: Duration.Input;
40}
41
42/** Convert the duration-typed test settings to the wire shape. */
43const toWireImageTests = (
44 config: ImageTestsConfiguration | undefined,
45): imagebuilder.ImageTestsConfiguration | undefined =>
46 config === undefined
47 ? undefined
48 : {
49 imageTestsEnabled: config.imageTestsEnabled,
50 timeoutMinutes: toWireMinutes(config.timeout),
51 };
52
53export interface ImagePipelineProps {
54 /**
55 * Name of the image pipeline. If omitted, a deterministic physical name
56 * is generated. Changing the name replaces the pipeline.
57 */
58 imagePipelineName?: string;
59 /**
60 * ARN of the image recipe that the pipeline builds. Exactly one of
61 * `imageRecipeArn` / `containerRecipeArn` is required.
62 */
63 imageRecipeArn?: string;
64 /**
65 * ARN of the container recipe that the pipeline builds.
66 */
67 containerRecipeArn?: string;
68 /**
69 * ARN of the infrastructure configuration used to build images.
70 */
71 infrastructureConfigurationArn: string;
72 /**
73 * ARN of the distribution configuration applied to output images.
74 */
75 distributionConfigurationArn?: string;
76 /**
77 * Description of the pipeline.
78 */
79 description?: string;
80 /**
81 * Image test settings (enable/disable tests, timeout).
82 */
83 imageTestsConfiguration?: ImageTestsConfiguration;
84 /**
85 * Collect additional information about the image being created,
86 * including the operating system and packages.
87 * @default true
88 */
89 enhancedImageMetadataEnabled?: boolean;
90 /**
91 * Build schedule (cron expression + start condition). Without a
92 * schedule the pipeline only builds on manual invocation.
93 */
94 schedule?: imagebuilder.Schedule;
95 /**
96 * Whether the pipeline schedule is active.
97 * @default "ENABLED"
98 */
99 status?: "ENABLED" | "DISABLED";
100 /**
101 * Image scanning (Amazon Inspector) settings for output images.
102 */
103 imageScanningConfiguration?: imagebuilder.ImageScanningConfiguration;
104 /**
105 * Custom build/test workflows to run.
106 */
107 workflows?: imagebuilder.WorkflowConfiguration[];
108 /**
109 * IAM role (name or ARN) that Image Builder assumes to run workflows.
110 */
111 executionRole?: string;
112 /**
113 * User-defined tags for the pipeline.
114 */
115 tags?: Record<string, string>;
116}
117
118export interface ImagePipeline extends Resource<
119 "AWS.ImageBuilder.ImagePipeline",
120 ImagePipelineProps,
121 {
122 /** The name of the image pipeline. */
123 imagePipelineName: string;
124 /** The ARN of the image pipeline. */
125 imagePipelineArn: string;
126 /** The OS platform of the pipeline's recipe (`Linux` / `Windows`). */
127 platform: string | undefined;
128 /** Whether the pipeline is `ENABLED` or `DISABLED`. */
129 status: string | undefined;
130 /** When the pipeline was created. */
131 dateCreated: string | undefined;
132 },
133 never,
134 Providers
135> {}
136
137/**
138 * An EC2 Image Builder image pipeline — wires a recipe to an infrastructure
139 * configuration (and optionally a distribution configuration) and automates
140 * image builds on a schedule or on demand.
141 *
142 * Creating the pipeline does not start a build; builds start on the
143 * configured schedule or when explicitly invoked.
144 * ### Creating an Image Pipeline
145 * **Example:** Manual-Only Pipeline
146 * ```typescript
147 * const pipeline = yield* ImageBuilder.ImagePipeline("Pipeline", {
148 * imageRecipeArn: recipe.imageRecipeArn,
149 * infrastructureConfigurationArn: infra.infrastructureConfigurationArn,
150 * status: "DISABLED",
151 * });
152 * ```
153 *
154 * **Example:** Scheduled Pipeline with Distribution
155 * ```typescript
156 * const pipeline = yield* ImageBuilder.ImagePipeline("Nightly", {
157 * imageRecipeArn: recipe.imageRecipeArn,
158 * infrastructureConfigurationArn: infra.infrastructureConfigurationArn,
159 * distributionConfigurationArn: distribution.distributionConfigurationArn,
160 * schedule: {
161 * scheduleExpression: "cron(0 9 * * ? *)",
162 * pipelineExecutionStartCondition:
163 * "EXPRESSION_MATCH_AND_DEPENDENCY_UPDATES_AVAILABLE",
164 * },
165 * });
166 * ```
167 *
168 * @resource
169 */
170export const ImagePipeline = Resource<ImagePipeline>(
171 "AWS.ImageBuilder.ImagePipeline",
172);
173
174export const ImagePipelineProvider = () =>
175 Provider.effect(
176 ImagePipeline,
177 Effect.gen(function* () {
178 const toName = (id: string, props: ImagePipelineProps) =>
179 props.imagePipelineName
180 ? Effect.succeed(props.imagePipelineName)
181 : createPhysicalName({ id, maxLength: 126 });
182
183 const toArn = (name: string) => imageBuilderArn("image-pipeline", name);
184
185 const getPipeline = Effect.fn(function* (arn: string) {
186 const response = yield* imagebuilder
187 .getImagePipeline({ imagePipelineArn: arn })
188 .pipe(
189 Effect.catchTag("ResourceNotFoundException", () =>
190 Effect.succeed(undefined),
191 ),
192 );
193 return response?.imagePipeline;
194 });
195
196 const toAttrs = Effect.fn(function* (
197 pipeline: imagebuilder.ImagePipeline,
198 ) {
199 if (!pipeline.arn || !pipeline.name) {
200 return yield* Effect.fail(
201 new Error(
202 "Image Builder image pipeline is missing its ARN or name",
203 ),
204 );
205 }
206 return {
207 imagePipelineName: pipeline.name,
208 imagePipelineArn: pipeline.arn,
209 platform: pipeline.platform,
210 status: pipeline.status,
211 dateCreated: pipeline.dateCreated,
212 };
213 });
214
215 /** Mutable aspects synced by a full-PUT update. */
216 const mutableKeys = [
217 "description",
218 "imageRecipeArn",
219 "containerRecipeArn",
220 "infrastructureConfigurationArn",
221 "distributionConfigurationArn",
222 "imageTestsConfiguration",
223 "enhancedImageMetadataEnabled",
224 "schedule",
225 "status",
226 "imageScanningConfiguration",
227 "workflows",
228 "executionRole",
229 ] as const;
230
231 return {
232 stables: ["imagePipelineName", "imagePipelineArn"],
233
234 diff: Effect.fn(function* ({ id, olds, news }) {
235 if (!isResolved(news)) return undefined;
236 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
237 return { action: "replace" } as const;
238 }
239 }),
240
241 read: Effect.fn(function* ({ id, olds, output }) {
242 const arn =
243 output?.imagePipelineArn ?? (yield* toArn(yield* toName(id, olds)));
244 const pipeline = yield* getPipeline(arn);
245 if (pipeline === undefined) return undefined;
246 const attrs = yield* toAttrs(pipeline);
247 const tags = toTagRecord(pipeline.tags);
248 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
249 }),
250
251 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
252 // One idempotency token per reconcile — retries within this run
253 // are deduplicated by the API.
254 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
255 const name = output?.imagePipelineName ?? (yield* toName(id, news));
256 const internalTags = yield* createInternalTags(id);
257 const desiredTags = { ...internalTags, ...news.tags };
258 // Desired state in wire shape (Duration timeout → whole minutes)
259 // so create/update/drift all compare like against like.
260 const desired = {
261 ...news,
262 imageTestsConfiguration: toWireImageTests(
263 news.imageTestsConfiguration,
264 ),
265 };
266
267 // 1. Observe.
268 const arn = output?.imagePipelineArn ?? (yield* toArn(name));
269 let observed = yield* getPipeline(arn);
270
271 // 2. Ensure — create if missing; tolerate an AlreadyExists race.
272 if (observed === undefined) {
273 const created = yield* imagebuilder
274 .createImagePipeline({
275 name,
276 description: news.description,
277 imageRecipeArn: news.imageRecipeArn,
278 containerRecipeArn: news.containerRecipeArn,
279 infrastructureConfigurationArn:
280 news.infrastructureConfigurationArn,
281 distributionConfigurationArn: news.distributionConfigurationArn,
282 imageTestsConfiguration: desired.imageTestsConfiguration,
283 enhancedImageMetadataEnabled: news.enhancedImageMetadataEnabled,
284 schedule: news.schedule,
285 status: news.status,
286 imageScanningConfiguration: news.imageScanningConfiguration,
287 workflows: news.workflows,
288 executionRole: news.executionRole,
289 tags: desiredTags,
290 clientToken,
291 })
292 .pipe(
293 Effect.catchTag("ResourceAlreadyExistsException", () =>
294 Effect.succeed(undefined),
295 ),
296 );
297 observed = yield* getPipeline(created?.imagePipelineArn ?? arn);
298 if (observed === undefined) {
299 return yield* Effect.fail(
300 new Error(
301 `created Image Builder pipeline '${name}' is not readable`,
302 ),
303 );
304 }
305 }
306
307 // 3. Sync — compare OBSERVED cloud state against the desired
308 // props; a full-PUT update converges any drift (`olds` is only
309 // a hint for removed props).
310 const drifted = mutableKeys.some(
311 (key) =>
312 driftedFrom(observed?.[key], desired[key]) ||
313 (desired[key] === undefined && olds?.[key] !== undefined),
314 );
315 if (drifted && observed.arn) {
316 yield* imagebuilder.updateImagePipeline({
317 imagePipelineArn: observed.arn,
318 description: news.description,
319 imageRecipeArn: news.imageRecipeArn,
320 containerRecipeArn: news.containerRecipeArn,
321 infrastructureConfigurationArn:
322 news.infrastructureConfigurationArn,
323 distributionConfigurationArn: news.distributionConfigurationArn,
324 imageTestsConfiguration: desired.imageTestsConfiguration,
325 enhancedImageMetadataEnabled: news.enhancedImageMetadataEnabled,
326 schedule: news.schedule,
327 status: news.status,
328 imageScanningConfiguration: news.imageScanningConfiguration,
329 workflows: news.workflows,
330 executionRole: news.executionRole,
331 clientToken,
332 });
333 observed = (yield* getPipeline(observed.arn)) ?? observed;
334 }
335
336 // 3b. Sync tags — diff against OBSERVED cloud tags.
337 if (observed.arn) {
338 yield* syncImageBuilderTags(observed.arn, desiredTags);
339 }
340
341 // 4. Return fresh attributes.
342 yield* session.note(name);
343 return yield* toAttrs(observed);
344 }),
345
346 delete: Effect.fn(function* ({ output }) {
347 yield* retryWhileDependedOn(
348 imagebuilder.deleteImagePipeline({
349 imagePipelineArn: output.imagePipelineArn,
350 }),
351 ).pipe(
352 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
353 );
354
355 // Image Builder creates this fixed group outside of the pipeline
356 // API. Delete only the exact group derived from this owned output,
357 // and only after the pipeline itself is observed absent.
358 for (let attempt = 0; attempt < 30; attempt++) {
359 if ((yield* getPipeline(output.imagePipelineArn)) === undefined) {
360 yield* deleteImageBuilderLogGroup(
361 `/aws/imagebuilder/pipeline/${output.imagePipelineName.toLowerCase()}`,
362 );
363 return;
364 }
365 yield* Effect.sleep("1 second");
366 }
367 return yield* Effect.die(
368 new Error(
369 `Image Builder pipeline ${output.imagePipelineArn} remained observable 30 seconds after delete`,
370 ),
371 );
372 }),
373
374 list: () =>
375 imagebuilder.listImagePipelines.pages({}).pipe(
376 Stream.runCollect,
377 Effect.map((chunk) =>
378 Array.from(chunk).flatMap((page) =>
379 (page.imagePipelineList ?? []).filter(
380 (
381 pipeline,
382 ): pipeline is imagebuilder.ImagePipeline & {
383 arn: string;
384 name: string;
385 } =>
386 pipeline.arn !== undefined && pipeline.name !== undefined,
387 ),
388 ),
389 ),
390 Effect.map((pipelines) =>
391 pipelines.map((pipeline) => ({
392 imagePipelineName: pipeline.name,
393 imagePipelineArn: pipeline.arn,
394 platform: pipeline.platform,
395 status: pipeline.status,
396 dateCreated: pipeline.dateCreated,
397 })),
398 ),
399 ),
400 };
401 }),
402 );