1import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { deepEqual, isResolved } from "../../Diff.ts";
7import { toSeconds } from "../../Util/Duration.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12import { createInternalTags, diffTags } from "../../Tags.ts";
13import type { AccountID } from "../Environment.ts";
14import type { RegionID } from "../Region.ts";
15
16export type TargetGroupName = string;
17export type TargetGroupArn =
18 `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:targetgroup/${string}`;
19
20export interface TargetGroupProps {
21 /** The target group name. If omitted, a unique name is generated. Changing it replaces the target group. */
22 name?: string;
23 /** The VPC the targets live in. Not required for `lambda` targets. Changing it replaces the target group. */
24 vpcId?: string;
25 /** The port on which targets receive traffic. Changing it replaces the target group. */
26 port?: number;
27 /**
28 * The protocol for routing traffic to targets. Changing it replaces the
29 * target group.
30 * @default "HTTP"
31 */
32 protocol?: "HTTP" | "HTTPS" | "TCP" | "UDP" | "TCP_UDP" | "TLS" | "GENEVE";
33 /**
34 * The application protocol version. Use `GRPC` for gRPC, `HTTP2` for HTTP/2.
35 * Changing it replaces the target group.
36 */
37 protocolVersion?: "HTTP1" | "HTTP2" | "GRPC";
38 /**
39 * The target type. Changing it replaces the target group.
40 * @default "ip"
41 */
42 targetType?: "ip" | "instance" | "lambda" | "alb";
43 /** The IP address type (`ipv4`/`ipv6`). Changing it replaces the target group. */
44 ipAddressType?: "ipv4" | "ipv6";
45 /** The health-check path (HTTP/HTTPS). Updated in place. */
46 healthCheckPath?: string;
47 /** The health-check port. Updated in place. */
48 healthCheckPort?: string;
49 /** The health-check protocol. Updated in place. */
50 healthCheckProtocol?: string;
51 /** Whether health checks are enabled. Updated in place. */
52 healthCheckEnabled?: boolean;
53 /** The approximate interval between health checks — e.g. `"15 seconds"`. Sent to AWS as whole seconds. Updated in place. */
54 healthCheckInterval?: Duration.Input;
55 /** The amount of time to wait for a health-check response — e.g. `"5 seconds"`. Sent to AWS as whole seconds. Updated in place. */
56 healthCheckTimeout?: Duration.Input;
57 /** The number of consecutive successes before a target is healthy. Updated in place. */
58 healthyThresholdCount?: number;
59 /** The number of consecutive failures before a target is unhealthy. Updated in place. */
60 unhealthyThresholdCount?: number;
61 /** The HTTP/gRPC codes used to determine a healthy response. Updated in place. */
62 matcher?: { HttpCode?: string; GrpcCode?: string };
63 /** Raw target-group attributes (deregistration delay, stickiness, slow start, ...). */
64 attributes?: Record<string, string>;
65 /** Tags to apply to the target group. */
66 tags?: Record<string, string>;
67}
68
69export interface TargetGroup extends Resource<
70 "AWS.ELBv2.TargetGroup",
71 TargetGroupProps,
72 {
73 /** The ARN of the target group. */
74 targetGroupArn: TargetGroupArn;
75 /** The name of the target group. */
76 targetGroupName: TargetGroupName;
77 /** Undefined for `lambda` target groups (they have no port). */
78 port: number | undefined;
79 /** Undefined for `lambda` target groups (they have no protocol). */
80 protocol: string | undefined;
81 /** The target type (`instance`, `ip`, `lambda`, or `alb`). */
82 targetType: string;
83 /** Undefined for `lambda` target groups (they are not VPC-scoped). */
84 vpcId: string | undefined;
85 /** The tags applied to the target group. */
86 tags: Record<string, string>;
87 },
88 never,
89 Providers
90> {}
91
92/**
93 * An ELBv2 target group. A target group routes requests to one or more
94 * registered targets (instances, IPs, Lambda functions, or another ALB) using
95 * the configured protocol and port, and runs health checks against them.
96 * ### Creating a Target Group
97 * **Example:** HTTP target group
98 * ```typescript
99 * const tg = yield* TargetGroup("web", {
100 * vpcId: vpc.vpcId,
101 * port: 80,
102 * protocol: "HTTP",
103 * targetType: "ip",
104 * });
105 * ```
106 *
107 * **Example:** Lambda target group
108 * ```typescript
109 * // No vpc/port/protocol — the target is a Lambda function.
110 * const tg = yield* TargetGroup("fn", {
111 * targetType: "lambda",
112 * });
113 * ```
114 *
115 * **Example:** gRPC target group
116 * ```typescript
117 * const tg = yield* TargetGroup("grpc", {
118 * vpcId: vpc.vpcId,
119 * port: 50051,
120 * protocol: "HTTP",
121 * protocolVersion: "GRPC",
122 * matcher: { GrpcCode: "0" },
123 * });
124 * ```
125 *
126 * ### Health Checks
127 * **Example:** Custom health-check thresholds
128 * ```typescript
129 * const tg = yield* TargetGroup("api", {
130 * vpcId: vpc.vpcId,
131 * port: 8080,
132 * protocol: "HTTP",
133 * healthCheckPath: "/healthz",
134 * healthCheckInterval: "15 seconds",
135 * healthyThresholdCount: 3,
136 * unhealthyThresholdCount: 3,
137 * });
138 * ```
139 *
140 * @resource
141 */
142export const TargetGroup = Resource<TargetGroup>("AWS.ELBv2.TargetGroup");
143
144export const TargetGroupProvider = () =>
145 Provider.effect(
146 TargetGroup,
147 Effect.gen(function* () {
148 const toName = (id: string, props: { name?: string } = {}) =>
149 props.name
150 ? Effect.succeed(props.name)
151 : createPhysicalName({ id, maxLength: 32, lowercase: true });
152
153 return {
154 stables: ["targetGroupArn", "targetGroupName", "vpcId"],
155 diff: Effect.fn(function* ({ id, olds, news }) {
156 if (!isResolved(news)) return;
157 if (
158 (yield* toName(id, olds ?? {})) !== (yield* toName(id, news ?? {}))
159 ) {
160 return { action: "replace" } as const;
161 }
162 if (
163 !deepEqual(
164 {
165 vpcId: olds.vpcId,
166 protocol: olds.protocol ?? "HTTP",
167 protocolVersion: olds.protocolVersion,
168 port: olds.port,
169 targetType: olds.targetType ?? "ip",
170 ipAddressType: olds.ipAddressType,
171 },
172 {
173 vpcId: news.vpcId,
174 protocol: news.protocol ?? "HTTP",
175 protocolVersion: news.protocolVersion,
176 port: news.port,
177 targetType: news.targetType ?? "ip",
178 ipAddressType: news.ipAddressType,
179 },
180 )
181 ) {
182 return { action: "replace" } as const;
183 }
184 }),
185 read: Effect.fn(function* ({ output }) {
186 if (!output) {
187 return undefined;
188 }
189 const described = yield* elbv2
190 .describeTargetGroups({
191 TargetGroupArns: [output.targetGroupArn],
192 })
193 .pipe(
194 Effect.catchTag("TargetGroupNotFoundException", () =>
195 Effect.succeed(undefined),
196 ),
197 );
198 const targetGroup = described?.TargetGroups?.[0];
199 if (!targetGroup?.TargetGroupArn) {
200 return undefined;
201 }
202 return {
203 ...output,
204 port: targetGroup.Port,
205 protocol: targetGroup.Protocol,
206 targetType: targetGroup.TargetType!,
207 vpcId: targetGroup.VpcId,
208 };
209 }),
210 // Target groups are account/region-scoped. Exhaustively paginate
211 // describeTargetGroups, then fetch tags per group (Attributes carry
212 // them) to produce the same shape `read` returns.
213 list: () =>
214 Effect.gen(function* () {
215 const targetGroups = yield* elbv2.describeTargetGroups
216 .pages({})
217 .pipe(
218 Stream.runCollect,
219 Effect.map((chunk) =>
220 Array.from(chunk).flatMap((page) =>
221 (page.TargetGroups ?? []).filter(
222 (
223 tg,
224 ): tg is elbv2.TargetGroup & { TargetGroupArn: string } =>
225 tg.TargetGroupArn != null,
226 ),
227 ),
228 ),
229 );
230 return yield* Effect.forEach(
231 targetGroups,
232 (tg) =>
233 Effect.gen(function* () {
234 const tagDescriptions = yield* elbv2
235 .describeTags({ ResourceArns: [tg.TargetGroupArn] })
236 .pipe(
237 Effect.catchTag("TargetGroupNotFoundException", () =>
238 Effect.succeed(undefined),
239 ),
240 );
241 const tags = Object.fromEntries(
242 (tagDescriptions?.TagDescriptions?.[0]?.Tags ?? [])
243 .filter(
244 (t): t is { Key: string; Value: string } =>
245 typeof t.Key === "string" &&
246 typeof t.Value === "string",
247 )
248 .map((t) => [t.Key, t.Value]),
249 );
250 return {
251 targetGroupArn: tg.TargetGroupArn as TargetGroupArn,
252 targetGroupName: tg.TargetGroupName!,
253 port: tg.Port,
254 protocol: tg.Protocol,
255 targetType: tg.TargetType!,
256 vpcId: tg.VpcId,
257 tags,
258 };
259 }),
260 { concurrency: 10 },
261 );
262 }),
263 reconcile: Effect.fn(function* ({ id, news, session }) {
264 const name = yield* toName(id, news);
265 const desiredTags = {
266 ...(yield* createInternalTags(id)),
267 ...news.tags,
268 };
269
270 // Observe — look up by deterministic name.
271 let described = yield* elbv2
272 .describeTargetGroups({
273 Names: [name],
274 })
275 .pipe(
276 Effect.catchTag("TargetGroupNotFoundException", () =>
277 Effect.succeed(undefined),
278 ),
279 );
280 let targetGroup = described?.TargetGroups?.[0];
281
282 // Ensure — create if missing. Stable axes (vpcId, port, protocol,
283 // targetType) are handled by diff so we don't deal with mismatch.
284 // Lambda target groups have no port/protocol/VPC — the API rejects
285 // those members, so omit them entirely.
286 const isLambdaTarget = news.targetType === "lambda";
287 if (!targetGroup?.TargetGroupArn) {
288 const created = yield* elbv2.createTargetGroup({
289 Name: name,
290 Port: isLambdaTarget ? undefined : news.port,
291 Protocol: isLambdaTarget ? undefined : (news.protocol ?? "HTTP"),
292 ProtocolVersion: isLambdaTarget
293 ? undefined
294 : news.protocolVersion,
295 VpcId: isLambdaTarget ? undefined : news.vpcId,
296 TargetType: news.targetType ?? "ip",
297 IpAddressType: isLambdaTarget ? undefined : news.ipAddressType,
298 HealthCheckPath: news.healthCheckPath,
299 HealthCheckPort: news.healthCheckPort,
300 HealthCheckProtocol: news.healthCheckProtocol,
301 HealthCheckEnabled: news.healthCheckEnabled,
302 HealthCheckIntervalSeconds: toSeconds(news.healthCheckInterval),
303 HealthCheckTimeoutSeconds: toSeconds(news.healthCheckTimeout),
304 HealthyThresholdCount: news.healthyThresholdCount,
305 UnhealthyThresholdCount: news.unhealthyThresholdCount,
306 Matcher: news.matcher,
307 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
308 Key,
309 Value,
310 })),
311 });
312 targetGroup = created.TargetGroups?.[0];
313 if (!targetGroup?.TargetGroupArn) {
314 return yield* Effect.die(
315 new Error("createTargetGroup returned no target group"),
316 );
317 }
318 }
319
320 const targetGroupArn = targetGroup.TargetGroupArn as TargetGroupArn;
321
322 // Sync health check — diff observed against desired; only call
323 // modifyTargetGroup when a health-check field actually changed.
324 const observedHc = {
325 HealthCheckPath: targetGroup.HealthCheckPath,
326 HealthCheckPort: targetGroup.HealthCheckPort,
327 HealthCheckProtocol: targetGroup.HealthCheckProtocol,
328 HealthCheckEnabled: targetGroup.HealthCheckEnabled,
329 HealthCheckIntervalSeconds: targetGroup.HealthCheckIntervalSeconds,
330 HealthCheckTimeoutSeconds: targetGroup.HealthCheckTimeoutSeconds,
331 HealthyThresholdCount: targetGroup.HealthyThresholdCount,
332 UnhealthyThresholdCount: targetGroup.UnhealthyThresholdCount,
333 Matcher: targetGroup.Matcher,
334 };
335 const desiredHc = {
336 HealthCheckPath: news.healthCheckPath ?? observedHc.HealthCheckPath,
337 HealthCheckPort: news.healthCheckPort ?? observedHc.HealthCheckPort,
338 HealthCheckProtocol:
339 news.healthCheckProtocol ?? observedHc.HealthCheckProtocol,
340 HealthCheckEnabled:
341 news.healthCheckEnabled ?? observedHc.HealthCheckEnabled,
342 HealthCheckIntervalSeconds:
343 toSeconds(news.healthCheckInterval) ??
344 observedHc.HealthCheckIntervalSeconds,
345 HealthCheckTimeoutSeconds:
346 toSeconds(news.healthCheckTimeout) ??
347 observedHc.HealthCheckTimeoutSeconds,
348 HealthyThresholdCount:
349 news.healthyThresholdCount ?? observedHc.HealthyThresholdCount,
350 UnhealthyThresholdCount:
351 news.unhealthyThresholdCount ??
352 observedHc.UnhealthyThresholdCount,
353 Matcher: news.matcher ?? observedHc.Matcher,
354 };
355 if (!deepEqual(observedHc, desiredHc)) {
356 yield* elbv2.modifyTargetGroup({
357 TargetGroupArn: targetGroupArn,
358 ...desiredHc,
359 });
360 }
361
362 // Sync attributes — observed ↔ desired. Always apply when desired
363 // attrs are non-empty.
364 if (news.attributes && Object.keys(news.attributes).length > 0) {
365 yield* elbv2.modifyTargetGroupAttributes({
366 TargetGroupArn: targetGroupArn,
367 Attributes: Object.entries(news.attributes).map(
368 ([Key, Value]) => ({
369 Key,
370 Value,
371 }),
372 ),
373 });
374 }
375
376 // Sync tags — diff observed cloud tags against desired.
377 const tagDescriptions = yield* elbv2.describeTags({
378 ResourceArns: [targetGroupArn],
379 });
380 const observedTags = Object.fromEntries(
381 (tagDescriptions.TagDescriptions?.[0]?.Tags ?? [])
382 .filter(
383 (t): t is { Key: string; Value: string } =>
384 typeof t.Key === "string" && typeof t.Value === "string",
385 )
386 .map((t) => [t.Key, t.Value]),
387 );
388 const { removed, upsert } = diffTags(observedTags, desiredTags);
389 if (upsert.length > 0) {
390 yield* elbv2.addTags({
391 ResourceArns: [targetGroupArn],
392 Tags: upsert,
393 });
394 }
395 if (removed.length > 0) {
396 yield* elbv2.removeTags({
397 ResourceArns: [targetGroupArn],
398 TagKeys: removed,
399 });
400 }
401
402 yield* session.note(targetGroupArn);
403 return {
404 targetGroupArn,
405 targetGroupName: targetGroup.TargetGroupName!,
406 port: targetGroup.Port,
407 protocol: targetGroup.Protocol,
408 targetType: targetGroup.TargetType!,
409 vpcId: targetGroup.VpcId,
410 tags: desiredTags,
411 };
412 }),
413 delete: Effect.fn(function* ({ output }) {
414 // deleteTargetGroup is idempotent on a missing target group (returns
415 // success). It only fails with ResourceInUseException while a
416 // listener/rule still references it — retry briefly for the
417 // eventual-consistency window after the dependents are removed.
418 yield* elbv2
419 .deleteTargetGroup({
420 TargetGroupArn: output.targetGroupArn,
421 })
422 .pipe(
423 Effect.retry({
424 while: (e) => e._tag === "ResourceInUseException",
425 schedule: Schedule.max([
426 Schedule.spaced("3 seconds"),
427 Schedule.recurs(8),
428 ]),
429 }),
430 Effect.catchTag("ResourceInUseException", () => Effect.void),
431 );
432 }),
433 };
434 }),
435 );