1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { tagRecord } from "../../Tags.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 DEFAULT_GLOBAL,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 rfc1035,
26 sameJson,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilGone,
31 waitUntilPresent,
32} from "./internal.ts";
33
34const COLLECTION = "clientTlsPolicies";
35
36export type GrpcEndpoint = {
37 /** gRPC UDS target URI. Must start with `unix:`. */
38 targetUri?: string;
39};
40
41export type CertificateProviderInstance = {
42 /**
43 * Plugin instance name. Use `google_cloud_private_spiffe` for
44 * Certificate Authority Service.
45 */
46 pluginInstance?: string;
47};
48
49export type ValidationCA = {
50 /** gRPC endpoint used to obtain the CA certificate. */
51 grpcEndpoint?: GrpcEndpoint;
52 /** Certificate provider instance loaded by the data plane. */
53 certificateProviderInstance?: CertificateProviderInstance;
54};
55
56export type CertificateProvider = {
57 /** gRPC endpoint used to obtain the cert and private key. */
58 grpcEndpoint?: GrpcEndpoint;
59 /** Certificate provider instance loaded by the data plane. */
60 certificateProviderInstance?: CertificateProviderInstance;
61};
62
63export type ClientTlsPolicyProps = {
64 /**
65 * ClientTlsPolicy id (the `{clientTlsPolicy}` segment of
66 * `projects/{project}/locations/{location}/clientTlsPolicies/{clientTlsPolicy}`).
67 * If omitted, a unique name is generated from the stack, stage, and
68 * logical id. Must be 1-63 characters and not start with a number.
69 * Immutable — changing it replaces the policy.
70 */
71 clientTlsPolicyId?: string;
72 /**
73 * Location (`global`, `us-central1`, …). Immutable — changing it
74 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
75 * `us-central1`.
76 * @default "global"
77 */
78 location?: string;
79 /**
80 * Human-readable description.
81 */
82 description?: string;
83 /**
84 * Server Name Indication presented during the TLS handshake
85 * (e.g. `secure.example.com`).
86 */
87 sni?: string;
88 /**
89 * Mechanism used to obtain CA certificates that validate the server
90 * certificate. Empty means the client does not validate the server.
91 */
92 serverValidationCa?: ValidationCA[];
93 /**
94 * Mechanism used to provision client identity for mTLS. Presence of
95 * this field enables mTLS.
96 */
97 clientCertificate?: CertificateProvider;
98 /**
99 * User labels. Alchemy ownership labels are merged in automatically.
100 */
101 labels?: Record<string, string>;
102};
103
104export type ClientTlsPolicy = Resource<
105 "GCP.NetworkSecurity.ClientTlsPolicy",
106 ClientTlsPolicyProps,
107 {
108 /** Full resource name `projects/{project}/locations/{location}/clientTlsPolicies/{clientTlsPolicy}`. */
109 name: string;
110 /** ClientTlsPolicy id (last path segment). */
111 clientTlsPolicyId: string;
112 /** Project id. */
113 project: string;
114 /** Location id (`global`, `us-central1`, …). */
115 location: string;
116 /** User-provided description. */
117 description: string | undefined;
118 /** SNI string presented to the server, if set. */
119 sni: string | undefined;
120 /** Server-validation CAs currently configured. */
121 serverValidationCa: ValidationCA[];
122 /** Client certificate provider used for mTLS, if set. */
123 clientCertificate: CertificateProvider | undefined;
124 /** User labels (Alchemy ownership labels stripped). */
125 labels: Record<string, string>;
126 /** RFC3339 creation timestamp. */
127 createTime: string | undefined;
128 /** RFC3339 last-update timestamp. */
129 updateTime: string | undefined;
130 },
131 never,
132 Providers
133>;
134
135/**
136 * A Network Security ClientTlsPolicy — how a client authenticates
137 * connections to backends. The policy has no effect until it is
138 * attached to a backend service.
139 *
140 * Changing `clientTlsPolicyId` or `location` replaces the policy.
141 * Description, labels, SNI, server-validation CAs, and the client
142 * certificate provider update in place.
143 *
144 * ### Creating a ClientTlsPolicy
145 * **Example:** Generated name
146 * ```typescript
147 * const policy = yield* GCP.NetworkSecurity.ClientTlsPolicy("BackendTls", {
148 * sni: "backend.example.com",
149 * });
150 * ```
151 *
152 * **Example:** Named policy with labels
153 * ```typescript
154 * const policy = yield* GCP.NetworkSecurity.ClientTlsPolicy("BackendTls", {
155 * clientTlsPolicyId: "app-backend-tls",
156 * description: "prod backends",
157 * labels: { env: "prod" },
158 * sni: "backend.example.com",
159 * });
160 * ```
161 *
162 * ### mTLS
163 * **Example:** Private SPIFFE identity plus CA validation
164 * ```typescript
165 * const policy = yield* GCP.NetworkSecurity.ClientTlsPolicy("Mtls", {
166 * sni: "secure.example.com",
167 * clientCertificate: {
168 * certificateProviderInstance: {
169 * pluginInstance: "google_cloud_private_spiffe",
170 * },
171 * },
172 * serverValidationCa: [
173 * {
174 * certificateProviderInstance: {
175 * pluginInstance: "google_cloud_private_spiffe",
176 * },
177 * },
178 * ],
179 * });
180 * ```
181 *
182 * @resource
183 * @category NetworkSecurity
184 */
185export const ClientTlsPolicy = Resource<ClientTlsPolicy>(
186 "GCP.NetworkSecurity.ClientTlsPolicy",
187);
188
189const resourceName = (
190 project: string,
191 location: string,
192 clientTlsPolicyId: string,
193) =>
194 `projects/${project}/locations/${location}/clientTlsPolicies/${clientTlsPolicyId}`;
195
196const toCa = (
197 ca: ValidationCA | networksecurity.ValidationCA,
198): ValidationCA => ({
199 grpcEndpoint: ca.grpcEndpoint
200 ? { targetUri: ca.grpcEndpoint.targetUri }
201 : undefined,
202 certificateProviderInstance: ca.certificateProviderInstance
203 ? { pluginInstance: ca.certificateProviderInstance.pluginInstance }
204 : undefined,
205});
206
207const toProvider = (
208 provider:
209 | CertificateProvider
210 | networksecurity.GoogleCloudNetworksecurityV1CertificateProvider
211 | undefined,
212): CertificateProvider | undefined => {
213 if (provider === undefined) return undefined;
214 const grpcEndpoint = provider.grpcEndpoint
215 ? { targetUri: provider.grpcEndpoint.targetUri }
216 : undefined;
217 const certificateProviderInstance = provider.certificateProviderInstance
218 ? { pluginInstance: provider.certificateProviderInstance.pluginInstance }
219 : undefined;
220 if (grpcEndpoint === undefined && certificateProviderInstance === undefined) {
221 return undefined;
222 }
223 return { grpcEndpoint, certificateProviderInstance };
224};
225
226const toAttrs = (policy: networksecurity.ClientTlsPolicy, project: string) => {
227 const name = policy.name ?? "";
228 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
229 return {
230 name,
231 clientTlsPolicyId: parsed.id,
232 project: parsed.project || project,
233 location: parsed.location || DEFAULT_GLOBAL,
234 description: policy.description,
235 sni: policy.sni,
236 serverValidationCa: (policy.serverValidationCa ?? []).map(toCa),
237 clientCertificate: toProvider(policy.clientCertificate),
238 labels: userLabels(policy.labels),
239 createTime: policy.createTime,
240 updateTime: policy.updateTime,
241 };
242};
243
244const getByName = (name: string) =>
245 networksecurity
246 .getProjectsLocationsClientTlsPolicies({ name })
247 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
248
249export const ClientTlsPolicyProvider = () =>
250 Provider.succeed(ClientTlsPolicy, {
251 stables: ["name", "clientTlsPolicyId", "project", "location", "createTime"],
252
253 diff: Effect.fn(function* ({ news, olds, output }) {
254 if (!isResolved(news)) return undefined;
255 const previousId = olds?.clientTlsPolicyId ?? output?.clientTlsPolicyId;
256 const nextId = news.clientTlsPolicyId
257 ? rfc1035(news.clientTlsPolicyId, "client-tls-policy")
258 : previousId;
259 const previousLocation = normalizeLocation(
260 olds?.location ?? output?.location,
261 DEFAULT_GLOBAL,
262 );
263 const nextLocation = normalizeLocation(
264 news.location ?? olds?.location ?? output?.location,
265 DEFAULT_GLOBAL,
266 );
267 if (
268 (previousId !== undefined &&
269 nextId !== undefined &&
270 nextId !== previousId) ||
271 previousLocation !== nextLocation
272 ) {
273 return { action: "replace" as const };
274 }
275 return undefined;
276 }),
277
278 read: Effect.fn(function* ({ id, olds, output }) {
279 const env = yield* GcpEnvironment.current;
280 const clientTlsPolicyId = yield* toPhysicalId(
281 id,
282 olds?.clientTlsPolicyId,
283 output?.clientTlsPolicyId,
284 "client-tls-policy",
285 );
286 const location = normalizeLocation(
287 olds?.location ?? output?.location,
288 DEFAULT_GLOBAL,
289 );
290 const name =
291 output?.name ?? resourceName(env.project, location, clientTlsPolicyId);
292 const existing = yield* getByName(name);
293 if (existing === undefined) return undefined;
294 const attrs = toAttrs(existing, env.project);
295 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
296 ? attrs
297 : Unowned(attrs);
298 }),
299
300 list: () =>
301 Effect.gen(function* () {
302 const env = yield* GcpEnvironment.current;
303 const items = yield* collectPages(
304 networksecurity.listProjectsLocationsClientTlsPolicies.pages({
305 parent: parentOf(env.project, "-"),
306 pageSize: 1000,
307 }),
308 (page) => page.clientTlsPolicies,
309 );
310 return items
311 .filter((item) => hasAlchemyLabelKeys(item.labels))
312 .map((item) => toAttrs(item, env.project));
313 }),
314
315 reconcile: Effect.fn(function* ({ id, news, output }) {
316 const env = yield* GcpEnvironment.current;
317 const clientTlsPolicyId = yield* toPhysicalId(
318 id,
319 news.clientTlsPolicyId,
320 output?.clientTlsPolicyId,
321 "client-tls-policy",
322 );
323 const location = normalizeLocation(
324 news.location ?? output?.location,
325 DEFAULT_GLOBAL,
326 );
327 const name = resourceName(env.project, location, clientTlsPolicyId);
328 const desiredLabels = {
329 ...toLabels(news.labels),
330 ...(yield* createInternalLabels(id)),
331 };
332 const desiredCa = (news.serverValidationCa ?? []).map(toCa);
333 const desiredClient = toProvider(news.clientCertificate);
334
335 let current = yield* getByName(name);
336
337 if (current === undefined) {
338 const created = yield* networksecurity
339 .createProjectsLocationsClientTlsPolicies({
340 parent: parentOf(env.project, location),
341 clientTlsPolicyId,
342 body: {
343 description: news.description,
344 labels: desiredLabels,
345 sni: news.sni,
346 serverValidationCa: desiredCa,
347 clientCertificate: desiredClient,
348 },
349 })
350 .pipe(
351 Effect.retry({
352 while: (error) => error._tag === "Conflict",
353 times: 5,
354 schedule: Schedule.spaced("2 seconds"),
355 }),
356 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
357 );
358 if (created !== undefined) {
359 yield* waitForOperation(created);
360 }
361 current = yield* waitUntilPresent(getByName(name), name);
362 }
363
364 const observedLabels = tagRecord(current.labels);
365 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
366 const labelsChanged = upsert.length > 0 || removed.length > 0;
367 const descriptionChanged =
368 (current.description ?? "") !== (news.description ?? "");
369 const sniChanged = (current.sni ?? "") !== (news.sni ?? "");
370 const caChanged = !sameJson(
371 (current.serverValidationCa ?? []).map(toCa),
372 desiredCa,
373 );
374 const clientChanged = !sameJson(
375 toProvider(current.clientCertificate),
376 desiredClient,
377 );
378
379 const updateMask = changedFields([
380 ["labels", labelsChanged],
381 ["description", descriptionChanged],
382 ["sni", sniChanged],
383 ["serverValidationCa", caChanged],
384 ["clientCertificate", clientChanged],
385 ]);
386
387 if (updateMask.length > 0) {
388 const operation =
389 yield* networksecurity.patchProjectsLocationsClientTlsPolicies({
390 name: current.name ?? name,
391 updateMask: updateMask.join(","),
392 body: {
393 name: current.name ?? name,
394 labels: desiredLabels,
395 description: news.description,
396 sni: news.sni,
397 serverValidationCa: desiredCa,
398 clientCertificate: desiredClient,
399 },
400 });
401 yield* waitForOperation(operation);
402 current = yield* waitUntilPresent(
403 getByName(current.name ?? name),
404 current.name ?? name,
405 );
406 }
407
408 return toAttrs(current, env.project);
409 }),
410
411 delete: Effect.fn(function* ({ output }) {
412 const operation = yield* networksecurity
413 .deleteProjectsLocationsClientTlsPolicies({ name: output.name })
414 .pipe(
415 Effect.retry({
416 while: (error) => error._tag === "Conflict",
417 times: 8,
418 schedule: Schedule.spaced("2 seconds"),
419 }),
420 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
421 );
422 if (operation !== undefined) {
423 yield* waitForOperation(operation, { notFoundOk: true });
424 }
425 yield* waitUntilGone(getByName(output.name), output.name);
426 }),
427 });