| 1 | import * as appregistry from "@distilled.cloud/aws/service-catalog-appregistry"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import 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 | */ |
| 15 | export class ResourceAssociationNotVisible extends Data.TaggedError( |
| 16 | "ResourceAssociationNotVisible", |
| 17 | )<{ |
| 18 | readonly application: string; |
| 19 | readonly resource: string; |
| 20 | }> {} |
| 21 | |
| 22 | export 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 | |
| 48 | export 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 | */ |
| 102 | export const ResourceAssociation = Resource<ResourceAssociation>( |
| 103 | "AWS.AppRegistry.ResourceAssociation", |
| 104 | ); |
| 105 | |
| 106 | const 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 | |
| 117 | export 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 | ); |