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 { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 normalizeLocation,
14 parentOf,
15 parseResourceName,
16 resourceName,
17 toId,
18 toResourcePath,
19} from "./names.ts";
20import { waitForOperation } from "./operations.ts";
21import {
22 createInternalLabels,
23 encodeDescription,
24 hasAlchemyLabels,
25 hasOwnershipMarker,
26 parseDescription,
27 sameStringList,
28} from "./ownership.ts";
29
30const COLLECTION = "tlsInspectionPolicies";
31
32export type TlsFeatureProfile =
33 | "PROFILE_COMPATIBLE"
34 | "PROFILE_MODERN"
35 | "PROFILE_RESTRICTED"
36 | "PROFILE_CUSTOM"
37 | (string & {});
38
39export type MinTlsVersion =
40 | "TLS_1_0"
41 | "TLS_1_1"
42 | "TLS_1_2"
43 | "TLS_1_3"
44 | (string & {});
45
46export type TlsInspectionPolicyProps = {
47 /**
48 * TlsInspectionPolicy id (the `{tlsInspectionPolicy}` segment of
49 * `projects/{project}/locations/{location}/tlsInspectionPolicies/{tlsInspectionPolicy}`).
50 * If omitted, a unique RFC1035 name is generated from the stack, stage,
51 * and logical id. Must match `^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`.
52 * Immutable — changing it replaces the policy.
53 */
54 tlsInspectionPolicyId?: string;
55 /**
56 * Location (`us-central1`, `us-east1`, …). Immutable — changing it
57 * replaces the policy. `US-CENTRAL1` is accepted and normalized to
58 * `us-central1`.
59 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
60 */
61 location?: string;
62 /**
63 * CA pool used to issue interception certificates, as
64 * `projects/{project}/locations/{location}/caPools/{caPool}` or a bare
65 * pool id (combined with `location`).
66 */
67 caPool: string;
68 /**
69 * Human-readable description. TlsInspectionPolicy has no labels field,
70 * so Alchemy ownership is stored in a `[alchemy …]` prefix and stripped
71 * from attributes.
72 */
73 description?: string;
74 /**
75 * TLS feature profile. Defaults to `PROFILE_COMPATIBLE` (broadest
76 * client/server set). Secure Web Proxy does not yet honor this field.
77 */
78 tlsFeatureProfile?: TlsFeatureProfile;
79 /**
80 * Minimum TLS version negotiated with clients and servers. Secure Web
81 * Proxy does not yet honor this field.
82 */
83 minTlsVersion?: MinTlsVersion;
84 /**
85 * Custom TLS cipher suites. Valid only when `tlsFeatureProfile` is
86 * `PROFILE_CUSTOM`. Secure Web Proxy does not yet honor this field.
87 */
88 customTlsFeatures?: string[];
89 /**
90 * When true, do not trust the default public CA set — only CAs in
91 * `trustConfig` are accepted. Defaults to false. Secure Web Proxy does
92 * not yet honor this field.
93 * @default false
94 */
95 excludePublicCaSet?: boolean;
96 /**
97 * Certificate Manager TrustConfig used when connecting to the TLS
98 * server (`projects/{project}/locations/{location}/trustConfigs/{trustConfig}`).
99 * Needed for private or self-signed server certificates. Secure Web
100 * Proxy does not yet honor this field.
101 */
102 trustConfig?: string;
103};
104
105export type TlsInspectionPolicy = Resource<
106 "GCP.NetworkSecurity.TlsInspectionPolicy",
107 TlsInspectionPolicyProps,
108 {
109 /** Full resource name `projects/{project}/locations/{location}/tlsInspectionPolicies/{tlsInspectionPolicy}`. */
110 name: string;
111 /** TlsInspectionPolicy id (last path segment). */
112 tlsInspectionPolicyId: string;
113 /** Project id. */
114 project: string;
115 /** Location id (`us-central1`, …). */
116 location: string;
117 /** CA pool used to issue interception certificates. */
118 caPool: string | undefined;
119 /** User description with the Alchemy ownership prefix stripped. */
120 description: string | undefined;
121 /** TLS feature profile currently configured. */
122 tlsFeatureProfile: string | undefined;
123 /** Minimum TLS version currently configured. */
124 minTlsVersion: string | undefined;
125 /** Custom TLS cipher suites, if `tlsFeatureProfile` is `PROFILE_CUSTOM`. */
126 customTlsFeatures: string[];
127 /** Whether the default public CA set is excluded. */
128 excludePublicCaSet: boolean;
129 /** TrustConfig used when connecting to TLS servers, if set. */
130 trustConfig: string | undefined;
131 /** RFC3339 creation timestamp. */
132 createTime: string | undefined;
133 /** RFC3339 last-update timestamp. */
134 updateTime: string | undefined;
135 },
136 never,
137 Providers
138>;
139
140/**
141 * A Network Security TlsInspectionPolicy — CA pool and TLS settings used
142 * to intercept TLS for Secure Web Proxy and Cloud NGFW.
143 *
144 * TlsInspectionPolicy has no labels field, so Alchemy stamps ownership
145 * into the description for `list` / nuke. Changing `tlsInspectionPolicyId`
146 * or `location` replaces the policy. `caPool`, description, TLS feature
147 * profile, min version, custom features, `excludePublicCaSet`, and
148 * `trustConfig` update in place.
149 *
150 * ### Creating a TlsInspectionPolicy
151 * **Example:** Intercept with a CaPool
152 * ```typescript
153 * const pool = yield* GCP.PrivateCA.CaPool("Intercept", {
154 * location: "us-central1",
155 * tier: "DEVOPS",
156 * });
157 * const policy = yield* GCP.NetworkSecurity.TlsInspectionPolicy("Inspect", {
158 * caPool: pool.name,
159 * });
160 * ```
161 *
162 * **Example:** Named policy with TLS constraints
163 * ```typescript
164 * const policy = yield* GCP.NetworkSecurity.TlsInspectionPolicy("Inspect", {
165 * tlsInspectionPolicyId: "app-inspect",
166 * location: "us-central1",
167 * caPool: pool.name,
168 * description: "prod intercept",
169 * excludePublicCaSet: true,
170 * minTlsVersion: "TLS_1_2",
171 * tlsFeatureProfile: "PROFILE_MODERN",
172 * });
173 * ```
174 *
175 * ### Updating a TlsInspectionPolicy
176 * **Example:** Tighten TLS
177 * ```typescript
178 * const policy = yield* GCP.NetworkSecurity.TlsInspectionPolicy("Inspect", {
179 * tlsInspectionPolicyId: "app-inspect",
180 * location: "us-central1",
181 * caPool: pool.name,
182 * description: "prod intercept v2",
183 * excludePublicCaSet: true,
184 * minTlsVersion: "TLS_1_2",
185 * });
186 * ```
187 *
188 * @resource
189 * @category NetworkSecurity
190 */
191export const TlsInspectionPolicy = Resource<TlsInspectionPolicy>(
192 "GCP.NetworkSecurity.TlsInspectionPolicy",
193);
194
195export class TlsInspectionPolicyNotResolved extends Data.TaggedError(
196 "GCP.NetworkSecurity.TlsInspectionPolicyNotResolved",
197)<{
198 name: string;
199}> {}
200
201export class TlsInspectionPolicyStillExists extends Data.TaggedError(
202 "GCP.NetworkSecurity.TlsInspectionPolicyStillExists",
203)<{
204 name: string;
205}> {}
206
207const caPoolName = (project: string, location: string, caPool: string) => {
208 const trimmed = toResourcePath(caPool);
209 if (trimmed.includes("/")) return trimmed;
210 return `projects/${project}/locations/${location}/caPools/${trimmed}`;
211};
212
213const trustConfigName = (
214 project: string,
215 location: string,
216 trustConfig: string | undefined,
217) => {
218 if (trustConfig === undefined || trustConfig.length === 0) return undefined;
219 const trimmed = toResourcePath(trustConfig);
220 if (trimmed.includes("/")) return trimmed;
221 return `projects/${project}/locations/${location}/trustConfigs/${trimmed}`;
222};
223
224const toAttrs = (
225 policy: networksecurity.TlsInspectionPolicy,
226 project: string,
227 region: string,
228) => {
229 const name = policy.name ?? "";
230 const parsed = parseResourceName(name, COLLECTION);
231 const owned = parseDescription(policy.description);
232 return {
233 name,
234 tlsInspectionPolicyId: parsed.id,
235 project: parsed.project || project,
236 location: parsed.location || region,
237 caPool: policy.caPool,
238 description: owned.description,
239 tlsFeatureProfile: policy.tlsFeatureProfile,
240 minTlsVersion: policy.minTlsVersion,
241 customTlsFeatures: policy.customTlsFeatures ?? [],
242 excludePublicCaSet: policy.excludePublicCaSet === true,
243 trustConfig: policy.trustConfig,
244 createTime: policy.createTime,
245 updateTime: policy.updateTime,
246 };
247};
248
249const getByName = (name: string) =>
250 networksecurity
251 .getProjectsLocationsTlsInspectionPolicies({ name })
252 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
253
254const waitUntilExists = (name: string) =>
255 getByName(name).pipe(
256 Effect.flatMap((policy) =>
257 policy
258 ? Effect.succeed(policy)
259 : Effect.fail(new TlsInspectionPolicyNotResolved({ name })),
260 ),
261 Effect.retry({
262 while: (error) =>
263 error._tag === "GCP.NetworkSecurity.TlsInspectionPolicyNotResolved",
264 times: 8,
265 schedule: Schedule.spaced("1 second"),
266 }),
267 );
268
269const waitUntilGone = (name: string) =>
270 getByName(name).pipe(
271 Effect.flatMap((policy) =>
272 policy === undefined
273 ? Effect.void
274 : Effect.fail(new TlsInspectionPolicyStillExists({ name })),
275 ),
276 Effect.retry({
277 while: (error) =>
278 error._tag === "GCP.NetworkSecurity.TlsInspectionPolicyStillExists",
279 times: 10,
280 schedule: Schedule.spaced("2 seconds"),
281 }),
282 );
283
284const listOwned = (project: string, region: string) =>
285 networksecurity.listProjectsLocationsTlsInspectionPolicies
286 .pages({
287 parent: parentOf(project, "-"),
288 pageSize: 1000,
289 })
290 .pipe(
291 Stream.flatMap((page) =>
292 Stream.fromIterable(page.tlsInspectionPolicies ?? []),
293 ),
294 Stream.filter((policy) => hasOwnershipMarker(policy.description)),
295 Stream.map((policy) => toAttrs(policy, project, region)),
296 Stream.runCollect,
297 Effect.map((chunk) => Array.from(chunk)),
298 Effect.catchTag("NotFound", () => Effect.succeed([])),
299 );
300
301export const TlsInspectionPolicyProvider = () =>
302 Provider.succeed(TlsInspectionPolicy, {
303 stables: [
304 "name",
305 "tlsInspectionPolicyId",
306 "project",
307 "location",
308 "createTime",
309 ],
310
311 diff: Effect.fn(function* ({ news, olds, output }) {
312 if (!isResolved(news)) return undefined;
313 const env = yield* GcpEnvironment.current;
314 const previousId =
315 olds?.tlsInspectionPolicyId ?? output?.tlsInspectionPolicyId;
316 const nextId = news.tlsInspectionPolicyId ?? previousId;
317 const previousLocation = normalizeLocation(
318 olds?.location ?? output?.location,
319 env.region,
320 );
321 const nextLocation = normalizeLocation(
322 news.location ?? olds?.location ?? output?.location,
323 env.region,
324 );
325 if (
326 (previousId !== undefined &&
327 nextId !== undefined &&
328 nextId !== previousId) ||
329 previousLocation !== nextLocation
330 ) {
331 return { action: "replace" as const };
332 }
333 return undefined;
334 }),
335
336 read: Effect.fn(function* ({ id, olds, output }) {
337 const env = yield* GcpEnvironment.current;
338 const tlsInspectionPolicyId = yield* toId(
339 id,
340 olds?.tlsInspectionPolicyId,
341 output?.tlsInspectionPolicyId,
342 "tlsinsp",
343 );
344 const location = normalizeLocation(
345 olds?.location ?? output?.location,
346 env.region,
347 );
348 const name =
349 output?.name ??
350 resourceName(env.project, location, COLLECTION, tlsInspectionPolicyId);
351 const existing = yield* getByName(name);
352 if (existing === undefined) return undefined;
353 const attrs = toAttrs(existing, env.project, env.region);
354 const { labels } = parseDescription(existing.description);
355 return (yield* hasAlchemyLabels(id, labels)) ? attrs : Unowned(attrs);
356 }),
357
358 list: () =>
359 Effect.gen(function* () {
360 const env = yield* GcpEnvironment.current;
361 return yield* listOwned(env.project, env.region);
362 }),
363
364 reconcile: Effect.fn(function* ({ id, news, output }) {
365 const env = yield* GcpEnvironment.current;
366 const tlsInspectionPolicyId = yield* toId(
367 id,
368 news.tlsInspectionPolicyId,
369 output?.tlsInspectionPolicyId,
370 "tlsinsp",
371 );
372 const location = normalizeLocation(
373 news.location ?? output?.location,
374 env.region,
375 );
376 const name = resourceName(
377 env.project,
378 location,
379 COLLECTION,
380 tlsInspectionPolicyId,
381 );
382 const ownership = yield* createInternalLabels(id);
383 const desiredDescription = encodeDescription(ownership, news.description);
384 const caPool = caPoolName(env.project, location, news.caPool);
385 const trustConfig = trustConfigName(
386 env.project,
387 location,
388 news.trustConfig,
389 );
390 const excludePublicCaSet = news.excludePublicCaSet === true;
391 const customTlsFeatures = news.customTlsFeatures ?? [];
392
393 let current = yield* getByName(output?.name ?? name);
394
395 if (current === undefined) {
396 const created = yield* networksecurity
397 .createProjectsLocationsTlsInspectionPolicies({
398 parent: parentOf(env.project, location),
399 tlsInspectionPolicyId,
400 body: {
401 description: desiredDescription,
402 caPool,
403 trustConfig,
404 excludePublicCaSet,
405 tlsFeatureProfile: news.tlsFeatureProfile,
406 minTlsVersion: news.minTlsVersion,
407 customTlsFeatures:
408 customTlsFeatures.length > 0 ? customTlsFeatures : undefined,
409 },
410 })
411 .pipe(
412 Effect.retry({
413 while: (error) => error._tag === "Conflict",
414 times: 5,
415 schedule: Schedule.spaced("2 seconds"),
416 }),
417 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
418 );
419 if (created !== undefined) {
420 yield* waitForOperation(created);
421 }
422 current = yield* waitUntilExists(name);
423 }
424
425 if (current === undefined) {
426 return yield* new TlsInspectionPolicyNotResolved({ name });
427 }
428
429 const descriptionChanged =
430 (current.description ?? "") !== desiredDescription;
431 const caPoolChanged = (current.caPool ?? "") !== caPool;
432 const trustChanged = (current.trustConfig ?? "") !== (trustConfig ?? "");
433 const excludeChanged =
434 (current.excludePublicCaSet === true) !== excludePublicCaSet;
435 const profileChanged =
436 (current.tlsFeatureProfile ?? "") !== (news.tlsFeatureProfile ?? "");
437 const minTlsChanged =
438 (current.minTlsVersion ?? "") !== (news.minTlsVersion ?? "");
439 const featuresChanged =
440 !sameStringList(
441 current.customTlsFeatures,
442 customTlsFeatures.length > 0
443 ? customTlsFeatures
444 : current.customTlsFeatures,
445 ) && news.customTlsFeatures !== undefined;
446
447 if (
448 descriptionChanged ||
449 caPoolChanged ||
450 trustChanged ||
451 excludeChanged ||
452 profileChanged ||
453 minTlsChanged ||
454 featuresChanged
455 ) {
456 const updateMask = [
457 descriptionChanged ? "description" : undefined,
458 caPoolChanged ? "caPool" : undefined,
459 trustChanged ? "trustConfig" : undefined,
460 excludeChanged ? "excludePublicCaSet" : undefined,
461 profileChanged ? "tlsFeatureProfile" : undefined,
462 minTlsChanged ? "minTlsVersion" : undefined,
463 featuresChanged ? "customTlsFeatures" : undefined,
464 ].filter((field): field is string => field !== undefined);
465 const operation =
466 yield* networksecurity.patchProjectsLocationsTlsInspectionPolicies({
467 name: current.name ?? name,
468 updateMask: updateMask.join(","),
469 body: {
470 name: current.name ?? name,
471 description: desiredDescription,
472 caPool,
473 trustConfig,
474 excludePublicCaSet,
475 tlsFeatureProfile: news.tlsFeatureProfile,
476 minTlsVersion: news.minTlsVersion,
477 customTlsFeatures:
478 news.customTlsFeatures !== undefined
479 ? customTlsFeatures
480 : current.customTlsFeatures,
481 },
482 });
483 yield* waitForOperation(operation);
484 current = yield* waitUntilExists(current.name ?? name);
485 }
486
487 return toAttrs(current, env.project, env.region);
488 }),
489
490 delete: Effect.fn(function* ({ output }) {
491 const operation = yield* networksecurity
492 .deleteProjectsLocationsTlsInspectionPolicies({
493 name: output.name,
494 force: true,
495 })
496 .pipe(
497 Effect.retry({
498 while: (error) => error._tag === "Conflict",
499 times: 8,
500 schedule: Schedule.spaced("2 seconds"),
501 }),
502 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
503 );
504 if (operation !== undefined) {
505 yield* waitForOperation(operation, { notFoundOk: true });
506 }
507 yield* waitUntilGone(output.name);
508 }),
509 });