1import * as appregistry from "@distilled.cloud/aws/service-catalog-appregistry";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11/**
12 * Raised when a freshly created resource association has not become visible
13 * to `getAssociatedResource` within the bounded eventual-consistency window.
14 */
15export class ResourceAssociationNotVisible extends Data.TaggedError(
16 "ResourceAssociationNotVisible",
17)<{
18 readonly application: string;
19 readonly resource: string;
20}> {}
21
22export interface ResourceAssociationProps {
23 /**
24 * The application to associate the resource with: its ID, name, or ARN.
25 * Changing it replaces the association.
26 */
27 application: string;
28 /**
29 * The type of resource being associated: `CFN_STACK` for a CloudFormation
30 * stack or `RESOURCE_TAG_VALUE` for a tag-value query. Changing it replaces
31 * the association.
32 */
33 resourceType: "CFN_STACK" | "RESOURCE_TAG_VALUE";
34 /**
35 * The name or ARN of the resource to associate (e.g. the CloudFormation
36 * stack name). Changing it replaces the association.
37 */
38 resource: string;
39 /**
40 * Association options. `APPLY_APPLICATION_TAG` (the service default) stamps
41 * the resource with the `awsApplication` tag; `SKIP_APPLICATION_TAG` leaves
42 * the resource's tags untouched. Updatable in place (the association is
43 * re-created under the hood).
44 */
45 options?: ("APPLY_APPLICATION_TAG" | "SKIP_APPLICATION_TAG")[];
46}
47
48export interface ResourceAssociation extends Resource<
49 "AWS.AppRegistry.ResourceAssociation",
50 ResourceAssociationProps,
51 {
52 /** The ID of the associated application. */
53 applicationId: string;
54 /** The ARN of the associated application. */
55 applicationArn: string;
56 /** The type of the associated resource. */
57 resourceType: string;
58 /** The canonical name of the associated resource. */
59 resourceName: string;
60 /** The ARN of the associated resource. */
61 resourceArn: string;
62 },
63 never,
64 Providers
65> {}
66
67/**
68 * Associates an AWS resource (a CloudFormation stack or a tag-value query)
69 * with an AppRegistry {@link Application} so the resource is inventoried
70 * under the application in myApplications.
71 *
72 * ### Associating a Resource
73 * **Example:** Associate a CloudFormation Stack
74 * ```typescript
75 * import * as AppRegistry from "alchemy/AWS/AppRegistry";
76 * import * as CloudFormation from "alchemy/AWS/CloudFormation";
77 *
78 * const app = yield* AppRegistry.Application("Storefront", {});
79 * const stack = yield* CloudFormation.Stack("Network", {
80 * templateBody: networkTemplateJson,
81 * });
82 *
83 * const association = yield* AppRegistry.ResourceAssociation("StackAssoc", {
84 * application: app.applicationId,
85 * resourceType: "CFN_STACK",
86 * resource: stack.stackName,
87 * });
88 * ```
89 *
90 * **Example:** Associate Without Applying the Application Tag
91 * ```typescript
92 * const association = yield* AppRegistry.ResourceAssociation("StackAssoc", {
93 * application: app.applicationId,
94 * resourceType: "CFN_STACK",
95 * resource: stack.stackName,
96 * options: ["SKIP_APPLICATION_TAG"],
97 * });
98 * ```
99 *
100 * @resource
101 */
102export const ResourceAssociation = Resource<ResourceAssociation>(
103 "AWS.AppRegistry.ResourceAssociation",
104);
105
106const sameOptions = (
107 observed: readonly string[] | undefined,
108 desired: readonly string[],
109): boolean => {
110 const observedSet = new Set(observed ?? []);
111 return (
112 desired.length === observedSet.size &&
113 desired.every((option) => observedSet.has(option))
114 );
115};
116
117export const ResourceAssociationProvider = () =>
118 Provider.effect(
119 ResourceAssociation,
120 Effect.gen(function* () {
121 const observeApplication = Effect.fn(function* (specifier: string) {
122 return yield* appregistry
123 .getApplication({ application: specifier })
124 .pipe(
125 Effect.catchTag("ResourceNotFoundException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129 });
130
131 const observeAssociation = Effect.fn(function* (
132 application: string,
133 resourceType: string,
134 resource: string,
135 ) {
136 return yield* appregistry
137 .getAssociatedResource({ application, resourceType, resource })
138 .pipe(
139 Effect.catchTag("ResourceNotFoundException", () =>
140 Effect.succeed(undefined),
141 ),
142 );
143 });
144
145 return ResourceAssociation.Provider.of({
146 stables: [
147 "applicationId",
148 "applicationArn",
149 "resourceType",
150 "resourceName",
151 "resourceArn",
152 ],
153 // The application/resource pair IS the association's identity —
154 // changing either side replaces it. `options` is synced in place.
155 diff: Effect.fn(function* ({ olds, news }) {
156 if (!isResolved(news)) return undefined;
157 if (
158 olds !== undefined &&
159 (olds.application !== news.application ||
160 olds.resourceType !== news.resourceType ||
161 olds.resource !== news.resource)
162 ) {
163 return { action: "replace" } as const;
164 }
165 }),
166 read: Effect.fn(function* ({ olds, output }) {
167 const applicationSpecifier =
168 output?.applicationId ?? olds?.application;
169 const resourceType = output?.resourceType ?? olds?.resourceType;
170 const resource = output?.resourceName ?? olds?.resource;
171 if (
172 applicationSpecifier === undefined ||
173 resourceType === undefined ||
174 resource === undefined
175 ) {
176 return undefined;
177 }
178 const application = yield* observeApplication(applicationSpecifier);
179 if (application?.id === undefined) return undefined;
180 const association = yield* observeAssociation(
181 application.id,
182 resourceType,
183 resource,
184 );
185 if (association?.resource?.arn === undefined) return undefined;
186 // Associations cannot carry tags; existence under our application
187 // is the ownership signal.
188 return {
189 applicationId: application.id,
190 applicationArn: application.arn!,
191 resourceType,
192 resourceName: association.resource.name ?? resource,
193 resourceArn: association.resource.arn,
194 };
195 }),
196 reconcile: Effect.fn(function* ({ news, session }) {
197 // 1. OBSERVE — resolve the application; a missing parent surfaces
198 // its typed ResourceNotFoundException.
199 const application = yield* appregistry.getApplication({
200 application: news.application,
201 });
202 const applicationId = application.id!;
203
204 const associate = appregistry
205 .associateResource({
206 application: applicationId,
207 resourceType: news.resourceType,
208 resource: news.resource,
209 options: news.options,
210 })
211 .pipe(Effect.catchTag("ConflictException", () => Effect.void));
212
213 let observed = yield* observeAssociation(
214 applicationId,
215 news.resourceType,
216 news.resource,
217 );
218
219 // 2. ENSURE — associate when missing; tolerate a concurrent
220 // associate race (ConflictException).
221 if (observed?.resource === undefined) {
222 yield* associate;
223 } else if (
224 news.options !== undefined &&
225 !sameOptions(observed.options, news.options)
226 ) {
227 // 3. SYNC options — the API has no update; converge by
228 // re-creating the association in place.
229 yield* appregistry
230 .disassociateResource({
231 application: applicationId,
232 resourceType: news.resourceType,
233 resource: news.resource,
234 })
235 .pipe(
236 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
237 );
238 yield* associate;
239 }
240
241 // 4. RETURN — re-read with a bounded wait for the association to
242 // become visible.
243 observed = yield* observeAssociation(
244 applicationId,
245 news.resourceType,
246 news.resource,
247 ).pipe(
248 Effect.flatMap((association) =>
249 association?.resource?.arn !== undefined
250 ? Effect.succeed(association)
251 : Effect.fail(
252 new ResourceAssociationNotVisible({
253 application: applicationId,
254 resource: news.resource,
255 }),
256 ),
257 ),
258 Effect.retry({
259 while: (e): boolean => e._tag === "ResourceAssociationNotVisible",
260 schedule: Schedule.max([
261 Schedule.fixed("2 seconds"),
262 Schedule.recurs(8),
263 ]),
264 }),
265 );
266
267 yield* session.note(`${applicationId}/${news.resource}`);
268 return {
269 applicationId,
270 applicationArn: application.arn!,
271 resourceType: news.resourceType,
272 resourceName: observed.resource!.name ?? news.resource,
273 resourceArn: observed.resource!.arn!,
274 };
275 }),
276 delete: Effect.fn(function* ({ output }) {
277 yield* appregistry
278 .disassociateResource({
279 application: output.applicationId,
280 resourceType: output.resourceType,
281 resource: output.resourceName,
282 })
283 .pipe(
284 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
285 );
286 }),
287 list: () =>
288 Effect.gen(function* () {
289 const applications = yield* appregistry.listApplications
290 .pages({})
291 .pipe(
292 Stream.runCollect,
293 Effect.map((chunk) =>
294 Array.from(chunk).flatMap((page) => page.applications ?? []),
295 ),
296 );
297 const results: {
298 applicationId: string;
299 applicationArn: string;
300 resourceType: string;
301 resourceName: string;
302 resourceArn: string;
303 }[] = [];
304 for (const application of applications) {
305 if (application.id === undefined) continue;
306 const resources = yield* appregistry.listAssociatedResources
307 .pages({ application: application.id })
308 .pipe(
309 Stream.runCollect,
310 Effect.map((chunk) =>
311 Array.from(chunk).flatMap((page) => page.resources ?? []),
312 ),
313 );
314 for (const resource of resources) {
315 if (resource.arn === undefined || resource.name === undefined) {
316 continue;
317 }
318 results.push({
319 applicationId: application.id,
320 applicationArn: application.arn!,
321 resourceType: resource.resourceType ?? "CFN_STACK",
322 resourceName: resource.name,
323 resourceArn: resource.arn,
324 });
325 }
326 }
327 return results;
328 }),
329 });
330 }),
331 );