1import * as sd from "@distilled.cloud/aws/servicediscovery";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { toWireSeconds } from "../../Util/Duration.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 awaitOperation,
15 ensureNamespace,
16 fetchObservedTags,
17 observeNamespace,
18 retryWhileResourceInUse,
19 syncTags,
20} from "./internal.ts";
21
22export interface PublicDnsNamespaceProps {
23 /**
24 * Name of the namespace — must be a domain name you intend to serve
25 * publicly (a Route 53 public hosted zone is created for it). Changing the
26 * name replaces the namespace.
27 * @default a generated DNS-compatible physical name
28 */
29 name?: string;
30 /**
31 * A description for the namespace.
32 */
33 description?: string;
34 /**
35 * The TTL of the SOA record for the namespace's Route 53 public hosted
36 * zone (e.g. `"60 seconds"` or `Duration.seconds(60)`; a bare number is
37 * milliseconds).
38 */
39 ttl?: Duration.Input;
40 /**
41 * Tags to apply to the namespace. Merged with internal Alchemy tags.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface PublicDnsNamespace extends Resource<
47 "AWS.CloudMap.PublicDnsNamespace",
48 PublicDnsNamespaceProps,
49 {
50 /**
51 * The unique identifier of the namespace.
52 */
53 namespaceId: string;
54 /**
55 * The ARN of the namespace.
56 */
57 namespaceArn: string;
58 /**
59 * Name of the namespace (the public DNS domain).
60 */
61 namespaceName: string;
62 /**
63 * The public Route 53 hosted zone Cloud Map created for the namespace.
64 */
65 hostedZoneId: string | undefined;
66 },
67 {},
68 Providers
69> {}
70
71/**
72 * An AWS Cloud Map public DNS namespace — a service registry backed by a
73 * Route 53 **public** hosted zone, so registered instances are discoverable
74 * on the public internet via DNS as well as via the `DiscoverInstances` API.
75 *
76 * The hosted zone incurs standard Route 53 charges while the namespace
77 * exists, and the namespace name is only useful if you control the domain.
78 *
79 * Namespace creation and deletion are asynchronous — the provider polls the
80 * Cloud Map operations API (bounded) until they complete.
81 * ### Creating Namespaces
82 * **Example:** Public DNS Namespace
83 * ```typescript
84 * import * as AWS from "alchemy/AWS";
85 *
86 * const namespace = yield* AWS.CloudMap.PublicDnsNamespace("PublicNamespace", {
87 * name: "discovery.example.com",
88 * });
89 * ```
90 *
91 * @resource
92 */
93export const PublicDnsNamespace = Resource<PublicDnsNamespace>(
94 "AWS.CloudMap.PublicDnsNamespace",
95);
96
97export const PublicDnsNamespaceProvider = () =>
98 Provider.effect(
99 PublicDnsNamespace,
100 Effect.gen(function* () {
101 const createName = Effect.fn(function* (
102 id: string,
103 props: { name?: string | undefined },
104 ) {
105 return (
106 props.name ??
107 (yield* createPhysicalName({ id, maxLength: 253, lowercase: true }))
108 );
109 });
110
111 const toAttributes = (namespace: sd.Namespace) => ({
112 namespaceId: namespace.Id!,
113 namespaceArn: namespace.Arn!,
114 namespaceName: namespace.Name!,
115 hostedZoneId: namespace.Properties?.DnsProperties?.HostedZoneId,
116 });
117
118 return PublicDnsNamespace.Provider.of({
119 stables: ["namespaceId", "namespaceArn", "namespaceName"],
120
121 list: () =>
122 Effect.gen(function* () {
123 const pages = yield* sd.listNamespaces
124 .pages({
125 Filters: [
126 { Name: "TYPE", Values: ["DNS_PUBLIC"], Condition: "EQ" },
127 ],
128 })
129 .pipe(Stream.runCollect);
130 return Array.from(pages)
131 .flatMap((page) => page.Namespaces ?? [])
132 .filter(
133 (n) =>
134 n.Id !== undefined &&
135 n.Arn !== undefined &&
136 n.Name !== undefined,
137 )
138 .map((n) => ({
139 namespaceId: n.Id!,
140 namespaceArn: n.Arn!,
141 namespaceName: n.Name!,
142 hostedZoneId: n.Properties?.DnsProperties?.HostedZoneId,
143 }));
144 }),
145
146 read: Effect.fn(function* ({ id, olds, output }) {
147 const name =
148 output?.namespaceName ?? (yield* createName(id, olds ?? {}));
149 const namespace = yield* observeNamespace(
150 "DNS_PUBLIC",
151 name,
152 output?.namespaceId,
153 );
154 if (namespace?.Id === undefined) {
155 return undefined;
156 }
157 const attrs = toAttributes(namespace);
158 const tags = yield* fetchObservedTags(attrs.namespaceArn);
159 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
160 }),
161
162 diff: Effect.fn(function* ({ id, news = {}, olds = {} }) {
163 if (!isResolved(news)) return undefined;
164 const oldName = yield* createName(id, olds);
165 const newName = yield* createName(id, news);
166 if (oldName !== newName) {
167 return { action: "replace" } as const;
168 }
169 // description/ttl/tags fall through to the default update path
170 }),
171
172 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
173 const name = output?.namespaceName ?? (yield* createName(id, news));
174 const internalTags = yield* createInternalTags(id);
175 const desiredTags = { ...news.tags, ...internalTags };
176 const desiredTtl = toWireSeconds(news.ttl);
177
178 // 1. OBSERVE
179 let namespace = yield* observeNamespace(
180 "DNS_PUBLIC",
181 name,
182 output?.namespaceId,
183 );
184
185 // 2. ENSURE — create if missing (async operation); the created
186 // namespace is observed by the operation's target id, riding out
187 // a same-name predecessor still deleting (see ensureNamespace)
188 if (namespace === undefined) {
189 yield* session.note(
190 `creating public DNS namespace ${name} (async)...`,
191 );
192 namespace = yield* ensureNamespace(
193 "DNS_PUBLIC",
194 name,
195 sd.createPublicDnsNamespace({
196 Name: name,
197 Description: news.description,
198 Properties:
199 desiredTtl !== undefined
200 ? { DnsProperties: { SOA: { TTL: desiredTtl } } }
201 : undefined,
202 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
203 Key,
204 Value,
205 })),
206 }),
207 );
208 }
209 if (namespace?.Id === undefined) {
210 return yield* Effect.fail(
211 new sd.NamespaceNotFound({
212 message: `namespace ${name} not visible after create`,
213 }),
214 );
215 }
216
217 // 3. SYNC — description + SOA TTL, observed vs desired
218 const observedTtl = namespace.Properties?.DnsProperties?.SOA?.TTL;
219 const descriptionDelta =
220 news.description !== undefined &&
221 news.description !== namespace.Description;
222 const ttlDelta =
223 desiredTtl !== undefined && desiredTtl !== observedTtl;
224 if (descriptionDelta || ttlDelta) {
225 const update = yield* sd.updatePublicDnsNamespace({
226 Id: namespace.Id,
227 Namespace: {
228 Description: descriptionDelta ? news.description : undefined,
229 Properties: ttlDelta
230 ? { DnsProperties: { SOA: { TTL: desiredTtl! } } }
231 : undefined,
232 },
233 });
234 if (update.OperationId !== undefined) {
235 yield* awaitOperation(update.OperationId);
236 }
237 }
238
239 // 3b. SYNC TAGS — diff against OBSERVED cloud tags
240 const observedTags = yield* fetchObservedTags(namespace.Arn!);
241 yield* syncTags(namespace.Arn!, observedTags, desiredTags);
242
243 yield* session.note(namespace.Id);
244 return toAttributes(namespace);
245 }),
246
247 delete: Effect.fn(function* ({ output }) {
248 const deleted = yield* retryWhileResourceInUse(
249 sd.deleteNamespace({ Id: output.namespaceId }),
250 ).pipe(
251 Effect.catchTag("NamespaceNotFound", () =>
252 Effect.succeed({ OperationId: undefined }),
253 ),
254 // an identical delete is already in flight — await THAT operation
255 // instead of silently skipping the deletion
256 Effect.catchTag("DuplicateRequest", (e) =>
257 Effect.succeed({ OperationId: e.DuplicateOperationId }),
258 ),
259 );
260 if (deleted.OperationId !== undefined) {
261 yield* awaitOperation(deleted.OperationId);
262 }
263 }),
264 });
265 }),
266 );