1import * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
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 type { Providers } from "../Providers.ts";
12import {
13 driftedFrom,
14 imageBuilderArn,
15 retryWhileDependedOn,
16 syncImageBuilderTags,
17 toTagRecord,
18} from "./internal.ts";
19
20export interface InfrastructureConfigurationProps {
21 /**
22 * Name of the infrastructure configuration. If omitted, a deterministic
23 * physical name is generated. Changing the name replaces the
24 * configuration.
25 */
26 infrastructureConfigurationName?: string;
27 /**
28 * Name of the IAM instance profile attached to build/test instances.
29 * The profile's role needs at least `EC2InstanceProfileForImageBuilder`
30 * and `AmazonSSMManagedInstanceCore`.
31 */
32 instanceProfileName: string;
33 /**
34 * EC2 instance types to use when building. Image Builder picks the
35 * type with the most available capacity.
36 */
37 instanceTypes?: string[];
38 /**
39 * Security group IDs applied to build/test instances.
40 */
41 securityGroupIds?: string[];
42 /**
43 * Subnet in which to place build/test instances.
44 */
45 subnetId?: string;
46 /**
47 * Description of the configuration.
48 */
49 description?: string;
50 /**
51 * S3 logging configuration for build logs.
52 */
53 logging?: imagebuilder.Logging;
54 /**
55 * EC2 key pair for debugging build instances.
56 */
57 keyPair?: string;
58 /**
59 * Terminate the build instance when the build fails (set `false` to
60 * keep it for debugging).
61 * @default true
62 */
63 terminateInstanceOnFailure?: boolean;
64 /**
65 * SNS topic ARN notified of image build events.
66 */
67 snsTopicArn?: string;
68 /**
69 * Tags applied to the EC2 resources (instances, volumes) created
70 * during builds.
71 */
72 resourceTags?: Record<string, string>;
73 /**
74 * Instance metadata service (IMDS) settings for build instances.
75 */
76 instanceMetadataOptions?: imagebuilder.InstanceMetadataOptions;
77 /**
78 * Placement settings (availability zone, tenancy, host) for build
79 * instances.
80 */
81 placement?: imagebuilder.Placement;
82 /**
83 * User-defined tags for the configuration.
84 */
85 tags?: Record<string, string>;
86}
87
88export interface InfrastructureConfiguration extends Resource<
89 "AWS.ImageBuilder.InfrastructureConfiguration",
90 InfrastructureConfigurationProps,
91 {
92 /** The name of the infrastructure configuration. */
93 infrastructureConfigurationName: string;
94 /** The ARN of the infrastructure configuration. */
95 infrastructureConfigurationArn: string;
96 /** The instance profile builds run with. */
97 instanceProfileName: string | undefined;
98 /** When the infrastructure configuration was created. */
99 dateCreated: string | undefined;
100 },
101 never,
102 Providers
103> {}
104
105/**
106 * An EC2 Image Builder infrastructure configuration — the environment
107 * (instance profile, instance types, network, logging) in which images are
108 * built and tested.
109 * ### Creating an Infrastructure Configuration
110 * **Example:** Minimal Configuration
111 * ```typescript
112 * const role = yield* IAM.Role("BuilderRole", {
113 * assumeRolePolicyDocument: {
114 * Version: "2012-10-17",
115 * Statement: [{
116 * Effect: "Allow",
117 * Principal: { Service: "ec2.amazonaws.com" },
118 * Action: ["sts:AssumeRole"],
119 * }],
120 * },
121 * managedPolicyArns: [
122 * "arn:aws:iam::aws:policy/EC2InstanceProfileForImageBuilder",
123 * "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore",
124 * ],
125 * });
126 * const profile = yield* IAM.InstanceProfile("BuilderProfile", {
127 * roleName: role.roleName,
128 * });
129 * const infra = yield* ImageBuilder.InfrastructureConfiguration("Infra", {
130 * instanceProfileName: profile.instanceProfileName,
131 * instanceTypes: ["t3.micro"],
132 * terminateInstanceOnFailure: true,
133 * });
134 * ```
135 *
136 * @resource
137 */
138export const InfrastructureConfiguration =
139 Resource<InfrastructureConfiguration>(
140 "AWS.ImageBuilder.InfrastructureConfiguration",
141 );
142
143/**
144 * A freshly created IAM instance profile takes a few seconds to become
145 * visible to Image Builder, which rejects it with
146 * `InvalidParameterValueException` ("The provided instance profile does not
147 * exist") in the interim — retry through the propagation window (bounded).
148 */
149const retryThroughIamPropagation = <
150 A,
151 E extends { readonly _tag: string; readonly message?: string },
152 R,
153>(
154 self: Effect.Effect<A, E, R>,
155): Effect.Effect<A, E, R> =>
156 Effect.retry(self, {
157 while: (e) =>
158 e._tag === "InvalidParameterValueException" &&
159 (e.message?.includes("instance profile does not exist") ?? false),
160 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(10)]),
161 });
162
163export const InfrastructureConfigurationProvider = () =>
164 Provider.effect(
165 InfrastructureConfiguration,
166 Effect.gen(function* () {
167 const toName = (id: string, props: InfrastructureConfigurationProps) =>
168 props.infrastructureConfigurationName
169 ? Effect.succeed(props.infrastructureConfigurationName)
170 : createPhysicalName({ id, maxLength: 126 });
171
172 const toArn = (name: string) =>
173 imageBuilderArn("infrastructure-configuration", name);
174
175 const getConfiguration = Effect.fn(function* (arn: string) {
176 const response = yield* imagebuilder
177 .getInfrastructureConfiguration({
178 infrastructureConfigurationArn: arn,
179 })
180 .pipe(
181 Effect.catchTag("ResourceNotFoundException", () =>
182 Effect.succeed(undefined),
183 ),
184 );
185 return response?.infrastructureConfiguration;
186 });
187
188 const toAttrs = Effect.fn(function* (
189 config: imagebuilder.InfrastructureConfiguration,
190 ) {
191 if (!config.arn || !config.name) {
192 return yield* Effect.fail(
193 new Error(
194 "Image Builder infrastructure configuration is missing its ARN or name",
195 ),
196 );
197 }
198 return {
199 infrastructureConfigurationName: config.name,
200 infrastructureConfigurationArn: config.arn,
201 instanceProfileName: config.instanceProfileName,
202 dateCreated: config.dateCreated,
203 };
204 });
205
206 /**
207 * Mutable aspects synced by a full-PUT update. Keys of both the
208 * observed struct and the Props (they share names).
209 */
210 const mutableKeys = [
211 "description",
212 "instanceTypes",
213 "instanceProfileName",
214 "securityGroupIds",
215 "subnetId",
216 "logging",
217 "keyPair",
218 "terminateInstanceOnFailure",
219 "snsTopicArn",
220 "resourceTags",
221 "instanceMetadataOptions",
222 "placement",
223 ] as const;
224
225 return {
226 stables: [
227 "infrastructureConfigurationName",
228 "infrastructureConfigurationArn",
229 ],
230
231 diff: Effect.fn(function* ({ id, olds, news }) {
232 if (!isResolved(news)) return undefined;
233 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
234 return { action: "replace" } as const;
235 }
236 }),
237
238 read: Effect.fn(function* ({ id, olds, output }) {
239 const arn =
240 output?.infrastructureConfigurationArn ??
241 (yield* toArn(yield* toName(id, olds)));
242 const config = yield* getConfiguration(arn);
243 if (config === undefined) return undefined;
244 const attrs = yield* toAttrs(config);
245 const tags = toTagRecord(config.tags);
246 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
247 }),
248
249 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
250 // One idempotency token per reconcile — retries within this run
251 // are deduplicated by the API.
252 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
253 const name =
254 output?.infrastructureConfigurationName ??
255 (yield* toName(id, news));
256 const internalTags = yield* createInternalTags(id);
257 const desiredTags = { ...internalTags, ...news.tags };
258
259 // 1. Observe — the configuration ARN is deterministic from the
260 // name; cloud state is authoritative.
261 const arn =
262 output?.infrastructureConfigurationArn ?? (yield* toArn(name));
263 let observed = yield* getConfiguration(arn);
264
265 // 2. Ensure — create if missing; tolerate an AlreadyExists race
266 // by re-observing.
267 if (observed === undefined) {
268 const created = yield* retryThroughIamPropagation(
269 imagebuilder.createInfrastructureConfiguration({
270 name,
271 description: news.description,
272 instanceTypes: news.instanceTypes,
273 instanceProfileName: news.instanceProfileName,
274 securityGroupIds: news.securityGroupIds,
275 subnetId: news.subnetId,
276 logging: news.logging,
277 keyPair: news.keyPair,
278 terminateInstanceOnFailure: news.terminateInstanceOnFailure,
279 snsTopicArn: news.snsTopicArn,
280 resourceTags: news.resourceTags,
281 instanceMetadataOptions: news.instanceMetadataOptions,
282 placement: news.placement,
283 tags: desiredTags,
284 clientToken,
285 }),
286 ).pipe(
287 Effect.catchTag("ResourceAlreadyExistsException", () =>
288 Effect.succeed(undefined),
289 ),
290 );
291 observed = yield* getConfiguration(
292 created?.infrastructureConfigurationArn ?? arn,
293 );
294 if (observed === undefined) {
295 return yield* Effect.fail(
296 new Error(
297 `created Image Builder infrastructure configuration '${name}' is not readable`,
298 ),
299 );
300 }
301 }
302
303 // 3. Sync — compare OBSERVED cloud state against the desired
304 // props; a full-PUT update converges any drift (including
305 // props the user removed since the last deploy — `olds` is
306 // only a hint for removals).
307 const drifted = mutableKeys.some(
308 (key) =>
309 driftedFrom(observed?.[key], news[key]) ||
310 (news[key] === undefined && olds?.[key] !== undefined),
311 );
312 if (drifted && observed.arn) {
313 yield* imagebuilder.updateInfrastructureConfiguration({
314 infrastructureConfigurationArn: observed.arn,
315 description: news.description,
316 instanceTypes: news.instanceTypes,
317 instanceProfileName: news.instanceProfileName,
318 securityGroupIds: news.securityGroupIds,
319 subnetId: news.subnetId,
320 logging: news.logging,
321 keyPair: news.keyPair,
322 terminateInstanceOnFailure: news.terminateInstanceOnFailure,
323 snsTopicArn: news.snsTopicArn,
324 resourceTags: news.resourceTags,
325 instanceMetadataOptions: news.instanceMetadataOptions,
326 placement: news.placement,
327 clientToken,
328 });
329 observed = (yield* getConfiguration(observed.arn)) ?? observed;
330 }
331
332 // 3b. Sync tags — diff against OBSERVED cloud tags.
333 if (observed.arn) {
334 yield* syncImageBuilderTags(observed.arn, desiredTags);
335 }
336
337 // 4. Return fresh attributes.
338 yield* session.note(name);
339 return yield* toAttrs(observed);
340 }),
341
342 delete: Effect.fn(function* ({ output }) {
343 yield* retryWhileDependedOn(
344 imagebuilder.deleteInfrastructureConfiguration({
345 infrastructureConfigurationArn:
346 output.infrastructureConfigurationArn,
347 }),
348 ).pipe(
349 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
350 );
351 }),
352
353 list: () =>
354 imagebuilder.listInfrastructureConfigurations.pages({}).pipe(
355 Stream.runCollect,
356 Effect.map((chunk) =>
357 Array.from(chunk).flatMap((page) =>
358 (page.infrastructureConfigurationSummaryList ?? []).filter(
359 (
360 summary,
361 ): summary is imagebuilder.InfrastructureConfigurationSummary & {
362 arn: string;
363 name: string;
364 } => summary.arn !== undefined && summary.name !== undefined,
365 ),
366 ),
367 ),
368 Effect.map((summaries) =>
369 summaries.map((summary) => ({
370 infrastructureConfigurationName: summary.name,
371 infrastructureConfigurationArn: summary.arn,
372 instanceProfileName: summary.instanceProfileName,
373 dateCreated: summary.dateCreated,
374 })),
375 ),
376 ),
377 };
378 }),
379 );