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 RegionTargetHttpsProxyProps = {
20 /**
21 * TargetHttpsProxy 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 targetHttpsProxyName?: 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 * RegionTargetHttpsProxy has no labels field).
37 */
38 description?: string;
39 /**
40 * Regional URL map this proxy routes to. Accepts a RegionUrlMap
41 * self-link, a `projects/{project}/regions/{region}/urlMaps/{name}`
42 * path, or the UrlMap name.
43 */
44 urlMap: string;
45 /**
46 * Regional SslCertificate URLs used to terminate HTTPS. Accepts
47 * self-links, `projects/{project}/regions/{region}/sslCertificates/{name}`
48 * paths, or certificate names. At least one is required.
49 */
50 sslCertificates?: string[];
51 /**
52 * Regional SslPolicy URL applied to client TLS. Empty string clears the
53 * policy. Accepts a self-link, path, or policy name.
54 */
55 sslPolicy?: string;
56 /**
57 * HTTP keep-alive timeout in seconds after a response while idle.
58 * Regional Application Load Balancers allow 5–600 (default 610).
59 */
60 httpKeepAliveTimeoutSec?: number;
61 /**
62 * Bind inbound traffic to the forwarding-rule address. Only applies when
63 * the referencing forwarding rule uses `INTERNAL_SELF_MANAGED`.
64 * @default false
65 */
66 proxyBind?: boolean;
67 /**
68 * networksecurity.ServerTlsPolicy URL used to authenticate inbound
69 * traffic.
70 */
71 serverTlsPolicy?: string;
72};
73
74export type RegionTargetHttpsProxy = Resource<
75 "GCP.Compute.RegionTargetHttpsProxy",
76 RegionTargetHttpsProxyProps,
77 {
78 /** TargetHttpsProxy name. */
79 targetHttpsProxyName: 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 regional UrlMap. */
87 urlMap: string;
88 /** SslCertificate URLs. */
89 sslCertificates: string[];
90 /** SslPolicy URL, if set. */
91 sslPolicy: string | undefined;
92 /** HTTP keep-alive timeout in seconds, if set. */
93 httpKeepAliveTimeoutSec: number | undefined;
94 /** Whether Envoy inbound bind is enabled. */
95 proxyBind: boolean;
96 /** Server TLS policy URL, if set. */
97 serverTlsPolicy: string | undefined;
98 /** Server-defined URL. */
99 selfLink: string | undefined;
100 /** Server-assigned numeric id. */
101 targetHttpsProxyId: string | undefined;
102 /** Optimistic-locking fingerprint. */
103 fingerprint: string | undefined;
104 /** RFC3339 creation timestamp. */
105 creationTimestamp: string | undefined;
106 /** Resource kind. */
107 kind: string | undefined;
108 },
109 never,
110 Providers
111>;
112
113/**
114 * A regional Compute Engine target HTTPS proxy.
115 *
116 * Regional target HTTPS proxies terminate HTTPS for regional Application
117 * Load Balancers. A forwarding rule points at the proxy; the proxy points
118 * at a regional URL map and one or more regional SSL certificates.
119 *
120 * This resource maps to the `regionTargetHttpsProxies` collection (the
121 * global `targetHttpsProxies` collection is `GCP.Compute.TargetHttpsProxy`).
122 * Compute RegionTargetHttpsProxy has no labels field — Alchemy ownership
123 * is stored in the description so nuke can find leaked proxies. Url map
124 * and certificates update in place; name and region replace.
125 *
126 * ### Creating a Regional Target HTTPS Proxy
127 * **Example:** Generated name in front of a regional URL map
128 * ```typescript
129 * const map = yield* GCP.Compute.RegionUrlMap("web", {
130 * region: "us-central1",
131 * defaultUrlRedirect: {
132 * httpsRedirect: true,
133 * hostRedirect: "example.com",
134 * stripQuery: false,
135 * },
136 * });
137 * const cert = yield* GCP.Compute.RegionSslCertificate("tls", {
138 * region: "us-central1",
139 * certificate: pemCertificate,
140 * privateKey: pemPrivateKey,
141 * });
142 * const proxy = yield* GCP.Compute.RegionTargetHttpsProxy("https", {
143 * region: "us-central1",
144 * urlMap: map.urlMapName,
145 * sslCertificates: [cert.sslCertificateName],
146 * });
147 * ```
148 *
149 * @resource
150 * @category Compute
151 */
152export const RegionTargetHttpsProxy = Resource<RegionTargetHttpsProxy>(
153 "GCP.Compute.RegionTargetHttpsProxy",
154);
155
156export class RegionTargetHttpsProxyNotResolved extends Data.TaggedError(
157 "GCP.Compute.RegionTargetHttpsProxyNotResolved",
158)<{
159 targetHttpsProxyName: string;
160 region: string;
161}> {}
162
163const lastSegment = (value: string | undefined) => {
164 if (value === undefined || value.length === 0) return "";
165 const trimmed = value.replace(/\/+$/, "");
166 const parts = trimmed.split("/");
167 return parts[parts.length - 1] || trimmed;
168};
169
170const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
171 lastSegment(region ?? defaultRegion).toLowerCase();
172
173const toName = (id: string, name: string | undefined, existing?: string) =>
174 Effect.gen(function* () {
175 if (name !== undefined) return name;
176 if (existing !== undefined) return existing;
177 const generated = yield* createPhysicalName({
178 id,
179 maxLength: 63,
180 lowercase: true,
181 });
182 return /^[a-z]/.test(generated) ? generated : `h${generated}`.slice(0, 63);
183 });
184
185const encodeDescription = (
186 labels: Record<string, string>,
187 description: string | undefined,
188): string => {
189 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
190 return description ? `${marker}\n${description}` : marker;
191};
192
193const parseDescription = (
194 description: string | undefined,
195): {
196 labels: Record<string, string>;
197 description: string | undefined;
198} => {
199 if (!description?.startsWith("[alchemy ")) {
200 return { labels: {}, description };
201 }
202 const end = description.indexOf("]");
203 if (end < 0) return { labels: {}, description };
204 const labels: Record<string, string> = {};
205 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
206 const eq = part.indexOf("=");
207 if (eq > 0) {
208 labels[part.slice(0, eq)] = part.slice(eq + 1);
209 }
210 }
211 const rest = description.slice(end + 1).replace(/^\n/, "");
212 return { labels, description: rest.length > 0 ? rest : undefined };
213};
214
215const resourceTail = (value: string | undefined): string => lastSegment(value);
216
217const toUrlMapRef = (
218 project: string,
219 region: string,
220 urlMap: string,
221): string => {
222 if (urlMap.includes("/")) return urlMap;
223 return `projects/${project}/regions/${region}/urlMaps/${urlMap}`;
224};
225
226const toSslCertRef = (
227 project: string,
228 region: string,
229 cert: string,
230): string => {
231 if (cert.includes("/")) return cert;
232 return `projects/${project}/regions/${region}/sslCertificates/${cert}`;
233};
234
235const toSslPolicyRef = (
236 project: string,
237 region: string,
238 policy: string,
239): string => {
240 if (policy.length === 0 || policy.includes("/")) return policy;
241 return `projects/${project}/regions/${region}/sslPolicies/${policy}`;
242};
243
244const sameResourceList = (
245 left: readonly string[] | undefined,
246 right: readonly string[] | undefined,
247) => {
248 const a = [...(left ?? [])].map(resourceTail).sort();
249 const b = [...(right ?? [])].map(resourceTail).sort();
250 return a.length === b.length && a.every((value, i) => value === b[i]);
251};
252
253const toAttrs = (proxy: compute.TargetHttpsProxy, project: string) => {
254 const parsed = parseDescription(proxy.description);
255 return {
256 targetHttpsProxyName: proxy.name ?? proxy.id ?? "",
257 project,
258 region: lastSegment(proxy.region).toLowerCase(),
259 description: parsed.description,
260 urlMap: proxy.urlMap ?? "",
261 sslCertificates: [...(proxy.sslCertificates ?? [])],
262 sslPolicy: proxy.sslPolicy,
263 httpKeepAliveTimeoutSec: proxy.httpKeepAliveTimeoutSec,
264 proxyBind: proxy.proxyBind === true,
265 serverTlsPolicy: proxy.serverTlsPolicy,
266 selfLink: proxy.selfLink,
267 targetHttpsProxyId: proxy.id,
268 fingerprint: proxy.fingerprint,
269 creationTimestamp: proxy.creationTimestamp,
270 kind: proxy.kind,
271 };
272};
273
274const getByName = (project: string, region: string, targetHttpsProxy: string) =>
275 compute
276 .getRegionTargetHttpsProxies({ project, region, targetHttpsProxy })
277 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
278
279export const RegionTargetHttpsProxyProvider = () =>
280 Provider.succeed(RegionTargetHttpsProxy, {
281 stables: [
282 "targetHttpsProxyName",
283 "project",
284 "region",
285 "targetHttpsProxyId",
286 "selfLink",
287 "creationTimestamp",
288 ],
289
290 diff: Effect.fn(function* ({ news, olds, output }) {
291 if (!isResolved(news)) return undefined;
292 const env = yield* GcpEnvironment.current;
293 const previous =
294 olds?.targetHttpsProxyName ?? output?.targetHttpsProxyName;
295 const next = news.targetHttpsProxyName;
296 const previousRegion = normalizeRegion(
297 olds?.region ?? output?.region,
298 env.region,
299 );
300 const nextRegion = normalizeRegion(
301 news.region ?? output?.region,
302 env.region,
303 );
304 if (previousRegion !== nextRegion) {
305 return { action: "replace" as const, deleteFirst: false };
306 }
307 if (previous !== undefined && next !== undefined && previous !== next) {
308 return { action: "replace" as const, deleteFirst: true };
309 }
310 return undefined;
311 }),
312
313 read: Effect.fn(function* ({ id, olds, output }) {
314 const env = yield* GcpEnvironment.current;
315 const targetHttpsProxyName = yield* toName(
316 id,
317 olds?.targetHttpsProxyName,
318 output?.targetHttpsProxyName,
319 );
320 const region = normalizeRegion(
321 olds?.region ?? output?.region,
322 env.region,
323 );
324 const existing = yield* getByName(
325 env.project,
326 region,
327 targetHttpsProxyName,
328 );
329 if (existing === undefined) return undefined;
330 const attrs = toAttrs(existing, env.project);
331 const { labels } = parseDescription(existing.description);
332 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
333 }),
334
335 list: () =>
336 Effect.gen(function* () {
337 const env = yield* GcpEnvironment.current;
338 const pages = yield* compute.aggregatedListTargetHttpsProxies
339 .pages({
340 project: env.project,
341 returnPartialSuccess: true,
342 maxResults: 500,
343 })
344 .pipe(Stream.runCollect);
345 return Array.from(pages).flatMap((page) =>
346 Object.values(page.items ?? {}).flatMap((scoped) =>
347 (scoped?.targetHttpsProxies ?? [])
348 .filter((proxy) => (proxy.region ?? "").length > 0)
349 .filter((proxy) => {
350 const { labels } = parseDescription(proxy.description);
351 return Object.keys(labels).some((key) =>
352 key.startsWith("alchemy-"),
353 );
354 })
355 .map((proxy) => toAttrs(proxy, env.project)),
356 ),
357 );
358 }),
359
360 reconcile: Effect.fn(function* ({ id, news, output }) {
361 const env = yield* GcpEnvironment.current;
362 const targetHttpsProxyName = yield* toName(
363 id,
364 news.targetHttpsProxyName,
365 output?.targetHttpsProxyName,
366 );
367 const region = normalizeRegion(news.region ?? output?.region, env.region);
368 const ownership = yield* createInternalLabels(id);
369 const desiredDescription = encodeDescription(ownership, news.description);
370 const desiredUrlMap = toUrlMapRef(env.project, region, news.urlMap);
371 const desiredCerts = (news.sslCertificates ?? []).map((cert) =>
372 toSslCertRef(env.project, region, cert),
373 );
374 const desiredSslPolicy =
375 news.sslPolicy !== undefined
376 ? toSslPolicyRef(env.project, region, news.sslPolicy)
377 : undefined;
378
379 let current = yield* getByName(env.project, region, targetHttpsProxyName);
380
381 if (current === undefined) {
382 const body: compute.TargetHttpsProxy = {
383 name: targetHttpsProxyName,
384 description: desiredDescription,
385 urlMap: desiredUrlMap,
386 };
387 if (desiredCerts.length > 0) {
388 body.sslCertificates = desiredCerts;
389 }
390 if (desiredSslPolicy !== undefined) {
391 body.sslPolicy = desiredSslPolicy;
392 }
393 if (news.httpKeepAliveTimeoutSec !== undefined) {
394 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
395 }
396 if (news.proxyBind !== undefined) {
397 body.proxyBind = news.proxyBind;
398 }
399 if (news.serverTlsPolicy !== undefined) {
400 body.serverTlsPolicy = news.serverTlsPolicy;
401 }
402 yield* compute
403 .insertRegionTargetHttpsProxies({
404 project: env.project,
405 region,
406 body,
407 })
408 .pipe(
409 Effect.flatMap((operation) =>
410 waitRegionOperation(env.project, region, operation, {
411 ignore: ["RESOURCE_ALREADY_EXISTS"],
412 }),
413 ),
414 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
415 );
416 current = yield* getByName(env.project, region, targetHttpsProxyName);
417 }
418
419 if (current === undefined) {
420 return yield* new RegionTargetHttpsProxyNotResolved({
421 targetHttpsProxyName,
422 region,
423 });
424 }
425
426 if (resourceTail(current.urlMap) !== resourceTail(desiredUrlMap)) {
427 yield* compute
428 .setUrlMapRegionTargetHttpsProxies({
429 project: env.project,
430 region,
431 targetHttpsProxy: targetHttpsProxyName,
432 body: { urlMap: desiredUrlMap },
433 })
434 .pipe(
435 Effect.flatMap((operation) =>
436 waitRegionOperation(env.project, region, operation),
437 ),
438 );
439 current = yield* getByName(env.project, region, targetHttpsProxyName);
440 if (current === undefined) {
441 return yield* new RegionTargetHttpsProxyNotResolved({
442 targetHttpsProxyName,
443 region,
444 });
445 }
446 }
447
448 if (
449 news.sslCertificates !== undefined &&
450 !sameResourceList(current.sslCertificates, desiredCerts)
451 ) {
452 yield* compute
453 .setSslCertificatesRegionTargetHttpsProxies({
454 project: env.project,
455 region,
456 targetHttpsProxy: targetHttpsProxyName,
457 body: { sslCertificates: desiredCerts },
458 })
459 .pipe(
460 Effect.flatMap((operation) =>
461 waitRegionOperation(env.project, region, operation),
462 ),
463 );
464 current = yield* getByName(env.project, region, targetHttpsProxyName);
465 if (current === undefined) {
466 return yield* new RegionTargetHttpsProxyNotResolved({
467 targetHttpsProxyName,
468 region,
469 });
470 }
471 }
472
473 const descriptionChanged =
474 (current.description ?? "") !== desiredDescription;
475 const keepAliveChanged =
476 news.httpKeepAliveTimeoutSec !== undefined &&
477 current.httpKeepAliveTimeoutSec !== news.httpKeepAliveTimeoutSec;
478 const proxyBindChanged =
479 news.proxyBind !== undefined &&
480 (current.proxyBind === true) !== news.proxyBind;
481 const serverTlsChanged =
482 news.serverTlsPolicy !== undefined &&
483 (current.serverTlsPolicy ?? "") !== news.serverTlsPolicy;
484 const sslPolicyChanged =
485 desiredSslPolicy !== undefined &&
486 resourceTail(current.sslPolicy) !== resourceTail(desiredSslPolicy);
487
488 if (
489 descriptionChanged ||
490 keepAliveChanged ||
491 proxyBindChanged ||
492 serverTlsChanged ||
493 sslPolicyChanged
494 ) {
495 const body: compute.TargetHttpsProxy = {
496 fingerprint: current.fingerprint,
497 description: desiredDescription,
498 };
499 if (news.httpKeepAliveTimeoutSec !== undefined) {
500 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
501 }
502 if (news.proxyBind !== undefined) {
503 body.proxyBind = news.proxyBind;
504 }
505 if (news.serverTlsPolicy !== undefined) {
506 body.serverTlsPolicy = news.serverTlsPolicy;
507 }
508 if (desiredSslPolicy !== undefined) {
509 body.sslPolicy = desiredSslPolicy;
510 }
511 yield* compute
512 .patchRegionTargetHttpsProxies({
513 project: env.project,
514 region,
515 targetHttpsProxy: targetHttpsProxyName,
516 body,
517 })
518 .pipe(
519 Effect.flatMap((operation) =>
520 waitRegionOperation(env.project, region, operation),
521 ),
522 );
523 current = yield* getByName(env.project, region, targetHttpsProxyName);
524 if (current === undefined) {
525 return yield* new RegionTargetHttpsProxyNotResolved({
526 targetHttpsProxyName,
527 region,
528 });
529 }
530 }
531
532 return toAttrs(current, env.project);
533 }),
534
535 delete: Effect.fn(function* ({ output }) {
536 const env = yield* GcpEnvironment.current;
537 const region = normalizeRegion(output.region, env.region);
538 const operation = yield* compute
539 .deleteRegionTargetHttpsProxies({
540 project: env.project,
541 region,
542 targetHttpsProxy: output.targetHttpsProxyName,
543 })
544 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
545 if (operation !== undefined) {
546 yield* waitRegionOperation(env.project, region, operation).pipe(
547 Effect.catchTag("NotFound", () => Effect.void),
548 );
549 }
550 }),
551 });