1import * as networksecurity from "@distilled.cloud/gcp/networksecurity_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 normalizeLocation,
20 parseResourceName,
21 projectParent,
22 ResourceNotResolved,
23 toPhysicalId,
24 userLabels,
25 waitForOperation,
26 waitUntilExists,
27 waitUntilGone,
28} from "./operations.ts";
29
30const DEFAULT_WELL_KNOWN_ROOTS =
31 "NONE" satisfies networksecurity.BackendAuthenticationConfigWellKnownRootsEnum;
32
33export type BackendAuthenticationConfigWellKnownRoots =
34 | networksecurity.BackendAuthenticationConfigWellKnownRootsEnum
35 | (string & {});
36
37export type BackendAuthenticationConfigProps = {
38 /**
39 * Backend authentication config id. If omitted, a unique RFC1035 name
40 * is generated from the stack, stage, and logical id. Immutable —
41 * changing it replaces the config.
42 */
43 backendAuthenticationConfigId?: string;
44 /**
45 * Location (`global`, `us-central1`, …). Immutable — changing it
46 * replaces the config.
47 * @default "global"
48 */
49 location?: string;
50 /**
51 * Certificate Manager TrustConfig used to validate backend server
52 * certificates. Required unless `wellKnownRoots` is `PUBLIC_ROOTS`.
53 */
54 trustConfig?: string;
55 /**
56 * Certificate Manager certificate (CLIENT_AUTH scope) presented to
57 * the backend for mTLS.
58 */
59 clientCertificate?: string;
60 /**
61 * Whether public CAs are trusted in addition to `trustConfig`.
62 * @default "NONE"
63 */
64 wellKnownRoots?: BackendAuthenticationConfigWellKnownRoots;
65 /**
66 * Human-readable description.
67 */
68 description?: string;
69 /**
70 * User labels. Alchemy ownership labels are merged in automatically.
71 */
72 labels?: Record<string, string>;
73};
74
75export type BackendAuthenticationConfig = Resource<
76 "GCP.NetworkSecurity.BackendAuthenticationConfig",
77 BackendAuthenticationConfigProps,
78 {
79 /** Full resource name `projects/{project}/locations/{location}/backendAuthenticationConfigs/{backendAuthenticationConfig}`. */
80 name: string;
81 /** Config id (last path segment). */
82 backendAuthenticationConfigId: string;
83 /** Project id. */
84 project: string;
85 /** Location id. */
86 location: string;
87 /** TrustConfig resource name, if set. */
88 trustConfig: string | undefined;
89 /** Client certificate resource name, if set. */
90 clientCertificate: string | undefined;
91 /** Well-known roots setting. */
92 wellKnownRoots: string | undefined;
93 /** User-provided description. */
94 description: string | undefined;
95 /** User labels (Alchemy ownership labels stripped). */
96 labels: Record<string, string>;
97 /** Server-computed checksum. */
98 etag: string | undefined;
99 /** RFC3339 creation timestamp. */
100 createTime: string | undefined;
101 /** RFC3339 last-update timestamp. */
102 updateTime: string | undefined;
103 },
104 never,
105 Providers
106>;
107
108/**
109 * A Network Security BackendAuthenticationConfig — how a load balancer
110 * authenticates to backends (TrustConfig, public roots, optional mTLS
111 * client certificate).
112 *
113 * Changing `backendAuthenticationConfigId` or `location` replaces the
114 * config. TrustConfig, client certificate, well-known roots,
115 * description, and labels update in place.
116 *
117 * ### Creating a Backend Authentication Config
118 * **Example:** Trust public roots
119 * ```typescript
120 * const config = yield* GCP.NetworkSecurity.BackendAuthenticationConfig(
121 * "BackendTls",
122 * { wellKnownRoots: "PUBLIC_ROOTS" },
123 * );
124 * ```
125 *
126 * **Example:** TrustConfig plus public roots
127 * ```typescript
128 * const config = yield* GCP.NetworkSecurity.BackendAuthenticationConfig(
129 * "BackendTls",
130 * {
131 * trustConfig: trust.name,
132 * wellKnownRoots: "PUBLIC_ROOTS",
133 * labels: { env: "prod" },
134 * },
135 * );
136 * ```
137 *
138 * @resource
139 * @category NetworkSecurity
140 */
141export const BackendAuthenticationConfig =
142 Resource<BackendAuthenticationConfig>(
143 "GCP.NetworkSecurity.BackendAuthenticationConfig",
144 );
145
146const resourceName = (
147 project: string,
148 location: string,
149 backendAuthenticationConfigId: string,
150) =>
151 `projects/${project}/locations/${location}/backendAuthenticationConfigs/${backendAuthenticationConfigId}`;
152
153const rootsOf = (value: string | undefined) =>
154 (value ?? DEFAULT_WELL_KNOWN_ROOTS).toUpperCase();
155
156const toAttrs = (
157 config: networksecurity.BackendAuthenticationConfig,
158 project: string,
159) => {
160 const name = config.name ?? "";
161 const parsed = parseResourceName(name);
162 return {
163 name,
164 backendAuthenticationConfigId: parsed.id,
165 project: parsed.parentId || project,
166 location: parsed.location,
167 trustConfig: config.trustConfig,
168 clientCertificate: config.clientCertificate,
169 wellKnownRoots: config.wellKnownRoots,
170 description: config.description,
171 labels: userLabels(config.labels),
172 etag: config.etag,
173 createTime: config.createTime,
174 updateTime: config.updateTime,
175 };
176};
177
178const getByName = (name: string) =>
179 networksecurity
180 .getProjectsLocationsBackendAuthenticationConfigs({ name })
181 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
182
183const listOwned = (project: string) =>
184 networksecurity.listProjectsLocationsBackendAuthenticationConfigs
185 .pages({
186 parent: projectParent(project, "-"),
187 pageSize: 1000,
188 })
189 .pipe(
190 Stream.flatMap((page) =>
191 Stream.fromIterable(page.backendAuthenticationConfigs ?? []),
192 ),
193 Stream.filter((config) =>
194 Object.keys(config.labels ?? {}).some((key) =>
195 key.startsWith("alchemy-"),
196 ),
197 ),
198 Stream.map((config) => toAttrs(config, project)),
199 Stream.runCollect,
200 Effect.map((chunk) => Array.from(chunk)),
201 Effect.catchTag("NotFound", () => Effect.succeed([])),
202 );
203
204export const BackendAuthenticationConfigProvider = () =>
205 Provider.succeed(BackendAuthenticationConfig, {
206 stables: [
207 "name",
208 "backendAuthenticationConfigId",
209 "project",
210 "location",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const previousId =
217 olds?.backendAuthenticationConfigId ??
218 output?.backendAuthenticationConfigId;
219 const nextId = news.backendAuthenticationConfigId ?? previousId;
220 const previousLocation = normalizeLocation(
221 olds?.location ?? output?.location,
222 );
223 const nextLocation = normalizeLocation(
224 news.location ?? olds?.location ?? output?.location,
225 );
226 const replace =
227 (previousId !== undefined &&
228 nextId !== undefined &&
229 nextId !== previousId) ||
230 previousLocation !== nextLocation;
231 if (!replace) return undefined;
232 return {
233 action: "replace" as const,
234 deleteFirst:
235 previousLocation === nextLocation &&
236 previousId !== undefined &&
237 nextId === previousId,
238 };
239 }),
240
241 read: Effect.fn(function* ({ id, olds, output }) {
242 const env = yield* GcpEnvironment.current;
243 const backendAuthenticationConfigId = yield* toPhysicalId(
244 id,
245 olds?.backendAuthenticationConfigId,
246 output?.backendAuthenticationConfigId,
247 "backendauth",
248 );
249 const location = normalizeLocation(olds?.location ?? output?.location);
250 const name =
251 output?.name ??
252 resourceName(env.project, location, backendAuthenticationConfigId);
253 const existing = yield* getByName(name);
254 if (existing === undefined) return undefined;
255 const attrs = toAttrs(existing, env.project);
256 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
257 ? attrs
258 : Unowned(attrs);
259 }),
260
261 list: () =>
262 Effect.gen(function* () {
263 const env = yield* GcpEnvironment.current;
264 return yield* listOwned(env.project);
265 }),
266
267 reconcile: Effect.fn(function* ({ id, news, output }) {
268 const env = yield* GcpEnvironment.current;
269 const backendAuthenticationConfigId = yield* toPhysicalId(
270 id,
271 news.backendAuthenticationConfigId,
272 output?.backendAuthenticationConfigId,
273 "backendauth",
274 );
275 const location = normalizeLocation(news.location ?? output?.location);
276 const name = resourceName(
277 env.project,
278 location,
279 backendAuthenticationConfigId,
280 );
281 const wellKnownRoots = rootsOf(news.wellKnownRoots);
282 const desiredLabels = {
283 ...toLabels(news.labels),
284 ...(yield* createInternalLabels(id)),
285 };
286
287 let current = yield* getByName(name);
288
289 if (current === undefined) {
290 const created = yield* networksecurity
291 .createProjectsLocationsBackendAuthenticationConfigs({
292 parent: projectParent(env.project, location),
293 backendAuthenticationConfigId,
294 body: {
295 trustConfig: news.trustConfig,
296 clientCertificate: news.clientCertificate,
297 wellKnownRoots,
298 description: news.description,
299 labels: desiredLabels,
300 },
301 })
302 .pipe(
303 Effect.retry({
304 while: (error) => error._tag === "Conflict",
305 times: 5,
306 schedule: Schedule.spaced("2 seconds"),
307 }),
308 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
309 );
310 if (created !== undefined) {
311 yield* waitForOperation(created);
312 }
313 current = yield* waitUntilExists(getByName(name), name);
314 }
315
316 if (current === undefined) {
317 return yield* new ResourceNotResolved({ name });
318 }
319
320 const observedLabels = tagRecord(current.labels);
321 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
322 const labelsChanged = upsert.length > 0 || removed.length > 0;
323 const descriptionChanged =
324 (current.description ?? "") !== (news.description ?? "");
325 const trustChanged =
326 (current.trustConfig ?? "") !== (news.trustConfig ?? "");
327 const certChanged =
328 (current.clientCertificate ?? "") !== (news.clientCertificate ?? "");
329 const rootsChanged = rootsOf(current.wellKnownRoots) !== wellKnownRoots;
330
331 if (
332 labelsChanged ||
333 descriptionChanged ||
334 trustChanged ||
335 certChanged ||
336 rootsChanged
337 ) {
338 const updateMask = [
339 labelsChanged ? "labels" : undefined,
340 descriptionChanged ? "description" : undefined,
341 trustChanged ? "trustConfig" : undefined,
342 certChanged ? "clientCertificate" : undefined,
343 rootsChanged ? "wellKnownRoots" : undefined,
344 ].filter((field): field is string => field !== undefined);
345
346 const operation =
347 yield* networksecurity.patchProjectsLocationsBackendAuthenticationConfigs(
348 {
349 name: current.name ?? name,
350 updateMask: updateMask.join(","),
351 body: {
352 name: current.name ?? name,
353 labels: desiredLabels,
354 description: news.description,
355 trustConfig: news.trustConfig,
356 clientCertificate: news.clientCertificate,
357 wellKnownRoots,
358 },
359 },
360 );
361 yield* waitForOperation(operation);
362 current = yield* waitUntilExists(
363 getByName(current.name ?? name),
364 current.name ?? name,
365 );
366 }
367
368 return toAttrs(current, env.project);
369 }),
370
371 delete: Effect.fn(function* ({ output }) {
372 const operation = yield* networksecurity
373 .deleteProjectsLocationsBackendAuthenticationConfigs({
374 name: output.name,
375 })
376 .pipe(
377 Effect.retry({
378 while: (error) => error._tag === "Conflict",
379 times: 8,
380 schedule: Schedule.spaced("2 seconds"),
381 }),
382 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
383 );
384 if (operation !== undefined) {
385 yield* waitForOperation(operation, { notFoundOk: true });
386 }
387 yield* waitUntilGone(getByName(output.name), output.name);
388 }),
389 });