1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 toLabels,
17} from "../Labels.ts";
18import type { Providers } from "../Providers.ts";
19import {
20 DEFAULT_LOCATION,
21 normalizeLocation,
22 parentOf,
23 parseResourceName,
24 resourceName,
25 toId,
26 userLabels,
27} from "./names.ts";
28import { waitForOperation } from "./operations.ts";
29import { sameJson } from "./ownership.ts";
30
31const COLLECTION = "serverTlsPolicies";
32
33export type ServerTlsGrpcEndpoint = {
34 /** gRPC endpoint URI. Only UDS paths starting with `unix:` are supported. */
35 targetUri?: string;
36};
37
38export type ServerTlsCertificateProviderInstance = {
39 /**
40 * Plugin instance name. Set to `google_cloud_private_spiffe` to use
41 * Certificate Authority Service.
42 */
43 pluginInstance?: string;
44};
45
46export type ServerTlsCertificateProvider = {
47 /** gRPC endpoint used to obtain the certificate and private key. */
48 grpcEndpoint?: ServerTlsGrpcEndpoint;
49 /** Certificate provider plugin instance loaded by the data plane. */
50 certificateProviderInstance?: ServerTlsCertificateProviderInstance;
51};
52
53export type ServerTlsValidationCA = ServerTlsCertificateProvider;
54
55export type ServerTlsClientValidationMode =
56 | "ALLOW_INVALID_OR_MISSING_CLIENT_CERT"
57 | "REJECT_INVALID"
58 | (string & {});
59
60export type ServerTlsMtlsPolicy = {
61 /**
62 * Certificate authorities used to validate the client certificate
63 * (Traffic Director). Must be empty for Application Load Balancers.
64 */
65 clientValidationCa?: ServerTlsValidationCA[];
66 /**
67 * Certificate Manager TrustConfig used for chain validation. Allowed
68 * only with Application Load Balancers.
69 */
70 clientValidationTrustConfig?: string;
71 /**
72 * How the load balancer handles a missing or invalid client
73 * certificate. Required for Application Load Balancers; must be empty
74 * for Traffic Director.
75 */
76 clientValidationMode?: ServerTlsClientValidationMode;
77};
78
79export type ServerTlsPolicyProps = {
80 /**
81 * ServerTlsPolicy id (the `{serverTlsPolicy}` segment of
82 * `projects/{project}/locations/{location}/serverTlsPolicies/{serverTlsPolicy}`).
83 * If omitted, a unique RFC1035 name is generated from the stack, stage,
84 * and logical id. Must be 1-63 characters, letters, numbers, hyphens,
85 * and underscores, and must not start with a number. Immutable —
86 * changing it replaces the policy.
87 */
88 serverTlsPolicyId?: string;
89 /**
90 * Location (`global`, `us-central1`, …). Immutable — changing it
91 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
92 * `us-central1`.
93 * @default "global"
94 */
95 location?: string;
96 /**
97 * Human-readable description.
98 */
99 description?: string;
100 /**
101 * User labels. Alchemy ownership labels are merged in automatically.
102 */
103 labels?: Record<string, string>;
104 /**
105 * When true, the server also accepts plaintext connections. Must be
106 * false for Application Load Balancer policies. Defaults to false.
107 * @default false
108 */
109 allowOpen?: boolean;
110 /**
111 * Server identity (public/private keys). Optional for Traffic Director.
112 * Must be empty for Application Load Balancers. Cannot be combined
113 * with `allowOpen`.
114 */
115 serverCertificate?: ServerTlsCertificateProvider;
116 /**
117 * Peer validation for mTLS. Required for Application Load Balancers;
118 * optional for Traffic Director. When omitted, client certificates are
119 * not requested (plain TLS).
120 */
121 mtlsPolicy?: ServerTlsMtlsPolicy;
122};
123
124export type ServerTlsPolicy = Resource<
125 "GCP.NetworkSecurity.ServerTlsPolicy",
126 ServerTlsPolicyProps,
127 {
128 /** Full resource name `projects/{project}/locations/{location}/serverTlsPolicies/{serverTlsPolicy}`. */
129 name: string;
130 /** ServerTlsPolicy id (last path segment). */
131 serverTlsPolicyId: string;
132 /** Project id. */
133 project: string;
134 /** Location id (`global`, `us-central1`, …). */
135 location: string;
136 /** User-provided description. */
137 description: string | undefined;
138 /** User labels (Alchemy ownership labels stripped). */
139 labels: Record<string, string>;
140 /** Whether plaintext connections are allowed. */
141 allowOpen: boolean;
142 /** Server certificate provider, if configured. */
143 serverCertificate: ServerTlsCertificateProvider | undefined;
144 /** mTLS policy, if configured. */
145 mtlsPolicy: ServerTlsMtlsPolicy | undefined;
146 /** RFC3339 creation timestamp. */
147 createTime: string | undefined;
148 /** RFC3339 last-update timestamp. */
149 updateTime: string | undefined;
150 },
151 never,
152 Providers
153>;
154
155/**
156 * A Network Security ServerTlsPolicy — how a server authenticates
157 * incoming requests. Attach it to a TargetHttpsProxy or EndpointPolicy;
158 * the policy itself does not serve traffic.
159 *
160 * Changing `serverTlsPolicyId` or `location` replaces the policy.
161 * Application Load Balancer policies (`mtlsPolicy.clientValidationMode`
162 * set) cannot be updated in place — any other change replaces them.
163 * Traffic Director policies update description, labels, `allowOpen`,
164 * `serverCertificate`, and `mtlsPolicy` in place.
165 *
166 * ### Creating a ServerTlsPolicy
167 * **Example:** Application Load Balancer mTLS
168 * ```typescript
169 * const policy = yield* GCP.NetworkSecurity.ServerTlsPolicy("FrontendTls", {
170 * description: "alb mtls",
171 * mtlsPolicy: {
172 * clientValidationMode: "ALLOW_INVALID_OR_MISSING_CLIENT_CERT",
173 * },
174 * });
175 * ```
176 *
177 * **Example:** Named policy with labels
178 * ```typescript
179 * const policy = yield* GCP.NetworkSecurity.ServerTlsPolicy("FrontendTls", {
180 * serverTlsPolicyId: "app-frontend-tls",
181 * location: "global",
182 * description: "prod frontend",
183 * labels: { env: "prod" },
184 * mtlsPolicy: {
185 * clientValidationMode: "ALLOW_INVALID_OR_MISSING_CLIENT_CERT",
186 * },
187 * });
188 * ```
189 *
190 * ### Updating a ServerTlsPolicy
191 * **Example:** Description and labels
192 * ```typescript
193 * const policy = yield* GCP.NetworkSecurity.ServerTlsPolicy("FrontendTls", {
194 * serverTlsPolicyId: "app-frontend-tls",
195 * location: "global",
196 * description: "prod frontend v2",
197 * labels: { env: "prod", role: "tls" },
198 * mtlsPolicy: {
199 * clientValidationMode: "ALLOW_INVALID_OR_MISSING_CLIENT_CERT",
200 * },
201 * });
202 * ```
203 *
204 * @resource
205 * @category NetworkSecurity
206 */
207export const ServerTlsPolicy = Resource<ServerTlsPolicy>(
208 "GCP.NetworkSecurity.ServerTlsPolicy",
209);
210
211export class ServerTlsPolicyNotResolved extends Data.TaggedError(
212 "GCP.NetworkSecurity.ServerTlsPolicyNotResolved",
213)<{
214 name: string;
215}> {}
216
217export class ServerTlsPolicyStillExists extends Data.TaggedError(
218 "GCP.NetworkSecurity.ServerTlsPolicyStillExists",
219)<{
220 name: string;
221}> {}
222
223const toCertificateProvider = (
224 value:
225 | networksecurity.GoogleCloudNetworksecurityV1CertificateProvider
226 | ServerTlsCertificateProvider
227 | undefined,
228): ServerTlsCertificateProvider | undefined => {
229 if (value === undefined) return undefined;
230 const grpcEndpoint = value.grpcEndpoint
231 ? { targetUri: value.grpcEndpoint.targetUri }
232 : undefined;
233 const certificateProviderInstance = value.certificateProviderInstance
234 ? {
235 pluginInstance: value.certificateProviderInstance.pluginInstance,
236 }
237 : undefined;
238 if (grpcEndpoint === undefined && certificateProviderInstance === undefined) {
239 return undefined;
240 }
241 return { grpcEndpoint, certificateProviderInstance };
242};
243
244const toMtlsPolicy = (
245 value: networksecurity.MTLSPolicy | ServerTlsMtlsPolicy | undefined,
246): ServerTlsMtlsPolicy | undefined => {
247 if (value === undefined) return undefined;
248 return {
249 clientValidationCa: (value.clientValidationCa ?? [])
250 .map((entry) => toCertificateProvider(entry))
251 .filter(
252 (entry): entry is ServerTlsCertificateProvider => entry !== undefined,
253 ),
254 clientValidationTrustConfig: value.clientValidationTrustConfig,
255 clientValidationMode: value.clientValidationMode,
256 };
257};
258
259const toAttrs = (policy: networksecurity.ServerTlsPolicy, project: string) => {
260 const name = policy.name ?? "";
261 const parsed = parseResourceName(name, COLLECTION);
262 return {
263 name,
264 serverTlsPolicyId: parsed.id,
265 project: parsed.project || project,
266 location: parsed.location || DEFAULT_LOCATION,
267 description: policy.description,
268 labels: userLabels(policy.labels),
269 allowOpen: policy.allowOpen === true,
270 serverCertificate: toCertificateProvider(policy.serverCertificate),
271 mtlsPolicy: toMtlsPolicy(policy.mtlsPolicy),
272 createTime: policy.createTime,
273 updateTime: policy.updateTime,
274 };
275};
276
277const getByName = (name: string) =>
278 networksecurity
279 .getProjectsLocationsServerTlsPolicies({ name })
280 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
281
282const waitUntilExists = (name: string) =>
283 getByName(name).pipe(
284 Effect.flatMap((policy) =>
285 policy
286 ? Effect.succeed(policy)
287 : Effect.fail(new ServerTlsPolicyNotResolved({ name })),
288 ),
289 Effect.retry({
290 while: (error) =>
291 error._tag === "GCP.NetworkSecurity.ServerTlsPolicyNotResolved",
292 times: 8,
293 schedule: Schedule.spaced("1 second"),
294 }),
295 );
296
297const waitUntilGone = (name: string) =>
298 getByName(name).pipe(
299 Effect.flatMap((policy) =>
300 policy === undefined
301 ? Effect.void
302 : Effect.fail(new ServerTlsPolicyStillExists({ name })),
303 ),
304 Effect.retry({
305 while: (error) =>
306 error._tag === "GCP.NetworkSecurity.ServerTlsPolicyStillExists",
307 times: 10,
308 schedule: Schedule.spaced("2 seconds"),
309 }),
310 );
311
312const listOwned = (project: string) =>
313 networksecurity.listProjectsLocationsServerTlsPolicies
314 .pages({
315 parent: parentOf(project, "-"),
316 pageSize: 1000,
317 returnPartialSuccess: true,
318 })
319 .pipe(
320 Stream.flatMap((page) =>
321 Stream.fromIterable(page.serverTlsPolicies ?? []),
322 ),
323 Stream.filter((policy) =>
324 Object.keys(policy.labels ?? {}).some((key) =>
325 key.startsWith("alchemy-"),
326 ),
327 ),
328 Stream.map((policy) => toAttrs(policy, project)),
329 Stream.runCollect,
330 Effect.map((chunk) => Array.from(chunk)),
331 Effect.catchTag("NotFound", () => Effect.succeed([])),
332 );
333
334const toCreateBody = (
335 news: ServerTlsPolicyProps,
336 desiredLabels: Record<string, string>,
337): networksecurity.ServerTlsPolicy => ({
338 description: news.description,
339 labels: desiredLabels,
340 allowOpen: news.allowOpen === true ? true : false,
341 serverCertificate: toCertificateProvider(news.serverCertificate),
342 mtlsPolicy: toMtlsPolicy(news.mtlsPolicy),
343});
344
345export const ServerTlsPolicyProvider = () =>
346 Provider.succeed(ServerTlsPolicy, {
347 stables: ["name", "serverTlsPolicyId", "project", "location", "createTime"],
348
349 diff: Effect.fn(function* ({ news, olds, output }) {
350 if (!isResolved(news)) return undefined;
351 const previousId = olds?.serverTlsPolicyId ?? output?.serverTlsPolicyId;
352 const nextId = news.serverTlsPolicyId ?? previousId;
353 const previousLocation = normalizeLocation(
354 olds?.location ?? output?.location,
355 );
356 const nextLocation = normalizeLocation(
357 news.location ?? olds?.location ?? output?.location,
358 );
359 if (
360 (previousId !== undefined &&
361 nextId !== undefined &&
362 nextId !== previousId) ||
363 previousLocation !== nextLocation
364 ) {
365 return { action: "replace" as const };
366 }
367 const alb =
368 (news.mtlsPolicy?.clientValidationMode ??
369 olds?.mtlsPolicy?.clientValidationMode ??
370 output?.mtlsPolicy?.clientValidationMode ??
371 "") !== "";
372 if (!alb) return undefined;
373 const descriptionChanged =
374 (news.description ?? "") !==
375 (olds?.description ?? output?.description ?? "");
376 const labelsChanged = !sameJson(
377 toLabels(news.labels ?? {}),
378 toLabels(olds?.labels ?? output?.labels ?? {}),
379 );
380 const allowOpenChanged =
381 (news.allowOpen === true) !==
382 (olds?.allowOpen === true || output?.allowOpen === true);
383 const mtlsChanged = !sameJson(
384 toMtlsPolicy(news.mtlsPolicy),
385 toMtlsPolicy(olds?.mtlsPolicy ?? output?.mtlsPolicy),
386 );
387 const certificateChanged = !sameJson(
388 toCertificateProvider(news.serverCertificate),
389 toCertificateProvider(
390 olds?.serverCertificate ?? output?.serverCertificate,
391 ),
392 );
393 if (
394 descriptionChanged ||
395 labelsChanged ||
396 allowOpenChanged ||
397 mtlsChanged ||
398 certificateChanged
399 ) {
400 return { action: "replace" as const };
401 }
402 return undefined;
403 }),
404
405 read: Effect.fn(function* ({ id, olds, output }) {
406 const env = yield* GcpEnvironment.current;
407 const serverTlsPolicyId = yield* toId(
408 id,
409 olds?.serverTlsPolicyId,
410 output?.serverTlsPolicyId,
411 "servertls",
412 );
413 const location = normalizeLocation(olds?.location ?? output?.location);
414 const name =
415 output?.name ??
416 resourceName(env.project, location, COLLECTION, serverTlsPolicyId);
417 const existing = yield* getByName(name);
418 if (existing === undefined) return undefined;
419 const attrs = toAttrs(existing, env.project);
420 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
421 ? attrs
422 : Unowned(attrs);
423 }),
424
425 list: () =>
426 Effect.gen(function* () {
427 const env = yield* GcpEnvironment.current;
428 return yield* listOwned(env.project);
429 }),
430
431 reconcile: Effect.fn(function* ({ id, news, output }) {
432 const env = yield* GcpEnvironment.current;
433 const serverTlsPolicyId = yield* toId(
434 id,
435 news.serverTlsPolicyId,
436 output?.serverTlsPolicyId,
437 "servertls",
438 );
439 const location = normalizeLocation(news.location ?? output?.location);
440 const name = resourceName(
441 env.project,
442 location,
443 COLLECTION,
444 serverTlsPolicyId,
445 );
446 const desiredLabels = {
447 ...toLabels(news.labels),
448 ...(yield* createInternalLabels(id)),
449 };
450 const allowOpen = news.allowOpen === true;
451 const desiredCertificate = toCertificateProvider(news.serverCertificate);
452 const desiredMtls = toMtlsPolicy(news.mtlsPolicy);
453
454 let current = yield* getByName(output?.name ?? name);
455
456 if (current === undefined) {
457 const created = yield* networksecurity
458 .createProjectsLocationsServerTlsPolicies({
459 parent: parentOf(env.project, location),
460 serverTlsPolicyId,
461 body: toCreateBody(news, desiredLabels),
462 })
463 .pipe(
464 Effect.retry({
465 while: (error) => error._tag === "Conflict",
466 times: 5,
467 schedule: Schedule.spaced("2 seconds"),
468 }),
469 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
470 );
471 if (created !== undefined) {
472 yield* waitForOperation(created);
473 }
474 current = yield* waitUntilExists(name);
475 }
476
477 if (current === undefined) {
478 return yield* new ServerTlsPolicyNotResolved({ name });
479 }
480
481 const observedLabels = tagRecord(current.labels);
482 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
483 const labelsChanged = upsert.length > 0 || removed.length > 0;
484 const descriptionChanged =
485 (current.description ?? "") !== (news.description ?? "");
486 const allowOpenChanged = (current.allowOpen === true) !== allowOpen;
487 const certificateChanged = !sameJson(
488 toCertificateProvider(current.serverCertificate),
489 desiredCertificate,
490 );
491 const mtlsChanged = !sameJson(
492 toMtlsPolicy(current.mtlsPolicy),
493 desiredMtls,
494 );
495
496 if (
497 labelsChanged ||
498 descriptionChanged ||
499 allowOpenChanged ||
500 certificateChanged ||
501 mtlsChanged
502 ) {
503 const updateMask = [
504 labelsChanged ? "labels" : undefined,
505 descriptionChanged ? "description" : undefined,
506 allowOpenChanged ? "allowOpen" : undefined,
507 certificateChanged ? "serverCertificate" : undefined,
508 mtlsChanged ? "mtlsPolicy" : undefined,
509 ].filter((field): field is string => field !== undefined);
510 const operation =
511 yield* networksecurity.patchProjectsLocationsServerTlsPolicies({
512 name: current.name ?? name,
513 updateMask: updateMask.join(","),
514 body: {
515 name: current.name ?? name,
516 labels: desiredLabels,
517 description: news.description,
518 allowOpen,
519 serverCertificate: desiredCertificate,
520 mtlsPolicy: desiredMtls,
521 },
522 });
523 yield* waitForOperation(operation);
524 current = yield* waitUntilExists(current.name ?? name);
525 }
526
527 return toAttrs(current, env.project);
528 }),
529
530 delete: Effect.fn(function* ({ output }) {
531 const operation = yield* networksecurity
532 .deleteProjectsLocationsServerTlsPolicies({ name: output.name })
533 .pipe(
534 Effect.retry({
535 while: (error) => error._tag === "Conflict",
536 times: 8,
537 schedule: Schedule.spaced("2 seconds"),
538 }),
539 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
540 );
541 if (operation !== undefined) {
542 yield* waitForOperation(operation, { notFoundOk: true });
543 }
544 yield* waitUntilGone(output.name);
545 }),
546 });