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 TargetHttpsProxyQuicOverride =
20 compute.TargetHttpsProxyQuicOverrideEnum;
21export type TargetHttpsProxyTlsEarlyData =
22 compute.TargetHttpsProxyTlsEarlyDataEnum;
23
24const DEFAULT_QUIC: TargetHttpsProxyQuicOverride = "NONE";
25
26export type TargetHttpsProxyProps = {
27 /**
28 * TargetHttpsProxy 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 targetHttpsProxyName?: string;
33 /**
34 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
35 * prefix so `list` / nuke can find resources (Compute TargetHttpsProxy
36 * has no labels field).
37 */
38 description?: string;
39 /**
40 * URL map this proxy routes to. Accepts a UrlMap self-link, a
41 * `projects/{project}/global/urlMaps/{name}` path, or the UrlMap name.
42 */
43 urlMap: string;
44 /**
45 * SslCertificate URLs used to terminate HTTPS. Accepts self-links,
46 * `projects/{project}/global/sslCertificates/{name}` paths, or
47 * certificate names. At least one is required unless `certificateMap`
48 * is set. Up to 15 classic certificates.
49 */
50 sslCertificates?: string[];
51 /**
52 * Certificate Manager map URL
53 * (`//certificatemanager.googleapis.com/projects/{project}/locations/{location}/certificateMaps/{name}`).
54 * When set, `sslCertificates` is ignored. Global external / classic
55 * Application Load Balancers only.
56 */
57 certificateMap?: string;
58 /**
59 * SslPolicy URL applied to client TLS. Empty string clears the policy.
60 */
61 sslPolicy?: string;
62 /**
63 * QUIC override (`NONE`, `ENABLE`, `DISABLE`).
64 * @default "NONE"
65 */
66 quicOverride?: TargetHttpsProxyQuicOverride | (string & {});
67 /**
68 * HTTP keep-alive timeout in seconds after a response while idle.
69 * Global external Application Load Balancers: 5–1200. Not supported
70 * on classic Application Load Balancers.
71 */
72 httpKeepAliveTimeoutSec?: number;
73 /**
74 * Whether TLS 1.3 0-RTT early data is accepted (`DISABLED`,
75 * `PERMISSIVE`, `STRICT`, `UNRESTRICTED`).
76 * @default "DISABLED"
77 */
78 tlsEarlyData?: TargetHttpsProxyTlsEarlyData | (string & {});
79 /**
80 * Bind inbound traffic to the forwarding-rule address. Only applies when
81 * the referencing forwarding rule uses `INTERNAL_SELF_MANAGED`.
82 * @default false
83 */
84 proxyBind?: boolean;
85 /**
86 * networksecurity.ServerTlsPolicy URL used to authenticate inbound
87 * traffic.
88 */
89 serverTlsPolicy?: string;
90 /**
91 * networksecurity.AuthorizationPolicy URL. Currently has no impact.
92 */
93 authorizationPolicy?: string;
94};
95
96export type TargetHttpsProxy = Resource<
97 "GCP.Compute.TargetHttpsProxy",
98 TargetHttpsProxyProps,
99 {
100 /** TargetHttpsProxy name. */
101 targetHttpsProxyName: string;
102 /** Project id. */
103 project: string;
104 /** User description with the Alchemy ownership prefix stripped. */
105 description: string | undefined;
106 /** URL of the attached UrlMap. */
107 urlMap: string;
108 /** SslCertificate URLs. */
109 sslCertificates: string[];
110 /** Certificate Manager map URL, if set. */
111 certificateMap: string | undefined;
112 /** SslPolicy URL, if set. */
113 sslPolicy: string | undefined;
114 /** QUIC override policy. */
115 quicOverride: string | undefined;
116 /** HTTP keep-alive timeout in seconds, if set. */
117 httpKeepAliveTimeoutSec: number | undefined;
118 /** TLS 1.3 early-data mode, if set. */
119 tlsEarlyData: string | undefined;
120 /** Whether Envoy inbound bind is enabled. */
121 proxyBind: boolean;
122 /** Server TLS policy URL, if set. */
123 serverTlsPolicy: string | undefined;
124 /** Authorization policy URL, if set. */
125 authorizationPolicy: string | undefined;
126 /** Server-defined URL. */
127 selfLink: string | undefined;
128 /** Server-assigned numeric id. */
129 targetHttpsProxyId: string | undefined;
130 /** Optimistic-locking fingerprint. */
131 fingerprint: string | undefined;
132 /** RFC3339 creation timestamp. */
133 creationTimestamp: string | undefined;
134 /** Resource kind. */
135 kind: string | undefined;
136 },
137 never,
138 Providers
139>;
140
141/**
142 * A global Compute Engine target HTTPS proxy.
143 *
144 * Target HTTPS proxies terminate HTTPS for global external Application
145 * Load Balancers, classic Application Load Balancers, cross-region
146 * internal Application Load Balancers, and Traffic Director. A forwarding
147 * rule points at the proxy; the proxy points at a URL map and one or more
148 * SSL certificates (or a Certificate Manager map).
149 *
150 * This resource maps to the global `targetHttpsProxies` collection
151 * (`regionTargetHttpsProxies` is a separate resource). Compute
152 * TargetHttpsProxy has no labels field — Alchemy ownership is stored in
153 * the description so nuke can find leaked proxies.
154 *
155 * ### Creating a Target HTTPS Proxy
156 * **Example:** Generated name in front of a URL map
157 * ```typescript
158 * const map = yield* GCP.Compute.UrlMap("web", {
159 * defaultUrlRedirect: {
160 * httpsRedirect: true,
161 * hostRedirect: "example.com",
162 * stripQuery: false,
163 * },
164 * });
165 * const cert = yield* GCP.Compute.SslCertificate("tls", {
166 * certificate: pemCertificate,
167 * privateKey: pemPrivateKey,
168 * });
169 * const proxy = yield* GCP.Compute.TargetHttpsProxy("https", {
170 * urlMap: map.urlMapName,
171 * sslCertificates: [cert.sslCertificateName],
172 * });
173 * ```
174 *
175 * **Example:** Explicit name, QUIC, and description
176 * ```typescript
177 * const proxy = yield* GCP.Compute.TargetHttpsProxy("https", {
178 * targetHttpsProxyName: "web-https",
179 * urlMap: map.selfLink,
180 * sslCertificates: [cert.selfLink],
181 * quicOverride: "ENABLE",
182 * description: "public https",
183 * });
184 * ```
185 *
186 * ### Updating a Target HTTPS Proxy
187 * **Example:** Swap certificates and enable QUIC
188 * ```typescript
189 * const proxy = yield* GCP.Compute.TargetHttpsProxy("https", {
190 * targetHttpsProxyName: "web-https",
191 * urlMap: map.urlMapName,
192 * sslCertificates: [nextCert.sslCertificateName],
193 * quicOverride: "ENABLE",
194 * });
195 * ```
196 *
197 * @resource
198 * @category Compute
199 */
200export const TargetHttpsProxy = Resource<TargetHttpsProxy>(
201 "GCP.Compute.TargetHttpsProxy",
202);
203
204export class TargetHttpsProxyNotResolved extends Data.TaggedError(
205 "GCP.Compute.TargetHttpsProxyNotResolved",
206)<{
207 targetHttpsProxyName: string;
208}> {}
209
210const toName = (id: string, name: string | undefined, existing?: string) =>
211 Effect.gen(function* () {
212 if (name !== undefined) return name;
213 if (existing !== undefined) return existing;
214 const generated = yield* createPhysicalName({
215 id,
216 maxLength: 63,
217 lowercase: true,
218 });
219 return /^[a-z]/.test(generated) ? generated : `h${generated}`.slice(0, 63);
220 });
221
222const encodeDescription = (
223 labels: Record<string, string>,
224 description: string | undefined,
225): string => {
226 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
227 return description ? `${marker}\n${description}` : marker;
228};
229
230const parseDescription = (
231 description: string | undefined,
232): {
233 labels: Record<string, string>;
234 description: string | undefined;
235} => {
236 if (!description?.startsWith("[alchemy ")) {
237 return { labels: {}, description };
238 }
239 const end = description.indexOf("]");
240 if (end < 0) return { labels: {}, description };
241 const labels: Record<string, string> = {};
242 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
243 const eq = part.indexOf("=");
244 if (eq > 0) {
245 labels[part.slice(0, eq)] = part.slice(eq + 1);
246 }
247 }
248 const rest = description.slice(end + 1).replace(/^\n/, "");
249 return { labels, description: rest.length > 0 ? rest : undefined };
250};
251
252const resourceTail = (value: string | undefined): string => {
253 if (value === undefined || value.length === 0) return "";
254 const parts = value.split("/").filter((part) => part.length > 0);
255 return parts[parts.length - 1] ?? "";
256};
257
258const toUrlMapRef = (project: string, urlMap: string): string => {
259 if (urlMap.includes("/")) return urlMap;
260 return `projects/${project}/global/urlMaps/${urlMap}`;
261};
262
263const toSslCertRef = (project: string, cert: string): string => {
264 if (cert.includes("/")) return cert;
265 return `projects/${project}/global/sslCertificates/${cert}`;
266};
267
268const sameResourceList = (
269 left: readonly string[] | undefined,
270 right: readonly string[] | undefined,
271) => {
272 const a = [...(left ?? [])].map(resourceTail).sort();
273 const b = [...(right ?? [])].map(resourceTail).sort();
274 return a.length === b.length && a.every((value, i) => value === b[i]);
275};
276
277const toAttrs = (proxy: compute.TargetHttpsProxy, project: string) => {
278 const parsed = parseDescription(proxy.description);
279 return {
280 targetHttpsProxyName: proxy.name ?? proxy.id ?? "",
281 project,
282 description: parsed.description,
283 urlMap: proxy.urlMap ?? "",
284 sslCertificates: [...(proxy.sslCertificates ?? [])],
285 certificateMap: proxy.certificateMap,
286 sslPolicy: proxy.sslPolicy,
287 quicOverride: proxy.quicOverride,
288 httpKeepAliveTimeoutSec: proxy.httpKeepAliveTimeoutSec,
289 tlsEarlyData: proxy.tlsEarlyData,
290 proxyBind: proxy.proxyBind === true,
291 serverTlsPolicy: proxy.serverTlsPolicy,
292 authorizationPolicy: proxy.authorizationPolicy,
293 selfLink: proxy.selfLink,
294 targetHttpsProxyId: proxy.id,
295 fingerprint: proxy.fingerprint,
296 creationTimestamp: proxy.creationTimestamp,
297 kind: proxy.kind,
298 };
299};
300
301const getByName = (project: string, targetHttpsProxy: string) =>
302 compute
303 .getTargetHttpsProxies({ project, targetHttpsProxy })
304 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
305
306export const TargetHttpsProxyProvider = () =>
307 Provider.succeed(TargetHttpsProxy, {
308 nuke: {
309 dependsOn: ["GCP.Compute.UrlMap"],
310 },
311 stables: [
312 "targetHttpsProxyName",
313 "project",
314 "targetHttpsProxyId",
315 "selfLink",
316 "creationTimestamp",
317 ],
318
319 diff: Effect.fn(function* ({ news, olds, output }) {
320 if (!isResolved(news)) return undefined;
321 const previous =
322 olds?.targetHttpsProxyName ?? output?.targetHttpsProxyName;
323 const next = news.targetHttpsProxyName;
324 if (previous !== undefined && next !== undefined && previous !== next) {
325 return { action: "replace" as const, deleteFirst: true };
326 }
327 return undefined;
328 }),
329
330 read: Effect.fn(function* ({ id, olds, output }) {
331 const env = yield* GcpEnvironment.current;
332 const targetHttpsProxyName = yield* toName(
333 id,
334 olds?.targetHttpsProxyName,
335 output?.targetHttpsProxyName,
336 );
337 const existing = yield* getByName(env.project, targetHttpsProxyName);
338 if (existing === undefined) return undefined;
339 const attrs = toAttrs(existing, env.project);
340 const { labels } = parseDescription(existing.description);
341 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
342 }),
343
344 list: () =>
345 Effect.gen(function* () {
346 const env = yield* GcpEnvironment.current;
347 return yield* compute.listTargetHttpsProxies
348 .items({ project: env.project, maxResults: 500 })
349 .pipe(
350 Stream.filter((proxy) => {
351 const { labels } = parseDescription(proxy.description);
352 return Object.keys(labels).some((key) =>
353 key.startsWith("alchemy-"),
354 );
355 }),
356 Stream.map((proxy) => toAttrs(proxy, env.project)),
357 Stream.runCollect,
358 Effect.map((chunk) => Array.from(chunk)),
359 );
360 }),
361
362 reconcile: Effect.fn(function* ({ id, news, output }) {
363 const env = yield* GcpEnvironment.current;
364 const targetHttpsProxyName = yield* toName(
365 id,
366 news.targetHttpsProxyName,
367 output?.targetHttpsProxyName,
368 );
369 const ownership = yield* createInternalLabels(id);
370 const desiredDescription = encodeDescription(ownership, news.description);
371 const desiredUrlMap = toUrlMapRef(env.project, news.urlMap);
372 const desiredCerts = (news.sslCertificates ?? []).map((cert) =>
373 toSslCertRef(env.project, cert),
374 );
375 const desiredQuic = news.quicOverride ?? DEFAULT_QUIC;
376
377 let current = yield* getByName(env.project, targetHttpsProxyName);
378
379 if (current === undefined) {
380 const body: compute.TargetHttpsProxy = {
381 name: targetHttpsProxyName,
382 description: desiredDescription,
383 urlMap: desiredUrlMap,
384 };
385 if (desiredCerts.length > 0) {
386 body.sslCertificates = desiredCerts;
387 }
388 if (news.certificateMap !== undefined) {
389 body.certificateMap = news.certificateMap;
390 }
391 if (news.sslPolicy !== undefined) {
392 body.sslPolicy = news.sslPolicy;
393 }
394 if (news.quicOverride !== undefined) {
395 body.quicOverride = news.quicOverride;
396 }
397 if (news.httpKeepAliveTimeoutSec !== undefined) {
398 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
399 }
400 if (news.tlsEarlyData !== undefined) {
401 body.tlsEarlyData = news.tlsEarlyData;
402 }
403 if (news.proxyBind !== undefined) {
404 body.proxyBind = news.proxyBind;
405 }
406 if (news.serverTlsPolicy !== undefined) {
407 body.serverTlsPolicy = news.serverTlsPolicy;
408 }
409 if (news.authorizationPolicy !== undefined) {
410 body.authorizationPolicy = news.authorizationPolicy;
411 }
412 yield* compute
413 .insertTargetHttpsProxies({
414 project: env.project,
415 body,
416 })
417 .pipe(
418 Effect.flatMap((operation) =>
419 waitGlobalOperation(env.project, operation),
420 ),
421 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
422 );
423 current = yield* getByName(env.project, targetHttpsProxyName);
424 }
425
426 if (current === undefined) {
427 return yield* new TargetHttpsProxyNotResolved({
428 targetHttpsProxyName,
429 });
430 }
431
432 if (resourceTail(current.urlMap) !== resourceTail(desiredUrlMap)) {
433 yield* compute
434 .setUrlMapTargetHttpsProxies({
435 project: env.project,
436 targetHttpsProxy: targetHttpsProxyName,
437 body: { urlMap: desiredUrlMap },
438 })
439 .pipe(
440 Effect.flatMap((operation) =>
441 waitGlobalOperation(env.project, operation),
442 ),
443 );
444 current = yield* getByName(env.project, targetHttpsProxyName);
445 if (current === undefined) {
446 return yield* new TargetHttpsProxyNotResolved({
447 targetHttpsProxyName,
448 });
449 }
450 }
451
452 if (
453 news.sslCertificates !== undefined &&
454 !sameResourceList(current.sslCertificates, desiredCerts)
455 ) {
456 yield* compute
457 .setSslCertificatesTargetHttpsProxies({
458 project: env.project,
459 targetHttpsProxy: targetHttpsProxyName,
460 body: { sslCertificates: desiredCerts },
461 })
462 .pipe(
463 Effect.flatMap((operation) =>
464 waitGlobalOperation(env.project, operation),
465 ),
466 );
467 current = yield* getByName(env.project, targetHttpsProxyName);
468 if (current === undefined) {
469 return yield* new TargetHttpsProxyNotResolved({
470 targetHttpsProxyName,
471 });
472 }
473 }
474
475 if (
476 news.certificateMap !== undefined &&
477 (current.certificateMap ?? "") !== news.certificateMap
478 ) {
479 yield* compute
480 .setCertificateMapTargetHttpsProxies({
481 project: env.project,
482 targetHttpsProxy: targetHttpsProxyName,
483 body: { certificateMap: news.certificateMap },
484 })
485 .pipe(
486 Effect.flatMap((operation) =>
487 waitGlobalOperation(env.project, operation),
488 ),
489 );
490 current = yield* getByName(env.project, targetHttpsProxyName);
491 if (current === undefined) {
492 return yield* new TargetHttpsProxyNotResolved({
493 targetHttpsProxyName,
494 });
495 }
496 }
497
498 if ((current.quicOverride ?? DEFAULT_QUIC) !== desiredQuic) {
499 yield* compute
500 .setQuicOverrideTargetHttpsProxies({
501 project: env.project,
502 targetHttpsProxy: targetHttpsProxyName,
503 body: { quicOverride: desiredQuic },
504 })
505 .pipe(
506 Effect.flatMap((operation) =>
507 waitGlobalOperation(env.project, operation),
508 ),
509 );
510 current = yield* getByName(env.project, targetHttpsProxyName);
511 if (current === undefined) {
512 return yield* new TargetHttpsProxyNotResolved({
513 targetHttpsProxyName,
514 });
515 }
516 }
517
518 if (
519 news.sslPolicy !== undefined &&
520 (current.sslPolicy ?? "") !== news.sslPolicy
521 ) {
522 yield* compute
523 .setSslPolicyTargetHttpsProxies({
524 project: env.project,
525 targetHttpsProxy: targetHttpsProxyName,
526 body: { sslPolicy: news.sslPolicy },
527 })
528 .pipe(
529 Effect.flatMap((operation) =>
530 waitGlobalOperation(env.project, operation),
531 ),
532 );
533 current = yield* getByName(env.project, targetHttpsProxyName);
534 if (current === undefined) {
535 return yield* new TargetHttpsProxyNotResolved({
536 targetHttpsProxyName,
537 });
538 }
539 }
540
541 const descriptionChanged =
542 (current.description ?? "") !== desiredDescription;
543 const keepAliveChanged =
544 news.httpKeepAliveTimeoutSec !== undefined &&
545 current.httpKeepAliveTimeoutSec !== news.httpKeepAliveTimeoutSec;
546 const tlsEarlyDataChanged =
547 news.tlsEarlyData !== undefined &&
548 (current.tlsEarlyData ?? "DISABLED") !== news.tlsEarlyData;
549 const proxyBindChanged =
550 news.proxyBind !== undefined &&
551 (current.proxyBind === true) !== news.proxyBind;
552 const serverTlsChanged =
553 news.serverTlsPolicy !== undefined &&
554 (current.serverTlsPolicy ?? "") !== news.serverTlsPolicy;
555 const authorizationChanged =
556 news.authorizationPolicy !== undefined &&
557 (current.authorizationPolicy ?? "") !== news.authorizationPolicy;
558
559 if (
560 descriptionChanged ||
561 keepAliveChanged ||
562 tlsEarlyDataChanged ||
563 proxyBindChanged ||
564 serverTlsChanged ||
565 authorizationChanged
566 ) {
567 const body: compute.TargetHttpsProxy = {
568 fingerprint: current.fingerprint,
569 description: desiredDescription,
570 };
571 if (news.httpKeepAliveTimeoutSec !== undefined) {
572 body.httpKeepAliveTimeoutSec = news.httpKeepAliveTimeoutSec;
573 }
574 if (news.tlsEarlyData !== undefined) {
575 body.tlsEarlyData = news.tlsEarlyData;
576 }
577 if (news.proxyBind !== undefined) {
578 body.proxyBind = news.proxyBind;
579 }
580 if (news.serverTlsPolicy !== undefined) {
581 body.serverTlsPolicy = news.serverTlsPolicy;
582 }
583 if (news.authorizationPolicy !== undefined) {
584 body.authorizationPolicy = news.authorizationPolicy;
585 }
586 yield* compute
587 .patchTargetHttpsProxies({
588 project: env.project,
589 targetHttpsProxy: targetHttpsProxyName,
590 body,
591 })
592 .pipe(
593 Effect.flatMap((operation) =>
594 waitGlobalOperation(env.project, operation),
595 ),
596 );
597 current = yield* getByName(env.project, targetHttpsProxyName);
598 if (current === undefined) {
599 return yield* new TargetHttpsProxyNotResolved({
600 targetHttpsProxyName,
601 });
602 }
603 }
604
605 return toAttrs(current, env.project);
606 }),
607
608 delete: Effect.fn(function* ({ output }) {
609 const env = yield* GcpEnvironment.current;
610 const operation = yield* compute
611 .deleteTargetHttpsProxies({
612 project: env.project,
613 targetHttpsProxy: output.targetHttpsProxyName,
614 })
615 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
616 if (operation !== undefined) {
617 yield* waitGlobalOperation(env.project, operation).pipe(
618 Effect.catchTag("NotFound", () => Effect.void),
619 );
620 }
621 }),
622 });