1import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import type { Input } from "../../Input.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10import { type TargetGroup, type TargetGroupArn } from "./TargetGroup.ts";
11
12export interface TargetGroupAttachmentProps {
13 /** The target group to register the target with. Changing it replaces the attachment. */
14 targetGroupArn: Input<TargetGroupArn> | TargetGroup;
15 /**
16 * The target ID: an instance ID (`instance` target type), an IP address
17 * (`ip`), a Lambda function ARN (`lambda`), or an ALB ARN (`alb`). Changing
18 * it replaces the attachment.
19 */
20 targetId: string;
21 /**
22 * The port on which the target receives traffic. Defaults to the target
23 * group port. Not applicable to `lambda` targets. Changing it replaces the
24 * attachment.
25 */
26 port?: number;
27 /**
28 * The Availability Zone of the target. Set to `all` to register an IP
29 * target outside the target group's VPC (e.g. an on-prem address). Changing
30 * it replaces the attachment.
31 */
32 availabilityZone?: string;
33}
34
35export interface TargetGroupAttachment extends Resource<
36 "AWS.ELBv2.TargetGroupAttachment",
37 TargetGroupAttachmentProps,
38 {
39 /** The ARN of the target group the target is registered with. */
40 targetGroupArn: TargetGroupArn;
41 /** The registered target: an instance ID, IP address, or Lambda/ALB ARN. */
42 targetId: string;
43 /** The port the target receives traffic on, if applicable. */
44 port: number | undefined;
45 /** The Availability Zone the target was registered in, if specified. */
46 availabilityZone: string | undefined;
47 },
48 never,
49 Providers
50> {}
51
52/**
53 * Registers a single target (instance, IP address, Lambda function, or ALB)
54 * with an ELBv2 target group. ECS services register their own tasks, so this
55 * resource matters for Lambda-behind-ALB, EC2 instances, and static IPs.
56 *
57 * For `lambda` targets, the Lambda function's resource policy must allow
58 * `elasticloadbalancing.amazonaws.com` to invoke it, scoped to the target
59 * group ARN — create a {@link Permission} first. The provider retries the
60 * registration briefly while that permission propagates.
61 * ### Registering Targets
62 * **Example:** Lambda function target
63 * ```typescript
64 * const tg = yield* TargetGroup("fn", { targetType: "lambda" });
65 * yield* Lambda.Permission("AlbInvoke", {
66 * action: "lambda:InvokeFunction",
67 * functionName: fn.functionArn.as<string>(),
68 * principal: "elasticloadbalancing.amazonaws.com",
69 * sourceArn: tg.targetGroupArn.as<string>(),
70 * });
71 * yield* TargetGroupAttachment("fn-target", {
72 * targetGroupArn: tg.targetGroupArn,
73 * targetId: fn.functionArn.as<string>(),
74 * });
75 * ```
76 *
77 * **Example:** IP address target
78 * ```typescript
79 * yield* TargetGroupAttachment("ip-target", {
80 * targetGroupArn: tg.targetGroupArn,
81 * targetId: "10.0.1.15",
82 * port: 8080,
83 * });
84 * ```
85 *
86 * **Example:** EC2 instance target
87 * ```typescript
88 * yield* TargetGroupAttachment("instance-target", {
89 * targetGroupArn: tg.targetGroupArn,
90 * targetId: instance.instanceId,
91 * port: 80,
92 * });
93 * ```
94 *
95 * @resource
96 */
97export const TargetGroupAttachment = Resource<TargetGroupAttachment>(
98 "AWS.ELBv2.TargetGroupAttachment",
99);
100
101// Registering a Lambda target validates that the function's resource policy
102// grants elasticloadbalancing.amazonaws.com invoke permission. The Permission
103// resource is often created in the same deploy with no data dependency on the
104// attachment, so registerTargets can run before the policy lands. ELB surfaces
105// the missing-permission case as either `InvalidTargetException` or an
106// empty-message `AccessDeniedException` (both members of the registerTargets
107// error union — AccessDeniedException via CommonErrors), so retry both briefly
108// while IAM propagates. Explicitly typed so `Retry.Return`'s conditional type
109// never leaks into declaration emit.
110const retryThroughPermissionPropagation = <A, E extends { _tag: string }, R>(
111 self: Effect.Effect<A, E, R>,
112): Effect.Effect<A, E, R> =>
113 Effect.retry(self, {
114 while: (e) =>
115 e._tag === "InvalidTargetException" || e._tag === "AccessDeniedException",
116 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(15)]),
117 });
118
119const wireTarget = (props: {
120 targetId: string;
121 port?: number;
122 availabilityZone?: string;
123}): elbv2.TargetDescription => ({
124 Id: props.targetId,
125 Port: props.port,
126 AvailabilityZone: props.availabilityZone,
127});
128
129export const TargetGroupAttachmentProvider = () =>
130 Provider.succeed(TargetGroupAttachment, {
131 stables: ["targetGroupArn", "targetId", "port", "availabilityZone"],
132 diff: Effect.fn(function* ({ olds, news }) {
133 if (!isResolved(news)) return;
134 // Registration has no mutable aspect — every change is a replacement.
135 if (
136 olds.targetGroupArn !== news.targetGroupArn ||
137 olds.targetId !== news.targetId ||
138 olds.port !== news.port ||
139 olds.availabilityZone !== news.availabilityZone
140 ) {
141 return { action: "replace" } as const;
142 }
143 }),
144 read: Effect.fn(function* ({ output }) {
145 if (!output) {
146 return undefined;
147 }
148 // describeTargetHealth without an explicit Targets filter lists only
149 // registered targets, so presence in the response means registered.
150 const health = yield* elbv2
151 .describeTargetHealth({ TargetGroupArn: output.targetGroupArn })
152 .pipe(
153 Effect.catchTag(["TargetGroupNotFoundException"], () =>
154 Effect.succeed(undefined),
155 ),
156 );
157 const registered = health?.TargetHealthDescriptions?.some(
158 (d) =>
159 d.Target?.Id === output.targetId &&
160 (output.port === undefined || d.Target?.Port === output.port),
161 );
162 return registered ? output : undefined;
163 }),
164 // Attachments belong to a target group. Enumerate every target group,
165 // then every registered target.
166 list: Effect.fn(function* () {
167 const targetGroupArns = yield* elbv2.describeTargetGroups.pages({}).pipe(
168 Stream.runCollect,
169 Effect.map((chunk) =>
170 Array.from(chunk).flatMap((page) =>
171 (page.TargetGroups ?? []).flatMap((tg) =>
172 tg.TargetGroupArn ? [tg.TargetGroupArn as TargetGroupArn] : [],
173 ),
174 ),
175 ),
176 );
177 const rows = yield* Effect.forEach(
178 targetGroupArns,
179 (targetGroupArn) =>
180 elbv2.describeTargetHealth({ TargetGroupArn: targetGroupArn }).pipe(
181 Effect.map((health) =>
182 (health.TargetHealthDescriptions ?? []).flatMap((d) =>
183 d.Target?.Id
184 ? [
185 {
186 targetGroupArn,
187 targetId: d.Target.Id,
188 port: d.Target.Port,
189 availabilityZone: d.Target.AvailabilityZone,
190 },
191 ]
192 : [],
193 ),
194 ),
195 // The group may vanish between enumeration and health lookup.
196 Effect.catchTag(["TargetGroupNotFoundException"], () =>
197 Effect.succeed([]),
198 ),
199 ),
200 { concurrency: 10 },
201 );
202 const result: TargetGroupAttachment["Attributes"][] = rows.flat();
203 return result;
204 }),
205 reconcile: Effect.fn(function* ({ news, session }) {
206 const targetGroupArn = news.targetGroupArn as TargetGroupArn;
207 const target = wireTarget(news);
208
209 // Observe — is the target already registered?
210 const health = yield* elbv2
211 .describeTargetHealth({ TargetGroupArn: targetGroupArn })
212 .pipe(
213 Effect.catchTag(["InvalidTargetException"], () =>
214 Effect.succeed(undefined),
215 ),
216 );
217 const registered = health?.TargetHealthDescriptions?.some(
218 (d) =>
219 d.Target?.Id === news.targetId &&
220 (news.port === undefined || d.Target?.Port === news.port),
221 );
222
223 // Ensure — registerTargets is an idempotent put, but skip the call
224 // (and its permission-propagation retry) when already registered.
225 if (!registered) {
226 yield* retryThroughPermissionPropagation(
227 elbv2.registerTargets({
228 TargetGroupArn: targetGroupArn,
229 Targets: [target],
230 }),
231 );
232 }
233
234 yield* session.note(`${news.targetId} -> ${targetGroupArn}`);
235 return {
236 targetGroupArn,
237 targetId: news.targetId,
238 port: news.port,
239 availabilityZone: news.availabilityZone,
240 };
241 }),
242 delete: Effect.fn(function* ({ output }) {
243 yield* elbv2
244 .deregisterTargets({
245 TargetGroupArn: output.targetGroupArn,
246 Targets: [
247 wireTarget({
248 targetId: output.targetId,
249 port: output.port,
250 availabilityZone: output.availabilityZone,
251 }),
252 ],
253 })
254 .pipe(
255 // Already deregistered / group already deleted.
256 Effect.catchTag(
257 ["InvalidTargetException", "TargetGroupNotFoundException"],
258 () => Effect.void,
259 ),
260 );
261 }),
262 });