1import * as profiles from "@distilled.cloud/aws/route53profiles";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface ProfileResourceAssociationProps {
12 /**
13 * ID of the Route 53 Profile to attach the resource to. Changing it
14 * forces replacement.
15 */
16 profileId: string;
17 /**
18 * ARN of the DNS resource to attach — a private hosted zone, Resolver
19 * rule, or DNS Firewall rule group. Changing it forces replacement.
20 */
21 resourceArn: string;
22 /**
23 * Friendly name recorded on the association. If omitted, a unique name
24 * is generated. Names can be updated in place.
25 */
26 name?: string;
27 /**
28 * Resource-specific configuration, as a JSON string. DNS Firewall rule
29 * group associations require a priority, e.g.
30 * `JSON.stringify({ priority: 102 })`. Updated in place.
31 */
32 resourceProperties?: string;
33}
34
35export interface ProfileResourceAssociation extends Resource<
36 "AWS.Route53Profiles.ProfileResourceAssociation",
37 ProfileResourceAssociationProps,
38 {
39 /** ID of the association (e.g. `rpr-...`). */
40 profileResourceAssociationId: string;
41 /** ID of the Profile. */
42 profileId: string;
43 /** ARN of the attached DNS resource. */
44 resourceArn: string;
45 /** Type of the attached resource (e.g. `FIREWALL_RULE_GROUP`). */
46 resourceType: string;
47 /** Name recorded on the association. */
48 name: string;
49 /** Resource-specific configuration JSON, if any. */
50 resourceProperties: string | undefined;
51 /**
52 * Status of the association at the end of the deploy. Associations
53 * settle asynchronously (typically within a minute or two), so this is
54 * usually still `UPDATING`.
55 */
56 status: profiles.ProfileStatus;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * An attachment of a DNS resource to a Route 53 Profile. Attach private
64 * hosted zones, Resolver rules, or DNS Firewall rule groups; every VPC the
65 * Profile is associated with picks up the resource.
66 * ### Attaching Resources
67 * **Example:** Attach a DNS Firewall Rule Group
68 * ```typescript
69 * import * as Route53Profiles from "alchemy/AWS/Route53Profiles";
70 *
71 * const attachment = yield* Route53Profiles.ProfileResourceAssociation(
72 * "FirewallRules",
73 * {
74 * profileId: profile.profileId,
75 * resourceArn: ruleGroupArn,
76 * resourceProperties: JSON.stringify({ priority: 102 }),
77 * },
78 * );
79 * ```
80 *
81 * **Example:** Attach a Resolver Rule
82 * ```typescript
83 * const attachment = yield* Route53Profiles.ProfileResourceAssociation(
84 * "CorpForwarding",
85 * {
86 * profileId: profile.profileId,
87 * resourceArn: rule.resolverRuleArn,
88 * },
89 * );
90 * ```
91 *
92 * @resource
93 */
94export const ProfileResourceAssociation = Resource<ProfileResourceAssociation>(
95 "AWS.Route53Profiles.ProfileResourceAssociation",
96);
97
98/**
99 * Associating/updating/disassociating while the Profile or the association
100 * is still settling surfaces `ConflictException`; retry on a bounded
101 * schedule (~60s).
102 *
103 * Explicitly typed: inlining `Effect.retry` in provider lifecycle code can
104 * widen the provider layer to `unknown` in declaration emit.
105 *
106 * @internal
107 */
108const retryConflict = <A, E extends { _tag: string }, R>(
109 self: Effect.Effect<A, E, R>,
110): Effect.Effect<A, E, R> =>
111 Effect.retry(self, {
112 while: (e) => e._tag === "ConflictException",
113 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
114 });
115
116/**
117 * Bounded wait (~2 min) for a disassociation to drain so the Profile and
118 * the attached resource can be deleted right after. If it is still draining
119 * after the budget we proceed — downstream deletes absorb the residual with
120 * their own bounded retries.
121 *
122 * @internal
123 */
124const untilAssociationGone = <E, R>(
125 self: Effect.Effect<profiles.ProfileResourceAssociation | undefined, E, R>,
126): Effect.Effect<profiles.ProfileResourceAssociation | undefined, E, R> =>
127 self.pipe(
128 Effect.repeat({
129 schedule: Schedule.fixed("4 seconds"),
130 until: (association) => association === undefined,
131 times: 30,
132 }),
133 );
134
135/**
136 * Compare two resource-properties JSON strings structurally (key order and
137 * whitespace insensitive). Invalid JSON falls back to string equality.
138 *
139 * @internal
140 */
141const sameResourceProperties = (
142 a: string | undefined,
143 b: string | undefined,
144): boolean => {
145 if (a === b) return true;
146 if (a === undefined || b === undefined) return false;
147 try {
148 return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b));
149 } catch {
150 return false;
151 }
152};
153
154export const ProfileResourceAssociationProvider = () =>
155 Provider.effect(
156 ProfileResourceAssociation,
157 Effect.gen(function* () {
158 const createName = Effect.fn(function* (
159 id: string,
160 props: { name?: string | undefined },
161 ) {
162 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
163 });
164
165 // `DELETING`/`DELETED` associations count as missing so reconcile
166 // re-associates and delete converges.
167 const isLive = (
168 association: profiles.ProfileResourceAssociation | undefined,
169 ) =>
170 association !== undefined &&
171 association.Status !== "DELETING" &&
172 association.Status !== "DELETED"
173 ? association
174 : undefined;
175
176 const observeById = (profileResourceAssociationId: string) =>
177 profiles
178 .getProfileResourceAssociation({
179 ProfileResourceAssociationId: profileResourceAssociationId,
180 })
181 .pipe(
182 Effect.map((r) => isLive(r.ProfileResourceAssociation)),
183 Effect.catchTag("ResourceNotFoundException", () =>
184 Effect.succeed(undefined),
185 ),
186 );
187
188 const observeByPair = (profileId: string, resourceArn: string) =>
189 profiles.listProfileResourceAssociations
190 .items({ ProfileId: profileId })
191 .pipe(
192 Stream.runCollect,
193 Effect.map((chunk) =>
194 Array.from(chunk)
195 .filter(
196 (association) => association.ResourceArn === resourceArn,
197 )
198 .map(isLive)
199 .find((association) => association !== undefined),
200 ),
201 Effect.catchTag("ResourceNotFoundException", () =>
202 Effect.succeed(undefined),
203 ),
204 );
205
206 const toAttributes = (
207 association: profiles.ProfileResourceAssociation,
208 ) => ({
209 profileResourceAssociationId: association.Id!,
210 profileId: association.ProfileId!,
211 resourceArn: association.ResourceArn!,
212 resourceType: association.ResourceType ?? "",
213 name: association.Name ?? "",
214 resourceProperties: association.ResourceProperties,
215 status: association.Status ?? "COMPLETE",
216 });
217
218 return ProfileResourceAssociation.Provider.of({
219 stables: ["profileResourceAssociationId", "profileId", "resourceArn"],
220 // Parent-keyed listing only — enumerate every Profile, then each
221 // Profile's resource associations.
222 list: () =>
223 profiles.listProfiles.items({}).pipe(
224 Stream.runCollect,
225 Effect.flatMap((chunk) =>
226 Effect.forEach(
227 Array.from(chunk).flatMap((summary) =>
228 summary.Id !== undefined ? [summary.Id] : [],
229 ),
230 (profileId) =>
231 profiles.listProfileResourceAssociations
232 .items({ ProfileId: profileId })
233 .pipe(
234 Stream.runCollect,
235 Effect.map(
236 Array.from<profiles.ProfileResourceAssociation>,
237 ),
238 // The profile can vanish between enumeration and listing.
239 Effect.catchTag("ResourceNotFoundException", () =>
240 Effect.succeed([]),
241 ),
242 ),
243 { concurrency: 5 },
244 ),
245 ),
246 Effect.map((groups) =>
247 groups
248 .flat()
249 .filter(
250 (association) =>
251 association.Id !== undefined &&
252 association.ProfileId !== undefined &&
253 association.ResourceArn !== undefined &&
254 association.Status !== "DELETING" &&
255 association.Status !== "DELETED",
256 )
257 .map(toAttributes),
258 ),
259 ),
260 read: Effect.fn(function* ({ olds, output }) {
261 const association = output?.profileResourceAssociationId
262 ? yield* observeById(output.profileResourceAssociationId)
263 : olds?.profileId !== undefined && olds.resourceArn !== undefined
264 ? yield* observeByPair(olds.profileId, olds.resourceArn)
265 : undefined;
266 if (association?.Id === undefined) return undefined;
267 return toAttributes(association);
268 }),
269 // Identity changes replace; name and resourceProperties update in
270 // place via the default update path.
271 diff: Effect.fn(function* ({ news, olds }) {
272 if (!isResolved(news)) return undefined;
273 if (
274 olds.profileId !== news.profileId ||
275 olds.resourceArn !== news.resourceArn
276 ) {
277 return { action: "replace" } as const;
278 }
279 }),
280 reconcile: Effect.fn(function* ({ id, news, output, session }) {
281 const name = output?.name ?? (yield* createName(id, news));
282
283 // OBSERVE
284 let association = output?.profileResourceAssociationId
285 ? yield* observeById(output.profileResourceAssociationId)
286 : undefined;
287 if (!association) {
288 association = yield* observeByPair(
289 news.profileId,
290 news.resourceArn,
291 );
292 }
293
294 // ENSURE
295 if (!association) {
296 association = yield* retryConflict(
297 profiles.associateResourceToProfile({
298 ProfileId: news.profileId,
299 ResourceArn: news.resourceArn,
300 Name: name,
301 ResourceProperties: news.resourceProperties,
302 }),
303 ).pipe(Effect.map((r) => r.ProfileResourceAssociation!));
304 }
305
306 // SYNC name + resourceProperties against OBSERVED state. Updates
307 // are accepted even while the association is still settling.
308 const nameDelta = association.Name !== name ? name : undefined;
309 const propsDelta =
310 news.resourceProperties !== undefined &&
311 !sameResourceProperties(
312 association.ResourceProperties,
313 news.resourceProperties,
314 )
315 ? news.resourceProperties
316 : undefined;
317 if (nameDelta !== undefined || propsDelta !== undefined) {
318 const updated = yield* retryConflict(
319 profiles.updateProfileResourceAssociation({
320 ProfileResourceAssociationId: association.Id!,
321 Name: nameDelta,
322 ResourceProperties: propsDelta,
323 }),
324 );
325 association = updated.ProfileResourceAssociation ?? association;
326 }
327
328 yield* session.note(`${news.profileId}/${news.resourceArn}`);
329 return toAttributes(association);
330 }),
331 delete: Effect.fn(function* ({ output }) {
332 yield* retryConflict(
333 profiles.disassociateResourceFromProfile({
334 ProfileId: output.profileId,
335 ResourceArn: output.resourceArn,
336 }),
337 ).pipe(
338 Effect.asVoid,
339 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
340 );
341 // Disassociation drains asynchronously (~1 min). Wait (bounded) so
342 // the Profile and the attached resource can be deleted right after.
343 yield* untilAssociationGone(
344 observeById(output.profileResourceAssociationId),
345 );
346 }),
347 });
348 }),
349 );