1import * as ga from "@distilled.cloud/aws/global-accelerator";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { toWireSeconds } from "../../Util/Duration.ts";
9import type { Providers } from "../Providers.ts";
10import { retryGaDeletion, withGaRegion } from "./common.ts";
11
12export interface EndpointConfiguration {
13 /**
14 * ID of the endpoint: an ALB or NLB ARN, an Elastic IP allocation ID, or
15 * an EC2 instance ID.
16 */
17 endpointId: string;
18 /**
19 * Relative traffic weight for this endpoint versus the other endpoints in
20 * the group (`0` - `255`).
21 * @default 128
22 */
23 weight?: number;
24 /**
25 * Preserve the client IP address through to the endpoint. Supported for
26 * ALB and EC2 instance endpoints.
27 * @default true for supported endpoint types
28 */
29 clientIPPreservationEnabled?: boolean;
30 /**
31 * ARN of the cross-account attachment authorizing this endpoint when it
32 * lives in another AWS account.
33 */
34 attachmentArn?: string;
35}
36
37export interface PortOverride {
38 /**
39 * The listener port to override.
40 */
41 listenerPort: number;
42 /**
43 * The endpoint port that traffic on the overridden listener port is
44 * routed to.
45 */
46 endpointPort: number;
47}
48
49export interface EndpointGroupProps {
50 /**
51 * ARN of the listener the endpoint group attaches to. Changing it
52 * replaces the endpoint group.
53 */
54 listenerArn: string;
55 /**
56 * The AWS Region where the endpoint group's endpoints live. One endpoint
57 * group per region per listener; changing it replaces the group.
58 */
59 endpointGroupRegion: string;
60 /**
61 * The endpoints (ALBs, NLBs, EC2 instances, Elastic IPs) traffic is
62 * routed to. Omitting it (or `[]`) keeps the group empty.
63 */
64 endpoints?: EndpointConfiguration[];
65 /**
66 * The percentage of the listener's traffic to send to this endpoint
67 * group (`0` - `100`), applied after location-based routing.
68 * @default 100
69 */
70 trafficDialPercentage?: number;
71 /**
72 * The port used for health checks.
73 * @default the first port of the listener's port ranges
74 */
75 healthCheckPort?: number;
76 /**
77 * The protocol used for health checks.
78 * @default "TCP"
79 */
80 healthCheckProtocol?: "TCP" | "HTTP" | "HTTPS";
81 /**
82 * The path for HTTP/HTTPS health checks.
83 * @default "/"
84 */
85 healthCheckPath?: string;
86 /**
87 * Time between health checks, e.g. `"10 seconds"` or
88 * `Duration.seconds(30)`. Rounded to whole seconds on the wire; the API
89 * accepts `10` or `30` seconds.
90 * @default "30 seconds"
91 */
92 healthCheckInterval?: Duration.Input;
93 /**
94 * Consecutive health-check successes/failures required to flip an
95 * endpoint healthy/unhealthy.
96 * @default 3
97 */
98 thresholdCount?: number;
99 /**
100 * Overrides routing specific listener ports to different endpoint ports.
101 */
102 portOverrides?: PortOverride[];
103}
104
105export interface EndpointGroup extends Resource<
106 "AWS.GlobalAccelerator.EndpointGroup",
107 EndpointGroupProps,
108 {
109 /** The ARN of the endpoint group. */
110 endpointGroupArn: string;
111 /** The ARN of the listener the endpoint group is attached to. */
112 listenerArn: string;
113 /** The AWS Region the group's endpoints live in. */
114 endpointGroupRegion: string;
115 /** The percentage of listener traffic dialed to this group. */
116 trafficDialPercentage: number;
117 /** The health-check protocol: `TCP`, `HTTP`, or `HTTPS`. */
118 healthCheckProtocol: string;
119 /** The port used for health checks. */
120 healthCheckPort: number | undefined;
121 /** The path used for HTTP/HTTPS health checks. */
122 healthCheckPath: string | undefined;
123 /** Seconds between health checks. */
124 healthCheckInterval: number;
125 /** Consecutive checks required to flip an endpoint's health state. */
126 thresholdCount: number;
127 /** The endpoints in the group with their observed health. */
128 endpoints: {
129 /** The endpoint's ID (ALB/NLB ARN, EIP allocation ID, or instance ID). */
130 endpointId: string | undefined;
131 /** Relative traffic weight of the endpoint. */
132 weight: number | undefined;
133 /** Observed health: `HEALTHY`, `UNHEALTHY`, or `INITIAL`. */
134 healthState: string | undefined;
135 /** Whether the client IP is preserved through to the endpoint. */
136 clientIPPreservationEnabled: boolean | undefined;
137 }[];
138 },
139 never,
140 Providers
141> {}
142
143/**
144 * A Global Accelerator endpoint group — the set of regional endpoints
145 * (ALBs, NLBs, EC2 instances, or Elastic IPs) that a listener routes
146 * traffic to in one AWS Region, with traffic-dial and health-check
147 * configuration.
148 *
149 * One endpoint group per region per listener. Everything except the
150 * listener and region is updatable in place.
151 * ### Creating Endpoint Groups
152 * **Example:** Route to an Application Load Balancer
153 * ```typescript
154 * const group = yield* GlobalAccelerator.EndpointGroup("UsWest2", {
155 * listenerArn: listener.listenerArn,
156 * endpointGroupRegion: "us-west-2",
157 * endpoints: [{ endpointId: alb.loadBalancerArn }],
158 * });
159 * ```
160 *
161 * **Example:** Weighted Endpoints with HTTP Health Checks
162 * ```typescript
163 * const group = yield* GlobalAccelerator.EndpointGroup("UsEast1", {
164 * listenerArn: listener.listenerArn,
165 * endpointGroupRegion: "us-east-1",
166 * endpoints: [
167 * { endpointId: blueAlb.loadBalancerArn, weight: 200 },
168 * { endpointId: greenAlb.loadBalancerArn, weight: 55 },
169 * ],
170 * healthCheckProtocol: "HTTP",
171 * healthCheckPath: "/health",
172 * healthCheckInterval: "10 seconds",
173 * });
174 * ```
175 *
176 * ### Traffic Management
177 * **Example:** Canary a Region with the Traffic Dial
178 * ```typescript
179 * const group = yield* GlobalAccelerator.EndpointGroup("Canary", {
180 * listenerArn: listener.listenerArn,
181 * endpointGroupRegion: "eu-west-1",
182 * trafficDialPercentage: 10,
183 * });
184 * ```
185 *
186 * @resource
187 */
188export const EndpointGroup = Resource<EndpointGroup>(
189 "AWS.GlobalAccelerator.EndpointGroup",
190);
191
192// Endpoint-group ARNs embed the parent listener ARN:
193// arn:...:accelerator/{id}/listener/{id}/endpoint-group/{id}
194const listenerArnOf = (endpointGroupArn: string) =>
195 endpointGroupArn.split("/endpoint-group/")[0]!;
196
197const toAttributes = (g: ga.EndpointGroup, endpointGroupArn: string) => ({
198 endpointGroupArn,
199 listenerArn: listenerArnOf(endpointGroupArn),
200 endpointGroupRegion: g.EndpointGroupRegion ?? "",
201 trafficDialPercentage: g.TrafficDialPercentage ?? 100,
202 healthCheckProtocol: g.HealthCheckProtocol ?? "TCP",
203 healthCheckPort: g.HealthCheckPort,
204 healthCheckPath: g.HealthCheckPath,
205 healthCheckInterval: g.HealthCheckIntervalSeconds ?? 30,
206 thresholdCount: g.ThresholdCount ?? 3,
207 endpoints: (g.EndpointDescriptions ?? []).map((d) => ({
208 endpointId: d.EndpointId,
209 weight: d.Weight,
210 healthState: d.HealthState,
211 clientIPPreservationEnabled: d.ClientIPPreservationEnabled,
212 })),
213});
214
215// The desired mutable configuration, shared by create and update. Defaults
216// are applied explicitly so removing a prop converges back to the default.
217const desiredConfig = (news: EndpointGroupProps) => ({
218 EndpointConfigurations: (news.endpoints ?? []).map((e) => ({
219 EndpointId: e.endpointId,
220 Weight: e.weight,
221 ClientIPPreservationEnabled: e.clientIPPreservationEnabled,
222 AttachmentArn: e.attachmentArn,
223 })),
224 TrafficDialPercentage: news.trafficDialPercentage ?? 100,
225 HealthCheckPort: news.healthCheckPort,
226 HealthCheckProtocol: news.healthCheckProtocol ?? "TCP",
227 HealthCheckPath: news.healthCheckPath,
228 HealthCheckIntervalSeconds: toWireSeconds(news.healthCheckInterval) ?? 30,
229 ThresholdCount: news.thresholdCount ?? 3,
230 PortOverrides: (news.portOverrides ?? []).map((o) => ({
231 ListenerPort: o.listenerPort,
232 EndpointPort: o.endpointPort,
233 })),
234});
235
236const normalizeOverrides = (overrides: ga.PortOverride[] | undefined) =>
237 JSON.stringify(
238 (overrides ?? [])
239 .map((o) => [o.ListenerPort ?? 0, o.EndpointPort ?? 0])
240 .sort((a, b) => a[0]! - b[0]!),
241 );
242
243// Compare observed cloud state against the desired props for every aspect we
244// manage. Fields the user never specified (healthCheckPort/path) fall back to
245// service-computed defaults and are only compared when explicitly desired.
246const hasDrift = (
247 live: ga.EndpointGroup,
248 news: EndpointGroupProps,
249): boolean => {
250 if (
251 (live.TrafficDialPercentage ?? 100) !== (news.trafficDialPercentage ?? 100)
252 ) {
253 return true;
254 }
255 if (
256 (live.HealthCheckProtocol ?? "TCP") !== (news.healthCheckProtocol ?? "TCP")
257 ) {
258 return true;
259 }
260 if (
261 (live.HealthCheckIntervalSeconds ?? 30) !==
262 (toWireSeconds(news.healthCheckInterval) ?? 30)
263 ) {
264 return true;
265 }
266 if ((live.ThresholdCount ?? 3) !== (news.thresholdCount ?? 3)) return true;
267 if (
268 news.healthCheckPort !== undefined &&
269 live.HealthCheckPort !== news.healthCheckPort
270 ) {
271 return true;
272 }
273 if (
274 news.healthCheckPath !== undefined &&
275 live.HealthCheckPath !== news.healthCheckPath
276 ) {
277 return true;
278 }
279 if (
280 normalizeOverrides(live.PortOverrides) !==
281 normalizeOverrides(desiredConfig(news).PortOverrides)
282 ) {
283 return true;
284 }
285 const observed = new Map(
286 (live.EndpointDescriptions ?? []).map((d) => [d.EndpointId ?? "", d]),
287 );
288 const desired = news.endpoints ?? [];
289 if (observed.size !== desired.length) return true;
290 for (const e of desired) {
291 const d = observed.get(e.endpointId);
292 if (!d) return true;
293 if (e.weight !== undefined && d.Weight !== e.weight) return true;
294 if (
295 e.clientIPPreservationEnabled !== undefined &&
296 d.ClientIPPreservationEnabled !== e.clientIPPreservationEnabled
297 ) {
298 return true;
299 }
300 }
301 return false;
302};
303
304const describeEndpointGroup = Effect.fn(function* (endpointGroupArn: string) {
305 return yield* withGaRegion(
306 ga.describeEndpointGroup({ EndpointGroupArn: endpointGroupArn }),
307 ).pipe(
308 Effect.map((r) => r.EndpointGroup),
309 Effect.catchTag("EndpointGroupNotFoundException", () =>
310 Effect.succeed(undefined),
311 ),
312 );
313});
314
315// An endpoint group's natural identity is (listener, region) — GA allows at
316// most one per region per listener, so this lookup is exact.
317const findEndpointGroup = Effect.fn(function* (
318 listenerArn: string | undefined,
319 region: string | undefined,
320) {
321 if (!listenerArn || !region) return undefined;
322 const groups = yield* withGaRegion(
323 ga.listEndpointGroups
324 .items({ ListenerArn: listenerArn })
325 .pipe(Stream.runCollect),
326 ).pipe(
327 Effect.map((chunk) => Array.from(chunk)),
328 Effect.catchTag("ListenerNotFoundException", () =>
329 Effect.succeed([] as ga.EndpointGroup[]),
330 ),
331 );
332 return groups.find((g) => g.EndpointGroupRegion === region);
333});
334
335export const EndpointGroupProvider = () =>
336 Provider.succeed(EndpointGroup, {
337 stables: ["endpointGroupArn", "listenerArn", "endpointGroupRegion"],
338 // Sub-resource keyed entirely by its parent listener (and untagged) —
339 // account-wide enumeration happens via the Accelerator resource.
340 list: () => Effect.succeed([]),
341 read: Effect.fn(function* ({ olds, output }) {
342 if (output?.endpointGroupArn) {
343 const live = yield* describeEndpointGroup(output.endpointGroupArn);
344 if (live) return toAttributes(live, output.endpointGroupArn);
345 }
346 const found = yield* findEndpointGroup(
347 olds?.listenerArn,
348 olds?.endpointGroupRegion,
349 );
350 if (!found?.EndpointGroupArn) return undefined;
351 return toAttributes(found, found.EndpointGroupArn);
352 }),
353 diff: Effect.fn(function* ({ news, olds }) {
354 if (!isResolved(news)) return undefined;
355 if (news.listenerArn !== olds.listenerArn) {
356 return { action: "replace" } as const;
357 }
358 if (news.endpointGroupRegion !== olds.endpointGroupRegion) {
359 return { action: "replace" } as const;
360 }
361 // everything else updates in place.
362 }),
363 reconcile: Effect.fn(function* ({ news, output, instanceId, session }) {
364 // Observe — cached ARN first, then the exact (listener, region) key.
365 let live = output?.endpointGroupArn
366 ? yield* describeEndpointGroup(output.endpointGroupArn)
367 : undefined;
368 if (!live?.EndpointGroupArn) {
369 live = yield* findEndpointGroup(
370 news.listenerArn,
371 news.endpointGroupRegion,
372 );
373 }
374
375 // Ensure — create if missing; a concurrent create for the same
376 // (listener, region) surfaces as AlreadyExists and we converge on it.
377 if (!live?.EndpointGroupArn) {
378 live = yield* withGaRegion(
379 ga.createEndpointGroup({
380 ListenerArn: news.listenerArn,
381 EndpointGroupRegion: news.endpointGroupRegion,
382 ...desiredConfig(news),
383 IdempotencyToken: instanceId,
384 }),
385 ).pipe(
386 Effect.map((r) => r.EndpointGroup),
387 Effect.catchTag("EndpointGroupAlreadyExistsException", () =>
388 findEndpointGroup(news.listenerArn, news.endpointGroupRegion),
389 ),
390 );
391 }
392 if (!live?.EndpointGroupArn) {
393 return yield* Effect.die(
394 new Error("CreateEndpointGroup returned no endpoint group"),
395 );
396 }
397 const endpointGroupArn = live.EndpointGroupArn;
398
399 // Sync — one update with the full desired config when anything drifts.
400 if (hasDrift(live, news)) {
401 const updated = yield* withGaRegion(
402 ga.updateEndpointGroup({
403 EndpointGroupArn: endpointGroupArn,
404 ...desiredConfig(news),
405 }),
406 ).pipe(Effect.map((r) => r.EndpointGroup));
407 if (updated) live = updated;
408 }
409
410 yield* session.note(endpointGroupArn);
411 return toAttributes(live, endpointGroupArn);
412 }),
413 delete: Effect.fn(function* ({ output }) {
414 yield* retryGaDeletion(
415 withGaRegion(
416 ga.deleteEndpointGroup({
417 EndpointGroupArn: output.endpointGroupArn,
418 }),
419 ),
420 ).pipe(
421 Effect.catchTag("EndpointGroupNotFoundException", () => Effect.void),
422 );
423 }),
424 });