1import * as vpclattice from "@distilled.cloud/aws/vpc-lattice";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import {
13 createInternalTags,
14 diffTags,
15 hasAlchemyTags,
16 tagRecord,
17} from "../../Tags.ts";
18import { toWireSeconds } from "../../Util/Duration.ts";
19import type { Providers } from "../Providers.ts";
20import {
21 retryOnConflict,
22 waitUntilAbsent,
23 waitUntilStable,
24} from "./internal.ts";
25
26/**
27 * The kind of compute a target group routes to.
28 */
29export type TargetGroupType = "IP" | "LAMBDA" | "INSTANCE" | "ALB";
30
31/**
32 * A target registered with a target group: an IP address, EC2 instance ID,
33 * ALB ARN, or Lambda function ARN depending on the group's `type`.
34 */
35export interface TargetGroupTarget {
36 /**
37 * The target identifier: IP address (`IP`), instance ID (`INSTANCE`),
38 * load balancer ARN (`ALB`), or Lambda function ARN (`LAMBDA`).
39 */
40 id: string;
41 /**
42 * Port the target listens on. Not applicable to `LAMBDA` targets.
43 */
44 port?: number;
45}
46
47/**
48 * Health check configuration for `IP` and `INSTANCE` target groups.
49 */
50export interface TargetGroupHealthCheck {
51 /**
52 * Whether health checking is enabled.
53 */
54 enabled?: boolean;
55 /**
56 * Protocol used for health check requests (`HTTP` or `HTTPS`).
57 */
58 protocol?: string;
59 /**
60 * Protocol version used for health check requests (`HTTP1` or `HTTP2`).
61 */
62 protocolVersion?: string;
63 /**
64 * Port used for health checks. Defaults to the target's port.
65 */
66 port?: number;
67 /**
68 * Destination path for health check requests.
69 * @default "/"
70 */
71 path?: string;
72 /**
73 * Approximate time between health checks of an individual target, e.g.
74 * `"30 seconds"` (a bare number is milliseconds). Rounded to whole seconds
75 * on the wire.
76 */
77 healthCheckInterval?: Duration.Input;
78 /**
79 * Time to wait before a health check request is considered failed, e.g.
80 * `"5 seconds"` (a bare number is milliseconds). Rounded to whole seconds
81 * on the wire.
82 */
83 healthCheckTimeout?: Duration.Input;
84 /**
85 * Consecutive successful checks required before an unhealthy target is
86 * considered healthy.
87 */
88 healthyThresholdCount?: number;
89 /**
90 * Consecutive failed checks required before a healthy target is considered
91 * unhealthy.
92 */
93 unhealthyThresholdCount?: number;
94 /**
95 * HTTP status codes counted as a healthy response, e.g. `{ httpCode: "200-299" }`.
96 */
97 matcher?: { httpCode: string };
98}
99
100export interface TargetGroupProps {
101 /**
102 * Name of the target group. If omitted, a unique name is generated.
103 * Immutable — changing it replaces the resource.
104 */
105 name?: string;
106 /**
107 * The kind of targets the group routes to. Immutable — changing it replaces
108 * the resource.
109 */
110 type: TargetGroupType;
111 /**
112 * Port the targets listen on. Required for every type except `LAMBDA`.
113 * Immutable — changing it replaces the resource.
114 */
115 port?: number;
116 /**
117 * Protocol used to route traffic to the targets (`HTTP`, `HTTPS`, or
118 * `TCP`). Not applicable to `LAMBDA`. Immutable — changing it replaces the
119 * resource.
120 */
121 protocol?: string;
122 /**
123 * Protocol version (`HTTP1`, `HTTP2`, or `GRPC`). Not applicable to
124 * `LAMBDA`. Immutable — changing it replaces the resource.
125 * @default "HTTP1"
126 */
127 protocolVersion?: string;
128 /**
129 * IP address type of the targets (`IPV4` or `IPV6`). `IP` type only.
130 * Immutable — changing it replaces the resource.
131 * @default "IPV4"
132 */
133 ipAddressType?: string;
134 /**
135 * ID of the VPC the targets live in. Required for every type except
136 * `LAMBDA`. Immutable — changing it replaces the resource.
137 */
138 vpcIdentifier?: string;
139 /**
140 * Version of the event structure a `LAMBDA` target receives (`V1` or
141 * `V2`). Immutable — changing it replaces the resource.
142 * @default "V1"
143 */
144 lambdaEventStructureVersion?: string;
145 /**
146 * Health check configuration. Mutable for `IP` and `INSTANCE` groups; not
147 * applicable to `LAMBDA` and `ALB`.
148 */
149 healthCheck?: TargetGroupHealthCheck;
150 /**
151 * Targets to keep registered with the group. Reconciled against the
152 * observed registrations: missing targets are registered and extra ones
153 * are deregistered.
154 */
155 targets?: TargetGroupTarget[];
156 /**
157 * User-defined tags to apply to the target group.
158 */
159 tags?: Record<string, string>;
160}
161
162export interface TargetGroup extends Resource<
163 "AWS.VpcLattice.TargetGroup",
164 TargetGroupProps,
165 {
166 /**
167 * Service-assigned unique ID of the target group.
168 */
169 targetGroupId: string;
170 /**
171 * ARN of the target group.
172 */
173 targetGroupArn: string;
174 /**
175 * Physical name of the target group.
176 */
177 name: string;
178 /**
179 * The kind of targets the group routes to.
180 */
181 type: TargetGroupType;
182 /**
183 * Current lifecycle status (e.g. `ACTIVE`).
184 */
185 status: string;
186 /**
187 * Current tags reported for the target group.
188 */
189 tags: Record<string, string>;
190 },
191 never,
192 Providers
193> {}
194
195/**
196 * An Amazon VPC Lattice target group — the collection of compute targets
197 * (IPs, EC2 instances, ALBs, or Lambda functions) that a lattice service's
198 * listeners and rules forward traffic to.
199 *
200 * ### Creating Target Groups
201 * **Example:** Lambda Target Group
202 * ```typescript
203 * const targets = yield* TargetGroup("ApiTargets", {
204 * type: "LAMBDA",
205 * targets: [{ id: fn.functionArn }],
206 * });
207 * ```
208 *
209 * **Example:** IP Target Group with Health Check
210 * ```typescript
211 * const targets = yield* TargetGroup("BackendTargets", {
212 * type: "IP",
213 * port: 80,
214 * protocol: "HTTP",
215 * vpcIdentifier: vpc.vpcId,
216 * healthCheck: {
217 * enabled: true,
218 * path: "/health",
219 * healthCheckInterval: "30 seconds",
220 * healthCheckTimeout: "5 seconds",
221 * },
222 * targets: [{ id: "10.0.1.10", port: 80 }],
223 * });
224 * ```
225 *
226 * @resource
227 */
228export const TargetGroup = Resource<TargetGroup>("AWS.VpcLattice.TargetGroup");
229
230const toWireHealthCheck = (
231 healthCheck: TargetGroupHealthCheck,
232): vpclattice.HealthCheckConfig => ({
233 enabled: healthCheck.enabled,
234 protocol: healthCheck.protocol,
235 protocolVersion: healthCheck.protocolVersion,
236 port: healthCheck.port,
237 path: healthCheck.path,
238 healthCheckIntervalSeconds: toWireSeconds(healthCheck.healthCheckInterval),
239 healthCheckTimeoutSeconds: toWireSeconds(healthCheck.healthCheckTimeout),
240 healthyThresholdCount: healthCheck.healthyThresholdCount,
241 unhealthyThresholdCount: healthCheck.unhealthyThresholdCount,
242 matcher: healthCheck.matcher,
243});
244
245const targetKey = (t: { id?: string; port?: number }) =>
246 `${t.id ?? ""}#${t.port ?? ""}`;
247
248/**
249 * RegisterTargets reports per-target failures in `unsuccessful` instead of
250 * throwing. Tagged so callers (e.g. `delete`'s not-found tolerance) can keep
251 * using `Effect.catchTag` on the fully typed error union.
252 */
253class TargetRegistrationFailed extends Data.TaggedError(
254 "AWS.VpcLattice.TargetRegistrationFailed",
255)<{
256 targetGroupId: string;
257 unsuccessful: vpclattice.TargetFailure[];
258}> {
259 override get message() {
260 return `Failed to register targets with target group ${this.targetGroupId}: ${JSON.stringify(this.unsuccessful)}`;
261 }
262}
263
264export const TargetGroupProvider = () =>
265 Provider.effect(
266 TargetGroup,
267 Effect.gen(function* () {
268 const toName = (id: string, props: { name?: string } = {}) =>
269 props.name
270 ? Effect.succeed(props.name)
271 : createPhysicalName({ id, maxLength: 63, lowercase: true });
272
273 const observe = (targetGroupIdentifier: string) =>
274 vpclattice
275 .getTargetGroup({ targetGroupIdentifier })
276 .pipe(
277 Effect.catchTag("ResourceNotFoundException", () =>
278 Effect.succeed(undefined),
279 ),
280 );
281
282 const findByName = (name: string) =>
283 vpclattice.listTargetGroups.pages({}).pipe(
284 Stream.runCollect,
285 Effect.map((chunk) =>
286 Array.from(chunk)
287 .flatMap((page) => page.items ?? [])
288 .find((t) => t.name === name),
289 ),
290 Effect.flatMap((summary) =>
291 summary?.id ? observe(summary.id) : Effect.succeed(undefined),
292 ),
293 );
294
295 const syncTags = Effect.fn(function* (
296 arn: string,
297 desiredTags: Record<string, string>,
298 ) {
299 const listed = yield* vpclattice.listTagsForResource({
300 resourceArn: arn,
301 });
302 const { removed, upsert } = diffTags(
303 tagRecord(listed.tags),
304 desiredTags,
305 );
306 if (upsert.length > 0) {
307 yield* vpclattice.tagResource({
308 resourceArn: arn,
309 tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
310 });
311 }
312 if (removed.length > 0) {
313 yield* vpclattice.untagResource({
314 resourceArn: arn,
315 tagKeys: removed,
316 });
317 }
318 });
319
320 const syncTargets = Effect.fn(function* (
321 targetGroupId: string,
322 desired: TargetGroupTarget[],
323 ) {
324 const observed = yield* vpclattice.listTargets
325 .pages({ targetGroupIdentifier: targetGroupId })
326 .pipe(
327 Stream.runCollect,
328 Effect.map((chunk) =>
329 Array.from(chunk).flatMap((page) => page.items ?? []),
330 ),
331 );
332 const desiredKeys = new Set(desired.map(targetKey));
333 const observedKeys = new Set(observed.map(targetKey));
334 // DRAINING targets are already being deregistered — leave them alone.
335 const toRemove = observed.filter(
336 (t): t is typeof t & { id: string } =>
337 t.id != null &&
338 !desiredKeys.has(targetKey(t)) &&
339 t.status !== "DRAINING",
340 );
341 const toAdd = desired.filter((t) => !observedKeys.has(targetKey(t)));
342 if (toRemove.length > 0) {
343 yield* vpclattice.deregisterTargets({
344 targetGroupIdentifier: targetGroupId,
345 targets: toRemove.map((t) => ({ id: t.id, port: t.port })),
346 });
347 }
348 if (toAdd.length > 0) {
349 // Partial failures are reported in `unsuccessful`, not thrown.
350 const result = yield* retryOnConflict(
351 vpclattice.registerTargets({
352 targetGroupIdentifier: targetGroupId,
353 targets: toAdd,
354 }),
355 );
356 const unsuccessful = result.unsuccessful ?? [];
357 if (unsuccessful.length > 0) {
358 return yield* Effect.fail(
359 new TargetRegistrationFailed({ targetGroupId, unsuccessful }),
360 );
361 }
362 }
363 });
364
365 return {
366 stables: ["targetGroupId", "targetGroupArn", "name", "type"],
367 diff: Effect.fn(function* ({ id, olds, news }) {
368 if (!isResolved(news)) return;
369 if (
370 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
371 ) {
372 return { action: "replace" } as const;
373 }
374 // Everything except healthCheck, targets, and tags is fixed at
375 // creation time.
376 if (
377 olds?.type !== news.type ||
378 (olds?.port ?? undefined) !== news.port ||
379 (olds?.protocol ?? undefined) !== news.protocol ||
380 (olds?.protocolVersion ?? undefined) !== news.protocolVersion ||
381 (olds?.ipAddressType ?? undefined) !== news.ipAddressType ||
382 (olds?.vpcIdentifier ?? undefined) !== news.vpcIdentifier ||
383 (olds?.lambdaEventStructureVersion ?? undefined) !==
384 news.lambdaEventStructureVersion
385 ) {
386 return { action: "replace" } as const;
387 }
388 }),
389 read: Effect.fn(function* ({ id, olds, output }) {
390 const group = output?.targetGroupId
391 ? yield* observe(output.targetGroupId)
392 : yield* findByName(yield* toName(id, olds ?? {}));
393 if (!group?.arn || !group.id) return undefined;
394 const listed = yield* vpclattice.listTagsForResource({
395 resourceArn: group.arn,
396 });
397 const attrs = {
398 targetGroupId: group.id,
399 targetGroupArn: group.arn,
400 name: group.name!,
401 type: (group.type as TargetGroupType) ?? "IP",
402 status: group.status ?? "UNKNOWN",
403 tags: tagRecord(listed.tags),
404 };
405 return (yield* hasAlchemyTags(id, listed.tags))
406 ? attrs
407 : Unowned(attrs);
408 }),
409 reconcile: Effect.fn(function* ({ id, news, output, session }) {
410 const name = yield* toName(id, news);
411 const internalTags = yield* createInternalTags(id);
412 const desiredTags = { ...internalTags, ...news.tags };
413
414 // Observe — prefer the stable id cache, fall back to name lookup.
415 let group = output?.targetGroupId
416 ? yield* observe(output.targetGroupId)
417 : yield* findByName(name);
418
419 // Ensure — create if missing.
420 if (!group?.arn || !group.id) {
421 const created = yield* vpclattice
422 .createTargetGroup({
423 name,
424 type: news.type,
425 config:
426 news.type === "LAMBDA" &&
427 news.lambdaEventStructureVersion === undefined
428 ? undefined
429 : {
430 port: news.port,
431 protocol: news.protocol,
432 protocolVersion: news.protocolVersion,
433 ipAddressType: news.ipAddressType,
434 vpcIdentifier: news.vpcIdentifier,
435 lambdaEventStructureVersion:
436 news.lambdaEventStructureVersion,
437 healthCheck: news.healthCheck
438 ? toWireHealthCheck(news.healthCheck)
439 : undefined,
440 },
441 })
442 .pipe(
443 Effect.catchTag("ConflictException", () => findByName(name)),
444 );
445 if (!created?.arn || !created.id) {
446 return yield* Effect.fail(
447 new Error(`Failed to create target group ${name}`),
448 );
449 }
450 group = yield* observe(created.id);
451 if (!group?.arn || !group.id) {
452 group = { id: created.id, arn: created.arn };
453 }
454 }
455 const targetGroupId = group.id;
456 const targetGroupArn = group.arn;
457 if (!targetGroupId || !targetGroupArn) {
458 return yield* Effect.fail(
459 new Error(`Target group ${name} is missing its id/arn`),
460 );
461 }
462
463 // Target groups reject updates while CREATE_IN_PROGRESS.
464 const stable = yield* waitUntilStable(observe(targetGroupId));
465
466 // Sync health check — the only mutable setting (IP/INSTANCE only).
467 if (news.healthCheck !== undefined) {
468 const desired = toWireHealthCheck(news.healthCheck);
469 const observedHealthCheck = stable?.config?.healthCheck ?? {};
470 const drifted = (
471 Object.entries(desired) as [
472 keyof vpclattice.HealthCheckConfig,
473 unknown,
474 ][]
475 ).some(
476 ([key, value]) =>
477 value !== undefined &&
478 JSON.stringify(observedHealthCheck[key]) !==
479 JSON.stringify(value),
480 );
481 if (drifted) {
482 yield* retryOnConflict(
483 vpclattice.updateTargetGroup({
484 targetGroupIdentifier: targetGroupId,
485 healthCheck: desired,
486 }),
487 );
488 }
489 }
490
491 // Sync targets — register missing, deregister extras.
492 yield* syncTargets(targetGroupId, news.targets ?? []);
493
494 yield* syncTags(targetGroupArn, desiredTags);
495
496 const final = yield* observe(targetGroupId);
497 yield* session.note(targetGroupArn);
498 return {
499 targetGroupId,
500 targetGroupArn,
501 name,
502 type: news.type,
503 status: final?.status ?? stable?.status ?? "ACTIVE",
504 tags: desiredTags,
505 };
506 }),
507 list: () =>
508 Effect.gen(function* () {
509 const summaries = yield* vpclattice.listTargetGroups.pages({}).pipe(
510 Stream.runCollect,
511 Effect.map((chunk) =>
512 Array.from(chunk).flatMap((page) => page.items ?? []),
513 ),
514 );
515 return yield* Effect.forEach(
516 summaries.filter(
517 (s): s is typeof s & { id: string; arn: string } =>
518 s.id != null && s.arn != null,
519 ),
520 (summary) =>
521 Effect.gen(function* () {
522 const listed = yield* vpclattice.listTagsForResource({
523 resourceArn: summary.arn,
524 });
525 return {
526 targetGroupId: summary.id,
527 targetGroupArn: summary.arn,
528 name: summary.name!,
529 type: (summary.type as TargetGroupType) ?? "IP",
530 status: summary.status ?? "UNKNOWN",
531 tags: tagRecord(listed.tags),
532 };
533 }),
534 { concurrency: 10 },
535 );
536 }),
537 delete: Effect.fn(function* ({ output }) {
538 // Deregister any remaining targets first so the group doesn't sit
539 // in a draining conflict while deleting.
540 yield* syncTargets(output.targetGroupId, []).pipe(
541 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
542 );
543 // Deregistered targets sit in DRAINING for a while and
544 // DeleteTargetGroup rejects with `ConflictException: TargetGroup
545 // has targets registered with it` until they are gone. Wait
546 // (bounded) for the drain to complete.
547 yield* Effect.repeat(
548 vpclattice
549 .listTargets({ targetGroupIdentifier: output.targetGroupId })
550 .pipe(
551 Effect.map((r) => r.items.length),
552 Effect.catchTag("ResourceNotFoundException", () =>
553 Effect.succeed(0),
554 ),
555 ),
556 {
557 schedule: Schedule.max([
558 Schedule.spaced("3 seconds"),
559 Schedule.recurs(15),
560 ]),
561 until: (remaining): boolean => remaining === 0,
562 },
563 );
564 yield* retryOnConflict(
565 vpclattice.deleteTargetGroup({
566 targetGroupIdentifier: output.targetGroupId,
567 }),
568 ).pipe(
569 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
570 );
571 // Deletion is asynchronous while targets drain; wait until the
572 // group is actually gone so dependent VPC deletes don't conflict.
573 yield* waitUntilAbsent(observe(output.targetGroupId));
574 }),
575 };
576 }),
577 );