1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitRegionOperation } from "./operations.ts";
3import * as Data from "effect/Data";
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 { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19
20const DEFAULT_SESSION_AFFINITY = "NONE";
21const MAX_NAME_LENGTH = 63;
22
23export type TargetPoolSessionAffinity =
24 | "NONE"
25 | "CLIENT_IP"
26 | "CLIENT_IP_PROTO"
27 | (string & {});
28
29export type TargetPoolProps = {
30 /**
31 * Target pool name (RFC1035, 1-63 characters). If omitted, a unique
32 * name is generated from the stack, stage, and logical id. Immutable —
33 * changing it replaces the pool.
34 */
35 targetPoolName?: string;
36 /**
37 * Region the pool lives in. Immutable — changing it replaces the pool.
38 * `US-CENTRAL1` is accepted and normalized to `us-central1`.
39 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
40 */
41 region?: string;
42 /**
43 * Optional description. Compute target pools have no labels field, so
44 * Alchemy ownership (`alchemy-stack` / `alchemy-stage` / `alchemy-id`)
45 * is stored in a `[alchemy …]` prefix for `list` / nuke. Immutable —
46 * changing the user-facing description replaces the pool.
47 */
48 description?: string;
49 /**
50 * Session affinity. Immutable — changing it replaces the pool.
51 * `NONE` spreads connections. `CLIENT_IP` pins by client IP.
52 * `CLIENT_IP_PROTO` pins by client IP and protocol.
53 * @default "NONE"
54 */
55 sessionAffinity?: TargetPoolSessionAffinity;
56 /**
57 * Backup target pool URL or name in the same region. Used with
58 * `failoverRatio` when this pool is the primary for a forwarding rule.
59 * Updated in place via `setBackup`.
60 */
61 backupPool?: string;
62 /**
63 * Failover threshold in `[0, 1]`. Required when `backupPool` is set.
64 * Traffic fails over when the ratio of healthy instances in this pool
65 * is at or below this value. Updated in place via `setBackup`.
66 */
67 failoverRatio?: number;
68 /**
69 * Legacy HttpHealthCheck URLs or names (at most one). Only the
70 * `httpHealthChecks` collection is supported — modern HealthCheck
71 * resources cannot be attached. When omitted, existing checks are
72 * left as-is; pass `[]` to detach.
73 */
74 healthChecks?: string[];
75 /**
76 * Member instance URLs (`projects/{project}/zones/{zone}/instances/{name}`)
77 * or `zone/name` shorthand. Instances must be in this pool's region.
78 * When omitted, membership is left as-is (managed instance groups may
79 * add members). Pass `[]` to detach every instance.
80 */
81 instances?: string[];
82 /**
83 * Cloud Armor security policy URL. When omitted, the current policy is
84 * left as-is. Pass `""` to detach. Updated in place via
85 * `setSecurityPolicy`.
86 */
87 securityPolicy?: string;
88};
89
90export type TargetPool = Resource<
91 "GCP.Compute.TargetPool",
92 TargetPoolProps,
93 {
94 /** Target pool name. */
95 targetPoolName: string;
96 /** Project id. */
97 project: string;
98 /** Region short name (`us-central1`). */
99 region: string;
100 /** User description with the Alchemy ownership prefix stripped. */
101 description: string | undefined;
102 /** Session affinity. */
103 sessionAffinity: string | undefined;
104 /** Backup pool URL, if set. */
105 backupPool: string | undefined;
106 /** Failover ratio, if set. */
107 failoverRatio: number | undefined;
108 /** Legacy HttpHealthCheck URLs. */
109 healthChecks: ReadonlyArray<string>;
110 /** Member instance URLs. */
111 instances: ReadonlyArray<string>;
112 /** Attached Cloud Armor policy URL, if any. */
113 securityPolicy: string | undefined;
114 /** Server-defined URL. */
115 selfLink: string | undefined;
116 /** Server-assigned numeric id. */
117 targetPoolId: string | undefined;
118 /** RFC3339 creation timestamp. */
119 creationTimestamp: string | undefined;
120 /** Resource kind. */
121 kind: string | undefined;
122 },
123 never,
124 Providers
125>;
126
127/**
128 * A regional Compute Engine target pool.
129 *
130 * Target pools are the backend for external network load balancers
131 * (target-pool forwarding rules). Members are zonal instances in the
132 * same region. Health checking uses legacy HttpHealthCheck resources
133 * only. Compute TargetPool has no labels field — Alchemy ownership is
134 * stored in the description so nuke can find leaked pools.
135 *
136 * ### Creating a Target Pool
137 * **Example:** Generated name
138 * ```typescript
139 * const pool = yield* GCP.Compute.TargetPool("backends", {});
140 * ```
141 *
142 * **Example:** Named pool with session affinity
143 * ```typescript
144 * const pool = yield* GCP.Compute.TargetPool("backends", {
145 * targetPoolName: "app-nlb",
146 * region: "us-central1",
147 * sessionAffinity: "CLIENT_IP",
148 * description: "network load balancer",
149 * });
150 * ```
151 *
152 * ### Failover
153 * **Example:** Primary pool with a backup
154 * ```typescript
155 * const backup = yield* GCP.Compute.TargetPool("failover", {});
156 * const primary = yield* GCP.Compute.TargetPool("backends", {
157 * backupPool: backup.selfLink,
158 * failoverRatio: 0.5,
159 * });
160 * ```
161 *
162 * ### Instances
163 * **Example:** Pin specific VMs
164 * ```typescript
165 * const pool = yield* GCP.Compute.TargetPool("backends", {
166 * instances: [
167 * "projects/{project}/zones/us-central1-a/instances/web-1",
168 * "us-central1-b/web-2",
169 * ],
170 * });
171 * ```
172 *
173 * @resource
174 * @category Compute
175 */
176export const TargetPool = Resource<TargetPool>("GCP.Compute.TargetPool");
177
178export class TargetPoolNotResolved extends Data.TaggedError(
179 "GCP.Compute.TargetPoolNotResolved",
180)<{
181 targetPoolName: string;
182 region: string;
183}> {}
184
185export class TargetPoolPending extends Data.TaggedError(
186 "GCP.Compute.TargetPoolPending",
187)<{
188 targetPoolName: string;
189 status: string;
190}> {}
191
192const lastSegment = (value: string | undefined) => {
193 if (!value) return "";
194 const trimmed = value.replace(/\/+$/, "");
195 const parts = trimmed.split("/");
196 return parts[parts.length - 1] || trimmed;
197};
198
199const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
200 lastSegment(region ?? defaultRegion).toLowerCase();
201
202const sessionAffinityOf = (value: string | undefined) =>
203 value && value.length > 0 ? value : DEFAULT_SESSION_AFFINITY;
204
205const encodeDescription = (
206 labels: Record<string, string>,
207 description: string | undefined,
208): string => {
209 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
210 return description ? `${marker}\n${description}` : marker;
211};
212
213const parseDescription = (
214 description: string | undefined,
215): {
216 labels: Record<string, string>;
217 description: string | undefined;
218} => {
219 if (!description?.startsWith("[alchemy ")) {
220 return { labels: {}, description };
221 }
222 const end = description.indexOf("]");
223 if (end < 0) return { labels: {}, description };
224 const labels: Record<string, string> = {};
225 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
226 const eq = part.indexOf("=");
227 if (eq > 0) {
228 labels[part.slice(0, eq)] = part.slice(eq + 1);
229 }
230 }
231 const rest = description.slice(end + 1).replace(/^\n/, "");
232 return { labels, description: rest.length > 0 ? rest : undefined };
233};
234
235const toName = (id: string, name: string | undefined, existing?: string) =>
236 Effect.gen(function* () {
237 if (name !== undefined) return name;
238 if (existing !== undefined) return existing;
239 const generated = yield* createPhysicalName({
240 id,
241 maxLength: MAX_NAME_LENGTH,
242 lowercase: true,
243 });
244 const rfc = generated.replace(/^[^a-z]+/, "t").replace(/-+$/g, "");
245 return rfc.slice(0, MAX_NAME_LENGTH);
246 });
247
248const toPoolUrl = (project: string, region: string, pool: string) => {
249 if (pool.includes("/")) return pool;
250 return `projects/${project}/regions/${region}/targetPools/${pool}`;
251};
252
253const toHealthCheckUrl = (project: string, healthCheck: string) => {
254 if (healthCheck.includes("/")) return healthCheck;
255 return `projects/${project}/global/httpHealthChecks/${healthCheck}`;
256};
257
258const toInstanceUrl = (project: string, instance: string) => {
259 if (instance.startsWith("http") || instance.startsWith("projects/")) {
260 return instance;
261 }
262 const parts = instance.split("/").filter((part) => part.length > 0);
263 if (parts.length === 2 && parts[0] !== "zones") {
264 return `projects/${project}/zones/${parts[0]}/instances/${parts[1]}`;
265 }
266 if (parts[0] === "zones") {
267 return `projects/${project}/${parts.join("/")}`;
268 }
269 return instance;
270};
271
272const instanceKey = (value: string) => {
273 const parts = value.replace(/\/+$/, "").split("/").filter(Boolean);
274 const index = parts.lastIndexOf("instances");
275 if (index >= 1 && parts[index + 1]) {
276 return `${parts[index - 1]}/${parts[index + 1]}`.toLowerCase();
277 }
278 if (parts.length === 2) {
279 return `${parts[0]}/${parts[1]}`.toLowerCase();
280 }
281 return lastSegment(value).toLowerCase();
282};
283
284const toAttrs = (
285 pool: compute.TargetPool,
286 project: string,
287): TargetPool["Attributes"] => {
288 const parsed = parseDescription(pool.description);
289 return {
290 targetPoolName: pool.name ?? "",
291 project,
292 region: lastSegment(pool.region).toLowerCase(),
293 description: parsed.description,
294 sessionAffinity: sessionAffinityOf(pool.sessionAffinity),
295 backupPool: pool.backupPool,
296 failoverRatio: pool.failoverRatio,
297 healthChecks: pool.healthChecks ?? [],
298 instances: pool.instances ?? [],
299 securityPolicy: pool.securityPolicy,
300 selfLink: pool.selfLink,
301 targetPoolId: pool.id,
302 creationTimestamp: pool.creationTimestamp,
303 kind: pool.kind,
304 };
305};
306
307const getByName = (project: string, region: string, targetPool: string) =>
308 compute
309 .getTargetPools({ project, region, targetPool })
310 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
311
312const runOp = <E, R>(
313 project: string,
314 region: string,
315 targetPoolName: string,
316 start: Effect.Effect<compute.Operation, E, R>,
317) =>
318 start.pipe(
319 Effect.flatMap((operation) =>
320 waitRegionOperation(project, region, operation),
321 ),
322 );
323
324const requirePool = (project: string, region: string, targetPoolName: string) =>
325 getByName(project, region, targetPoolName).pipe(
326 Effect.flatMap((pool) =>
327 pool
328 ? Effect.succeed(pool)
329 : Effect.fail(new TargetPoolNotResolved({ targetPoolName, region })),
330 ),
331 Effect.retry({
332 while: (error) => error._tag === "GCP.Compute.TargetPoolNotResolved",
333 schedule: Schedule.spaced("1 second"),
334 times: 8,
335 }),
336 );
337
338const waitUntilGone = (
339 project: string,
340 region: string,
341 targetPoolName: string,
342) =>
343 getByName(project, region, targetPoolName).pipe(
344 Effect.flatMap((pool) =>
345 pool === undefined
346 ? Effect.void
347 : Effect.fail(
348 new TargetPoolPending({
349 targetPoolName,
350 status: "EXISTS",
351 }),
352 ),
353 ),
354 Effect.retry({
355 while: (error) => error._tag === "GCP.Compute.TargetPoolPending",
356 times: 10,
357 schedule: Schedule.spaced("2 seconds"),
358 }),
359 );
360
361const sameRef = (left: string | undefined, right: string | undefined) =>
362 lastSegment(left).toLowerCase() === lastSegment(right).toLowerCase();
363
364export const TargetPoolProvider = () =>
365 Provider.succeed(TargetPool, {
366 stables: [
367 "targetPoolName",
368 "project",
369 "region",
370 "targetPoolId",
371 "selfLink",
372 "sessionAffinity",
373 "creationTimestamp",
374 ],
375
376 diff: Effect.fn(function* ({ news, olds, output }) {
377 if (!isResolved(news)) return undefined;
378 const env = yield* GcpEnvironment.current;
379 const previousName = olds?.targetPoolName ?? output?.targetPoolName;
380 const nextName = news.targetPoolName ?? previousName;
381 const nameChanged =
382 previousName !== undefined &&
383 nextName !== undefined &&
384 nextName !== previousName;
385
386 const previousRegion = normalizeRegion(
387 olds?.region ?? output?.region,
388 env.region,
389 );
390 const nextRegion = normalizeRegion(
391 news.region ?? output?.region,
392 env.region,
393 );
394 const regionChanged = previousRegion !== nextRegion;
395
396 const affinityChanged =
397 news.sessionAffinity !== undefined &&
398 sessionAffinityOf(news.sessionAffinity) !==
399 sessionAffinityOf(olds?.sessionAffinity ?? output?.sessionAffinity);
400
401 const previousDescription =
402 olds?.description ?? output?.description ?? "";
403 const descriptionChanged =
404 news.description !== undefined &&
405 (news.description ?? "") !== previousDescription;
406
407 if (nameChanged || regionChanged) {
408 return { action: "replace" as const, deleteFirst: false };
409 }
410 if (affinityChanged || descriptionChanged) {
411 return {
412 action: "replace" as const,
413 deleteFirst:
414 previousName !== undefined &&
415 (news.targetPoolName === undefined ||
416 news.targetPoolName === previousName),
417 };
418 }
419 return undefined;
420 }),
421
422 read: Effect.fn(function* ({ id, olds, output }) {
423 const env = yield* GcpEnvironment.current;
424 const targetPoolName = yield* toName(
425 id,
426 olds?.targetPoolName,
427 output?.targetPoolName,
428 );
429 const region = normalizeRegion(
430 olds?.region ?? output?.region,
431 env.region,
432 );
433 const existing = yield* getByName(env.project, region, targetPoolName);
434 if (existing === undefined) return undefined;
435 const attrs = toAttrs(existing, env.project);
436 const { labels } = parseDescription(existing.description);
437 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
438 }),
439
440 list: () =>
441 Effect.gen(function* () {
442 const env = yield* GcpEnvironment.current;
443 const pages = yield* compute.aggregatedListTargetPools
444 .pages({
445 project: env.project,
446 returnPartialSuccess: true,
447 maxResults: 500,
448 })
449 .pipe(Stream.runCollect);
450 return Array.from(pages).flatMap((page) =>
451 Object.values(page.items ?? {}).flatMap((scoped) =>
452 (scoped?.targetPools ?? [])
453 .filter((item) => {
454 const { labels } = parseDescription(item.description);
455 return Object.keys(labels).some((key) =>
456 key.startsWith("alchemy-"),
457 );
458 })
459 .map((item) => toAttrs(item, env.project)),
460 ),
461 );
462 }),
463
464 reconcile: Effect.fn(function* ({ id, news, output }) {
465 const env = yield* GcpEnvironment.current;
466 const targetPoolName = yield* toName(
467 id,
468 news.targetPoolName,
469 output?.targetPoolName,
470 );
471 const region = normalizeRegion(news.region ?? output?.region, env.region);
472 const ownership = yield* createInternalLabels(id);
473 const desiredDescription = encodeDescription(ownership, news.description);
474 const sessionAffinity = sessionAffinityOf(news.sessionAffinity);
475 const backupPool =
476 news.backupPool !== undefined && news.backupPool.length > 0
477 ? toPoolUrl(env.project, region, news.backupPool)
478 : undefined;
479 const healthChecks = news.healthChecks?.map((check) =>
480 toHealthCheckUrl(env.project, check),
481 );
482 const instances = news.instances?.map((instance) =>
483 toInstanceUrl(env.project, instance),
484 );
485
486 let current = yield* getByName(env.project, region, targetPoolName);
487
488 if (current === undefined) {
489 const created = yield* compute
490 .insertTargetPools({
491 project: env.project,
492 region,
493 body: {
494 name: targetPoolName,
495 description: desiredDescription,
496 sessionAffinity,
497 backupPool,
498 failoverRatio: news.failoverRatio,
499 healthChecks,
500 instances,
501 },
502 })
503 .pipe(
504 Effect.flatMap((operation) =>
505 waitRegionOperation(env.project, region, operation, {
506 ignore: ["RESOURCE_ALREADY_EXISTS"],
507 }).pipe(
508 Effect.flatMap(() =>
509 requirePool(env.project, region, targetPoolName),
510 ),
511 ),
512 ),
513 Effect.catchTag("Conflict", () =>
514 getByName(env.project, region, targetPoolName),
515 ),
516 );
517 current = created ?? undefined;
518 }
519
520 if (current === undefined) {
521 return yield* new TargetPoolNotResolved({ targetPoolName, region });
522 }
523
524 if (news.backupPool !== undefined || news.failoverRatio !== undefined) {
525 const desiredBackup =
526 news.backupPool !== undefined
527 ? news.backupPool.length > 0
528 ? toPoolUrl(env.project, region, news.backupPool)
529 : ""
530 : (current.backupPool ?? "");
531 const desiredFailover =
532 news.failoverRatio ?? current.failoverRatio ?? 0;
533 if (
534 !sameRef(desiredBackup, current.backupPool) ||
535 desiredFailover !== (current.failoverRatio ?? 0)
536 ) {
537 yield* runOp(
538 env.project,
539 region,
540 targetPoolName,
541 compute.setBackupTargetPools({
542 project: env.project,
543 region,
544 targetPool: targetPoolName,
545 failoverRatio: desiredFailover,
546 body: { target: desiredBackup },
547 }),
548 ).pipe(Effect.catchTag("Conflict", () => Effect.void));
549 current =
550 (yield* getByName(env.project, region, targetPoolName)) ?? current;
551 }
552 }
553
554 if (healthChecks !== undefined) {
555 const observed = current.healthChecks ?? [];
556 const observedKeys = new Set(observed.map((url) => lastSegment(url)));
557 const desiredKeys = new Set(
558 healthChecks.map((url) => lastSegment(url)),
559 );
560 const toAdd = healthChecks.filter(
561 (url) => !observedKeys.has(lastSegment(url)),
562 );
563 const toRemove = observed.filter(
564 (url) => !desiredKeys.has(lastSegment(url)),
565 );
566 if (toAdd.length > 0) {
567 yield* runOp(
568 env.project,
569 region,
570 targetPoolName,
571 compute.addHealthCheckTargetPools({
572 project: env.project,
573 region,
574 targetPool: targetPoolName,
575 body: {
576 healthChecks: toAdd.map((healthCheck) => ({ healthCheck })),
577 },
578 }),
579 );
580 }
581 if (toRemove.length > 0) {
582 yield* runOp(
583 env.project,
584 region,
585 targetPoolName,
586 compute.removeHealthCheckTargetPools({
587 project: env.project,
588 region,
589 targetPool: targetPoolName,
590 body: {
591 healthChecks: toRemove.map((healthCheck) => ({ healthCheck })),
592 },
593 }),
594 );
595 }
596 current =
597 (yield* getByName(env.project, region, targetPoolName)) ?? current;
598 }
599
600 if (instances !== undefined) {
601 const observed = current.instances ?? [];
602 const observedKeys = new Set(observed.map(instanceKey));
603 const desiredKeys = new Set(instances.map(instanceKey));
604 const toAdd = instances.filter(
605 (url) => !observedKeys.has(instanceKey(url)),
606 );
607 const toRemove = observed.filter(
608 (url) => !desiredKeys.has(instanceKey(url)),
609 );
610 if (toAdd.length > 0) {
611 yield* runOp(
612 env.project,
613 region,
614 targetPoolName,
615 compute.addInstanceTargetPools({
616 project: env.project,
617 region,
618 targetPool: targetPoolName,
619 body: {
620 instances: toAdd.map((instance) => ({ instance })),
621 },
622 }),
623 );
624 }
625 if (toRemove.length > 0) {
626 yield* runOp(
627 env.project,
628 region,
629 targetPoolName,
630 compute.removeInstanceTargetPools({
631 project: env.project,
632 region,
633 targetPool: targetPoolName,
634 body: {
635 instances: toRemove.map((instance) => ({ instance })),
636 },
637 }),
638 );
639 }
640 current =
641 (yield* getByName(env.project, region, targetPoolName)) ?? current;
642 }
643
644 if (news.securityPolicy !== undefined) {
645 const desiredPolicy = news.securityPolicy;
646 if (!sameRef(desiredPolicy, current.securityPolicy ?? "")) {
647 yield* runOp(
648 env.project,
649 region,
650 targetPoolName,
651 compute.setSecurityPolicyTargetPools({
652 project: env.project,
653 region,
654 targetPool: targetPoolName,
655 body: { securityPolicy: desiredPolicy },
656 }),
657 );
658 current =
659 (yield* getByName(env.project, region, targetPoolName)) ?? current;
660 }
661 }
662
663 return toAttrs(current, env.project);
664 }),
665
666 delete: Effect.fn(function* ({ output }) {
667 const env = yield* GcpEnvironment.current;
668 const project = output.project || env.project;
669 const region = normalizeRegion(output.region, env.region);
670 yield* compute
671 .deleteTargetPools({
672 project,
673 region,
674 targetPool: output.targetPoolName,
675 })
676 .pipe(
677 Effect.flatMap((operation) =>
678 waitRegionOperation(project, region, operation, {
679 ignore: ["RESOURCE_NOT_FOUND"],
680 }),
681 ),
682 Effect.catchTag("NotFound", () => Effect.void),
683 Effect.retry({
684 while: (error) => error._tag === "Conflict",
685 times: 8,
686 schedule: Schedule.spaced("2 seconds"),
687 }),
688 );
689 yield* waitUntilGone(project, region, output.targetPoolName);
690 }),
691 });