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 TargetSslProxyProxyHeader = compute.TargetSslProxyProxyHeaderEnum;
20
21const DEFAULT_PROXY_HEADER: TargetSslProxyProxyHeader = "NONE";
22
23export type TargetSslProxyProps = {
24 /**
25 * TargetSslProxy name (RFC1035, 1-63 characters). If omitted, a unique
26 * name is generated from the stack, stage, and logical id. Changing it
27 * replaces the resource.
28 */
29 targetSslProxyName?: string;
30 /**
31 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
32 * prefix so `list` / nuke can find resources (Compute TargetSslProxy
33 * has no labels field and no update API for description). Changing it
34 * replaces the resource.
35 */
36 description?: string;
37 /**
38 * Backend service this proxy forwards decrypted traffic to. Accepts a
39 * BackendService self-link, a
40 * `projects/{project}/global/backendServices/{name}` path, or the
41 * BackendService name. The backend service protocol must be `SSL`.
42 */
43 service: string;
44 /**
45 * SslCertificate URLs used to terminate SSL. 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 SSL proxies only.
55 */
56 certificateMap?: string;
57 /**
58 * SslPolicy URL applied to client TLS. Empty string clears the policy.
59 */
60 sslPolicy?: string;
61 /**
62 * Proxy header appended before sending data to the backend (`NONE` or
63 * `PROXY_V1`).
64 * @default "NONE"
65 */
66 proxyHeader?: TargetSslProxyProxyHeader | (string & {});
67};
68
69export type TargetSslProxy = Resource<
70 "GCP.Compute.TargetSslProxy",
71 TargetSslProxyProps,
72 {
73 /** TargetSslProxy name. */
74 targetSslProxyName: string;
75 /** Project id. */
76 project: string;
77 /** User description with the Alchemy ownership prefix stripped. */
78 description: string | undefined;
79 /** URL of the attached BackendService. */
80 service: string;
81 /** SslCertificate URLs. */
82 sslCertificates: string[];
83 /** Certificate Manager map URL, if set. */
84 certificateMap: string | undefined;
85 /** SslPolicy URL, if set. */
86 sslPolicy: string | undefined;
87 /** Proxy header prepended to backend connections. */
88 proxyHeader: string | undefined;
89 /** Server-defined URL. */
90 selfLink: string | undefined;
91 /** Server-assigned numeric id. */
92 targetSslProxyId: string | undefined;
93 /** RFC3339 creation timestamp. */
94 creationTimestamp: string | undefined;
95 /** Resource kind. */
96 kind: string | undefined;
97 },
98 never,
99 Providers
100>;
101
102/**
103 * A global Compute Engine target SSL proxy.
104 *
105 * Target SSL proxies terminate SSL/TLS for Proxy Network Load Balancers
106 * (SSL proxy). A forwarding rule points at the proxy; the proxy points at
107 * a backend service (protocol `SSL`) and one or more SSL certificates (or
108 * a Certificate Manager map).
109 *
110 * This resource maps to the global `targetSslProxies` collection. Compute
111 * TargetSslProxy has no labels field — Alchemy ownership is stored in the
112 * description so nuke can find leaked proxies. Description is immutable
113 * after create.
114 *
115 * ### Creating a Target SSL Proxy
116 * **Example:** Generated name in front of an SSL backend
117 * ```typescript
118 * const check = yield* GCP.Compute.HealthCheck("ssl", {
119 * type: "TCP",
120 * tcpHealthCheck: { port: 443 },
121 * });
122 * const backend = yield* GCP.Compute.BackendService("ssl", {
123 * protocol: "SSL",
124 * healthChecks: [check.selfLink],
125 * });
126 * const cert = yield* GCP.Compute.SslCertificate("tls", {
127 * certificate: pemCertificate,
128 * privateKey: pemPrivateKey,
129 * });
130 * const proxy = yield* GCP.Compute.TargetSslProxy("ssl", {
131 * service: backend.name,
132 * sslCertificates: [cert.sslCertificateName],
133 * });
134 * ```
135 *
136 * **Example:** Explicit name, PROXY protocol, and description
137 * ```typescript
138 * const proxy = yield* GCP.Compute.TargetSslProxy("ssl", {
139 * targetSslProxyName: "web-ssl",
140 * service: backend.selfLink,
141 * sslCertificates: [cert.selfLink],
142 * proxyHeader: "PROXY_V1",
143 * description: "public ssl",
144 * });
145 * ```
146 *
147 * ### Updating a Target SSL Proxy
148 * **Example:** Swap the backend and certificates
149 * ```typescript
150 * const proxy = yield* GCP.Compute.TargetSslProxy("ssl", {
151 * targetSslProxyName: "web-ssl",
152 * service: nextBackend.name,
153 * sslCertificates: [nextCert.sslCertificateName],
154 * proxyHeader: "PROXY_V1",
155 * });
156 * ```
157 *
158 * @resource
159 * @category Compute
160 */
161export const TargetSslProxy = Resource<TargetSslProxy>(
162 "GCP.Compute.TargetSslProxy",
163);
164
165export class TargetSslProxyNotResolved extends Data.TaggedError(
166 "GCP.Compute.TargetSslProxyNotResolved",
167)<{
168 targetSslProxyName: string;
169}> {}
170
171const toName = (id: string, name: string | undefined, existing?: string) =>
172 Effect.gen(function* () {
173 if (name !== undefined) return name;
174 if (existing !== undefined) return existing;
175 const generated = yield* createPhysicalName({
176 id,
177 maxLength: 63,
178 lowercase: true,
179 });
180 return /^[a-z]/.test(generated) ? generated : `s${generated}`.slice(0, 63);
181 });
182
183const encodeDescription = (
184 labels: Record<string, string>,
185 description: string | undefined,
186): string => {
187 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
188 return description ? `${marker}\n${description}` : marker;
189};
190
191const parseDescription = (
192 description: string | undefined,
193): {
194 labels: Record<string, string>;
195 description: string | undefined;
196} => {
197 if (!description?.startsWith("[alchemy ")) {
198 return { labels: {}, description };
199 }
200 const end = description.indexOf("]");
201 if (end < 0) return { labels: {}, description };
202 const labels: Record<string, string> = {};
203 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
204 const eq = part.indexOf("=");
205 if (eq > 0) {
206 labels[part.slice(0, eq)] = part.slice(eq + 1);
207 }
208 }
209 const rest = description.slice(end + 1).replace(/^\n/, "");
210 return { labels, description: rest.length > 0 ? rest : undefined };
211};
212
213const resourceTail = (value: string | undefined): string => {
214 if (value === undefined || value.length === 0) return "";
215 const parts = value.split("/").filter((part) => part.length > 0);
216 return parts[parts.length - 1] ?? "";
217};
218
219const toServiceRef = (project: string, service: string): string => {
220 if (service.includes("/")) return service;
221 return `projects/${project}/global/backendServices/${service}`;
222};
223
224const toSslCertRef = (project: string, cert: string): string => {
225 if (cert.includes("/")) return cert;
226 return `projects/${project}/global/sslCertificates/${cert}`;
227};
228
229const sameResourceList = (
230 left: readonly string[] | undefined,
231 right: readonly string[] | undefined,
232) => {
233 const a = [...(left ?? [])].map(resourceTail).sort();
234 const b = [...(right ?? [])].map(resourceTail).sort();
235 return a.length === b.length && a.every((value, i) => value === b[i]);
236};
237
238const toAttrs = (proxy: compute.TargetSslProxy, project: string) => {
239 const parsed = parseDescription(proxy.description);
240 return {
241 targetSslProxyName: proxy.name ?? proxy.id ?? "",
242 project,
243 description: parsed.description,
244 service: proxy.service ?? "",
245 sslCertificates: [...(proxy.sslCertificates ?? [])],
246 certificateMap: proxy.certificateMap,
247 sslPolicy: proxy.sslPolicy,
248 proxyHeader: proxy.proxyHeader,
249 selfLink: proxy.selfLink,
250 targetSslProxyId: proxy.id,
251 creationTimestamp: proxy.creationTimestamp,
252 kind: proxy.kind,
253 };
254};
255
256const getByName = (project: string, targetSslProxy: string) =>
257 compute
258 .getTargetSslProxies({ project, targetSslProxy })
259 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
260
261export const TargetSslProxyProvider = () =>
262 Provider.succeed(TargetSslProxy, {
263 stables: [
264 "targetSslProxyName",
265 "project",
266 "targetSslProxyId",
267 "selfLink",
268 "creationTimestamp",
269 ],
270
271 diff: Effect.fn(function* ({ news, olds, output }) {
272 if (!isResolved(news)) return undefined;
273 const previousName =
274 olds?.targetSslProxyName ?? output?.targetSslProxyName;
275 const nextName = news.targetSslProxyName;
276 if (
277 previousName !== undefined &&
278 nextName !== undefined &&
279 previousName !== nextName
280 ) {
281 return { action: "replace" as const, deleteFirst: true };
282 }
283 const previousDescription = olds?.description ?? output?.description;
284 if (
285 (olds !== undefined || output !== undefined) &&
286 previousDescription !== news.description
287 ) {
288 return { action: "replace" as const, deleteFirst: true };
289 }
290 return undefined;
291 }),
292
293 read: Effect.fn(function* ({ id, olds, output }) {
294 const env = yield* GcpEnvironment.current;
295 const targetSslProxyName = yield* toName(
296 id,
297 olds?.targetSslProxyName,
298 output?.targetSslProxyName,
299 );
300 const existing = yield* getByName(env.project, targetSslProxyName);
301 if (existing === undefined) return undefined;
302 const attrs = toAttrs(existing, env.project);
303 const { labels } = parseDescription(existing.description);
304 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
305 }),
306
307 list: () =>
308 Effect.gen(function* () {
309 const env = yield* GcpEnvironment.current;
310 return yield* compute.listTargetSslProxies
311 .items({ project: env.project, maxResults: 500 })
312 .pipe(
313 Stream.filter((proxy) => {
314 const { labels } = parseDescription(proxy.description);
315 return Object.keys(labels).some((key) =>
316 key.startsWith("alchemy-"),
317 );
318 }),
319 Stream.map((proxy) => toAttrs(proxy, env.project)),
320 Stream.runCollect,
321 Effect.map((chunk) => Array.from(chunk)),
322 );
323 }),
324
325 reconcile: Effect.fn(function* ({ id, news, output }) {
326 const env = yield* GcpEnvironment.current;
327 const targetSslProxyName = yield* toName(
328 id,
329 news.targetSslProxyName,
330 output?.targetSslProxyName,
331 );
332 const ownership = yield* createInternalLabels(id);
333 const desiredDescription = encodeDescription(ownership, news.description);
334 const desiredService = toServiceRef(env.project, news.service);
335 const desiredCerts = (news.sslCertificates ?? []).map((cert) =>
336 toSslCertRef(env.project, cert),
337 );
338 const desiredProxyHeader = news.proxyHeader ?? DEFAULT_PROXY_HEADER;
339
340 let current = yield* getByName(env.project, targetSslProxyName);
341
342 if (current === undefined) {
343 const body: compute.TargetSslProxy = {
344 name: targetSslProxyName,
345 description: desiredDescription,
346 service: desiredService,
347 };
348 if (desiredCerts.length > 0) {
349 body.sslCertificates = desiredCerts;
350 }
351 if (news.certificateMap !== undefined) {
352 body.certificateMap = news.certificateMap;
353 }
354 if (news.sslPolicy !== undefined) {
355 body.sslPolicy = news.sslPolicy;
356 }
357 if (news.proxyHeader !== undefined) {
358 body.proxyHeader = news.proxyHeader;
359 }
360 yield* compute
361 .insertTargetSslProxies({
362 project: env.project,
363 body,
364 })
365 .pipe(
366 Effect.flatMap((operation) =>
367 waitGlobalOperation(env.project, operation),
368 ),
369 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
370 );
371 current = yield* getByName(env.project, targetSslProxyName);
372 }
373
374 if (current === undefined) {
375 return yield* new TargetSslProxyNotResolved({
376 targetSslProxyName,
377 });
378 }
379
380 if (resourceTail(current.service) !== resourceTail(desiredService)) {
381 yield* compute
382 .setBackendServiceTargetSslProxies({
383 project: env.project,
384 targetSslProxy: targetSslProxyName,
385 body: { service: desiredService },
386 })
387 .pipe(
388 Effect.flatMap((operation) =>
389 waitGlobalOperation(env.project, operation),
390 ),
391 );
392 current = yield* getByName(env.project, targetSslProxyName);
393 if (current === undefined) {
394 return yield* new TargetSslProxyNotResolved({
395 targetSslProxyName,
396 });
397 }
398 }
399
400 if (
401 news.sslCertificates !== undefined &&
402 !sameResourceList(current.sslCertificates, desiredCerts)
403 ) {
404 yield* compute
405 .setSslCertificatesTargetSslProxies({
406 project: env.project,
407 targetSslProxy: targetSslProxyName,
408 body: { sslCertificates: desiredCerts },
409 })
410 .pipe(
411 Effect.flatMap((operation) =>
412 waitGlobalOperation(env.project, operation),
413 ),
414 );
415 current = yield* getByName(env.project, targetSslProxyName);
416 if (current === undefined) {
417 return yield* new TargetSslProxyNotResolved({
418 targetSslProxyName,
419 });
420 }
421 }
422
423 if (
424 news.certificateMap !== undefined &&
425 (current.certificateMap ?? "") !== news.certificateMap
426 ) {
427 yield* compute
428 .setCertificateMapTargetSslProxies({
429 project: env.project,
430 targetSslProxy: targetSslProxyName,
431 body: { certificateMap: news.certificateMap },
432 })
433 .pipe(
434 Effect.flatMap((operation) =>
435 waitGlobalOperation(env.project, operation),
436 ),
437 );
438 current = yield* getByName(env.project, targetSslProxyName);
439 if (current === undefined) {
440 return yield* new TargetSslProxyNotResolved({
441 targetSslProxyName,
442 });
443 }
444 }
445
446 if (
447 (current.proxyHeader ?? DEFAULT_PROXY_HEADER) !== desiredProxyHeader
448 ) {
449 yield* compute
450 .setProxyHeaderTargetSslProxies({
451 project: env.project,
452 targetSslProxy: targetSslProxyName,
453 body: { proxyHeader: desiredProxyHeader },
454 })
455 .pipe(
456 Effect.flatMap((operation) =>
457 waitGlobalOperation(env.project, operation),
458 ),
459 );
460 current = yield* getByName(env.project, targetSslProxyName);
461 if (current === undefined) {
462 return yield* new TargetSslProxyNotResolved({
463 targetSslProxyName,
464 });
465 }
466 }
467
468 if (
469 news.sslPolicy !== undefined &&
470 (current.sslPolicy ?? "") !== news.sslPolicy
471 ) {
472 yield* compute
473 .setSslPolicyTargetSslProxies({
474 project: env.project,
475 targetSslProxy: targetSslProxyName,
476 body: { sslPolicy: news.sslPolicy },
477 })
478 .pipe(
479 Effect.flatMap((operation) =>
480 waitGlobalOperation(env.project, operation),
481 ),
482 );
483 current = yield* getByName(env.project, targetSslProxyName);
484 if (current === undefined) {
485 return yield* new TargetSslProxyNotResolved({
486 targetSslProxyName,
487 });
488 }
489 }
490
491 return toAttrs(current, env.project);
492 }),
493
494 delete: Effect.fn(function* ({ output }) {
495 const env = yield* GcpEnvironment.current;
496 const operation = yield* compute
497 .deleteTargetSslProxies({
498 project: env.project,
499 targetSslProxy: output.targetSslProxyName,
500 })
501 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
502 if (operation !== undefined) {
503 yield* waitGlobalOperation(env.project, operation).pipe(
504 Effect.catchTag("NotFound", () => Effect.void),
505 );
506 }
507 }),
508 });