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 PrivateDnsNamespaceProps {
23 /**
24 * Name of the namespace — services are discoverable in DNS as
25 * `{service}.{name}` inside the associated VPC (e.g. `internal.example.com`
26 * yields `backend.internal.example.com`). Changing the name replaces the
27 * namespace.
28 * @default a generated DNS-compatible physical name
29 */
30 name?: string;
31 /**
32 * The ID of the Amazon VPC that the private DNS namespace is associated
33 * with (a Route 53 private hosted zone is created and associated with it).
34 * Changing the VPC replaces the namespace.
35 */
36 vpc: string;
37 /**
38 * A description for the namespace.
39 */
40 description?: string;
41 /**
42 * The TTL of the SOA record for the namespace's Route 53 private hosted
43 * zone (e.g. `"60 seconds"` or `Duration.seconds(60)`; a bare number is
44 * milliseconds).
45 */
46 ttl?: Duration.Input;
47 /**
48 * Tags to apply to the namespace. Merged with internal Alchemy tags.
49 */
50 tags?: Record<string, string>;
51}
52
53export interface PrivateDnsNamespace extends Resource<
54 "AWS.CloudMap.PrivateDnsNamespace",
55 PrivateDnsNamespaceProps,
56 {
57 /**
58 * The unique identifier of the namespace.
59 */
60 namespaceId: string;
61 /**
62 * The ARN of the namespace.
63 */
64 namespaceArn: string;
65 /**
66 * Name of the namespace (the private DNS domain, e.g. `internal.example`).
67 */
68 namespaceName: string;
69 /**
70 * The private Route 53 hosted zone Cloud Map created for the namespace.
71 */
72 hostedZoneId: string | undefined;
73 },
74 {},
75 Providers
76> {}
77
78/**
79 * An AWS Cloud Map private DNS namespace — the DNS-based service registry
80 * that ECS Service Connect and `serviceRegistries` point at. Services
81 * registered in the namespace are discoverable inside the associated VPC via
82 * DNS (`{service}.{namespace}`) and from anywhere via the
83 * `DiscoverInstances` API.
84 *
85 * Namespace creation and deletion are asynchronous — the provider polls the
86 * Cloud Map operations API (bounded) until they complete, which typically
87 * takes 30-60 seconds.
88 * ### Creating Namespaces
89 * **Example:** Private DNS Namespace in a VPC
90 * ```typescript
91 * import * as AWS from "alchemy/AWS";
92 *
93 * const vpc = yield* AWS.EC2.Vpc("AppVpc", { cidrBlock: "10.0.0.0/16" });
94 * const namespace = yield* AWS.CloudMap.PrivateDnsNamespace("AppNamespace", {
95 * name: "internal.example.com",
96 * vpc: vpc.vpcId,
97 * });
98 * ```
99 *
100 * **Example:** Namespace with SOA TTL and Description
101 * ```typescript
102 * const namespace = yield* AWS.CloudMap.PrivateDnsNamespace("AppNamespace", {
103 * name: "internal.example.com",
104 * vpc: vpc.vpcId,
105 * description: "service discovery for the app tier",
106 * ttl: "60 seconds",
107 * });
108 * ```
109 *
110 * ### Registering Services
111 * **Example:** Service with A Records
112 * ```typescript
113 * const service = yield* AWS.CloudMap.Service("Backend", {
114 * namespaceId: namespace.namespaceId,
115 * dnsRecords: [{ type: "A", ttl: "10 seconds" }],
116 * routingPolicy: "MULTIVALUE",
117 * });
118 * ```
119 *
120 * @resource
121 */
122export const PrivateDnsNamespace = Resource<PrivateDnsNamespace>(
123 "AWS.CloudMap.PrivateDnsNamespace",
124);
125
126export const PrivateDnsNamespaceProvider = () =>
127 Provider.effect(
128 PrivateDnsNamespace,
129 Effect.gen(function* () {
130 const createName = Effect.fn(function* (
131 id: string,
132 props: { name?: string | undefined },
133 ) {
134 return (
135 props.name ??
136 (yield* createPhysicalName({ id, maxLength: 253, lowercase: true }))
137 );
138 });
139
140 const toAttributes = (namespace: sd.Namespace) => ({
141 namespaceId: namespace.Id!,
142 namespaceArn: namespace.Arn!,
143 namespaceName: namespace.Name!,
144 hostedZoneId: namespace.Properties?.DnsProperties?.HostedZoneId,
145 });
146
147 return PrivateDnsNamespace.Provider.of({
148 stables: ["namespaceId", "namespaceArn", "namespaceName"],
149
150 list: () =>
151 Effect.gen(function* () {
152 const pages = yield* sd.listNamespaces
153 .pages({
154 Filters: [
155 { Name: "TYPE", Values: ["DNS_PRIVATE"], Condition: "EQ" },
156 ],
157 })
158 .pipe(Stream.runCollect);
159 return Array.from(pages)
160 .flatMap((page) => page.Namespaces ?? [])
161 .filter(
162 (n) =>
163 n.Id !== undefined &&
164 n.Arn !== undefined &&
165 n.Name !== undefined,
166 )
167 .map((n) => ({
168 namespaceId: n.Id!,
169 namespaceArn: n.Arn!,
170 namespaceName: n.Name!,
171 hostedZoneId: n.Properties?.DnsProperties?.HostedZoneId,
172 }));
173 }),
174
175 read: Effect.fn(function* ({ id, olds, output }) {
176 const name =
177 output?.namespaceName ?? (yield* createName(id, olds ?? {}));
178 const namespace = yield* observeNamespace(
179 "DNS_PRIVATE",
180 name,
181 output?.namespaceId,
182 );
183 if (namespace?.Id === undefined) {
184 return undefined;
185 }
186 const attrs = toAttributes(namespace);
187 const tags = yield* fetchObservedTags(attrs.namespaceArn);
188 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
189 }),
190
191 diff: Effect.fn(function* ({ id, news, olds }) {
192 if (!isResolved(news)) return undefined;
193 const oldName = yield* createName(id, olds);
194 const newName = yield* createName(id, news);
195 if (oldName !== newName) {
196 return { action: "replace" } as const;
197 }
198 if (olds.vpc !== news.vpc) {
199 return { action: "replace" } as const;
200 }
201 // description/ttl/tags fall through to the default update path
202 }),
203
204 reconcile: Effect.fn(function* ({ id, news, output, session }) {
205 const name = output?.namespaceName ?? (yield* createName(id, news));
206 const internalTags = yield* createInternalTags(id);
207 const desiredTags = { ...news.tags, ...internalTags };
208 const desiredTtl = toWireSeconds(news.ttl);
209
210 // 1. OBSERVE — cloud state is authoritative; output is an id cache
211 let namespace = yield* observeNamespace(
212 "DNS_PRIVATE",
213 name,
214 output?.namespaceId,
215 );
216
217 // 2. ENSURE — create if missing; namespace creation is async. The
218 // created namespace is observed by the operation's target id,
219 // riding out a same-name predecessor still deleting
220 if (namespace === undefined) {
221 yield* session.note(
222 `creating private DNS namespace ${name} (async)...`,
223 );
224 namespace = yield* ensureNamespace(
225 "DNS_PRIVATE",
226 name,
227 sd.createPrivateDnsNamespace({
228 Name: name,
229 Vpc: news.vpc,
230 Description: news.description,
231 Properties:
232 desiredTtl !== undefined
233 ? { DnsProperties: { SOA: { TTL: desiredTtl } } }
234 : undefined,
235 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
236 Key,
237 Value,
238 })),
239 }),
240 );
241 }
242 if (namespace?.Id === undefined) {
243 return yield* Effect.fail(
244 new sd.NamespaceNotFound({
245 message: `namespace ${name} not visible after create`,
246 }),
247 );
248 }
249
250 // 3. SYNC — description + SOA TTL, observed vs desired
251 const observedTtl = namespace.Properties?.DnsProperties?.SOA?.TTL;
252 const descriptionDelta =
253 news.description !== undefined &&
254 news.description !== namespace.Description;
255 const ttlDelta =
256 desiredTtl !== undefined && desiredTtl !== observedTtl;
257 if (descriptionDelta || ttlDelta) {
258 const update = yield* sd.updatePrivateDnsNamespace({
259 Id: namespace.Id,
260 Namespace: {
261 Description: descriptionDelta ? news.description : undefined,
262 Properties: ttlDelta
263 ? { DnsProperties: { SOA: { TTL: desiredTtl! } } }
264 : undefined,
265 },
266 });
267 if (update.OperationId !== undefined) {
268 yield* awaitOperation(update.OperationId);
269 }
270 }
271
272 // 3b. SYNC TAGS — diff against OBSERVED cloud tags
273 const observedTags = yield* fetchObservedTags(namespace.Arn!);
274 yield* syncTags(namespace.Arn!, observedTags, desiredTags);
275
276 yield* session.note(namespace.Id);
277 return toAttributes(namespace);
278 }),
279
280 delete: Effect.fn(function* ({ output }) {
281 const deleted = yield* retryWhileResourceInUse(
282 sd.deleteNamespace({ Id: output.namespaceId }),
283 ).pipe(
284 Effect.catchTag("NamespaceNotFound", () =>
285 Effect.succeed({ OperationId: undefined }),
286 ),
287 // an identical delete is already in flight — await THAT operation
288 // instead of silently skipping the deletion
289 Effect.catchTag("DuplicateRequest", (e) =>
290 Effect.succeed({ OperationId: e.DuplicateOperationId }),
291 ),
292 );
293 if (deleted.OperationId !== undefined) {
294 yield* awaitOperation(deleted.OperationId);
295 }
296 }),
297 });
298 }),
299 );