1import * as sd from "@distilled.cloud/aws/servicediscovery";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { diffTags } from "../../Tags.ts";
6
7/**
8 * Raised when an asynchronous Cloud Map operation (namespace create/delete,
9 * instance register/deregister, service update) terminates with `FAIL` or
10 * fails to reach a terminal status within the polling budget.
11 */
12export class CloudMapOperationFailed extends Data.TaggedError(
13 "CloudMapOperationFailed",
14)<{
15 readonly operationId: string;
16 readonly status: string | undefined;
17 readonly errorCode: string | undefined;
18 readonly errorMessage: string | undefined;
19}> {}
20
21/**
22 * Repeat a `getOperation` poll until the operation reaches a terminal status
23 * (`SUCCESS` | `FAIL`), spaced 2s and bounded at 60 repetitions (~2 minutes —
24 * namespace operations typically take 30-60s).
25 *
26 * Expressed as an explicitly-typed helper: inlining `Effect.repeat` in
27 * lifecycle code leaves its conditional return type unresolved in the
28 * provider's declaration emit, which widens the `AWS.providers()` layer type
29 * for every downstream consumer.
30 */
31const untilTerminalStatus = <E, R>(
32 self: Effect.Effect<sd.GetOperationResponse, E, R>,
33): Effect.Effect<sd.Operation | undefined, E, R> =>
34 Effect.repeat(
35 Effect.map(self, (response) => response.Operation),
36 {
37 schedule: Schedule.spaced("2 seconds"),
38 until: (operation) =>
39 operation?.Status === "SUCCESS" || operation?.Status === "FAIL",
40 times: 60,
41 },
42 );
43
44/**
45 * Await a Cloud Map async operation: poll `getOperation` (bounded) until it
46 * reaches a terminal status and fail with `CloudMapOperationFailed` unless
47 * that status is `SUCCESS`. Returns the terminal `Operation` (whose
48 * `Targets` map carries the created NAMESPACE/SERVICE/INSTANCE ids).
49 */
50export const awaitOperation = Effect.fn("AWS.CloudMap.awaitOperation")(
51 function* (operationId: string) {
52 const operation = yield* untilTerminalStatus(
53 sd.getOperation({ OperationId: operationId }),
54 );
55 if (operation?.Status !== "SUCCESS") {
56 return yield* Effect.fail(
57 new CloudMapOperationFailed({
58 operationId,
59 status: operation?.Status,
60 errorCode: operation?.ErrorCode,
61 errorMessage: operation?.ErrorMessage,
62 }),
63 );
64 }
65 return operation;
66 },
67);
68
69/**
70 * Bounded retry through transient `ResourceInUse` dependency violations —
71 * e.g. deleting a namespace while its last service deletion is still
72 * propagating, or deleting a service while an instance deregistration is in
73 * flight. Explicitly typed for the same declaration-emit reason as above.
74 */
75export const retryWhileResourceInUse = <A, E extends { _tag: string }, R>(
76 self: Effect.Effect<A, E, R>,
77): Effect.Effect<A, E, R> =>
78 Effect.retry(self, {
79 while: (e) => e._tag === "ResourceInUse",
80 schedule: Schedule.max([Schedule.fixed("5 seconds"), Schedule.recurs(20)]),
81 });
82
83/**
84 * Deregister every instance still registered on a service and await the
85 * async deregistration operations. Instances registered at runtime (via the
86 * `RegisterInstance` binding) are data-plane ephemera owned by the service —
87 * they would otherwise block `DeleteService` with `ResourceInUse` forever.
88 * Deregistrations already in flight surface as `DuplicateRequest` and are
89 * tolerated; `DeleteService`'s own `ResourceInUse` retry rides out the
90 * remaining visibility window.
91 */
92export const deregisterAllInstances = Effect.fn(
93 "AWS.CloudMap.deregisterAllInstances",
94)(function* (serviceId: string) {
95 const operationIds: string[] = [];
96 let nextToken: string | undefined;
97 do {
98 const page = yield* sd.listInstances({
99 ServiceId: serviceId,
100 NextToken: nextToken,
101 });
102 for (const instance of page.Instances ?? []) {
103 if (instance.Id === undefined) {
104 continue;
105 }
106 const response = yield* sd
107 .deregisterInstance({ ServiceId: serviceId, InstanceId: instance.Id })
108 .pipe(
109 // already gone / a deregistration already in flight — both mean
110 // the instance is on its way out
111 Effect.catchTag(["InstanceNotFound", "DuplicateRequest"], () =>
112 Effect.succeed(undefined),
113 ),
114 );
115 if (response?.OperationId !== undefined) {
116 operationIds.push(response.OperationId);
117 }
118 }
119 nextToken = page.NextToken;
120 } while (nextToken !== undefined);
121 for (const operationId of operationIds) {
122 yield* awaitOperation(operationId);
123 }
124});
125
126export type NamespaceKind = "DNS_PRIVATE" | "DNS_PUBLIC" | "HTTP";
127
128/** Find a namespace of the given type by exact name via the list API. */
129export const findNamespaceByName = Effect.fn(
130 "AWS.CloudMap.findNamespaceByName",
131)(function* (type: NamespaceKind, name: string) {
132 const response = yield* sd.listNamespaces({
133 Filters: [
134 { Name: "TYPE", Values: [type], Condition: "EQ" },
135 { Name: "NAME", Values: [name], Condition: "EQ" },
136 ],
137 });
138 return response.Namespaces?.[0];
139});
140
141/**
142 * Observe a namespace: by cached id when we have one (tolerating out-of-band
143 * deletion), falling back to an exact-name list lookup.
144 */
145export const observeNamespace = Effect.fn("AWS.CloudMap.observeNamespace")(
146 function* (
147 type: NamespaceKind,
148 name: string,
149 namespaceId: string | undefined,
150 ) {
151 if (namespaceId !== undefined) {
152 const byId = yield* sd.getNamespace({ Id: namespaceId }).pipe(
153 Effect.map((r) => r.Namespace),
154 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined)),
155 );
156 if (byId !== undefined) {
157 return byId;
158 }
159 }
160 const summary = yield* findNamespaceByName(type, name);
161 if (summary?.Id === undefined) {
162 return undefined;
163 }
164 // hydrate the summary into the full Namespace shape (keeps one return
165 // type; also tolerates a delete race between list and get)
166 return yield* sd.getNamespace({ Id: summary.Id }).pipe(
167 Effect.map((r) => r.Namespace),
168 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(undefined)),
169 );
170 },
171);
172
173/**
174 * Ensure a namespace exists: submit `create`, await its async operation, and
175 * observe the created namespace **by the operation's target id** — the
176 * `listNamespaces` name lookup is eventually consistent and can miss a
177 * namespace created milliseconds earlier.
178 *
179 * A same-name predecessor still mid-deletion (e.g. the old physical resource
180 * of a replacement, or an out-of-band cleaner) surfaces the create as
181 * `NamespaceAlreadyExists` while remaining invisible to observation; the
182 * whole create+observe sequence retries (bounded, ~40s) until the deletion
183 * completes and the create goes through. A `DuplicateRequest` means an
184 * identical create is already in flight — its operation is awaited instead.
185 */
186const retryWhileNamespaceNotVisible = <A, E extends { _tag: string }, R>(
187 self: Effect.Effect<A, E, R>,
188): Effect.Effect<A, E, R> =>
189 Effect.retry(self, {
190 while: (e) => e._tag === "NamespaceNotFound",
191 schedule: Schedule.max([Schedule.spaced("5 seconds"), Schedule.recurs(8)]),
192 });
193
194/**
195 * The (identical) typed error union of the three namespace create operations.
196 * Kept concrete — a generic error parameter here would flow into
197 * `Effect.catchTag`, leaving an un-narrowable `{ _tag: unknown }` residue in
198 * the handler under TypeScript 7.
199 */
200type CreateNamespaceError =
201 | sd.CreateHttpNamespaceError
202 | sd.CreatePrivateDnsNamespaceError
203 | sd.CreatePublicDnsNamespaceError;
204
205export const ensureNamespace = <R>(
206 type: NamespaceKind,
207 name: string,
208 create: Effect.Effect<
209 { OperationId?: string | undefined },
210 CreateNamespaceError,
211 R
212 >,
213) =>
214 retryWhileNamespaceNotVisible(
215 Effect.gen(function* () {
216 const created: {
217 OperationId?: string | undefined;
218 NamespaceId?: string | undefined;
219 } = yield* create.pipe(
220 Effect.catchTag(["NamespaceAlreadyExists", "DuplicateRequest"], (e) =>
221 e._tag === "NamespaceAlreadyExists"
222 ? // the name is taken — by an ACTIVE namespace (observed below)
223 // or by one still deleting (observation misses it and the
224 // bounded outer retry re-submits the create)
225 Effect.succeed({
226 OperationId: undefined,
227 NamespaceId: e.NamespaceId,
228 })
229 : // an identical create is already in flight — await THAT one
230 Effect.succeed({
231 OperationId: e.DuplicateOperationId,
232 NamespaceId: undefined,
233 }),
234 ),
235 );
236 let namespaceId = created.NamespaceId;
237 if (created.OperationId !== undefined) {
238 const operation = yield* awaitOperation(created.OperationId);
239 namespaceId = operation?.Targets?.NAMESPACE ?? namespaceId;
240 }
241 const namespace = yield* observeNamespace(type, name, namespaceId);
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 return namespace;
250 }),
251 );
252
253/**
254 * Fetch the observed Cloud Map tags for a resource ARN as a plain record,
255 * tolerating a missing resource (race during create/delete) as `{}`.
256 */
257export const fetchObservedTags = Effect.fn("AWS.CloudMap.fetchObservedTags")(
258 function* (resourceArn: string) {
259 const tags = yield* sd
260 .listTagsForResource({ ResourceARN: resourceArn })
261 .pipe(
262 Effect.map((response) => response.Tags ?? []),
263 Effect.catch(() => Effect.succeed([] as sd.Tag[])),
264 );
265 return Object.fromEntries(tags.map((tag) => [tag.Key, tag.Value]));
266 },
267);
268
269/**
270 * Sync a Cloud Map resource's tags: diff the OBSERVED cloud tags against the
271 * desired set and apply only the delta via tagResource/untagResource.
272 */
273export const syncTags = Effect.fn("AWS.CloudMap.syncTags")(function* (
274 resourceArn: string,
275 observed: Record<string, string>,
276 desired: Record<string, string>,
277) {
278 const { upsert, removed } = diffTags(observed, desired);
279 if (upsert.length > 0) {
280 yield* sd.tagResource({ ResourceARN: resourceArn, Tags: upsert });
281 }
282 if (removed.length > 0) {
283 yield* sd.untagResource({ ResourceARN: resourceArn, TagKeys: removed });
284 }
285});