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