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