| 1 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 2 | import type * as Duration from "effect/Duration"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 7 | import { toSeconds } from "../../Util/Duration.ts"; |
| 8 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 9 | import * as Provider from "../../Provider.ts"; |
| 10 | import { Resource } from "../../Resource.ts"; |
| 11 | import type { Providers } from "../Providers.ts"; |
| 12 | import { createInternalTags, diffTags } from "../../Tags.ts"; |
| 13 | import type { AccountID } from "../Environment.ts"; |
| 14 | import type { RegionID } from "../Region.ts"; |
| 15 | |
| 16 | export type TargetGroupName = string; |
| 17 | export type TargetGroupArn = |
| 18 | `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:targetgroup/${string}`; |
| 19 | |
| 20 | export 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 | |
| 69 | export 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 | */ |
| 142 | export const TargetGroup = Resource<TargetGroup>("AWS.ELBv2.TargetGroup"); |
| 143 | |
| 144 | export 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 | ); |