1import * as compute from "@distilled.cloud/gcp/compute_v1";
2import { waitGlobalOperation } from "./operations.ts";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 alchemyLabelKeys,
14 createInternalLabels,
15 hasAlchemyLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18
19export type TargetTcpProxyProxyHeader = compute.TargetTcpProxyProxyHeaderEnum;
20export type TargetTcpProxyLoadBalancingScheme =
21 compute.TargetTcpProxyLoadBalancingSchemeEnum;
22
23const DEFAULT_PROXY_HEADER: TargetTcpProxyProxyHeader = "NONE";
24
25export type TargetTcpProxyProps = {
26 /**
27 * TargetTcpProxy name (RFC1035, 1-63 characters). If omitted, a unique
28 * name is generated from the stack, stage, and logical id. Changing it
29 * replaces the resource.
30 */
31 targetTcpProxyName?: string;
32 /**
33 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
34 * prefix so `list` / nuke can find resources (Compute TargetTcpProxy has
35 * no labels field). The API cannot update this field — changing it
36 * replaces the resource.
37 */
38 description?: string;
39 /**
40 * Backend service this proxy routes to. Accepts a BackendService
41 * self-link, a `projects/{project}/global/backendServices/{name}` path,
42 * or the BackendService name.
43 */
44 service: string;
45 /**
46 * Proxy header prepended before sending data to the backend (`NONE` or
47 * `PROXY_V1`).
48 * @default "NONE"
49 */
50 proxyHeader?: TargetTcpProxyProxyHeader | (string & {});
51 /**
52 * Bind inbound traffic to the forwarding-rule address. Only applies when
53 * the referencing forwarding rule uses `INTERNAL_SELF_MANAGED`. Changing
54 * it replaces the resource.
55 * @default false
56 */
57 proxyBind?: boolean;
58 /**
59 * Load balancing scheme (`EXTERNAL`, `EXTERNAL_MANAGED`,
60 * `INTERNAL_MANAGED`). Changing it replaces the resource.
61 */
62 loadBalancingScheme?: TargetTcpProxyLoadBalancingScheme | (string & {});
63};
64
65export type TargetTcpProxy = Resource<
66 "GCP.Compute.TargetTcpProxy",
67 TargetTcpProxyProps,
68 {
69 /** TargetTcpProxy name. */
70 targetTcpProxyName: string;
71 /** Project id. */
72 project: string;
73 /** User description with the Alchemy ownership prefix stripped. */
74 description: string | undefined;
75 /** URL of the attached BackendService. */
76 service: string;
77 /** Proxy header prepended to backend connections. */
78 proxyHeader: string | undefined;
79 /** Whether Envoy inbound bind is enabled. */
80 proxyBind: boolean;
81 /** Load balancing scheme, if set. */
82 loadBalancingScheme: string | undefined;
83 /** Server-defined URL. */
84 selfLink: string | undefined;
85 /** Server-assigned numeric id. */
86 targetTcpProxyId: string | undefined;
87 /** RFC3339 creation timestamp. */
88 creationTimestamp: string | undefined;
89 /** Resource kind. */
90 kind: string | undefined;
91 },
92 never,
93 Providers
94>;
95
96/**
97 * A global Compute Engine target TCP proxy.
98 *
99 * Target TCP proxies are referenced by global forwarding rules and point
100 * at a backend service. They are a component of Proxy Network Load
101 * Balancers (classic and global external). This resource maps to the
102 * global `targetTcpProxies` collection (`regionTargetTcpProxies` is a
103 * separate resource). Compute TargetTcpProxy has no labels field —
104 * Alchemy ownership is stored in the description so nuke can find leaked
105 * proxies.
106 *
107 * The API can update the backend service and proxy header in place.
108 * `targetTcpProxyName`, `description`, `proxyBind`, and
109 * `loadBalancingScheme` replace the resource.
110 *
111 * ### Creating a Target TCP Proxy
112 * **Example:** Generated name in front of a TCP backend service
113 * ```typescript
114 * const backend = yield* GCP.Compute.BackendService("tcp", {
115 * protocol: "TCP",
116 * loadBalancingScheme: "EXTERNAL",
117 * });
118 * const proxy = yield* GCP.Compute.TargetTcpProxy("tcp", {
119 * service: backend.name,
120 * });
121 * ```
122 *
123 * **Example:** Explicit name, PROXY protocol, and description
124 * ```typescript
125 * const proxy = yield* GCP.Compute.TargetTcpProxy("tcp", {
126 * targetTcpProxyName: "app-tcp",
127 * description: "public tcp frontend",
128 * service: backend.selfLink,
129 * proxyHeader: "PROXY_V1",
130 * });
131 * ```
132 *
133 * ### Updating a Target TCP Proxy
134 * **Example:** Swap the backend service and enable PROXY protocol
135 * ```typescript
136 * const proxy = yield* GCP.Compute.TargetTcpProxy("tcp", {
137 * targetTcpProxyName: "app-tcp",
138 * service: otherBackend.name,
139 * proxyHeader: "PROXY_V1",
140 * });
141 * ```
142 *
143 * @resource
144 * @category Compute
145 */
146export const TargetTcpProxy = Resource<TargetTcpProxy>(
147 "GCP.Compute.TargetTcpProxy",
148);
149
150export class TargetTcpProxyNotResolved extends Data.TaggedError(
151 "GCP.Compute.TargetTcpProxyNotResolved",
152)<{
153 targetTcpProxyName: string;
154}> {}
155
156const toName = (id: string, name: string | undefined, existing?: string) =>
157 Effect.gen(function* () {
158 if (name !== undefined) return name;
159 if (existing !== undefined) return existing;
160 const generated = yield* createPhysicalName({
161 id,
162 maxLength: 63,
163 lowercase: true,
164 });
165 return /^[a-z]/.test(generated) ? generated : `t${generated}`.slice(0, 63);
166 });
167
168const encodeDescription = (
169 labels: Record<string, string>,
170 description: string | undefined,
171): string => {
172 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
173 return description ? `${marker}\n${description}` : marker;
174};
175
176const parseDescription = (
177 description: string | undefined,
178): {
179 labels: Record<string, string>;
180 description: string | undefined;
181} => {
182 if (!description?.startsWith("[alchemy ")) {
183 return { labels: {}, description };
184 }
185 const end = description.indexOf("]");
186 if (end < 0) return { labels: {}, description };
187 const labels: Record<string, string> = {};
188 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
189 const eq = part.indexOf("=");
190 if (eq > 0) {
191 labels[part.slice(0, eq)] = part.slice(eq + 1);
192 }
193 }
194 const rest = description.slice(end + 1).replace(/^\n/, "");
195 return { labels, description: rest.length > 0 ? rest : undefined };
196};
197
198const resourceTail = (value: string | undefined): string => {
199 if (value === undefined || value.length === 0) return "";
200 const parts = value.split("/").filter((part) => part.length > 0);
201 return parts[parts.length - 1] ?? "";
202};
203
204const toBackendServiceRef = (project: string, service: string): string => {
205 if (service.includes("/")) return service;
206 return `projects/${project}/global/backendServices/${service}`;
207};
208
209const toAttrs = (proxy: compute.TargetTcpProxy, project: string) => {
210 const parsed = parseDescription(proxy.description);
211 return {
212 targetTcpProxyName: proxy.name ?? proxy.id ?? "",
213 project,
214 description: parsed.description,
215 service: proxy.service ?? "",
216 proxyHeader: proxy.proxyHeader,
217 proxyBind: proxy.proxyBind === true,
218 loadBalancingScheme: proxy.loadBalancingScheme,
219 selfLink: proxy.selfLink,
220 targetTcpProxyId: proxy.id,
221 creationTimestamp: proxy.creationTimestamp,
222 kind: proxy.kind,
223 };
224};
225
226const getByName = (project: string, targetTcpProxy: string) =>
227 compute
228 .getTargetTcpProxies({ project, targetTcpProxy })
229 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
230
231export const TargetTcpProxyProvider = () =>
232 Provider.succeed(TargetTcpProxy, {
233 stables: [
234 "targetTcpProxyName",
235 "project",
236 "targetTcpProxyId",
237 "selfLink",
238 "creationTimestamp",
239 ],
240
241 diff: Effect.fn(function* ({ news, olds, output }) {
242 if (!isResolved(news)) return undefined;
243 const previous = olds?.targetTcpProxyName ?? output?.targetTcpProxyName;
244 const next = news.targetTcpProxyName;
245 if (previous !== undefined && next !== undefined && previous !== next) {
246 return { action: "replace" as const, deleteFirst: true };
247 }
248 if (olds !== undefined) {
249 if ((olds.description ?? "") !== (news.description ?? "")) {
250 return { action: "replace" as const, deleteFirst: true };
251 }
252 if ((olds.proxyBind ?? false) !== (news.proxyBind ?? false)) {
253 return { action: "replace" as const, deleteFirst: true };
254 }
255 if (
256 (olds.loadBalancingScheme ?? "") !== (news.loadBalancingScheme ?? "")
257 ) {
258 return { action: "replace" as const, deleteFirst: true };
259 }
260 }
261 return undefined;
262 }),
263
264 read: Effect.fn(function* ({ id, olds, output }) {
265 const env = yield* GcpEnvironment.current;
266 const targetTcpProxyName = yield* toName(
267 id,
268 olds?.targetTcpProxyName,
269 output?.targetTcpProxyName,
270 );
271 const existing = yield* getByName(env.project, targetTcpProxyName);
272 if (existing === undefined) return undefined;
273 const attrs = toAttrs(existing, env.project);
274 const { labels } = parseDescription(existing.description);
275 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
276 }),
277
278 list: () =>
279 Effect.gen(function* () {
280 const env = yield* GcpEnvironment.current;
281 return yield* compute.listTargetTcpProxies
282 .items({ project: env.project, maxResults: 500 })
283 .pipe(
284 Stream.filter((proxy) => {
285 const { labels } = parseDescription(proxy.description);
286 return Object.keys(labels).some((key) =>
287 key.startsWith("alchemy-"),
288 );
289 }),
290 Stream.map((proxy) => toAttrs(proxy, env.project)),
291 Stream.runCollect,
292 Effect.map((chunk) => Array.from(chunk)),
293 );
294 }),
295
296 reconcile: Effect.fn(function* ({ id, news, output }) {
297 const env = yield* GcpEnvironment.current;
298 const targetTcpProxyName = yield* toName(
299 id,
300 news.targetTcpProxyName,
301 output?.targetTcpProxyName,
302 );
303 const ownership = yield* createInternalLabels(id);
304 const desiredDescription = encodeDescription(ownership, news.description);
305 const desiredService = toBackendServiceRef(env.project, news.service);
306 const desiredProxyHeader = news.proxyHeader ?? DEFAULT_PROXY_HEADER;
307
308 let current = yield* getByName(env.project, targetTcpProxyName);
309
310 if (current === undefined) {
311 const body: compute.TargetTcpProxy = {
312 name: targetTcpProxyName,
313 description: desiredDescription,
314 service: desiredService,
315 };
316 if (news.proxyHeader !== undefined) {
317 body.proxyHeader = news.proxyHeader;
318 }
319 if (news.proxyBind !== undefined) {
320 body.proxyBind = news.proxyBind;
321 }
322 if (news.loadBalancingScheme !== undefined) {
323 body.loadBalancingScheme = news.loadBalancingScheme;
324 }
325 yield* compute
326 .insertTargetTcpProxies({
327 project: env.project,
328 body,
329 })
330 .pipe(
331 Effect.flatMap((operation) =>
332 waitGlobalOperation(env.project, operation),
333 ),
334 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
335 );
336 current = yield* getByName(env.project, targetTcpProxyName);
337 }
338
339 if (current === undefined) {
340 return yield* new TargetTcpProxyNotResolved({ targetTcpProxyName });
341 }
342
343 if (resourceTail(current.service) !== resourceTail(desiredService)) {
344 yield* compute
345 .setBackendServiceTargetTcpProxies({
346 project: env.project,
347 targetTcpProxy: targetTcpProxyName,
348 body: { service: desiredService },
349 })
350 .pipe(
351 Effect.flatMap((operation) =>
352 waitGlobalOperation(env.project, operation),
353 ),
354 );
355 current = yield* getByName(env.project, targetTcpProxyName);
356 if (current === undefined) {
357 return yield* new TargetTcpProxyNotResolved({
358 targetTcpProxyName,
359 });
360 }
361 }
362
363 if (
364 (current.proxyHeader ?? DEFAULT_PROXY_HEADER) !== desiredProxyHeader
365 ) {
366 yield* compute
367 .setProxyHeaderTargetTcpProxies({
368 project: env.project,
369 targetTcpProxy: targetTcpProxyName,
370 body: { proxyHeader: desiredProxyHeader },
371 })
372 .pipe(
373 Effect.flatMap((operation) =>
374 waitGlobalOperation(env.project, operation),
375 ),
376 );
377 current = yield* getByName(env.project, targetTcpProxyName);
378 if (current === undefined) {
379 return yield* new TargetTcpProxyNotResolved({
380 targetTcpProxyName,
381 });
382 }
383 }
384
385 return toAttrs(current, env.project);
386 }),
387
388 delete: Effect.fn(function* ({ output }) {
389 const env = yield* GcpEnvironment.current;
390 const operation = yield* compute
391 .deleteTargetTcpProxies({
392 project: env.project,
393 targetTcpProxy: output.targetTcpProxyName,
394 })
395 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
396 if (operation !== undefined) {
397 yield* waitGlobalOperation(env.project, operation).pipe(
398 Effect.catchTag("NotFound", () => Effect.void),
399 );
400 }
401 }),
402 });