1import * as sd from "@distilled.cloud/aws/servicediscovery";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import type { Providers } from "../Providers.ts";
7import { awaitOperation, retryWhileResourceInUse } from "./internal.ts";
8
9export interface InstanceRegistrationProps {
10 /**
11 * The ID of the Cloud Map service to register the instance with.
12 * Changing the service replaces the registration.
13 */
14 serviceId: string;
15 /**
16 * The instance ID — unique within the service. Changing it replaces the
17 * registration.
18 */
19 instanceId: string;
20 /**
21 * Instance attributes. For DNS services these drive record creation:
22 * `AWS_INSTANCE_IPV4` (A records), `AWS_INSTANCE_IPV6` (AAAA),
23 * `AWS_INSTANCE_PORT` (SRV), `AWS_INSTANCE_CNAME` (CNAME). Custom keys are
24 * returned by `DiscoverInstances`. Mutable — re-registering with the same
25 * instance ID updates the attributes.
26 */
27 attributes: Record<string, string>;
28}
29
30export interface InstanceRegistration extends Resource<
31 "AWS.CloudMap.InstanceRegistration",
32 InstanceRegistrationProps,
33 {
34 /**
35 * The Cloud Map service the instance is registered with.
36 */
37 serviceId: string;
38 /**
39 * The identifier of the registered instance.
40 */
41 instanceId: string;
42 },
43 {},
44 Providers
45> {}
46
47/**
48 * A manual AWS Cloud Map instance registration — registers a static
49 * endpoint (an IP, port, CNAME, or an arbitrary attribute bag) with a Cloud
50 * Map service so it is returned by `DiscoverInstances` and, for DNS
51 * services, resolvable via Route 53.
52 *
53 * Use this for non-ECS targets: static IPs, on-prem hosts, external
54 * dependencies. ECS registers its own tasks automatically via
55 * `serviceRegistries`.
56 *
57 * `RegisterInstance` is an upsert — reconcile re-registers with the desired
58 * attributes and Cloud Map converges the records. Registration and
59 * deregistration are asynchronous; the provider polls the operations API
60 * (bounded) until they complete.
61 * ### Registering Instances
62 * **Example:** Register a Static IP
63 * ```typescript
64 * import * as AWS from "alchemy/AWS";
65 *
66 * const instance = yield* AWS.CloudMap.InstanceRegistration("Primary", {
67 * serviceId: service.serviceId,
68 * instanceId: "primary",
69 * attributes: { AWS_INSTANCE_IPV4: "10.0.1.10" },
70 * });
71 * ```
72 *
73 * **Example:** Register an API-only Instance with Custom Attributes
74 * ```typescript
75 * const instance = yield* AWS.CloudMap.InstanceRegistration("Worker", {
76 * serviceId: service.serviceId,
77 * instanceId: "worker-1",
78 * attributes: { endpoint: "https://worker-1.internal:8443", zone: "us-west-2a" },
79 * });
80 * ```
81 *
82 * @resource
83 */
84export const InstanceRegistration = Resource<InstanceRegistration>(
85 "AWS.CloudMap.InstanceRegistration",
86);
87
88export const InstanceRegistrationProvider = () =>
89 Provider.effect(
90 InstanceRegistration,
91 Effect.gen(function* () {
92 return InstanceRegistration.Provider.of({
93 stables: ["serviceId", "instanceId"],
94
95 // Sub-resource keyed by its parent service — there is no
96 // account-level enumeration without a service id, so listing is
97 // handled by the parent's lifecycle.
98 list: () => Effect.succeed([]),
99
100 read: Effect.fn(function* ({ olds, output }) {
101 const serviceId = output?.serviceId ?? olds?.serviceId;
102 const instanceId = output?.instanceId ?? olds?.instanceId;
103 if (serviceId === undefined || instanceId === undefined) {
104 return undefined;
105 }
106 const instance = yield* sd
107 .getInstance({ ServiceId: serviceId, InstanceId: instanceId })
108 .pipe(
109 Effect.map((r) => r.Instance),
110 Effect.catchTag(["InstanceNotFound", "ServiceNotFound"], () =>
111 Effect.succeed(undefined),
112 ),
113 );
114 if (instance === undefined) {
115 return undefined;
116 }
117 // instances carry no tags, so ownership can't be branded — treat
118 // presence as ours (identity is fully determined by the props)
119 return { serviceId, instanceId };
120 }),
121
122 diff: Effect.fn(function* ({ news, olds }) {
123 if (!isResolved(news)) return undefined;
124 if (
125 olds.serviceId !== news.serviceId ||
126 olds.instanceId !== news.instanceId
127 ) {
128 return { action: "replace" } as const;
129 }
130 // attributes fall through to the default update path (upsert)
131 }),
132
133 reconcile: Effect.fn(function* ({ news, session }) {
134 // OBSERVE — compare live attributes so an unchanged registration
135 // is a no-op instead of a Route 53 record churn
136 const observed = yield* sd
137 .getInstance({
138 ServiceId: news.serviceId,
139 InstanceId: news.instanceId,
140 })
141 .pipe(
142 Effect.map((r) => r.Instance),
143 Effect.catchTag(["InstanceNotFound", "ServiceNotFound"], () =>
144 Effect.succeed(undefined),
145 ),
146 );
147
148 const observedAttributes = observed?.Attributes ?? undefined;
149 const desiredEntries = Object.entries(news.attributes);
150 const matches =
151 observedAttributes !== undefined &&
152 desiredEntries.length === Object.keys(observedAttributes).length &&
153 desiredEntries.every(
154 ([key, value]) => observedAttributes[key] === value,
155 );
156
157 // ENSURE/SYNC — registerInstance is a create-or-update upsert
158 if (!matches) {
159 const registered = yield* retryWhileResourceInUse(
160 sd.registerInstance({
161 ServiceId: news.serviceId,
162 InstanceId: news.instanceId,
163 Attributes: news.attributes,
164 }),
165 );
166 if (registered.OperationId !== undefined) {
167 yield* awaitOperation(registered.OperationId);
168 }
169 }
170
171 yield* session.note(news.instanceId);
172 return { serviceId: news.serviceId, instanceId: news.instanceId };
173 }),
174
175 delete: Effect.fn(function* ({ output }) {
176 const deregistered = yield* retryWhileResourceInUse(
177 sd.deregisterInstance({
178 ServiceId: output.serviceId,
179 InstanceId: output.instanceId,
180 }),
181 ).pipe(
182 Effect.catchTag(["InstanceNotFound", "ServiceNotFound"], () =>
183 Effect.succeed({ OperationId: undefined }),
184 ),
185 // an identical deregistration is already in flight — await THAT
186 // operation instead of silently skipping it
187 Effect.catchTag("DuplicateRequest", (e) =>
188 Effect.succeed({ OperationId: e.DuplicateOperationId }),
189 ),
190 );
191 if (deregistered.OperationId !== undefined) {
192 yield* awaitOperation(deregistered.OperationId);
193 }
194 }),
195 });
196 }),
197 );