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 { createInternalTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11
12export interface ProfileAssociationProps {
13 /**
14 * ID of the Route 53 Profile to apply. Changing it forces replacement.
15 */
16 profileId: string;
17 /**
18 * ID of the VPC to associate the Profile with. A VPC can have only one
19 * Profile associated with it. Changing it forces replacement.
20 */
21 resourceId: string;
22 /**
23 * Friendly name recorded on the association. If omitted, a unique name
24 * is generated. Associations cannot be renamed, so changing it forces
25 * replacement.
26 */
27 name?: string;
28}
29
30export interface ProfileAssociation extends Resource<
31 "AWS.Route53Profiles.ProfileAssociation",
32 ProfileAssociationProps,
33 {
34 /** ID of the association (e.g. `rpassoc-...`). */
35 profileAssociationId: string;
36 /** ID of the associated Profile. */
37 profileId: string;
38 /** ID of the associated VPC. */
39 resourceId: string;
40 /** Name recorded on the association. */
41 name: string;
42 /**
43 * Status of the association at the end of the deploy. Associations
44 * complete asynchronously (typically within a couple of minutes), so
45 * this is usually `CREATING`; the Profile's DNS settings take effect in
46 * the VPC once the association reaches `COMPLETE`.
47 */
48 status: profiles.ProfileStatus;
49 },
50 never,
51 Providers
52> {}
53
54/**
55 * An association between a Route 53 Profile and a VPC. Once the association
56 * completes, every DNS resource attached to the Profile (private hosted
57 * zones, Resolver rules, DNS Firewall rule groups) takes effect in the VPC.
58 *
59 * A VPC can have only one Profile associated with it; a Profile can be
60 * associated with thousands of VPCs. The association is created immediately
61 * but completes asynchronously — the returned `status` is typically still
62 * `CREATING` when the deploy finishes.
63 * ### Associating a Profile
64 * **Example:** Apply a Profile to a VPC
65 * ```typescript
66 * import * as Route53Profiles from "alchemy/AWS/Route53Profiles";
67 *
68 * const association = yield* Route53Profiles.ProfileAssociation("AppVpcDns", {
69 * profileId: profile.profileId,
70 * resourceId: vpc.vpcId,
71 * });
72 * ```
73 *
74 * @resource
75 */
76export const ProfileAssociation = Resource<ProfileAssociation>(
77 "AWS.Route53Profiles.ProfileAssociation",
78);
79
80/**
81 * Associating/disassociating while the Profile or a prior association is
82 * still settling surfaces `ConflictException`; retry on a bounded schedule
83 * (~60s).
84 *
85 * Explicitly typed: inlining `Effect.retry` in provider lifecycle code can
86 * widen the provider layer to `unknown` in declaration emit.
87 *
88 * @internal
89 */
90const retryConflict = <A, E extends { _tag: string }, R>(
91 self: Effect.Effect<A, E, R>,
92): Effect.Effect<A, E, R> =>
93 Effect.retry(self, {
94 while: (e) => e._tag === "ConflictException",
95 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(20)]),
96 });
97
98/**
99 * Bounded wait (~2 min) for a disassociation to drain. Disassociation was
100 * observed to take ~1–2 minutes; waiting here keeps teardown ordering sound
101 * (the Profile and the VPC both reject deletion while the association
102 * exists). If the association is still draining after the budget we proceed
103 * — the Profile and VPC providers absorb the residual with their own
104 * bounded conflict/dependency retries.
105 *
106 * @internal
107 */
108const untilAssociationGone = <E, R>(
109 self: Effect.Effect<profiles.ProfileAssociation | undefined, E, R>,
110): Effect.Effect<profiles.ProfileAssociation | undefined, E, R> =>
111 self.pipe(
112 Effect.repeat({
113 schedule: Schedule.fixed("4 seconds"),
114 until: (association) => association === undefined,
115 times: 30,
116 }),
117 );
118
119export const ProfileAssociationProvider = () =>
120 Provider.effect(
121 ProfileAssociation,
122 Effect.gen(function* () {
123 const createName = Effect.fn(function* (
124 id: string,
125 props: { name?: string | undefined },
126 ) {
127 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
128 });
129
130 // `DELETING`/`DELETED` associations count as missing so reconcile
131 // re-associates and delete converges.
132 const isLive = (association: profiles.ProfileAssociation | undefined) =>
133 association !== undefined &&
134 association.Status !== "DELETING" &&
135 association.Status !== "DELETED"
136 ? association
137 : undefined;
138
139 const observeById = (profileAssociationId: string) =>
140 profiles
141 .getProfileAssociation({
142 ProfileAssociationId: profileAssociationId,
143 })
144 .pipe(
145 Effect.map((r) => isLive(r.ProfileAssociation)),
146 Effect.catchTag("ResourceNotFoundException", () =>
147 Effect.succeed(undefined),
148 ),
149 );
150
151 const observeByPair = (profileId: string, resourceId: string) =>
152 profiles.listProfileAssociations
153 .items({ ProfileId: profileId, ResourceId: resourceId })
154 .pipe(
155 Stream.runCollect,
156 Effect.map((chunk) =>
157 Array.from(chunk)
158 .map(isLive)
159 .find((association) => association !== undefined),
160 ),
161 );
162
163 return ProfileAssociation.Provider.of({
164 stables: ["profileAssociationId", "profileId", "resourceId", "name"],
165 // Top-level enumeration: associations are listable account-wide.
166 list: () =>
167 profiles.listProfileAssociations.items({}).pipe(
168 Stream.runCollect,
169 Effect.map((chunk) =>
170 Array.from(chunk).flatMap((association) =>
171 association.Id !== undefined &&
172 association.ProfileId !== undefined &&
173 association.ResourceId !== undefined &&
174 association.Status !== "DELETING" &&
175 association.Status !== "DELETED"
176 ? [
177 {
178 profileAssociationId: association.Id,
179 profileId: association.ProfileId,
180 resourceId: association.ResourceId,
181 name: association.Name ?? "",
182 status: association.Status ?? "COMPLETE",
183 },
184 ]
185 : [],
186 ),
187 ),
188 ),
189 read: Effect.fn(function* ({ olds, output }) {
190 const association = output?.profileAssociationId
191 ? yield* observeById(output.profileAssociationId)
192 : olds?.profileId !== undefined && olds.resourceId !== undefined
193 ? yield* observeByPair(olds.profileId, olds.resourceId)
194 : undefined;
195 if (association?.Id === undefined) return undefined;
196 return {
197 profileAssociationId: association.Id,
198 profileId: association.ProfileId!,
199 resourceId: association.ResourceId!,
200 name: association.Name ?? "",
201 status: association.Status ?? "COMPLETE",
202 };
203 }),
204 // Existence-only resource — every identifying property change
205 // replaces the association (there is no update API).
206 diff: Effect.fn(function* ({ id, news, olds }) {
207 if (!isResolved(news)) return undefined;
208 const oldName = yield* createName(id, olds);
209 const newName = yield* createName(id, news);
210 if (
211 olds.profileId !== news.profileId ||
212 olds.resourceId !== news.resourceId ||
213 oldName !== newName
214 ) {
215 return { action: "replace" } as const;
216 }
217 }),
218 // Existence-only: observe → if missing, associate. There is no sync
219 // step. We deliberately do NOT wait for `COMPLETE` — the association
220 // takes a couple of minutes to propagate and is usable as a
221 // reference immediately.
222 reconcile: Effect.fn(function* ({ id, news, output, session }) {
223 const name = output?.name ?? (yield* createName(id, news));
224 const internalTags = yield* createInternalTags(id);
225
226 let association = output?.profileAssociationId
227 ? yield* observeById(output.profileAssociationId)
228 : undefined;
229 if (!association) {
230 association = yield* observeByPair(news.profileId, news.resourceId);
231 }
232
233 if (!association) {
234 association = yield* retryConflict(
235 profiles.associateProfile({
236 ProfileId: news.profileId,
237 ResourceId: news.resourceId,
238 Name: name,
239 Tags: Object.entries(internalTags).map(([Key, Value]) => ({
240 Key,
241 Value,
242 })),
243 }),
244 ).pipe(
245 Effect.map((r) => r.ProfileAssociation!),
246 // A concurrent reconcile won the race — re-observe.
247 Effect.catchTag("ResourceExistsException", () =>
248 observeByPair(news.profileId, news.resourceId).pipe(
249 Effect.map((existing) => existing!),
250 ),
251 ),
252 );
253 }
254
255 yield* session.note(`${news.profileId}/${news.resourceId}`);
256 return {
257 profileAssociationId: association.Id!,
258 profileId: news.profileId,
259 resourceId: news.resourceId,
260 name: association.Name ?? name,
261 status: association.Status ?? "CREATING",
262 };
263 }),
264 delete: Effect.fn(function* ({ output }) {
265 yield* retryConflict(
266 profiles.disassociateProfile({
267 ProfileId: output.profileId,
268 ResourceId: output.resourceId,
269 }),
270 ).pipe(
271 Effect.asVoid,
272 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
273 );
274 // Disassociation drains asynchronously (~1–2 min). Wait (bounded)
275 // so the Profile and VPC can be deleted right after.
276 yield* untilAssociationGone(observeById(output.profileAssociationId));
277 }),
278 });
279 }),
280 );