1import * as appconfig from "@distilled.cloud/aws/appconfig";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 extensionAssociationArn,
13 readAppConfigTags,
14 syncAppConfigTags,
15 toTagRecord,
16} from "./internal.ts";
17
18export interface ExtensionAssociationProps {
19 /**
20 * The extension to associate: its ID, name, or ARN. Changing it replaces
21 * the association.
22 */
23 extensionIdentifier: string;
24 /**
25 * The version of the extension to pin. If omitted, AppConfig uses the
26 * latest version. Changing it replaces the association.
27 */
28 extensionVersionNumber?: number;
29 /**
30 * ARN of the AppConfig resource the extension attaches to: an application,
31 * environment, or configuration profile. Changing it replaces the
32 * association.
33 */
34 resourceIdentifier: string;
35 /**
36 * Values for the parameters declared by the extension, keyed by parameter
37 * name.
38 */
39 parameters?: Record<string, string>;
40 /**
41 * User-defined tags for the extension association.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface ExtensionAssociation extends Resource<
47 "AWS.AppConfig.ExtensionAssociation",
48 ExtensionAssociationProps,
49 {
50 extensionAssociationId: string;
51 extensionAssociationArn: string;
52 extensionArn: string;
53 resourceArn: string;
54 },
55 never,
56 Providers
57> {}
58
59/**
60 * An AWS AppConfig extension association — attaches an {@link Extension} to an
61 * application, environment, or configuration profile so the extension's
62 * actions fire for that resource's workflow events.
63 *
64 * ### Associating an Extension
65 * **Example:** Attach an Extension to an Application
66 * ```typescript
67 * const association = yield* AppConfig.ExtensionAssociation("Hook", {
68 * extensionIdentifier: extension.extensionId,
69 * resourceIdentifier: app.applicationArn,
70 * });
71 * ```
72 *
73 * **Example:** Attach with Parameter Values
74 * ```typescript
75 * const association = yield* AppConfig.ExtensionAssociation("Hook", {
76 * extensionIdentifier: extension.extensionId,
77 * resourceIdentifier: env.environmentArn,
78 * parameters: { topicArn: topic.topicArn },
79 * });
80 * ```
81 *
82 * @resource
83 */
84export const ExtensionAssociation = Resource<ExtensionAssociation>(
85 "AWS.AppConfig.ExtensionAssociation",
86);
87
88export const ExtensionAssociationProvider = () =>
89 Provider.effect(
90 ExtensionAssociation,
91 Effect.gen(function* () {
92 const readAssociation = Effect.fn(function* (
93 extensionAssociationId: string,
94 ) {
95 return yield* appconfig
96 .getExtensionAssociation({
97 ExtensionAssociationId: extensionAssociationId,
98 })
99 .pipe(
100 Effect.catchTag("ResourceNotFoundException", () =>
101 Effect.succeed(undefined),
102 ),
103 );
104 });
105
106 /**
107 * Find an existing association between the extension and the resource
108 * (associations have no name; the pair is the natural identity).
109 *
110 * `ListExtensionAssociations` rejects requests carrying BOTH
111 * `ExtensionIdentifier` and `ResourceIdentifier` ("Request cannot
112 * contain both..."), so list by resource and match the extension
113 * client-side via its resolved id (association `ExtensionArn`s are
114 * versioned, `.../extension/{id}/{version}`).
115 */
116 const findByIdentifiers = Effect.fn(function* (
117 props: Partial<ExtensionAssociationProps>,
118 ) {
119 if (
120 props.extensionIdentifier === undefined ||
121 props.resourceIdentifier === undefined
122 ) {
123 return undefined;
124 }
125 const extension = yield* appconfig
126 .getExtension({ ExtensionIdentifier: props.extensionIdentifier })
127 .pipe(
128 Effect.catchTag("ResourceNotFoundException", () =>
129 Effect.succeed(undefined),
130 ),
131 );
132 if (extension?.Id === undefined) return undefined;
133 const summaries = yield* appconfig.listExtensionAssociations
134 .pages({ ResourceIdentifier: props.resourceIdentifier })
135 .pipe(
136 Stream.runCollect,
137 Effect.map((chunk) =>
138 Array.from(chunk).flatMap((page) => page.Items ?? []),
139 ),
140 );
141 const summary = summaries.find(
142 (item) =>
143 item.Id !== undefined &&
144 item.ExtensionArn !== undefined &&
145 item.ExtensionArn.includes(extension.Id!),
146 );
147 return summary?.Id === undefined
148 ? undefined
149 : yield* readAssociation(summary.Id);
150 });
151
152 const toAttrs = Effect.fn(function* (
153 association: appconfig.ExtensionAssociation,
154 ) {
155 const { accountId, region } = yield* AWSEnvironment.current;
156 return {
157 extensionAssociationId: association.Id!,
158 extensionAssociationArn:
159 association.Arn ??
160 extensionAssociationArn(region, accountId, association.Id!),
161 extensionArn: association.ExtensionArn!,
162 resourceArn: association.ResourceArn!,
163 };
164 });
165
166 return {
167 stables: [
168 "extensionAssociationId",
169 "extensionAssociationArn",
170 "extensionArn",
171 "resourceArn",
172 ],
173
174 diff: Effect.fn(function* ({ olds, news }) {
175 if (!isResolved(news)) return undefined;
176 if (
177 olds !== undefined &&
178 (olds.extensionIdentifier !== news.extensionIdentifier ||
179 olds.resourceIdentifier !== news.resourceIdentifier ||
180 (olds.extensionVersionNumber ?? undefined) !==
181 (news.extensionVersionNumber ?? undefined))
182 ) {
183 return { action: "replace" } as const;
184 }
185 }),
186
187 read: Effect.fn(function* ({ id, olds, output }) {
188 const association = output?.extensionAssociationId
189 ? yield* readAssociation(output.extensionAssociationId)
190 : yield* findByIdentifiers(olds ?? {});
191 if (association?.Id === undefined) return undefined;
192 const attrs = yield* toAttrs(association);
193 const tags = yield* readAppConfigTags(attrs.extensionAssociationArn);
194 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
195 }),
196
197 reconcile: Effect.fn(function* ({ id, news, output, session }) {
198 const internalTags = yield* createInternalTags(id);
199 const desiredTags = { ...internalTags, ...news.tags };
200
201 // 1. Observe.
202 let observed = output?.extensionAssociationId
203 ? yield* readAssociation(output.extensionAssociationId)
204 : undefined;
205 if (observed === undefined) {
206 observed = yield* findByIdentifiers(news);
207 }
208
209 // 2. Ensure.
210 if (observed?.Id === undefined) {
211 observed = yield* appconfig.createExtensionAssociation({
212 ExtensionIdentifier: news.extensionIdentifier,
213 ExtensionVersionNumber: news.extensionVersionNumber,
214 ResourceIdentifier: news.resourceIdentifier,
215 Parameters: news.parameters,
216 Tags: desiredTags,
217 });
218 } else {
219 // 3. Sync — parameter values are mutable in place.
220 const observedParameters = toTagRecord(observed.Parameters);
221 const desiredParameters = news.parameters ?? {};
222 const { removed, upsert } = diffTags(
223 observedParameters,
224 desiredParameters,
225 );
226 if (removed.length > 0 || upsert.length > 0) {
227 observed = yield* appconfig.updateExtensionAssociation({
228 ExtensionAssociationId: observed.Id,
229 Parameters: desiredParameters,
230 });
231 }
232 }
233
234 const attrs = yield* toAttrs(observed);
235
236 // 3b. Sync tags against observed cloud tags.
237 yield* syncAppConfigTags(attrs.extensionAssociationArn, desiredTags);
238
239 yield* session.note(attrs.extensionAssociationId);
240 return attrs;
241 }),
242
243 delete: Effect.fn(function* ({ output }) {
244 yield* appconfig
245 .deleteExtensionAssociation({
246 ExtensionAssociationId: output.extensionAssociationId,
247 })
248 .pipe(
249 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
250 );
251 }),
252
253 list: () =>
254 Effect.gen(function* () {
255 const { accountId, region } = yield* AWSEnvironment.current;
256 const summaries = yield* appconfig.listExtensionAssociations
257 .pages({})
258 .pipe(
259 Stream.runCollect,
260 Effect.map((chunk) =>
261 Array.from(chunk).flatMap((page) => page.Items ?? []),
262 ),
263 );
264 return summaries.flatMap((summary) =>
265 summary.Id !== undefined &&
266 summary.ExtensionArn !== undefined &&
267 summary.ResourceArn !== undefined
268 ? [
269 {
270 extensionAssociationId: summary.Id,
271 extensionAssociationArn: extensionAssociationArn(
272 region,
273 accountId,
274 summary.Id,
275 ),
276 extensionArn: summary.ExtensionArn,
277 resourceArn: summary.ResourceArn,
278 },
279 ]
280 : [],
281 );
282 }),
283 };
284 }),
285 );