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 { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12
13export interface ProfileProps {
14 /**
15 * Name of the Profile. If omitted, a unique name is generated from the
16 * app, stage, and logical ID. Profiles have no update API for the name,
17 * so changing it replaces the Profile.
18 */
19 name?: string;
20
21 /**
22 * User tags to attach to the Profile. Merged with internal Alchemy tags.
23 */
24 tags?: Record<string, string>;
25}
26
27export interface Profile extends Resource<
28 "AWS.Route53Profiles.Profile",
29 ProfileProps,
30 {
31 /** ID of the Profile (e.g. `rp-...`). */
32 profileId: string;
33 /** ARN of the Profile. */
34 profileArn: string;
35 /** Name of the Profile. */
36 profileName: string;
37 },
38 never,
39 Providers
40> {}
41
42/**
43 * An Amazon Route 53 Profile — a reusable container of DNS configuration
44 * (private hosted zones, Resolver rules, and DNS Firewall rule groups) that
45 * can be associated with many VPCs at once.
46 *
47 * Attach DNS resources to the Profile with `ProfileResourceAssociation` and
48 * apply the whole bundle to a VPC with `ProfileAssociation`.
49 * ### Creating Profiles
50 * **Example:** Basic Profile
51 * ```typescript
52 * import * as Route53Profiles from "alchemy/AWS/Route53Profiles";
53 *
54 * const profile = yield* Route53Profiles.Profile("DnsProfile");
55 * ```
56 *
57 * **Example:** Profile with Tags
58 * ```typescript
59 * const profile = yield* Route53Profiles.Profile("DnsProfile", {
60 * name: "shared-dns-config",
61 * tags: { team: "platform" },
62 * });
63 * ```
64 *
65 * ### Applying a Profile to VPCs
66 * **Example:** Associate the Profile with a VPC
67 * ```typescript
68 * const vpc = yield* EC2.Vpc("AppVpc", { cidrBlock: "10.0.0.0/16" });
69 *
70 * yield* Route53Profiles.ProfileAssociation("AppVpcDns", {
71 * profileId: profile.profileId,
72 * resourceId: vpc.vpcId,
73 * });
74 * ```
75 *
76 * @resource
77 */
78export const Profile = Resource<Profile>("AWS.Route53Profiles.Profile");
79
80/**
81 * A Profile whose VPC associations are still draining rejects `DeleteProfile`
82 * with `ConflictException`. Disassociation completes asynchronously (observed
83 * ~1–2 minutes), so retry deletion on a bounded schedule (~2 min).
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 retryDeleteConflict = <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("4 seconds"), Schedule.recurs(30)]),
96 });
97
98/**
99 * Bounded wait (~30s) for a freshly created Profile to leave `CREATING`
100 * (observed to settle in a few seconds).
101 *
102 * @internal
103 */
104const untilProfileSettled = <E, R>(
105 self: Effect.Effect<profiles.Profile | undefined, E, R>,
106): Effect.Effect<profiles.Profile | undefined, E, R> =>
107 self.pipe(
108 Effect.repeat({
109 schedule: Schedule.fixed("2 seconds"),
110 until: (profile) =>
111 profile === undefined || profile.Status !== "CREATING",
112 times: 15,
113 }),
114 );
115
116export const ProfileProvider = () =>
117 Provider.effect(
118 Profile,
119 Effect.gen(function* () {
120 // `props` may be undefined at runtime — all ProfileProps fields are
121 // optional, so callers can omit the props object entirely.
122 const createName = Effect.fn(function* (
123 id: string,
124 props: ProfileProps | undefined,
125 ) {
126 return (
127 props?.name ?? (yield* createPhysicalName({ id, maxLength: 64 }))
128 );
129 });
130
131 // A deleted Profile lingers in `DELETING`/`DELETED` and is still
132 // returned by `GetProfile` — those states count as missing.
133 const observe = (profileId: string) =>
134 profiles.getProfile({ ProfileId: profileId }).pipe(
135 Effect.map((r) =>
136 r.Profile === undefined ||
137 r.Profile.Status === "DELETING" ||
138 r.Profile.Status === "DELETED"
139 ? undefined
140 : r.Profile,
141 ),
142 Effect.catchTag("ResourceNotFoundException", () =>
143 Effect.succeed(undefined),
144 ),
145 );
146
147 const findByName = (name: string) =>
148 profiles.listProfiles.items({}).pipe(
149 Stream.runCollect,
150 Effect.map((chunk) =>
151 Array.from(chunk).find((summary) => summary.Name === name),
152 ),
153 );
154
155 const readTags = (profileArn: string) =>
156 profiles.listTagsForResource({ ResourceArn: profileArn }).pipe(
157 Effect.map((r) => {
158 const tags: Record<string, string> = {};
159 for (const [key, value] of Object.entries(r.Tags)) {
160 if (value !== undefined) tags[key] = value;
161 }
162 return tags;
163 }),
164 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
165 );
166
167 const syncTags = Effect.fn(function* (
168 id: string,
169 profileArn: string,
170 userTags: Record<string, string> | undefined,
171 ) {
172 const internalTags = yield* createInternalTags(id);
173 // Diff against OBSERVED cloud tags so adoption converges.
174 const currentTags = yield* readTags(profileArn);
175 const { upsert, removed } = diffTags(currentTags, {
176 ...userTags,
177 ...internalTags,
178 });
179 if (upsert.length > 0) {
180 yield* profiles.tagResource({
181 ResourceArn: profileArn,
182 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
183 });
184 }
185 if (removed.length > 0) {
186 yield* profiles.untagResource({
187 ResourceArn: profileArn,
188 TagKeys: removed,
189 });
190 }
191 });
192
193 return Profile.Provider.of({
194 stables: ["profileId", "profileArn", "profileName"],
195 // Top-level enumeration of every Profile in the account/region.
196 list: () =>
197 profiles.listProfiles.items({}).pipe(
198 Stream.runCollect,
199 Effect.map((chunk) =>
200 Array.from(chunk).flatMap((summary) =>
201 summary.Id !== undefined &&
202 summary.Arn !== undefined &&
203 summary.Name !== undefined
204 ? [
205 {
206 profileId: summary.Id,
207 profileArn: summary.Arn,
208 profileName: summary.Name,
209 },
210 ]
211 : [],
212 ),
213 ),
214 ),
215 read: Effect.fn(function* ({ id, olds, output }) {
216 const profileId =
217 output?.profileId ??
218 (yield* createName(id, olds ?? {}).pipe(
219 Effect.flatMap(findByName),
220 Effect.map((summary) => summary?.Id),
221 ));
222 if (profileId === undefined) return undefined;
223 const live = yield* observe(profileId);
224 if (!live) return undefined;
225 const attrs = {
226 profileId,
227 profileArn: live.Arn!,
228 profileName: live.Name!,
229 };
230 const tags = yield* readTags(live.Arn!);
231 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
232 }),
233 diff: Effect.fn(function* ({ id, news, olds }) {
234 if (!isResolved(news)) return undefined;
235 const oldName = yield* createName(id, olds);
236 const newName = yield* createName(id, news);
237 // Profiles have no update API for the name.
238 if (oldName !== newName) return { action: "replace" } as const;
239 // Fall through: tags changes take the default update path.
240 }),
241 reconcile: Effect.fn(function* ({
242 id,
243 instanceId,
244 news,
245 output,
246 session,
247 }) {
248 const name = output?.profileName ?? (yield* createName(id, news));
249
250 // OBSERVE — output is only an id cache; fall back to a lookup by
251 // deterministic name so state loss / adoption converges.
252 let live = output?.profileId
253 ? yield* observe(output.profileId)
254 : undefined;
255 if (!live) {
256 const found = yield* findByName(name);
257 live = found?.Id ? yield* observe(found.Id) : undefined;
258 }
259
260 // ENSURE — the instance ID is the idempotency token, so a crashed
261 // reconcile that re-runs the create is a no-op returning the same
262 // Profile.
263 if (!live) {
264 const created = yield* profiles.createProfile({
265 Name: name,
266 ClientToken: instanceId,
267 });
268 live = created.Profile!;
269 }
270
271 // Profiles settle to COMPLETE within seconds.
272 const settled = yield* untilProfileSettled(observe(live.Id!));
273 live = settled ?? live;
274
275 // SYNC tags against observed cloud tags.
276 yield* syncTags(id, live.Arn!, news?.tags);
277
278 yield* session.note(live.Id!);
279 return {
280 profileId: live.Id!,
281 profileArn: live.Arn!,
282 profileName: live.Name ?? name,
283 };
284 }),
285 delete: Effect.fn(function* ({ output }) {
286 // VPC disassociations drain asynchronously; DeleteProfile conflicts
287 // until they finish, so retry on a bounded schedule.
288 yield* retryDeleteConflict(
289 profiles.deleteProfile({ ProfileId: output.profileId }),
290 ).pipe(
291 Effect.asVoid,
292 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
293 );
294 }),
295 });
296 }),
297 );