1import * as imagebuilder from "@distilled.cloud/aws/imagebuilder";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.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 driftedFrom,
13 imageBuilderArn,
14 retryWhileDependedOn,
15 syncImageBuilderTags,
16 toTagRecord,
17} from "./internal.ts";
18
19export interface DistributionConfigurationProps {
20 /**
21 * Name of the distribution configuration. If omitted, a deterministic
22 * physical name is generated. Changing the name replaces the
23 * configuration.
24 */
25 distributionConfigurationName?: string;
26 /**
27 * Per-region distribution settings: output AMI naming/tags/permissions,
28 * launch template updates, SSM parameter publication, and more. At
29 * least one entry (the build region) is required.
30 */
31 distributions: imagebuilder.Distribution[];
32 /**
33 * Description of the configuration.
34 */
35 description?: string;
36 /**
37 * User-defined tags for the configuration.
38 */
39 tags?: Record<string, string>;
40}
41
42export interface DistributionConfiguration extends Resource<
43 "AWS.ImageBuilder.DistributionConfiguration",
44 DistributionConfigurationProps,
45 {
46 /** The name of the distribution configuration. */
47 distributionConfigurationName: string;
48 /** The ARN of the distribution configuration. */
49 distributionConfigurationArn: string;
50 /** When the distribution configuration was created. */
51 dateCreated: string | undefined;
52 },
53 never,
54 Providers
55> {}
56
57/**
58 * An EC2 Image Builder distribution configuration — defines where and how
59 * the output AMIs (or containers) of a pipeline are distributed across
60 * regions and accounts.
61 * ### Creating a Distribution Configuration
62 * **Example:** Distribute in the Build Region
63 * ```typescript
64 * const distribution = yield* ImageBuilder.DistributionConfiguration("Dist", {
65 * distributions: [{
66 * region: "us-west-2",
67 * amiDistributionConfiguration: {
68 * name: "my-app-{{ imagebuilder:buildDate }}",
69 * amiTags: { project: "my-app" },
70 * },
71 * }],
72 * });
73 * ```
74 *
75 * ### Using in a Pipeline
76 * **Example:** Wire into an Image Pipeline
77 * ```typescript
78 * const pipeline = yield* ImageBuilder.ImagePipeline("Pipeline", {
79 * imageRecipeArn: recipe.imageRecipeArn,
80 * infrastructureConfigurationArn: infra.infrastructureConfigurationArn,
81 * distributionConfigurationArn: distribution.distributionConfigurationArn,
82 * });
83 * ```
84 *
85 * @resource
86 */
87export const DistributionConfiguration = Resource<DistributionConfiguration>(
88 "AWS.ImageBuilder.DistributionConfiguration",
89);
90
91export const DistributionConfigurationProvider = () =>
92 Provider.effect(
93 DistributionConfiguration,
94 Effect.gen(function* () {
95 const toName = (id: string, props: DistributionConfigurationProps) =>
96 props.distributionConfigurationName
97 ? Effect.succeed(props.distributionConfigurationName)
98 : createPhysicalName({ id, maxLength: 126 });
99
100 const toArn = (name: string) =>
101 imageBuilderArn("distribution-configuration", name);
102
103 const getConfiguration = Effect.fn(function* (arn: string) {
104 const response = yield* imagebuilder
105 .getDistributionConfiguration({
106 distributionConfigurationArn: arn,
107 })
108 .pipe(
109 Effect.catchTag("ResourceNotFoundException", () =>
110 Effect.succeed(undefined),
111 ),
112 );
113 return response?.distributionConfiguration;
114 });
115
116 const toAttrs = Effect.fn(function* (
117 config: imagebuilder.DistributionConfiguration,
118 ) {
119 if (!config.arn || !config.name) {
120 return yield* Effect.fail(
121 new Error(
122 "Image Builder distribution configuration is missing its ARN or name",
123 ),
124 );
125 }
126 return {
127 distributionConfigurationName: config.name,
128 distributionConfigurationArn: config.arn,
129 dateCreated: config.dateCreated,
130 };
131 });
132
133 return {
134 stables: [
135 "distributionConfigurationName",
136 "distributionConfigurationArn",
137 ],
138
139 diff: Effect.fn(function* ({ id, olds, news }) {
140 if (!isResolved(news)) return undefined;
141 if ((yield* toName(id, olds)) !== (yield* toName(id, news))) {
142 return { action: "replace" } as const;
143 }
144 }),
145
146 read: Effect.fn(function* ({ id, olds, output }) {
147 const arn =
148 output?.distributionConfigurationArn ??
149 (yield* toArn(yield* toName(id, olds)));
150 const config = yield* getConfiguration(arn);
151 if (config === undefined) return undefined;
152 const attrs = yield* toAttrs(config);
153 const tags = toTagRecord(config.tags);
154 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
155 }),
156
157 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
158 // One idempotency token per reconcile — retries within this run
159 // are deduplicated by the API.
160 const clientToken = yield* Effect.sync(() => crypto.randomUUID());
161 const name =
162 output?.distributionConfigurationName ?? (yield* toName(id, news));
163 const internalTags = yield* createInternalTags(id);
164 const desiredTags = { ...internalTags, ...news.tags };
165
166 // 1. Observe.
167 const arn =
168 output?.distributionConfigurationArn ?? (yield* toArn(name));
169 let observed = yield* getConfiguration(arn);
170
171 // 2. Ensure — create if missing; tolerate an AlreadyExists race.
172 if (observed === undefined) {
173 const created = yield* imagebuilder
174 .createDistributionConfiguration({
175 name,
176 description: news.description,
177 distributions: news.distributions,
178 tags: desiredTags,
179 clientToken,
180 })
181 .pipe(
182 Effect.catchTag("ResourceAlreadyExistsException", () =>
183 Effect.succeed(undefined),
184 ),
185 );
186 observed = yield* getConfiguration(
187 created?.distributionConfigurationArn ?? arn,
188 );
189 if (observed === undefined) {
190 return yield* Effect.fail(
191 new Error(
192 `created Image Builder distribution configuration '${name}' is not readable`,
193 ),
194 );
195 }
196 }
197
198 // 3. Sync — compare OBSERVED distributions/description against
199 // desired; a full-PUT update converges any drift.
200 const drifted =
201 driftedFrom(observed.distributions, news.distributions) ||
202 driftedFrom(observed.description, news.description) ||
203 (news.description === undefined && olds?.description !== undefined);
204 if (drifted && observed.arn) {
205 yield* imagebuilder.updateDistributionConfiguration({
206 distributionConfigurationArn: observed.arn,
207 description: news.description,
208 distributions: news.distributions,
209 clientToken,
210 });
211 observed = (yield* getConfiguration(observed.arn)) ?? observed;
212 }
213
214 // 3b. Sync tags — diff against OBSERVED cloud tags.
215 if (observed.arn) {
216 yield* syncImageBuilderTags(observed.arn, desiredTags);
217 }
218
219 // 4. Return fresh attributes.
220 yield* session.note(name);
221 return yield* toAttrs(observed);
222 }),
223
224 delete: Effect.fn(function* ({ output }) {
225 yield* retryWhileDependedOn(
226 imagebuilder.deleteDistributionConfiguration({
227 distributionConfigurationArn: output.distributionConfigurationArn,
228 }),
229 ).pipe(
230 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
231 );
232 }),
233
234 list: () =>
235 imagebuilder.listDistributionConfigurations.pages({}).pipe(
236 Stream.runCollect,
237 Effect.map((chunk) =>
238 Array.from(chunk).flatMap((page) =>
239 (page.distributionConfigurationSummaryList ?? []).filter(
240 (
241 summary,
242 ): summary is imagebuilder.DistributionConfigurationSummary & {
243 arn: string;
244 name: string;
245 } => summary.arn !== undefined && summary.name !== undefined,
246 ),
247 ),
248 ),
249 Effect.map((summaries) =>
250 summaries.map((summary) => ({
251 distributionConfigurationName: summary.name,
252 distributionConfigurationArn: summary.arn,
253 dateCreated: summary.dateCreated,
254 })),
255 ),
256 ),
257 };
258 }),
259 );