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";
19import type {
20 TargetTcpProxyLoadBalancingScheme,
21 TargetTcpProxyProxyHeader,
22} from "./TargetTcpProxy.ts";
23
24const DEFAULT_PROXY_HEADER: TargetTcpProxyProxyHeader = "NONE";
25
26export type RegionTargetTcpProxyProps = {
27 /**
28 * TargetTcpProxy name (RFC1035, 1-63 characters). If omitted, a unique
29 * name is generated from the stack, stage, and logical id. Changing it
30 * replaces the resource.
31 */
32 targetTcpProxyName?: string;
33 /**
34 * Region the proxy lives in (e.g. `us-central1`). Immutable — changing
35 * it replaces the resource. `US-CENTRAL1` is accepted and normalized to
36 * `us-central1`.
37 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
38 */
39 region?: string;
40 /**
41 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
42 * prefix so `list` / nuke can find resources (Compute RegionTargetTcpProxy
43 * has no labels field). The regional API cannot update this field —
44 * changing it replaces the resource.
45 */
46 description?: string;
47 /**
48 * Regional backend service this proxy routes to. Accepts a
49 * RegionBackendService self-link, a
50 * `projects/{project}/regions/{region}/backendServices/{name}` path, or
51 * the BackendService name. Changing it replaces the resource.
52 */
53 service: string;
54 /**
55 * Proxy header prepended before sending data to the backend (`NONE` or
56 * `PROXY_V1`). Changing it replaces the resource.
57 * @default "NONE"
58 */
59 proxyHeader?: TargetTcpProxyProxyHeader | (string & {});
60 /**
61 * Bind inbound traffic to the forwarding-rule address. Only applies when
62 * the referencing forwarding rule uses `INTERNAL_SELF_MANAGED`. Changing
63 * it replaces the resource.
64 * @default false
65 */
66 proxyBind?: boolean;
67 /**
68 * Load balancing scheme (`INTERNAL_MANAGED`, `EXTERNAL_MANAGED`).
69 * Changing it replaces the resource.
70 */
71 loadBalancingScheme?: TargetTcpProxyLoadBalancingScheme | (string & {});
72};
73
74export type RegionTargetTcpProxy = Resource<
75 "GCP.Compute.RegionTargetTcpProxy",
76 RegionTargetTcpProxyProps,
77 {
78 /** TargetTcpProxy name. */
79 targetTcpProxyName: string;
80 /** Project id. */
81 project: string;
82 /** Region short name (`us-central1`). */
83 region: string;
84 /** User description with the Alchemy ownership prefix stripped. */
85 description: string | undefined;
86 /** URL of the attached BackendService. */
87 service: string;
88 /** Proxy header prepended to backend connections. */
89 proxyHeader: string | undefined;
90 /** Whether Envoy inbound bind is enabled. */
91 proxyBind: boolean;
92 /** Load balancing scheme, if set. */
93 loadBalancingScheme: string | undefined;
94 /** Server-defined URL. */
95 selfLink: string | undefined;
96 /** Server-assigned numeric id. */
97 targetTcpProxyId: string | undefined;
98 /** RFC3339 creation timestamp. */
99 creationTimestamp: string | undefined;
100 /** Resource kind. */
101 kind: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * A regional Compute Engine target TCP proxy.
109 *
110 * Regional target TCP proxies are referenced by regional forwarding rules
111 * and point at a regional backend service. They are a component of
112 * regional proxy Network Load Balancers. This resource maps to the
113 * `regionTargetTcpProxies` collection (the global `targetTcpProxies`
114 * collection is `GCP.Compute.TargetTcpProxy`). Compute RegionTargetTcpProxy
115 * has no labels field and no in-place update API (`setBackendService` exists
116 * only on the global collection). Alchemy ownership is stored in the
117 * description so nuke can find leaked proxies. Changing the backend,
118 * proxy header, description, `proxyBind`, or load-balancing scheme
119 * deletes and recreates the proxy so the observed backend always matches.
120 *
121 * ### Creating a Regional Target TCP Proxy
122 * **Example:** Generated name in front of a TCP backend service
123 * ```typescript
124 * const backend = yield* GCP.Compute.RegionBackendService("tcp", {
125 * region: "us-central1",
126 * protocol: "TCP",
127 * loadBalancingScheme: "INTERNAL_MANAGED",
128 * });
129 * const proxy = yield* GCP.Compute.RegionTargetTcpProxy("tcp", {
130 * region: "us-central1",
131 * service: backend.name,
132 * });
133 * ```
134 *
135 * @resource
136 * @category Compute
137 */
138export const RegionTargetTcpProxy = Resource<RegionTargetTcpProxy>(
139 "GCP.Compute.RegionTargetTcpProxy",
140);
141
142export class RegionTargetTcpProxyNotResolved extends Data.TaggedError(
143 "GCP.Compute.RegionTargetTcpProxyNotResolved",
144)<{
145 targetTcpProxyName: string;
146 region: string;
147}> {}
148
149export class RegionTargetTcpProxyStillExists extends Data.TaggedError(
150 "GCP.Compute.RegionTargetTcpProxyStillExists",
151)<{
152 targetTcpProxyName: string;
153 region: string;
154}> {}
155
156const lastSegment = (value: string | undefined) => {
157 if (value === undefined || value.length === 0) return "";
158 const trimmed = value.replace(/\/+$/, "");
159 const parts = trimmed.split("/");
160 return parts[parts.length - 1] || trimmed;
161};
162
163const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
164 lastSegment(region ?? defaultRegion).toLowerCase();
165
166const toName = (id: string, name: string | undefined, existing?: string) =>
167 Effect.gen(function* () {
168 if (name !== undefined) return name;
169 if (existing !== undefined) return existing;
170 const generated = yield* createPhysicalName({
171 id,
172 maxLength: 63,
173 lowercase: true,
174 });
175 return /^[a-z]/.test(generated) ? generated : `t${generated}`.slice(0, 63);
176 });
177
178const encodeDescription = (
179 labels: Record<string, string>,
180 description: string | undefined,
181): string => {
182 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
183 return description ? `${marker}\n${description}` : marker;
184};
185
186const parseDescription = (
187 description: string | undefined,
188): {
189 labels: Record<string, string>;
190 description: string | undefined;
191} => {
192 if (!description?.startsWith("[alchemy ")) {
193 return { labels: {}, description };
194 }
195 const end = description.indexOf("]");
196 if (end < 0) return { labels: {}, description };
197 const labels: Record<string, string> = {};
198 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
199 const eq = part.indexOf("=");
200 if (eq > 0) {
201 labels[part.slice(0, eq)] = part.slice(eq + 1);
202 }
203 }
204 const rest = description.slice(end + 1).replace(/^\n/, "");
205 return { labels, description: rest.length > 0 ? rest : undefined };
206};
207
208const resourceTail = (value: string | undefined): string => lastSegment(value);
209
210const toBackendServiceRef = (
211 project: string,
212 region: string,
213 service: string,
214): string => {
215 if (service.includes("/")) return service;
216 return `projects/${project}/regions/${region}/backendServices/${service}`;
217};
218
219const toAttrs = (proxy: compute.TargetTcpProxy, project: string) => {
220 const parsed = parseDescription(proxy.description);
221 return {
222 targetTcpProxyName: proxy.name ?? proxy.id ?? "",
223 project,
224 region: lastSegment(proxy.region).toLowerCase(),
225 description: parsed.description,
226 service: proxy.service ?? "",
227 proxyHeader: proxy.proxyHeader,
228 proxyBind: proxy.proxyBind === true,
229 loadBalancingScheme: proxy.loadBalancingScheme,
230 selfLink: proxy.selfLink,
231 targetTcpProxyId: proxy.id,
232 creationTimestamp: proxy.creationTimestamp,
233 kind: proxy.kind,
234 };
235};
236
237const getByName = (project: string, region: string, targetTcpProxy: string) =>
238 compute
239 .getRegionTargetTcpProxies({ project, region, targetTcpProxy })
240 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
241
242const waitUntilGone = (
243 project: string,
244 region: string,
245 targetTcpProxyName: string,
246) =>
247 getByName(project, region, targetTcpProxyName).pipe(
248 Effect.flatMap((proxy) =>
249 proxy === undefined
250 ? Effect.void
251 : Effect.fail(
252 new RegionTargetTcpProxyStillExists({
253 targetTcpProxyName,
254 region,
255 }),
256 ),
257 ),
258 Effect.retry({
259 while: (error) =>
260 error._tag === "GCP.Compute.RegionTargetTcpProxyStillExists",
261 times: 18,
262 schedule: Schedule.spaced("2 seconds"),
263 }),
264 );
265
266const immutableChanged = (
267 news: RegionTargetTcpProxyProps,
268 olds: RegionTargetTcpProxyProps | undefined,
269 output: RegionTargetTcpProxy["Attributes"] | undefined,
270) => {
271 const previousDescription = olds?.description ?? output?.description ?? "";
272 if ((news.description ?? "") !== previousDescription) return true;
273 const previousService = resourceTail(olds?.service ?? output?.service);
274 const nextService = resourceTail(news.service);
275 if (
276 previousService.length > 0 &&
277 nextService.length > 0 &&
278 previousService !== nextService
279 ) {
280 return true;
281 }
282 const previousHeader =
283 olds?.proxyHeader ?? output?.proxyHeader ?? DEFAULT_PROXY_HEADER;
284 const nextHeader = news.proxyHeader ?? DEFAULT_PROXY_HEADER;
285 if (previousHeader !== nextHeader) return true;
286 const previousBind = olds?.proxyBind ?? output?.proxyBind ?? false;
287 if ((news.proxyBind ?? false) !== previousBind) return true;
288 const previousScheme =
289 olds?.loadBalancingScheme ?? output?.loadBalancingScheme ?? "";
290 if ((news.loadBalancingScheme ?? "") !== previousScheme) return true;
291 return false;
292};
293
294export const RegionTargetTcpProxyProvider = () =>
295 Provider.succeed(RegionTargetTcpProxy, {
296 stables: [
297 "targetTcpProxyName",
298 "project",
299 "region",
300 "targetTcpProxyId",
301 "selfLink",
302 "creationTimestamp",
303 ],
304
305 diff: Effect.fn(function* ({ news, olds, output }) {
306 if (!isResolved(news)) return undefined;
307 const env = yield* GcpEnvironment.current;
308 const previous = olds?.targetTcpProxyName ?? output?.targetTcpProxyName;
309 const next = news.targetTcpProxyName ?? previous;
310 const previousRegion = normalizeRegion(
311 olds?.region ?? output?.region,
312 env.region,
313 );
314 const nextRegion = normalizeRegion(
315 news.region ?? output?.region,
316 env.region,
317 );
318 const regionChanged = previousRegion !== nextRegion;
319 const nameChanged =
320 previous !== undefined && next !== undefined && previous !== next;
321 if (
322 nameChanged ||
323 regionChanged ||
324 immutableChanged(news, olds, output)
325 ) {
326 return {
327 action: "replace" as const,
328 deleteFirst: !regionChanged,
329 };
330 }
331 return undefined;
332 }),
333
334 read: Effect.fn(function* ({ id, olds, output }) {
335 const env = yield* GcpEnvironment.current;
336 const targetTcpProxyName = yield* toName(
337 id,
338 olds?.targetTcpProxyName,
339 output?.targetTcpProxyName,
340 );
341 const region = normalizeRegion(
342 olds?.region ?? output?.region,
343 env.region,
344 );
345 const existing = yield* getByName(
346 env.project,
347 region,
348 targetTcpProxyName,
349 );
350 if (existing === undefined) return undefined;
351 const attrs = toAttrs(existing, env.project);
352 const { labels } = parseDescription(existing.description);
353 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
354 }),
355
356 list: () =>
357 Effect.gen(function* () {
358 const env = yield* GcpEnvironment.current;
359 const pages = yield* compute.aggregatedListTargetTcpProxies
360 .pages({
361 project: env.project,
362 returnPartialSuccess: true,
363 maxResults: 500,
364 })
365 .pipe(Stream.runCollect);
366 return Array.from(pages).flatMap((page) =>
367 Object.values(page.items ?? {}).flatMap((scoped) =>
368 (scoped?.targetTcpProxies ?? [])
369 .filter((proxy) => (proxy.region ?? "").length > 0)
370 .filter((proxy) => {
371 const { labels } = parseDescription(proxy.description);
372 return Object.keys(labels).some((key) =>
373 key.startsWith("alchemy-"),
374 );
375 })
376 .map((proxy) => toAttrs(proxy, env.project)),
377 ),
378 );
379 }),
380
381 reconcile: Effect.fn(function* ({ id, news, output }) {
382 const env = yield* GcpEnvironment.current;
383 const targetTcpProxyName = yield* toName(
384 id,
385 news.targetTcpProxyName,
386 output?.targetTcpProxyName,
387 );
388 const region = normalizeRegion(news.region ?? output?.region, env.region);
389 const ownership = yield* createInternalLabels(id);
390 const desiredDescription = encodeDescription(ownership, news.description);
391 const desiredService = toBackendServiceRef(
392 env.project,
393 region,
394 news.service,
395 );
396 const desiredHeader = news.proxyHeader ?? DEFAULT_PROXY_HEADER;
397 const desiredBind = news.proxyBind ?? false;
398 const desiredScheme = news.loadBalancingScheme ?? "";
399
400 const insertProxy = () => {
401 const body: compute.TargetTcpProxy = {
402 name: targetTcpProxyName,
403 description: desiredDescription,
404 service: desiredService,
405 proxyHeader: desiredHeader,
406 };
407 if (news.proxyBind !== undefined) {
408 body.proxyBind = news.proxyBind;
409 }
410 if (news.loadBalancingScheme !== undefined) {
411 body.loadBalancingScheme = news.loadBalancingScheme;
412 }
413 return compute
414 .insertRegionTargetTcpProxies({
415 project: env.project,
416 region,
417 body,
418 })
419 .pipe(
420 Effect.flatMap((operation) =>
421 waitRegionOperation(env.project, region, operation, {
422 ignore: ["RESOURCE_ALREADY_EXISTS"],
423 }),
424 ),
425 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
426 );
427 };
428
429 const deleteProxy = () =>
430 compute
431 .deleteRegionTargetTcpProxies({
432 project: env.project,
433 region,
434 targetTcpProxy: targetTcpProxyName,
435 })
436 .pipe(
437 Effect.flatMap((operation) =>
438 waitRegionOperation(env.project, region, operation),
439 ),
440 Effect.catchTag("NotFound", () => Effect.void),
441 );
442
443 let current = yield* getByName(env.project, region, targetTcpProxyName);
444
445 // Regional target TCP proxies have no setBackendService API. If the
446 // observed backend (or other immutable fields) drifted, delete then
447 // insert so update-as-well-as-replace converges.
448 if (current !== undefined) {
449 const serviceChanged =
450 resourceTail(current.service) !== resourceTail(desiredService);
451 const headerChanged =
452 (current.proxyHeader ?? DEFAULT_PROXY_HEADER) !== desiredHeader;
453 const descriptionChanged =
454 (current.description ?? "") !== desiredDescription;
455 const bindChanged = (current.proxyBind === true) !== desiredBind;
456 const schemeChanged =
457 (current.loadBalancingScheme ?? "") !== desiredScheme;
458 if (
459 serviceChanged ||
460 headerChanged ||
461 descriptionChanged ||
462 bindChanged ||
463 schemeChanged
464 ) {
465 yield* deleteProxy();
466 yield* waitUntilGone(env.project, region, targetTcpProxyName);
467 current = undefined;
468 }
469 }
470
471 if (current === undefined) {
472 yield* insertProxy();
473 current = yield* getByName(env.project, region, targetTcpProxyName);
474 }
475
476 if (current === undefined) {
477 return yield* new RegionTargetTcpProxyNotResolved({
478 targetTcpProxyName,
479 region,
480 });
481 }
482
483 return toAttrs(current, env.project);
484 }),
485
486 delete: Effect.fn(function* ({ output }) {
487 const env = yield* GcpEnvironment.current;
488 const region = normalizeRegion(output.region, env.region);
489 const operation = yield* compute
490 .deleteRegionTargetTcpProxies({
491 project: env.project,
492 region,
493 targetTcpProxy: output.targetTcpProxyName,
494 })
495 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
496 if (operation !== undefined) {
497 yield* waitRegionOperation(env.project, region, operation).pipe(
498 Effect.catchTag("NotFound", () => Effect.void),
499 );
500 }
501 yield* waitUntilGone(env.project, region, output.targetTcpProxyName);
502 }),
503 });