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 Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { Unowned } from "../../AdoptPolicy.ts";
8import { isResolved } from "../../Diff.ts";
9import { createPhysicalName } from "../../PhysicalName.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { GcpEnvironment } from "../Environment.ts";
13import {
14 alchemyLabelKeys,
15 createInternalLabels,
16 hasAlchemyLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import type {
20 SslPolicyMinTlsVersion,
21 SslPolicyPostQuantumKeyExchange,
22 SslPolicyProfile,
23} from "./SslPolicy.ts";
24
25const DEFAULT_PROFILE: SslPolicyProfile = "COMPATIBLE";
26const DEFAULT_MIN_TLS: SslPolicyMinTlsVersion = "TLS_1_0";
27
28export type RegionSslPolicyProps = {
29 /**
30 * SSL policy name (RFC1035, 1-63 chars). If omitted, a unique name is
31 * generated from the stack, stage, and logical id. Changing it replaces
32 * the resource.
33 */
34 sslPolicyName?: string;
35 /**
36 * Region the policy lives in (e.g. `us-central1`). Immutable — changing
37 * it replaces the resource. `US-CENTRAL1` is accepted and normalized to
38 * `us-central1`.
39 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
40 */
41 region?: string;
42 /**
43 * Optional description. Alchemy ownership is stored in a `[alchemy …]`
44 * prefix so `list` / nuke can find resources (Compute SslPolicy has no
45 * labels field).
46 */
47 description?: string;
48 /**
49 * Minimum TLS version clients may use (`TLS_1_0`, `TLS_1_1`, `TLS_1_2`,
50 * `TLS_1_3`). `TLS_1_3` requires `profile: "RESTRICTED"`.
51 * @default "TLS_1_0"
52 */
53 minTlsVersion?: SslPolicyMinTlsVersion;
54 /**
55 * Cipher-suite profile (`COMPATIBLE`, `MODERN`, `RESTRICTED`,
56 * `FIPS_202205`, `CUSTOM`). `CUSTOM` requires `customFeatures`.
57 * `FIPS_202205` requires `minTlsVersion: "TLS_1_2"`.
58 * @default "COMPATIBLE"
59 */
60 profile?: SslPolicyProfile;
61 /**
62 * Features enabled when `profile` is `CUSTOM`. Must be empty for every
63 * other profile.
64 */
65 customFeatures?: string[];
66 /**
67 * Whether the load balancer negotiates X25519MLKEM768 (`DEFAULT`,
68 * `ENABLED`, `DEFERRED`).
69 */
70 postQuantumKeyExchange?: SslPolicyPostQuantumKeyExchange;
71};
72
73export type RegionSslPolicy = Resource<
74 "GCP.Compute.RegionSslPolicy",
75 RegionSslPolicyProps,
76 {
77 /** SSL policy name. */
78 sslPolicyName: string;
79 /** Project id. */
80 project: string;
81 /** Region short name (`us-central1`). */
82 region: string;
83 /** User description with the Alchemy ownership prefix stripped. */
84 description: string | undefined;
85 /** Minimum TLS version. */
86 minTlsVersion: SslPolicyMinTlsVersion;
87 /** Cipher-suite profile. */
88 profile: SslPolicyProfile;
89 /** Custom features (only when `profile` is `CUSTOM`). */
90 customFeatures: string[];
91 /** Features currently enabled by the selected profile. */
92 enabledFeatures: string[];
93 /** Post-quantum key exchange mode, if set. */
94 postQuantumKeyExchange: string | undefined;
95 /** Server-defined URL. */
96 selfLink: string | undefined;
97 /** Server-assigned numeric id. */
98 sslPolicyId: string | undefined;
99 /** Optimistic-locking fingerprint. */
100 fingerprint: string | undefined;
101 /** RFC3339 creation timestamp. */
102 creationTimestamp: string | undefined;
103 /** Resource kind. */
104 kind: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A regional Compute Engine SSL policy for HTTPS and SSL load balancing.
112 *
113 * SSL policies control the TLS versions and cipher suites offered by
114 * regional Application Load Balancers and proxy Network Load Balancers.
115 * This resource maps to the `regionSslPolicies` collection (the global
116 * `sslPolicies` collection is `GCP.Compute.SslPolicy`). Compute SslPolicy
117 * has no labels field — Alchemy ownership is stored in the description
118 * so nuke can find leaked policies.
119 *
120 * ### Creating a Regional SSL Policy
121 * **Example:** Generated name (COMPATIBLE, TLS 1.0)
122 * ```typescript
123 * const policy = yield* GCP.Compute.RegionSslPolicy("Frontend", {
124 * region: "us-central1",
125 * });
126 * ```
127 *
128 * **Example:** Modern profile and TLS 1.2
129 * ```typescript
130 * const policy = yield* GCP.Compute.RegionSslPolicy("Frontend", {
131 * region: "us-central1",
132 * description: "prod frontend",
133 * profile: "MODERN",
134 * minTlsVersion: "TLS_1_2",
135 * });
136 * ```
137 *
138 * @resource
139 * @category Compute
140 */
141export const RegionSslPolicy = Resource<RegionSslPolicy>(
142 "GCP.Compute.RegionSslPolicy",
143);
144
145export class RegionSslPolicyNotResolved extends Data.TaggedError(
146 "GCP.Compute.RegionSslPolicyNotResolved",
147)<{
148 sslPolicyName: string;
149 region: string;
150}> {}
151
152const lastSegment = (value: string | undefined) => {
153 if (value === undefined || value.length === 0) return "";
154 const trimmed = value.replace(/\/+$/, "");
155 const parts = trimmed.split("/");
156 return parts[parts.length - 1] || trimmed;
157};
158
159const normalizeRegion = (region: string | undefined, defaultRegion: string) =>
160 lastSegment(region ?? defaultRegion).toLowerCase();
161
162const rfc1035 = (name: string): string => {
163 let next = name
164 .toLowerCase()
165 .replace(/[^a-z0-9-]/g, "-")
166 .replace(/-+/g, "-")
167 .replace(/^-+/, "")
168 .replace(/-+$/, "");
169 if (!/^[a-z]/.test(next)) {
170 next = `s${next}`;
171 }
172 next = next.slice(0, 63).replace(/-+$/, "");
173 return next.length > 0 ? next : "sslpolicy";
174};
175
176const toName = (id: string, name: string | undefined, existing?: string) =>
177 Effect.gen(function* () {
178 if (name !== undefined) return name;
179 if (existing !== undefined) return existing;
180 return rfc1035(
181 yield* createPhysicalName({
182 id,
183 maxLength: 63,
184 lowercase: true,
185 }),
186 );
187 });
188
189const encodeDescription = (
190 labels: Record<string, string>,
191 description: string | undefined,
192): string => {
193 const marker = `[alchemy ${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]} ${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]} ${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}]`;
194 return description ? `${marker}\n${description}` : marker;
195};
196
197const parseDescription = (
198 description: string | undefined,
199): {
200 labels: Record<string, string>;
201 description: string | undefined;
202} => {
203 if (!description?.startsWith("[alchemy ")) {
204 return { labels: {}, description };
205 }
206 const end = description.indexOf("]");
207 if (end < 0) return { labels: {}, description };
208 const labels: Record<string, string> = {};
209 for (const part of description.slice("[alchemy ".length, end).split(/\s+/)) {
210 const eq = part.indexOf("=");
211 if (eq > 0) {
212 labels[part.slice(0, eq)] = part.slice(eq + 1);
213 }
214 }
215 const rest = description.slice(end + 1).replace(/^\n/, "");
216 return { labels, description: rest.length > 0 ? rest : undefined };
217};
218
219const asProfile = (value: string | undefined): SslPolicyProfile => {
220 switch (value) {
221 case "COMPATIBLE":
222 case "MODERN":
223 case "RESTRICTED":
224 case "FIPS_202205":
225 case "CUSTOM":
226 return value;
227 default:
228 return DEFAULT_PROFILE;
229 }
230};
231
232const asMinTlsVersion = (value: string | undefined): SslPolicyMinTlsVersion => {
233 switch (value) {
234 case "TLS_1_0":
235 case "TLS_1_1":
236 case "TLS_1_2":
237 case "TLS_1_3":
238 return value;
239 default:
240 return DEFAULT_MIN_TLS;
241 }
242};
243
244const featuresOf = (features: readonly string[] | undefined): string[] => [
245 ...(features ?? []),
246];
247
248const sameFeatures = (
249 observed?: readonly string[],
250 desired?: readonly string[],
251) =>
252 [...(observed ?? [])].sort().join("\0") ===
253 [...(desired ?? [])].sort().join("\0");
254
255const toBody = (
256 sslPolicyName: string,
257 props: RegionSslPolicyProps,
258 ownership: Record<string, string>,
259 fingerprint?: string,
260): compute.SslPolicy => {
261 const profile = props.profile ?? DEFAULT_PROFILE;
262 return {
263 name: sslPolicyName,
264 fingerprint,
265 description: encodeDescription(ownership, props.description),
266 profile,
267 minTlsVersion: props.minTlsVersion ?? DEFAULT_MIN_TLS,
268 customFeatures:
269 profile === "CUSTOM" ? featuresOf(props.customFeatures) : [],
270 postQuantumKeyExchange: props.postQuantumKeyExchange,
271 };
272};
273
274const toAttrs = (
275 policy: compute.SslPolicy,
276 project: string,
277): RegionSslPolicy["Attributes"] => {
278 const parsed = parseDescription(policy.description);
279 return {
280 sslPolicyName: policy.name ?? policy.id ?? "",
281 project,
282 region: lastSegment(policy.region).toLowerCase(),
283 description: parsed.description,
284 minTlsVersion: asMinTlsVersion(policy.minTlsVersion),
285 profile: asProfile(policy.profile),
286 customFeatures: featuresOf(policy.customFeatures),
287 enabledFeatures: featuresOf(policy.enabledFeatures),
288 postQuantumKeyExchange: policy.postQuantumKeyExchange,
289 selfLink: policy.selfLink,
290 sslPolicyId: policy.id,
291 fingerprint: policy.fingerprint,
292 creationTimestamp: policy.creationTimestamp,
293 kind: policy.kind,
294 };
295};
296
297const needsUpdate = (
298 current: compute.SslPolicy,
299 desired: compute.SslPolicy,
300) => {
301 if ((current.description ?? "") !== (desired.description ?? "")) return true;
302 if (asProfile(current.profile) !== asProfile(desired.profile)) return true;
303 if (
304 asMinTlsVersion(current.minTlsVersion) !==
305 asMinTlsVersion(desired.minTlsVersion)
306 ) {
307 return true;
308 }
309 if (!sameFeatures(current.customFeatures, desired.customFeatures)) {
310 return true;
311 }
312 if (
313 desired.postQuantumKeyExchange !== undefined &&
314 (current.postQuantumKeyExchange ?? "DEFAULT") !==
315 desired.postQuantumKeyExchange
316 ) {
317 return true;
318 }
319 return false;
320};
321
322const getByName = (project: string, region: string, sslPolicy: string) =>
323 compute
324 .getRegionSslPolicies({ project, region, sslPolicy })
325 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
326
327const awaitResource = (
328 project: string,
329 region: string,
330 sslPolicyName: string,
331) =>
332 getByName(project, region, sslPolicyName).pipe(
333 Effect.repeat({
334 schedule: Schedule.spaced("1 second"),
335 until: (policy) => policy !== undefined,
336 times: 8,
337 }),
338 );
339
340export const RegionSslPolicyProvider = () =>
341 Provider.succeed(RegionSslPolicy, {
342 stables: [
343 "sslPolicyName",
344 "project",
345 "region",
346 "sslPolicyId",
347 "selfLink",
348 "creationTimestamp",
349 ],
350
351 diff: Effect.fn(function* ({ news, olds, output }) {
352 if (!isResolved(news)) return undefined;
353 const env = yield* GcpEnvironment.current;
354 const previousName = olds?.sslPolicyName ?? output?.sslPolicyName;
355 const nextName = news.sslPolicyName;
356 const previousRegion = normalizeRegion(
357 olds?.region ?? output?.region,
358 env.region,
359 );
360 const nextRegion = normalizeRegion(
361 news.region ?? output?.region,
362 env.region,
363 );
364 if (previousRegion !== nextRegion) {
365 return { action: "replace" as const, deleteFirst: false };
366 }
367 if (
368 previousName !== undefined &&
369 nextName !== undefined &&
370 previousName !== nextName
371 ) {
372 return { action: "replace" as const };
373 }
374 return undefined;
375 }),
376
377 read: Effect.fn(function* ({ id, olds, output }) {
378 const env = yield* GcpEnvironment.current;
379 const sslPolicyName = yield* toName(
380 id,
381 olds?.sslPolicyName,
382 output?.sslPolicyName,
383 );
384 const region = normalizeRegion(
385 olds?.region ?? output?.region,
386 env.region,
387 );
388 const existing = yield* getByName(env.project, region, sslPolicyName);
389 if (existing === undefined) return undefined;
390 const attrs = toAttrs(existing, env.project);
391 const { labels } = parseDescription(existing.description);
392 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
393 }),
394
395 list: () =>
396 Effect.gen(function* () {
397 const env = yield* GcpEnvironment.current;
398 const pages = yield* compute.aggregatedListSslPolicies
399 .pages({
400 project: env.project,
401 returnPartialSuccess: true,
402 maxResults: 500,
403 })
404 .pipe(Stream.runCollect);
405 return Array.from(pages).flatMap((page) =>
406 Object.values(page.items ?? {}).flatMap((scoped) =>
407 (scoped?.sslPolicies ?? [])
408 .filter((policy) => (policy.region ?? "").length > 0)
409 .filter((policy) => {
410 const { labels } = parseDescription(policy.description);
411 return Object.keys(labels).some((key) =>
412 key.startsWith("alchemy-"),
413 );
414 })
415 .map((policy) => toAttrs(policy, env.project)),
416 ),
417 );
418 }),
419
420 reconcile: Effect.fn(function* ({ id, news, output }) {
421 const env = yield* GcpEnvironment.current;
422 const sslPolicyName = yield* toName(
423 id,
424 news.sslPolicyName,
425 output?.sslPolicyName,
426 );
427 const region = normalizeRegion(news.region ?? output?.region, env.region);
428 const ownership = yield* createInternalLabels(id);
429 const desired = toBody(sslPolicyName, news, ownership);
430
431 let current = yield* getByName(env.project, region, sslPolicyName);
432
433 if (current === undefined) {
434 yield* compute
435 .insertRegionSslPolicies({
436 project: env.project,
437 region,
438 body: desired,
439 })
440 .pipe(
441 Effect.flatMap((operation) =>
442 waitRegionOperation(env.project, region, operation, {
443 ignore: ["RESOURCE_ALREADY_EXISTS"],
444 }),
445 ),
446 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
447 );
448 current = yield* awaitResource(env.project, region, sslPolicyName);
449 }
450
451 if (current === undefined) {
452 return yield* new RegionSslPolicyNotResolved({
453 sslPolicyName,
454 region,
455 });
456 }
457
458 if (needsUpdate(current, desired)) {
459 yield* compute
460 .patchRegionSslPolicies({
461 project: env.project,
462 region,
463 sslPolicy: sslPolicyName,
464 body: toBody(sslPolicyName, news, ownership, current.fingerprint),
465 })
466 .pipe(
467 Effect.flatMap((operation) =>
468 waitRegionOperation(env.project, region, operation),
469 ),
470 );
471 current = yield* getByName(env.project, region, sslPolicyName);
472 if (current === undefined) {
473 return yield* new RegionSslPolicyNotResolved({
474 sslPolicyName,
475 region,
476 });
477 }
478 }
479
480 return toAttrs(current, env.project);
481 }),
482
483 delete: Effect.fn(function* ({ output }) {
484 const env = yield* GcpEnvironment.current;
485 const region = normalizeRegion(output.region, env.region);
486 const operation = yield* compute
487 .deleteRegionSslPolicies({
488 project: env.project,
489 region,
490 sslPolicy: output.sslPolicyName,
491 })
492 .pipe(
493 Effect.retry({
494 while: (error) => error._tag === "Conflict",
495 schedule: Schedule.spaced("2 seconds"),
496 times: 8,
497 }),
498 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
499 );
500 if (operation !== undefined) {
501 yield* waitRegionOperation(env.project, region, operation).pipe(
502 Effect.catchTag("NotFound", () => Effect.void),
503 );
504 }
505 }),
506 });