1import * as sd from "@distilled.cloud/aws/servicediscovery";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.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 { toWireSeconds } from "../../Util/Duration.ts";
13import type { Providers } from "../Providers.ts";
14import {
15 awaitOperation,
16 deregisterAllInstances,
17 fetchObservedTags,
18 retryWhileResourceInUse,
19 syncTags,
20} from "./internal.ts";
21
22export interface ServiceDnsRecord {
23 /**
24 * The DNS record type Cloud Map creates in the namespace's hosted zone
25 * when an instance is registered. Changing the set of record types
26 * replaces the service.
27 */
28 type: "A" | "AAAA" | "SRV" | "CNAME";
29 /**
30 * The TTL of the DNS record (e.g. `"10 seconds"` or
31 * `Duration.seconds(10)`; a bare number is milliseconds). Mutable.
32 */
33 ttl: Duration.Input;
34}
35
36export interface ServiceProps {
37 /**
38 * Name of the service — for DNS namespaces this becomes the DNS label
39 * (`{name}.{namespace}`). Changing the name replaces the service.
40 * @default a generated DNS-compatible physical name
41 */
42 name?: string;
43 /**
44 * The ID of the namespace to register the service in. Changing the
45 * namespace replaces the service.
46 */
47 namespaceId: string;
48 /**
49 * A description for the service.
50 */
51 description?: string;
52 /**
53 * DNS records Cloud Map creates when instances are registered. Omit for
54 * API-only (HTTP namespace) services. TTLs are mutable; changing the set
55 * of record types replaces the service.
56 */
57 dnsRecords?: ServiceDnsRecord[];
58 /**
59 * How Route 53 responds to DNS queries: `MULTIVALUE` returns up to eight
60 * healthy records, `WEIGHTED` returns one. Create-only — changing it
61 * replaces the service.
62 * @default "MULTIVALUE"
63 */
64 routingPolicy?: "MULTIVALUE" | "WEIGHTED";
65 /**
66 * A Route 53 health check for instances (public DNS namespaces only).
67 * Mutually exclusive with `healthCheckCustomConfig`.
68 */
69 healthCheckConfig?: {
70 /** The endpoint protocol Route 53 health-checks: HTTP, HTTPS, or TCP. */
71 type: "HTTP" | "HTTPS" | "TCP";
72 /** The path Route 53 requests, e.g. `/health` (HTTP/HTTPS only). */
73 resourcePath?: string;
74 /** Consecutive failures before the instance is considered unhealthy. */
75 failureThreshold?: number;
76 };
77 /**
78 * Custom health checking — instance health is pushed via
79 * `UpdateInstanceCustomHealthStatus` instead of probed by Route 53.
80 * Create-only: it cannot be added, changed, or removed after creation, so
81 * any change replaces the service.
82 */
83 healthCheckCustomConfig?: {
84 /** Deprecated by AWS (always behaves as 1); retained for parity. */
85 failureThreshold?: number;
86 };
87 /**
88 * Set to `"HTTP"` to force API-only discovery even inside a DNS namespace.
89 * Create-only — changing it replaces the service.
90 */
91 type?: "HTTP";
92 /**
93 * Custom service-level attributes (up to 30 key/value pairs) stored on the
94 * service and readable at runtime via `GetServiceAttributes`. Mutable —
95 * reconcile diffs the observed attributes against this map, upserting
96 * changed keys and deleting keys no longer declared.
97 */
98 attributes?: Record<string, string>;
99 /**
100 * Tags to apply to the service. Merged with internal Alchemy tags.
101 */
102 tags?: Record<string, string>;
103}
104
105export interface Service extends Resource<
106 "AWS.CloudMap.Service",
107 ServiceProps,
108 {
109 /**
110 * The unique identifier of the service.
111 */
112 serviceId: string;
113 /**
114 * The ARN of the service.
115 */
116 serviceArn: string;
117 /**
118 * Name of the service.
119 */
120 serviceName: string;
121 /**
122 * The namespace the service belongs to.
123 */
124 namespaceId: string;
125 /**
126 * Name of the namespace the service belongs to.
127 */
128 namespaceName: string;
129 },
130 {},
131 Providers
132> {}
133
134/**
135 * An AWS Cloud Map service — a named entry in a namespace that instances
136 * register against. For DNS namespaces, Cloud Map creates the configured DNS
137 * records per registered instance; every service is also queryable via the
138 * `DiscoverInstances` API. This is what ECS `serviceRegistries[].registryArn`
139 * consumes.
140 * ### Creating Services
141 * **Example:** DNS Service with A Records
142 * ```typescript
143 * import * as AWS from "alchemy/AWS";
144 *
145 * const namespace = yield* AWS.CloudMap.PrivateDnsNamespace("AppNamespace", {
146 * name: "internal.example.com",
147 * vpc: vpc.vpcId,
148 * });
149 *
150 * const service = yield* AWS.CloudMap.Service("Backend", {
151 * namespaceId: namespace.namespaceId,
152 * dnsRecords: [{ type: "A", ttl: "10 seconds" }],
153 * routingPolicy: "MULTIVALUE",
154 * });
155 * ```
156 *
157 * **Example:** API-only Service in an HTTP Namespace
158 * ```typescript
159 * const namespace = yield* AWS.CloudMap.HttpNamespace("AppNamespace");
160 * const service = yield* AWS.CloudMap.Service("Backend", {
161 * namespaceId: namespace.namespaceId,
162 * });
163 * ```
164 *
165 * **Example:** Service with Custom Health Checks
166 * ```typescript
167 * const service = yield* AWS.CloudMap.Service("Backend", {
168 * namespaceId: namespace.namespaceId,
169 * dnsRecords: [{ type: "SRV", ttl: "10 seconds" }],
170 * healthCheckCustomConfig: {},
171 * });
172 * ```
173 *
174 * **Example:** Service with Custom Attributes
175 * ```typescript
176 * const service = yield* AWS.CloudMap.Service("Backend", {
177 * namespaceId: namespace.namespaceId,
178 * attributes: { tier: "backend", version: "2" },
179 * });
180 * ```
181 *
182 * ### Discovering Instances
183 * **Example:** Discover Healthy Instances from a Lambda
184 * ```typescript
185 * // init
186 * const discover = yield* AWS.CloudMap.DiscoverInstances(service);
187 *
188 * // runtime
189 * const { Instances } = yield* discover({ HealthStatus: "HEALTHY" });
190 * ```
191 *
192 * @resource
193 */
194export const Service = Resource<Service>("AWS.CloudMap.Service");
195
196export const ServiceProvider = () =>
197 Provider.effect(
198 Service,
199 Effect.gen(function* () {
200 const createName = Effect.fn(function* (
201 id: string,
202 props: { name?: string | undefined },
203 ) {
204 // 63 = the Route 53 per-label limit; the service name becomes the
205 // DNS label `{name}.{namespace}` for DNS namespaces
206 return (
207 props.name ??
208 (yield* createPhysicalName({ id, maxLength: 63, lowercase: true }))
209 );
210 });
211
212 /** Observe by cached id, falling back to name lookup in the namespace. */
213 const observeService = Effect.fn(function* (
214 namespaceId: string,
215 name: string,
216 serviceId: string | undefined,
217 ) {
218 if (serviceId !== undefined) {
219 const byId = yield* sd.getService({ Id: serviceId }).pipe(
220 Effect.map((r) => r.Service),
221 Effect.catchTag("ServiceNotFound", () => Effect.succeed(undefined)),
222 );
223 if (byId !== undefined) {
224 return byId;
225 }
226 }
227 const summary = yield* sd.listServices
228 .pages({
229 Filters: [
230 { Name: "NAMESPACE_ID", Values: [namespaceId], Condition: "EQ" },
231 ],
232 })
233 .pipe(
234 Stream.map((page) => page.Services ?? []),
235 Stream.flattenIterable,
236 Stream.filter((s) => s.Name === name),
237 Stream.runHead,
238 Effect.map(Option.getOrUndefined),
239 );
240 if (summary?.Id === undefined) {
241 return undefined;
242 }
243 return yield* sd.getService({ Id: summary.Id }).pipe(
244 Effect.map((r) => r.Service),
245 Effect.catchTag("ServiceNotFound", () => Effect.succeed(undefined)),
246 );
247 });
248
249 const resolveNamespaceName = Effect.fn(function* (namespaceId: string) {
250 const namespace = yield* sd.getNamespace({ Id: namespaceId });
251 return namespace.Namespace?.Name ?? "";
252 });
253
254 const toDesiredDnsConfig = (props: ServiceProps) =>
255 props.dnsRecords !== undefined
256 ? {
257 DnsRecords: props.dnsRecords.map((record) => ({
258 Type: record.type,
259 TTL: toWireSeconds(record.ttl)!,
260 })),
261 RoutingPolicy: props.routingPolicy,
262 }
263 : undefined;
264
265 const toDesiredHealthCheckConfig = (props: ServiceProps) =>
266 props.healthCheckConfig !== undefined
267 ? {
268 Type: props.healthCheckConfig.type,
269 ResourcePath: props.healthCheckConfig.resourcePath,
270 FailureThreshold: props.healthCheckConfig.failureThreshold,
271 }
272 : undefined;
273
274 const recordKey = (records: { Type: string; TTL: number }[]) =>
275 records
276 .map((r) => `${r.Type}:${r.TTL}`)
277 .sort()
278 .join(",");
279
280 const recordTypeKey = (records: { type: string }[] | undefined) =>
281 (records ?? [])
282 .map((r) => r.type)
283 .sort()
284 .join(",");
285
286 return Service.Provider.of({
287 stables: [
288 "serviceId",
289 "serviceArn",
290 "serviceName",
291 "namespaceId",
292 "namespaceName",
293 ],
294
295 list: () =>
296 Effect.gen(function* () {
297 const pages = yield* sd.listServices
298 .pages({})
299 .pipe(Stream.runCollect);
300 const summaries = Array.from(pages).flatMap(
301 (page) => page.Services ?? [],
302 );
303 const items = yield* Effect.forEach(
304 summaries,
305 (summary) =>
306 Effect.gen(function* () {
307 if (summary.Id === undefined) return undefined;
308 const service = yield* sd.getService({ Id: summary.Id }).pipe(
309 Effect.map((r) => r.Service),
310 Effect.catchTag("ServiceNotFound", () =>
311 Effect.succeed(undefined),
312 ),
313 );
314 if (
315 service?.Id === undefined ||
316 service.Arn === undefined ||
317 service.Name === undefined ||
318 service.NamespaceId === undefined
319 ) {
320 return undefined;
321 }
322 const namespaceName = yield* resolveNamespaceName(
323 service.NamespaceId,
324 ).pipe(
325 Effect.catchTag("NamespaceNotFound", () =>
326 Effect.succeed(""),
327 ),
328 );
329 return {
330 serviceId: service.Id,
331 serviceArn: service.Arn,
332 serviceName: service.Name,
333 namespaceId: service.NamespaceId,
334 namespaceName,
335 };
336 }),
337 { concurrency: 10 },
338 );
339 return items.filter(
340 (item): item is Service["Attributes"] => item !== undefined,
341 );
342 }),
343
344 read: Effect.fn(function* ({ id, olds, output }) {
345 const namespaceId = output?.namespaceId ?? olds?.namespaceId;
346 if (namespaceId === undefined) return undefined;
347 const name =
348 output?.serviceName ?? (yield* createName(id, olds ?? {}));
349 const service = yield* observeService(
350 namespaceId,
351 name,
352 output?.serviceId,
353 );
354 if (
355 service?.Id === undefined ||
356 service.Arn === undefined ||
357 service.NamespaceId === undefined
358 ) {
359 return undefined;
360 }
361 const namespaceName = yield* resolveNamespaceName(
362 service.NamespaceId,
363 ).pipe(
364 Effect.catchTag("NamespaceNotFound", () => Effect.succeed("")),
365 );
366 const attrs = {
367 serviceId: service.Id,
368 serviceArn: service.Arn,
369 serviceName: service.Name ?? name,
370 namespaceId: service.NamespaceId,
371 namespaceName,
372 };
373 const tags = yield* fetchObservedTags(attrs.serviceArn);
374 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
375 }),
376
377 diff: Effect.fn(function* ({ id, news, olds }) {
378 if (!isResolved(news)) return undefined;
379 const oldName = yield* createName(id, olds);
380 const newName = yield* createName(id, news);
381 if (oldName !== newName) {
382 return { action: "replace" } as const;
383 }
384 if (olds.namespaceId !== news.namespaceId) {
385 return { action: "replace" } as const;
386 }
387 if (olds.routingPolicy !== news.routingPolicy) {
388 return { action: "replace" } as const;
389 }
390 if (
391 recordTypeKey(olds.dnsRecords) !== recordTypeKey(news.dnsRecords)
392 ) {
393 return { action: "replace" } as const;
394 }
395 // healthCheckCustomConfig cannot be added, changed, or removed
396 if (
397 (olds.healthCheckCustomConfig === undefined) !==
398 (news.healthCheckCustomConfig === undefined)
399 ) {
400 return { action: "replace" } as const;
401 }
402 if (olds.type !== news.type) {
403 return { action: "replace" } as const;
404 }
405 // description / record TTLs / healthCheckConfig / tags → update
406 }),
407
408 reconcile: Effect.fn(function* ({ id, news, output, session }) {
409 const name = output?.serviceName ?? (yield* createName(id, news));
410 const internalTags = yield* createInternalTags(id);
411 const desiredTags = { ...news.tags, ...internalTags };
412
413 // 1. OBSERVE — cloud state is authoritative
414 let service = yield* observeService(
415 news.namespaceId,
416 name,
417 output?.serviceId,
418 );
419
420 // 2. ENSURE — createService is synchronous
421 if (service === undefined) {
422 service = yield* sd
423 .createService({
424 Name: name,
425 NamespaceId: news.namespaceId,
426 Description: news.description,
427 DnsConfig: toDesiredDnsConfig(news),
428 HealthCheckConfig: toDesiredHealthCheckConfig(news),
429 HealthCheckCustomConfig: news.healthCheckCustomConfig
430 ? {
431 FailureThreshold:
432 news.healthCheckCustomConfig.failureThreshold,
433 }
434 : undefined,
435 Type: news.type,
436 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
437 Key,
438 Value,
439 })),
440 })
441 .pipe(
442 Effect.map((r) => r.Service),
443 // a concurrent reconciler created it — observe instead
444 Effect.catchTag("ServiceAlreadyExists", (e) =>
445 e.ServiceId !== undefined
446 ? sd
447 .getService({ Id: e.ServiceId })
448 .pipe(Effect.map((r) => r.Service))
449 : observeService(news.namespaceId, name, undefined),
450 ),
451 );
452 }
453 if (service?.Id === undefined || service.Arn === undefined) {
454 return yield* Effect.fail(
455 new sd.ServiceNotFound({
456 message: `service ${name} not visible after create`,
457 }),
458 );
459 }
460
461 // 3. SYNC — description / DNS record TTLs / health check config.
462 // UpdateService deletes DnsRecords/HealthCheckConfig omitted from
463 // the change, so we always send the FULL desired configuration and
464 // only skip the call when observed already matches desired.
465 const desiredDns = toDesiredDnsConfig(news);
466 const desiredHealth = toDesiredHealthCheckConfig(news);
467 const observedRecords = service.DnsConfig?.DnsRecords ?? [];
468 const desiredRecords = desiredDns?.DnsRecords ?? [];
469 const dnsDelta =
470 recordKey([...observedRecords]) !== recordKey(desiredRecords);
471 const descriptionDelta =
472 (news.description ?? undefined) !==
473 (service.Description ?? undefined);
474 const observedHealth = service.HealthCheckConfig;
475 const healthDelta =
476 (desiredHealth === undefined) !== (observedHealth === undefined) ||
477 (desiredHealth !== undefined &&
478 (desiredHealth.Type !== observedHealth?.Type ||
479 (desiredHealth.ResourcePath ?? undefined) !==
480 (observedHealth?.ResourcePath ?? undefined) ||
481 (desiredHealth.FailureThreshold ?? undefined) !==
482 (observedHealth?.FailureThreshold ?? undefined)));
483 if (dnsDelta || descriptionDelta || healthDelta) {
484 const update = yield* sd.updateService({
485 Id: service.Id,
486 Service: {
487 Description: news.description,
488 DnsConfig:
489 desiredDns !== undefined
490 ? { DnsRecords: desiredDns.DnsRecords }
491 : undefined,
492 HealthCheckConfig: desiredHealth,
493 },
494 });
495 if (update.OperationId !== undefined) {
496 yield* awaitOperation(update.OperationId);
497 }
498 }
499
500 // 3b. SYNC SERVICE ATTRIBUTES — diff OBSERVED custom attributes
501 // against the desired map; upsert changed keys, delete undeclared
502 const observedAttributes: { [key: string]: string | undefined } =
503 yield* sd.getServiceAttributes({ ServiceId: service.Id }).pipe(
504 Effect.map((r) => r.ServiceAttributes?.Attributes ?? {}),
505 Effect.catchTag("ServiceNotFound", () => Effect.succeed({})),
506 );
507 const desiredAttributes = news.attributes ?? {};
508 const attributeUpserts = Object.entries(desiredAttributes).filter(
509 ([key, value]) => observedAttributes[key] !== value,
510 );
511 const attributeRemovals = Object.keys(observedAttributes).filter(
512 (key) => !(key in desiredAttributes),
513 );
514 if (attributeUpserts.length > 0) {
515 yield* sd.updateServiceAttributes({
516 ServiceId: service.Id,
517 Attributes: Object.fromEntries(attributeUpserts),
518 });
519 }
520 if (attributeRemovals.length > 0) {
521 yield* sd.deleteServiceAttributes({
522 ServiceId: service.Id,
523 Attributes: attributeRemovals,
524 });
525 }
526
527 // 3c. SYNC TAGS — diff against OBSERVED cloud tags
528 const observedTags = yield* fetchObservedTags(service.Arn);
529 yield* syncTags(service.Arn, observedTags, desiredTags);
530
531 const namespaceId = service.NamespaceId ?? news.namespaceId;
532 const namespaceName = yield* resolveNamespaceName(namespaceId);
533
534 yield* session.note(service.Id);
535 return {
536 serviceId: service.Id,
537 serviceArn: service.Arn,
538 serviceName: service.Name ?? name,
539 namespaceId,
540 namespaceName,
541 };
542 }),
543
544 delete: Effect.fn(function* ({ output }) {
545 // OBSERVE — instances registered at runtime (RegisterInstance
546 // binding) block DeleteService; deregister any that remain and
547 // await the async operations before deleting.
548 yield* deregisterAllInstances(output.serviceId).pipe(
549 Effect.catchTag("ServiceNotFound", () => Effect.void),
550 );
551 // deregistrations still propagating surface as ResourceInUse —
552 // retry through the visibility window (bounded)
553 yield* retryWhileResourceInUse(
554 sd.deleteService({ Id: output.serviceId }),
555 ).pipe(Effect.catchTag("ServiceNotFound", () => Effect.void));
556 }),
557 });
558 }),
559 );