1import * as appconfig from "@distilled.cloud/aws/appconfig";
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 { readAppConfigTags, syncAppConfigTags } from "./internal.ts";
13
14/**
15 * The workflow points at which AppConfig invokes an extension's actions.
16 * `PRE_*` action points run synchronously and can validate or modify the
17 * operation; `ON_*` action points are notifications fired as a deployment
18 * progresses.
19 */
20export type ExtensionActionPoint =
21 | "PRE_CREATE_HOSTED_CONFIGURATION_VERSION"
22 | "PRE_START_DEPLOYMENT"
23 | "AT_DEPLOYMENT_TICK"
24 | "ON_DEPLOYMENT_START"
25 | "ON_DEPLOYMENT_STEP"
26 | "ON_DEPLOYMENT_BAKING"
27 | "ON_DEPLOYMENT_COMPLETE"
28 | "ON_DEPLOYMENT_ROLLED_BACK";
29
30/**
31 * A single action AppConfig performs when the owning action point fires.
32 */
33export interface ExtensionAction {
34 /** Name of the action (unique within the extension). */
35 name: string;
36 /** Description of what the action does. */
37 description?: string;
38 /**
39 * ARN of the integration target: a Lambda function, SNS topic, SQS queue,
40 * or EventBridge event bus.
41 */
42 uri: string;
43 /**
44 * ARN of an IAM role AppConfig assumes to invoke the target. Required for
45 * Lambda, SNS, and SQS targets; not used for EventBridge targets.
46 */
47 roleArn?: string;
48}
49
50/**
51 * A parameter the extension accepts. Values are supplied per association via
52 * {@link ExtensionAssociationProps.parameters}.
53 */
54export interface ExtensionParameter {
55 /** Description of the parameter. */
56 description?: string;
57 /** Whether every association must supply a value. */
58 required?: boolean;
59 /** Whether the value may be supplied at deployment time. */
60 dynamic?: boolean;
61}
62
63export interface ExtensionProps {
64 /**
65 * Name of the extension. If omitted, a deterministic physical name is
66 * generated. Changing the name replaces the extension.
67 */
68 extensionName?: string;
69 /**
70 * Description of the extension.
71 */
72 description?: string;
73 /**
74 * The actions to perform, keyed by the action point that triggers them.
75 */
76 actions: { [P in ExtensionActionPoint]?: ExtensionAction[] };
77 /**
78 * Parameters accepted by the extension, keyed by parameter name. Values are
79 * supplied when the extension is associated with a resource.
80 */
81 parameters?: Record<string, ExtensionParameter>;
82 /**
83 * User-defined tags for the extension.
84 */
85 tags?: Record<string, string>;
86}
87
88export interface Extension extends Resource<
89 "AWS.AppConfig.Extension",
90 ExtensionProps,
91 {
92 extensionId: string;
93 extensionName: string;
94 extensionArn: string;
95 versionNumber: number;
96 },
97 never,
98 Providers
99> {}
100
101/**
102 * An AWS AppConfig extension — a set of actions AppConfig performs at
103 * specific points of the configuration workflow (before a version is
104 * created, before/while/after a deployment). Actions can invoke Lambda
105 * functions, publish to SNS/SQS, or emit EventBridge events.
106 *
107 * Associate the extension with an application, environment, or configuration
108 * profile using {@link ExtensionAssociation}.
109 *
110 * ### Creating an Extension
111 * **Example:** Notify a Lambda when a deployment completes
112 * ```typescript
113 * const extension = yield* AppConfig.Extension("DeployHook", {
114 * actions: {
115 * ON_DEPLOYMENT_COMPLETE: [
116 * {
117 * name: "notify",
118 * uri: fn.functionArn,
119 * roleArn: role.roleArn,
120 * },
121 * ],
122 * },
123 * });
124 * ```
125 *
126 * **Example:** Validate content before a deployment starts
127 * ```typescript
128 * const extension = yield* AppConfig.Extension("PreflightCheck", {
129 * description: "Reject deployments outside business hours",
130 * actions: {
131 * PRE_START_DEPLOYMENT: [
132 * { name: "preflight", uri: fn.functionArn, roleArn: role.roleArn },
133 * ],
134 * },
135 * });
136 * ```
137 *
138 * @resource
139 */
140export const Extension = Resource<Extension>("AWS.AppConfig.Extension");
141
142const toWireActions = (
143 actions: ExtensionProps["actions"],
144): { [key: string]: appconfig.Action[] | undefined } =>
145 Object.fromEntries(
146 Object.entries(actions).map(([point, list]) => [
147 point,
148 list?.map((action) => ({
149 Name: action.name,
150 Description: action.description,
151 Uri: action.uri,
152 RoleArn: action.roleArn,
153 })),
154 ]),
155 );
156
157const toWireParameters = (
158 parameters: ExtensionProps["parameters"],
159): { [key: string]: appconfig.Parameter | undefined } | undefined =>
160 parameters === undefined
161 ? undefined
162 : Object.fromEntries(
163 Object.entries(parameters).map(([name, parameter]) => [
164 name,
165 {
166 Description: parameter.description,
167 Required: parameter.required,
168 Dynamic: parameter.dynamic,
169 },
170 ]),
171 );
172
173/** Stable JSON for order-insensitive comparison of wire action/param maps. */
174const stableJson = (value: unknown): string =>
175 JSON.stringify(value, (_, v) =>
176 v !== null && typeof v === "object" && !Array.isArray(v)
177 ? Object.fromEntries(
178 Object.entries(v as Record<string, unknown>)
179 .filter(([, fieldValue]) => fieldValue !== undefined)
180 .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
181 )
182 : v,
183 );
184
185export const ExtensionProvider = () =>
186 Provider.effect(
187 Extension,
188 Effect.gen(function* () {
189 const toName = (id: string, props: Partial<ExtensionProps>) =>
190 props.extensionName
191 ? Effect.succeed(props.extensionName)
192 : createPhysicalName({ id, maxLength: 64 });
193
194 const readExtension = Effect.fn(function* (extensionId: string) {
195 return yield* appconfig
196 .getExtension({ ExtensionIdentifier: extensionId })
197 .pipe(
198 Effect.catchTag("ResourceNotFoundException", () =>
199 Effect.succeed(undefined),
200 ),
201 );
202 });
203
204 const findByName = Effect.fn(function* (name: string) {
205 const summaries = yield* appconfig.listExtensions
206 .pages({ Name: name })
207 .pipe(
208 Stream.runCollect,
209 Effect.map((chunk) =>
210 Array.from(chunk).flatMap((page) => page.Items ?? []),
211 ),
212 );
213 // listExtensions returns one summary per version — take the latest.
214 const latest = summaries.reduce(
215 (
216 acc: appconfig.ExtensionSummary | undefined,
217 summary: appconfig.ExtensionSummary,
218 ) =>
219 acc === undefined ||
220 (summary.VersionNumber ?? 0) > (acc.VersionNumber ?? 0)
221 ? summary
222 : acc,
223 undefined,
224 );
225 return latest?.Id === undefined
226 ? undefined
227 : yield* readExtension(latest.Id);
228 });
229
230 const toAttrs = (extension: appconfig.Extension) => ({
231 extensionId: extension.Id!,
232 extensionName: extension.Name!,
233 extensionArn: extension.Arn!,
234 versionNumber: extension.VersionNumber ?? 1,
235 });
236
237 return {
238 stables: ["extensionId", "extensionName", "extensionArn"],
239
240 diff: Effect.fn(function* ({ id, olds, news }) {
241 if (!isResolved(news)) return undefined;
242 if (
243 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
244 ) {
245 return { action: "replace" } as const;
246 }
247 }),
248
249 read: Effect.fn(function* ({ id, olds, output }) {
250 const extension = output?.extensionId
251 ? yield* readExtension(output.extensionId)
252 : yield* findByName(yield* toName(id, olds ?? {}));
253 if (extension?.Id === undefined) return undefined;
254 const attrs = toAttrs(extension);
255 const tags = yield* readAppConfigTags(attrs.extensionArn);
256 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
257 }),
258
259 reconcile: Effect.fn(function* ({ id, news, output, session }) {
260 const name = output?.extensionName ?? (yield* toName(id, news));
261 const internalTags = yield* createInternalTags(id);
262 const desiredTags = { ...internalTags, ...news.tags };
263 const desiredActions = toWireActions(news.actions);
264 const desiredParameters = toWireParameters(news.parameters);
265
266 // 1. Observe.
267 let observed = output?.extensionId
268 ? yield* readExtension(output.extensionId)
269 : undefined;
270 if (observed === undefined) {
271 observed = yield* findByName(name);
272 }
273
274 // 2. Ensure. A freshly created invoke role may not be assumable
275 // yet (IAM propagation), which AppConfig surfaces as
276 // BadRequestException — absorb that window with a bounded retry.
277 if (observed?.Id === undefined) {
278 observed = yield* appconfig
279 .createExtension({
280 Name: name,
281 Description: news.description,
282 Actions: desiredActions,
283 Parameters: desiredParameters,
284 Tags: desiredTags,
285 })
286 .pipe(
287 Effect.retry({
288 while: (e): boolean => e._tag === "BadRequestException",
289 schedule: Schedule.fixed("3 seconds"),
290 times: 5,
291 }),
292 );
293 } else if (
294 (observed.Description ?? undefined) !==
295 (news.description ?? undefined) ||
296 stableJson(observed.Actions ?? {}) !== stableJson(desiredActions) ||
297 stableJson(observed.Parameters ?? {}) !==
298 stableJson(desiredParameters ?? {})
299 ) {
300 // 3. Sync — description, actions, and parameters are mutable
301 // (AppConfig records the change as a new extension version).
302 observed = yield* appconfig.updateExtension({
303 ExtensionIdentifier: observed.Id,
304 Description: news.description,
305 Actions: desiredActions,
306 Parameters: desiredParameters,
307 });
308 }
309
310 const attrs = toAttrs(observed);
311
312 // 3b. Sync tags against observed cloud tags.
313 yield* syncAppConfigTags(attrs.extensionArn, desiredTags);
314
315 yield* session.note(name);
316 return attrs;
317 }),
318
319 delete: Effect.fn(function* ({ output }) {
320 // Associations must be gone before the extension can be deleted;
321 // the engine deletes them first, but the API is eventually
322 // consistent, so absorb the dependency-violation window (surfaced
323 // as BadRequestException) with a bounded retry.
324 yield* appconfig
325 .deleteExtension({ ExtensionIdentifier: output.extensionId })
326 .pipe(
327 Effect.retry({
328 while: (e): boolean => e._tag === "BadRequestException",
329 schedule: Schedule.fixed("3 seconds"),
330 times: 5,
331 }),
332 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
333 );
334 }),
335
336 list: () =>
337 Effect.gen(function* () {
338 const summaries = yield* appconfig.listExtensions.pages({}).pipe(
339 Stream.runCollect,
340 Effect.map((chunk) =>
341 Array.from(chunk).flatMap((page) => page.Items ?? []),
342 ),
343 );
344 // One summary per version — keep the latest version per id.
345 const latest = new Map<string, appconfig.ExtensionSummary>();
346 for (const summary of summaries) {
347 if (summary.Id === undefined) continue;
348 const existing = latest.get(summary.Id);
349 if (
350 existing === undefined ||
351 (summary.VersionNumber ?? 0) > (existing.VersionNumber ?? 0)
352 ) {
353 latest.set(summary.Id, summary);
354 }
355 }
356 return Array.from(latest.values()).flatMap((summary) =>
357 summary.Id !== undefined &&
358 summary.Name !== undefined &&
359 summary.Arn !== undefined &&
360 // AWS-authored extensions (AWS.AppConfig.FeatureFlags,
361 // AWS.AppConfig.DeploymentNotificationsTo*, ...) are managed by
362 // AWS and cannot be deleted; their ARNs have an empty account
363 // segment (arn:aws:appconfig:{region}::extension/...).
364 summary.Arn.split(":")[4] !== ""
365 ? [
366 {
367 extensionId: summary.Id,
368 extensionName: summary.Name,
369 extensionArn: summary.Arn,
370 versionNumber: summary.VersionNumber ?? 1,
371 },
372 ]
373 : [],
374 );
375 }),
376 };
377 }),
378 );