1import * as ga from "@distilled.cloud/aws/global-accelerator";
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, diffTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 retryGaDeletion,
13 retryGaTransaction,
14 retryUntilAcceleratorDeletable,
15 retryUntilListenerDeletable,
16 withGaRegion,
17} from "./common.ts";
18
19export interface AcceleratorProps {
20 /**
21 * Name of the accelerator. Up to 64 characters; letters, digits, and
22 * hyphens only, and must not begin or end with a hyphen.
23 * @default ${app}-${id}-${stage}-${suffix}
24 */
25 name?: string;
26 /**
27 * The IP address type that the accelerator's static addresses use.
28 * `DUAL_STACK` assigns both IPv4 and IPv6 addresses.
29 * @default "IPV4"
30 */
31 ipAddressType?: "IPV4" | "DUAL_STACK";
32 /**
33 * Optionally specify one or two static IPv4 addresses from your own
34 * BYOIP address pools. Changing them replaces the accelerator.
35 * @default addresses assigned from Amazon's pool
36 */
37 ipAddresses?: string[];
38 /**
39 * Whether the accelerator accepts and routes traffic. A disabled
40 * accelerator keeps its static IP addresses but serves nothing.
41 * @default true
42 */
43 enabled?: boolean;
44 /**
45 * Publish flow logs describing the traffic the accelerator serves to an
46 * S3 bucket. The bucket must live in the same account and carry a bucket
47 * policy granting `delivery.logs.amazonaws.com` permission to
48 * `s3:PutObject` (and `s3:GetBucketAcl`). Omit to keep flow logs
49 * disabled.
50 * @default disabled
51 */
52 flowLogs?: FlowLogs;
53 /**
54 * Tags to apply to the accelerator. Merged with internal Alchemy tags.
55 */
56 tags?: Record<string, string>;
57}
58
59export interface FlowLogs {
60 /**
61 * Name of the S3 bucket flow logs are delivered to.
62 */
63 bucket: string;
64 /**
65 * Key prefix for the flow-log objects within the bucket.
66 * @default logs are delivered under `AWSLogs/` at the bucket root
67 */
68 prefix?: string;
69}
70
71export interface Accelerator extends Resource<
72 "AWS.GlobalAccelerator.Accelerator",
73 AcceleratorProps,
74 {
75 /** The ARN of the accelerator. */
76 acceleratorArn: string;
77 /** The name of the accelerator. */
78 name: string;
79 /** The DNS name that points to the accelerator's static IPv4 addresses. */
80 dnsName: string | undefined;
81 /** The DNS name for dual-stack (IPv4 + IPv6) accelerators. */
82 dualStackDnsName: string | undefined;
83 /** The static anycast IP addresses assigned to the accelerator. */
84 ipAddresses: string[];
85 /** The IP address type: `IPV4` or `DUAL_STACK`. */
86 ipAddressType: string;
87 /** Whether the accelerator accepts and routes traffic. */
88 enabled: boolean;
89 /** Deployment status: `DEPLOYED` or `IN_PROGRESS`. */
90 status: string;
91 /** Whether flow logs are published to S3. */
92 flowLogsEnabled: boolean;
93 /** The S3 bucket receiving flow logs, when enabled. */
94 flowLogsS3Bucket: string | undefined;
95 /** The S3 key prefix for flow logs, when enabled. */
96 flowLogsS3Prefix: string | undefined;
97 },
98 never,
99 Providers
100> {}
101
102/**
103 * An AWS Global Accelerator standard accelerator — two anycast static IP
104 * addresses that route client traffic over the AWS global network to the
105 * closest healthy regional endpoint.
106 *
107 * Accelerators are global resources (the control-plane API lives in
108 * us-west-2 regardless of your deployment region — alchemy pins it
109 * automatically). Attach `Listener`s to accept traffic and `EndpointGroup`s
110 * to route it to ALBs, NLBs, EC2 instances, or Elastic IPs per region.
111 * ### Creating Accelerators
112 * **Example:** Basic Accelerator
113 * ```typescript
114 * import * as GlobalAccelerator from "alchemy/AWS/GlobalAccelerator";
115 *
116 * const accelerator = yield* GlobalAccelerator.Accelerator("Edge");
117 * ```
118 *
119 * **Example:** Dual-Stack Accelerator
120 * ```typescript
121 * const accelerator = yield* GlobalAccelerator.Accelerator("Edge", {
122 * ipAddressType: "DUAL_STACK",
123 * });
124 * ```
125 *
126 * ### Flow Logs
127 * **Example:** Publish Flow Logs to S3
128 * ```typescript
129 * // the bucket policy must grant delivery.logs.amazonaws.com
130 * // s3:PutObject + s3:GetBucketAcl
131 * const accelerator = yield* GlobalAccelerator.Accelerator("Edge", {
132 * flowLogs: { bucket: logBucket.bucketName, prefix: "ga-flow-logs" },
133 * });
134 * ```
135 *
136 * ### Routing Traffic
137 * **Example:** Accelerator with Listener and Endpoint Group
138 * ```typescript
139 * const accelerator = yield* GlobalAccelerator.Accelerator("Edge");
140 * const listener = yield* GlobalAccelerator.Listener("Web", {
141 * acceleratorArn: accelerator.acceleratorArn,
142 * portRanges: [{ fromPort: 443, toPort: 443 }],
143 * protocol: "TCP",
144 * });
145 * yield* GlobalAccelerator.EndpointGroup("UsWest2", {
146 * listenerArn: listener.listenerArn,
147 * endpointGroupRegion: "us-west-2",
148 * endpoints: [{ endpointId: alb.loadBalancerArn }],
149 * });
150 * ```
151 *
152 * @resource
153 */
154export const Accelerator = Resource<Accelerator>(
155 "AWS.GlobalAccelerator.Accelerator",
156);
157
158const createAcceleratorName = Effect.fn(function* (
159 id: string,
160 props: { name?: string | undefined },
161) {
162 return props.name ?? (yield* createPhysicalName({ id, maxLength: 64 }));
163});
164
165const toAttributes = (
166 a: ga.Accelerator,
167 acceleratorArn: string,
168 flowLogs?: ga.AcceleratorAttributes,
169) => ({
170 acceleratorArn,
171 name: a.Name ?? "",
172 dnsName: a.DnsName,
173 dualStackDnsName: a.DualStackDnsName,
174 ipAddresses: a.IpSets?.flatMap((set) => set.IpAddresses ?? []) ?? [],
175 ipAddressType: a.IpAddressType ?? "IPV4",
176 enabled: a.Enabled ?? true,
177 status: a.Status ?? "IN_PROGRESS",
178 flowLogsEnabled: flowLogs?.FlowLogsEnabled ?? false,
179 flowLogsS3Bucket: flowLogs?.FlowLogsS3Bucket,
180 flowLogsS3Prefix: flowLogs?.FlowLogsS3Prefix,
181});
182
183const describeAccelerator = Effect.fn(function* (acceleratorArn: string) {
184 return yield* withGaRegion(
185 ga.describeAccelerator({ AcceleratorArn: acceleratorArn }),
186 ).pipe(
187 Effect.map((r) => r.Accelerator),
188 Effect.catchTag("AcceleratorNotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192});
193
194const describeFlowLogs = Effect.fn(function* (acceleratorArn: string) {
195 return yield* withGaRegion(
196 ga.describeAcceleratorAttributes({ AcceleratorArn: acceleratorArn }),
197 ).pipe(
198 Effect.map((r) => r.AcceleratorAttributes),
199 Effect.catchTag("AcceleratorNotFoundException", () =>
200 Effect.succeed(undefined),
201 ),
202 );
203});
204
205const findAcceleratorByName = Effect.fn(function* (name: string) {
206 const accelerators = yield* withGaRegion(
207 ga.listAccelerators.items({}).pipe(Stream.runCollect),
208 );
209 return Array.from(accelerators).find((a) => a.Name === name);
210});
211
212const fetchTags = Effect.fn(function* (resourceArn: string) {
213 return yield* withGaRegion(
214 ga.listTagsForResource({ ResourceArn: resourceArn }),
215 ).pipe(
216 Effect.map((r) =>
217 Object.fromEntries((r.Tags ?? []).map((t) => [t.Key, t.Value])),
218 ),
219 Effect.catch(() => Effect.succeed({} as Record<string, string>)),
220 );
221});
222
223/**
224 * Global Accelerator's listener and endpoint-group resources are untagged
225 * children and cannot be enumerated independently by account-wide nuke.
226 * When nuke explicitly force-deletes an accelerator, discover and remove the
227 * complete child tree first. Normal stack destroy still relies on dependency
228 * ordering and deletes each child through its own provider.
229 */
230const deleteAcceleratorChildren = Effect.fn(function* (acceleratorArn: string) {
231 const listeners = yield* withGaRegion(
232 ga.listListeners
233 .items({ AcceleratorArn: acceleratorArn })
234 .pipe(Stream.runCollect),
235 ).pipe(
236 Effect.map((chunk) => Array.from(chunk)),
237 Effect.catchTag("AcceleratorNotFoundException", () =>
238 Effect.succeed([] as ga.Listener[]),
239 ),
240 );
241
242 // GA serializes mutations per accelerator. Keep this traversal sequential
243 // so deleting several children does not manufacture transaction conflicts.
244 yield* Effect.forEach(
245 listeners,
246 Effect.fn(function* (listener) {
247 if (!listener.ListenerArn) return;
248 const listenerArn = listener.ListenerArn;
249 const endpointGroups = yield* withGaRegion(
250 ga.listEndpointGroups
251 .items({ ListenerArn: listenerArn })
252 .pipe(Stream.runCollect),
253 ).pipe(
254 Effect.map((chunk) => Array.from(chunk)),
255 Effect.catchTag("ListenerNotFoundException", () =>
256 Effect.succeed([] as ga.EndpointGroup[]),
257 ),
258 );
259
260 yield* Effect.forEach(
261 endpointGroups,
262 (group) =>
263 group.EndpointGroupArn
264 ? retryGaDeletion(
265 withGaRegion(
266 ga.deleteEndpointGroup({
267 EndpointGroupArn: group.EndpointGroupArn,
268 }),
269 ),
270 ).pipe(
271 Effect.catchTag(
272 "EndpointGroupNotFoundException",
273 () => Effect.void,
274 ),
275 )
276 : Effect.void,
277 { discard: true },
278 );
279
280 // Endpoint-group detachment is asynchronous. The same bounded retry
281 // used by the child provider waits until the listener is deletable.
282 yield* retryUntilListenerDeletable(
283 withGaRegion(ga.deleteListener({ ListenerArn: listenerArn })),
284 ).pipe(Effect.catchTag("ListenerNotFoundException", () => Effect.void));
285 }),
286 { discard: true },
287 );
288});
289
290export const AcceleratorProvider = () =>
291 Provider.succeed(Accelerator, {
292 stables: ["acceleratorArn", "dnsName", "dualStackDnsName", "ipAddresses"],
293 // Top-level, account-scoped collection: enumerate every accelerator.
294 list: () =>
295 withGaRegion(
296 ga.listAccelerators.items({}).pipe(
297 Stream.runCollect,
298 Effect.map((chunk) =>
299 Array.from(chunk)
300 .filter((a) => a.AcceleratorArn !== undefined)
301 .map((a) => toAttributes(a, a.AcceleratorArn!)),
302 ),
303 ),
304 ),
305 read: Effect.fn(function* ({ id, olds, output }) {
306 let live = output?.acceleratorArn
307 ? yield* describeAccelerator(output.acceleratorArn)
308 : undefined;
309 if (!live?.AcceleratorArn) {
310 // No cached ARN (or it vanished) — fall back to the deterministic
311 // physical name to recover from lost state / support adoption.
312 const name = yield* createAcceleratorName(id, olds ?? {});
313 live = yield* findAcceleratorByName(name);
314 }
315 if (!live?.AcceleratorArn) return undefined;
316 const flowLogs = yield* describeFlowLogs(live.AcceleratorArn);
317 const attrs = toAttributes(live, live.AcceleratorArn, flowLogs);
318 const tags = yield* fetchTags(live.AcceleratorArn);
319 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
320 }),
321 diff: Effect.fn(function* ({ id, news, olds }) {
322 if (!isResolved(news)) return undefined;
323 // Static BYOIP addresses are the accelerator's identity — changing
324 // them requires a replacement. Name, ipAddressType, and enabled are
325 // all mutable via updateAccelerator.
326 if (
327 JSON.stringify(news.ipAddresses ?? []) !==
328 JSON.stringify(olds.ipAddresses ?? [])
329 ) {
330 return { action: "replace" } as const;
331 }
332 const oldName = yield* createAcceleratorName(id, olds);
333 const newName = yield* createAcceleratorName(id, news);
334 if (oldName !== newName) {
335 // Renames are applied in place by reconcile.
336 return undefined;
337 }
338 }),
339 reconcile: Effect.fn(function* ({ id, news, output, instanceId, session }) {
340 const name = yield* createAcceleratorName(id, news);
341 const internalTags = yield* createInternalTags(id);
342 const desiredTags: Record<string, string> = {
343 ...news.tags,
344 ...internalTags,
345 };
346 const desiredEnabled = news.enabled ?? true;
347
348 // Observe — cached ARN first, deterministic name as fallback (covers
349 // create-then-persist-failure and adoption).
350 let live = output?.acceleratorArn
351 ? yield* describeAccelerator(output.acceleratorArn)
352 : undefined;
353 if (!live?.AcceleratorArn) {
354 live = yield* findAcceleratorByName(output?.name ?? name);
355 }
356
357 // Ensure — create if missing. The idempotency token (derived from the
358 // instance id) makes a crashed-and-retried create safe.
359 if (!live?.AcceleratorArn) {
360 live = yield* retryGaTransaction(
361 withGaRegion(
362 ga.createAccelerator({
363 Name: name,
364 IpAddressType: news.ipAddressType,
365 IpAddresses: news.ipAddresses,
366 Enabled: desiredEnabled,
367 IdempotencyToken: instanceId,
368 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
369 Key,
370 Value,
371 })),
372 }),
373 ),
374 ).pipe(Effect.map((r) => r.Accelerator));
375 }
376 if (!live?.AcceleratorArn) {
377 return yield* Effect.die(
378 new Error("CreateAccelerator returned no accelerator"),
379 );
380 }
381 const acceleratorArn = live.AcceleratorArn;
382
383 // Sync settings — diff observed vs desired, apply only the delta.
384 const update: ga.UpdateAcceleratorRequest = {
385 AcceleratorArn: acceleratorArn,
386 };
387 let dirty = false;
388 if ((live.Name ?? "") !== name) {
389 update.Name = name;
390 dirty = true;
391 }
392 if (
393 news.ipAddressType !== undefined &&
394 live.IpAddressType !== news.ipAddressType
395 ) {
396 update.IpAddressType = news.ipAddressType;
397 dirty = true;
398 }
399 if ((live.Enabled ?? true) !== desiredEnabled) {
400 update.Enabled = desiredEnabled;
401 dirty = true;
402 }
403 if (dirty) {
404 const updated = yield* retryGaTransaction(
405 withGaRegion(ga.updateAccelerator(update)),
406 ).pipe(Effect.map((r) => r.Accelerator));
407 if (updated) live = updated;
408 }
409
410 // Sync flow logs — diff observed attributes against the desired
411 // flowLogs prop; only call updateAcceleratorAttributes on drift.
412 let flowLogs = yield* describeFlowLogs(acceleratorArn);
413 const desiredFlowLogsEnabled = news.flowLogs !== undefined;
414 const flowLogsDrift =
415 (flowLogs?.FlowLogsEnabled ?? false) !== desiredFlowLogsEnabled ||
416 (desiredFlowLogsEnabled &&
417 (flowLogs?.FlowLogsS3Bucket !== news.flowLogs!.bucket ||
418 (news.flowLogs!.prefix !== undefined &&
419 flowLogs?.FlowLogsS3Prefix !== news.flowLogs!.prefix)));
420 if (flowLogsDrift) {
421 flowLogs = yield* retryGaTransaction(
422 withGaRegion(
423 ga.updateAcceleratorAttributes({
424 AcceleratorArn: acceleratorArn,
425 FlowLogsEnabled: desiredFlowLogsEnabled,
426 ...(desiredFlowLogsEnabled
427 ? {
428 FlowLogsS3Bucket: news.flowLogs!.bucket,
429 FlowLogsS3Prefix: news.flowLogs!.prefix,
430 }
431 : {}),
432 }),
433 ),
434 ).pipe(Effect.map((r) => r.AcceleratorAttributes));
435 }
436
437 // Sync tags against OBSERVED cloud tags so adoption converges.
438 const observedTags = yield* fetchTags(acceleratorArn);
439 const { upsert, removed } = diffTags(observedTags, desiredTags);
440 if (upsert.length > 0) {
441 yield* withGaRegion(
442 ga.tagResource({ ResourceArn: acceleratorArn, Tags: upsert }),
443 );
444 }
445 if (removed.length > 0) {
446 yield* withGaRegion(
447 ga.untagResource({ ResourceArn: acceleratorArn, TagKeys: removed }),
448 );
449 }
450
451 yield* session.note(acceleratorArn);
452 return toAttributes(live, acceleratorArn, flowLogs);
453 }),
454 delete: Effect.fn(function* ({ output, session, force }) {
455 const acceleratorArn = output.acceleratorArn;
456 if (force) {
457 yield* session.note(
458 "deleting accelerator listeners and endpoint groups",
459 );
460 yield* deleteAcceleratorChildren(acceleratorArn);
461 }
462 // An accelerator must be disabled before it can be deleted.
463 const exists = yield* retryGaTransaction(
464 withGaRegion(
465 ga.updateAccelerator({
466 AcceleratorArn: acceleratorArn,
467 Enabled: false,
468 }),
469 ),
470 ).pipe(
471 Effect.map(() => true),
472 Effect.catchTag("AcceleratorNotFoundException", () =>
473 Effect.succeed(false),
474 ),
475 );
476 if (!exists) return;
477 yield* session.note("waiting for accelerator to disable");
478 // The disable propagates asynchronously; deleteAccelerator rejects with
479 // AcceleratorNotDisabledException until it lands, so retry bounded.
480 yield* retryUntilAcceleratorDeletable(
481 withGaRegion(ga.deleteAccelerator({ AcceleratorArn: acceleratorArn })),
482 ).pipe(
483 Effect.catchTag("AcceleratorNotFoundException", () => Effect.void),
484 );
485 }),
486 });