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 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 RegionTargetHttpProxyProps = {
20 /**
21 * TargetHttpProxy name (RFC1035, 1-63 characters). If omitted, a unique
22 * name is generated from the stack, stage, and logical id. Changing it
23 * replaces the resource.
24 */
25 targetHttpProxyName?: string;
26 /**
27 * Region the proxy lives in (e.g. `us-central1`). Immutable — changing
28 * it replaces the resource. `US-CENTRAL1` is accepted and normalized to
29 * `us-central1`.
30 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
31 */
32 region?: string;
33 /**
34 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
35 * prefix so `list` / nuke can find resources (Compute
36 * RegionTargetHttpProxy has no labels field and no patch API, so
37 * changing the description replaces the proxy).
38 */
39 description?: string;
40 /**
41 * Regional URL map this proxy routes to. Accepts a RegionUrlMap
42 * self-link, a `projects/{project}/regions/{region}/urlMaps/{name}`
43 * path, or the UrlMap name.
44 */
45 urlMap: string;
46 /**
47 * Bind inbound traffic to the forwarding-rule address. Only applies when
48 * the referencing forwarding rule uses `INTERNAL_SELF_MANAGED`.
49 * Immutable — changing it replaces the proxy.
50 * @default false
51 */
52 proxyBind?: boolean;
53 /**
54 * Idle keep-alive timeout in seconds after a response completes with no
55 * matching traffic. Regional Application Load Balancers allow 5–600
56 * (default 610). Immutable — changing it replaces the proxy.
57 */
58 httpKeepAliveTimeoutSec?: number;
59};
60
61export type RegionTargetHttpProxy = Resource<
62 "GCP.Compute.RegionTargetHttpProxy",
63 RegionTargetHttpProxyProps,
64 {
65 /** TargetHttpProxy name. */
66 targetHttpProxyName: string;
67 /** Project id. */
68 project: string;
69 /** Region short name (`us-central1`). */
70 region: string;
71 /** User description with the Alchemy ownership prefix stripped. */
72 description: string | undefined;
73 /** URL of the attached regional UrlMap. */
74 urlMap: string;
75 /** Whether Envoy inbound bind is enabled. */
76 proxyBind: boolean;
77 /** HTTP keep-alive timeout in seconds, if set. */
78 httpKeepAliveTimeoutSec: number | undefined;
79 /** Server-defined URL. */
80 selfLink: string | undefined;
81 /** Server-assigned numeric id. */
82 targetHttpProxyId: string | undefined;
83 /** Optimistic-locking fingerprint. */
84 fingerprint: string | undefined;
85 /** RFC3339 creation timestamp. */
86 creationTimestamp: string | undefined;
87 /** Resource kind. */
88 kind: string | undefined;
89 },
90 never,
91 Providers
92>;
93
94/**
95 * A regional Compute Engine target HTTP proxy.
96 *
97 * Regional target HTTP proxies are referenced by regional forwarding rules
98 * and point at a regional URL map that routes host/path to a backend
99 * service or redirect. This resource maps to the `regionTargetHttpProxies`
100 * collection (`targetHttpProxies` is `GCP.Compute.TargetHttpProxy`).
101 * Compute RegionTargetHttpProxy has no labels field — Alchemy ownership is
102 * stored in the description so nuke can find leaked proxies. The only
103 * in-place mutation is `setUrlMap`; name, region, description,
104 * `proxyBind`, and `httpKeepAliveTimeoutSec` replace the proxy.
105 *
106 * ### Creating a Regional Target HTTP Proxy
107 * **Example:** Generated name in front of a regional URL map
108 * ```typescript
109 * const map = yield* GCP.Compute.RegionUrlMap("web", {
110 * region: "us-central1",
111 * defaultUrlRedirect: {
112 * httpsRedirect: true,
113 * hostRedirect: "example.com",
114 * stripQuery: false,
115 * },
116 * });
117 * const proxy = yield* GCP.Compute.RegionTargetHttpProxy("http", {
118 * region: "us-central1",
119 * urlMap: map.urlMapName,
120 * });
121 * ```
122 *
123 * **Example:** Explicit name and description
124 * ```typescript
125 * const proxy = yield* GCP.Compute.RegionTargetHttpProxy("http", {
126 * targetHttpProxyName: "app-http",
127 * region: "us-central1",
128 * description: "public http frontend",
129 * urlMap: map.selfLink,
130 * });
131 * ```
132 *
133 * @resource
134 * @category Compute
135 */
136export const RegionTargetHttpProxy = Resource<RegionTargetHttpProxy>(
137 "GCP.Compute.RegionTargetHttpProxy",
138);
139
140export class RegionTargetHttpProxyNotResolved extends Data.TaggedError(
141 "GCP.Compute.RegionTargetHttpProxyNotResolved",
142)<{
143 targetHttpProxyName: string;
144 region: string;
145}> {}
146
147const lastSegment = (value: string) => {
148 const trimmed = value.replace(/\/+$/, "");
149 const parts = trimmed.split("/");
150 return parts[parts.length - 1] || trimmed;
151};
152
153const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
154 lastSegment(region ?? defaultRegion).toLowerCase();
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 : `p${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 hasOwnershipMarker = (description: string | undefined) =>
199 Object.keys(parseDescription(description).labels).some((key) =>
200 key.startsWith("alchemy-"),
201 );
202
203const resourceTail = (value: string | undefined): string => {
204 if (value === undefined || value.length === 0) return "";
205 return lastSegment(value);
206};
207
208const toUrlMapRef = (
209 project: string,
210 region: string,
211 urlMap: string,
212): string => {
213 if (urlMap.includes("/")) return urlMap;
214 return `projects/${project}/regions/${region}/urlMaps/${urlMap}`;
215};
216
217const toAttrs = (proxy: compute.TargetHttpProxy, project: string) => {
218 const parsed = parseDescription(proxy.description);
219 return {
220 targetHttpProxyName: proxy.name ?? proxy.id ?? "",
221 project,
222 region: lastSegment(proxy.region ?? "").toLowerCase(),
223 description: parsed.description,
224 urlMap: proxy.urlMap ?? "",
225 proxyBind: proxy.proxyBind === true,
226 httpKeepAliveTimeoutSec: proxy.httpKeepAliveTimeoutSec,
227 selfLink: proxy.selfLink,
228 targetHttpProxyId: proxy.id,
229 fingerprint: proxy.fingerprint,
230 creationTimestamp: proxy.creationTimestamp,
231 kind: proxy.kind,
232 };
233};
234
235const getByName = (project: string, region: string, targetHttpProxy: string) =>
236 compute
237 .getRegionTargetHttpProxies({ project, region, targetHttpProxy })
238 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
239
240const immutableChanged = (
241 news: RegionTargetHttpProxyProps,
242 olds: RegionTargetHttpProxyProps | undefined,
243 output: RegionTargetHttpProxy["Attributes"] | undefined,
244) => {
245 const previousDescription = olds?.description ?? output?.description;
246 if (
247 news.description !== undefined &&
248 news.description !== previousDescription
249 ) {
250 return true;
251 }
252 const previousBind = olds?.proxyBind ?? output?.proxyBind;
253 if (news.proxyBind !== undefined && news.proxyBind !== previousBind) {
254 return true;
255 }
256 const previousKeepAlive =
257 olds?.httpKeepAliveTimeoutSec ?? output?.httpKeepAliveTimeoutSec;
258 if (
259 news.httpKeepAliveTimeoutSec !== undefined &&
260 news.httpKeepAliveTimeoutSec !== previousKeepAlive
261 ) {
262 return true;
263 }
264 return false;
265};
266
267export const RegionTargetHttpProxyProvider = () =>
268 Provider.succeed(RegionTargetHttpProxy, {
269 stables: [
270 "targetHttpProxyName",
271 "project",
272 "region",
273 "targetHttpProxyId",
274 "selfLink",
275 "creationTimestamp",
276 ],
277
278 diff: Effect.fn(function* ({ news, olds, output }) {
279 if (!isResolved(news)) return undefined;
280 const env = yield* GcpEnvironment.current;
281 const previousName =
282 olds?.targetHttpProxyName ?? output?.targetHttpProxyName;
283 const nextName = news.targetHttpProxyName ?? previousName;
284 const nameChanged =
285 previousName !== undefined &&
286 nextName !== undefined &&
287 previousName !== nextName;
288 const previousRegion = normalizeRegion(
289 olds?.region ?? output?.region,
290 env.region,
291 );
292 const nextRegion = normalizeRegion(
293 news.region ?? output?.region,
294 env.region,
295 );
296 const regionChanged = previousRegion !== nextRegion;
297 if (
298 nameChanged ||
299 regionChanged ||
300 immutableChanged(news, olds, output)
301 ) {
302 return {
303 action: "replace" as const,
304 deleteFirst: !regionChanged,
305 };
306 }
307 return undefined;
308 }),
309
310 read: Effect.fn(function* ({ id, olds, output }) {
311 const env = yield* GcpEnvironment.current;
312 const targetHttpProxyName = yield* toName(
313 id,
314 olds?.targetHttpProxyName,
315 output?.targetHttpProxyName,
316 );
317 const region = normalizeRegion(
318 olds?.region ?? output?.region,
319 env.region,
320 );
321 const existing = yield* getByName(
322 env.project,
323 region,
324 targetHttpProxyName,
325 );
326 if (existing === undefined) return undefined;
327 const attrs = toAttrs(existing, env.project);
328 const { labels } = parseDescription(existing.description);
329 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
330 }),
331
332 list: () =>
333 Effect.gen(function* () {
334 const env = yield* GcpEnvironment.current;
335 const pages = yield* compute.aggregatedListTargetHttpProxies
336 .pages({
337 project: env.project,
338 returnPartialSuccess: true,
339 maxResults: 500,
340 })
341 .pipe(Stream.runCollect);
342 return Array.from(pages).flatMap((page) =>
343 Object.values(page.items ?? {}).flatMap((scoped) =>
344 (scoped?.targetHttpProxies ?? [])
345 .filter((proxy) => (proxy.region ?? "").length > 0)
346 .filter((proxy) => hasOwnershipMarker(proxy.description))
347 .map((proxy) => toAttrs(proxy, env.project)),
348 ),
349 );
350 }),
351
352 reconcile: Effect.fn(function* ({ id, news, output }) {
353 const env = yield* GcpEnvironment.current;
354 const targetHttpProxyName = yield* toName(
355 id,
356 news.targetHttpProxyName,
357 output?.targetHttpProxyName,
358 );
359 const region = normalizeRegion(news.region ?? output?.region, env.region);
360 const ownership = yield* createInternalLabels(id);
361 const desiredDescription = encodeDescription(ownership, news.description);
362 const desiredUrlMap = toUrlMapRef(env.project, region, news.urlMap);
363
364 let current = yield* getByName(env.project, region, targetHttpProxyName);
365
366 if (current === undefined) {
367 const body: compute.TargetHttpProxy = {
368 name: targetHttpProxyName,
369 description: desiredDescription,
370 urlMap: desiredUrlMap,
371 };
372 if (news.proxyBind !== undefined) {
373 body.proxyBind = news.proxyBind;
374 }
375 if (news.httpKeepAliveTimeoutSec !== undefined) {
376 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
377 }
378 yield* compute
379 .insertRegionTargetHttpProxies({
380 project: env.project,
381 region,
382 body,
383 })
384 .pipe(
385 Effect.flatMap((operation) =>
386 waitRegionOperation(env.project, region, operation, {
387 ignore: ["RESOURCE_ALREADY_EXISTS"],
388 }),
389 ),
390 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
391 );
392 current = yield* getByName(env.project, region, targetHttpProxyName);
393 }
394
395 if (current === undefined) {
396 return yield* new RegionTargetHttpProxyNotResolved({
397 targetHttpProxyName,
398 region,
399 });
400 }
401
402 if (resourceTail(current.urlMap) !== resourceTail(desiredUrlMap)) {
403 yield* compute
404 .setUrlMapRegionTargetHttpProxies({
405 project: env.project,
406 region,
407 targetHttpProxy: targetHttpProxyName,
408 body: { urlMap: desiredUrlMap },
409 })
410 .pipe(
411 Effect.flatMap((operation) =>
412 waitRegionOperation(env.project, region, operation),
413 ),
414 );
415 current = yield* getByName(env.project, region, targetHttpProxyName);
416 if (current === undefined) {
417 return yield* new RegionTargetHttpProxyNotResolved({
418 targetHttpProxyName,
419 region,
420 });
421 }
422 }
423
424 return toAttrs(current, env.project);
425 }),
426
427 delete: Effect.fn(function* ({ output }) {
428 const env = yield* GcpEnvironment.current;
429 const region = normalizeRegion(output.region, env.region);
430 const operation = yield* compute
431 .deleteRegionTargetHttpProxies({
432 project: env.project,
433 region,
434 targetHttpProxy: output.targetHttpProxyName,
435 })
436 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
437 if (operation !== undefined) {
438 yield* waitRegionOperation(env.project, region, operation).pipe(
439 Effect.catchTag("NotFound", () => Effect.void),
440 );
441 }
442 }),
443 });