1import * as beyondcorp from "@distilled.cloud/gcp/beyondcorp_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 { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_GLOBAL,
13 ResourceNotResolved,
14 collectPages,
15 encodeOwnershipLine,
16 fieldMask,
17 fingerprint,
18 hasOwnershipMarker,
19 listAtLocation,
20 normalizeLocation,
21 ownedByAlchemy,
22 parentOf,
23 parseName,
24 parseOwnership,
25 replaceOnIdentity,
26 rfc1035,
27 toPhysicalId,
28 waitForDeleteOperation,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32 waitUntilReady,
33} from "./internal.ts";
34
35const COLLECTION = "securityGateways";
36
37export type SecurityGatewayInternetGateway = {
38 /** IP addresses assigned to Cloud NAT. Output only. */
39 assignedIps?: string[];
40};
41
42export type SecurityGatewayHub = {
43 /** Internet gateway configuration for the hub. */
44 internetGateway?: SecurityGatewayInternetGateway;
45};
46
47export type SecurityGatewayServiceDiscovery = {
48 /** External API-gateway discovery settings. */
49 apiGateway?: {
50 /** Path used to fetch resource-model updates. */
51 resourceOverride?: { path?: string };
52 };
53};
54
55export type SecurityGatewayContextualHeaders = {
56 /** Delegated device-info output type. */
57 deviceInfo?: { outputType?: string };
58 /** Delegated user-info output type. */
59 userInfo?: { outputType?: string };
60 /** Delegated group-info output type. */
61 groupInfo?: { outputType?: string };
62 /** Default output type for enabled headers. */
63 outputType?: string;
64};
65
66export type SecurityGatewayProxyProtocolConfig = {
67 /** Allowed client header names. */
68 allowedClientHeaders?: string[];
69 /** Custom metadata headers. */
70 metadataHeaders?: Record<string, string>;
71 /** Gateway identity included in the proxy protocol. */
72 gatewayIdentity?: string;
73 /** Contextual header configuration. */
74 contextualHeaders?: SecurityGatewayContextualHeaders;
75 /** Include the client IP when true. */
76 clientIp?: boolean;
77};
78
79export type SecurityGatewayProps = {
80 /**
81 * SecurityGateway id (the `{securityGateway}` segment of
82 * `projects/{project}/locations/{location}/securityGateways/{securityGateway}`).
83 * If omitted, a unique RFC1035 name is generated. Must be 4-63
84 * characters matching `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable —
85 * changing it replaces the gateway.
86 */
87 securityGatewayId?: string;
88 /**
89 * Location. Security gateways live in `global`. Immutable — changing
90 * it replaces the gateway.
91 * @default "global"
92 */
93 location?: string;
94 /**
95 * Human-readable name. Cannot exceed 64 characters. SecurityGateway
96 * has no labels field, so Alchemy stamps ownership into this value
97 * and strips it from attributes.
98 */
99 displayName?: string;
100 /**
101 * Regional hubs keyed by GCP region. Defaults to an internet-gateway
102 * hub in the stack's GCP region (`GCP.Region`, else the profile region,
103 * else `us-central1`); once created, an unset value keeps the current hubs.
104 */
105 hubs?: Record<string, SecurityGatewayHub>;
106 /**
107 * Service discovery settings.
108 */
109 serviceDiscovery?: SecurityGatewayServiceDiscovery;
110 /**
111 * Shared proxy-protocol configuration for applications.
112 */
113 proxyProtocolConfig?: SecurityGatewayProxyProtocolConfig;
114};
115
116export type SecurityGateway = Resource<
117 "GCP.BeyondCorp.SecurityGateway",
118 SecurityGatewayProps,
119 {
120 /** Full resource name `projects/{project}/locations/{location}/securityGateways/{securityGateway}`. */
121 name: string;
122 /** SecurityGateway id (last path segment). */
123 securityGatewayId: string;
124 /** Project id. */
125 project: string;
126 /** Location id (`global`). */
127 location: string;
128 /** User display name with the Alchemy ownership prefix stripped. */
129 displayName: string | undefined;
130 /** Regional hubs. */
131 hubs: Record<string, SecurityGatewayHub> | undefined;
132 /** Service discovery settings. */
133 serviceDiscovery: SecurityGatewayServiceDiscovery | undefined;
134 /** Shared proxy-protocol configuration. */
135 proxyProtocolConfig: SecurityGatewayProxyProtocolConfig | undefined;
136 /** Server-reported state (`RUNNING`, `CREATING`, …). */
137 state: string | undefined;
138 /** Service account used for consumer-project operations. */
139 delegatingServiceAccount: string | undefined;
140 /** External IPs used to reach endpoints. */
141 externalIps: string[];
142 /** RFC3339 creation timestamp. */
143 createTime: string | undefined;
144 /** RFC3339 last-update timestamp. */
145 updateTime: string | undefined;
146 },
147 never,
148 Providers
149>;
150
151/**
152 * A BeyondCorp Security Gateway that fronts Chrome Enterprise Premium
153 * applications.
154 *
155 * The API has no labels field, so Alchemy stamps ownership into
156 * `displayName` for `list` / nuke. Changing `securityGatewayId` or
157 * `location` replaces the gateway. Display name and hubs update in
158 * place.
159 *
160 * ### Creating a SecurityGateway
161 * **Example:** Generated name with a us-central1 hub
162 * ```typescript
163 * const gateway = yield* GCP.BeyondCorp.SecurityGateway("Pep", {});
164 * ```
165 *
166 * **Example:** Explicit id and display name
167 * ```typescript
168 * const gateway = yield* GCP.BeyondCorp.SecurityGateway("Pep", {
169 * securityGatewayId: "app-pep",
170 * displayName: "prod gateway",
171 * hubs: {
172 * "us-central1": { internetGateway: {} },
173 * },
174 * });
175 * ```
176 *
177 * @resource
178 * @category BeyondCorp
179 */
180export const SecurityGateway = Resource<SecurityGateway>(
181 "GCP.BeyondCorp.SecurityGateway",
182);
183
184const resourceName = (
185 project: string,
186 location: string,
187 securityGatewayId: string,
188) =>
189 `projects/${project}/locations/${location}/securityGateways/${securityGatewayId}`;
190
191const hubKeys = (hubs: Record<string, SecurityGatewayHub> | undefined) =>
192 Object.keys(hubs ?? {}).sort();
193
194const toHubs = (
195 hubs: beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1HubMap | undefined,
196): Record<string, SecurityGatewayHub> | undefined => {
197 if (hubs === undefined) return undefined;
198 return Object.fromEntries(
199 Object.entries(hubs).map(([region, hub]) => [
200 region,
201 {
202 internetGateway: hub?.internetGateway
203 ? {
204 assignedIps: hub.internetGateway.assignedIps
205 ? [...hub.internetGateway.assignedIps]
206 : undefined,
207 }
208 : undefined,
209 },
210 ]),
211 );
212};
213
214const toServiceDiscovery = (
215 value:
216 | beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1ServiceDiscovery
217 | undefined,
218): SecurityGatewayServiceDiscovery | undefined => {
219 if (value === undefined) return undefined;
220 return {
221 apiGateway: value.apiGateway
222 ? {
223 resourceOverride: value.apiGateway.resourceOverride
224 ? { path: value.apiGateway.resourceOverride.path }
225 : undefined,
226 }
227 : undefined,
228 };
229};
230
231const toContextualHeaders = (
232 value:
233 | beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1ContextualHeaders
234 | undefined,
235): SecurityGatewayContextualHeaders | undefined => {
236 if (value === undefined) return undefined;
237 return {
238 deviceInfo: value.deviceInfo
239 ? {
240 outputType:
241 value.deviceInfo.outputType === undefined
242 ? undefined
243 : `${value.deviceInfo.outputType}`,
244 }
245 : undefined,
246 userInfo: value.userInfo
247 ? {
248 outputType:
249 value.userInfo.outputType === undefined
250 ? undefined
251 : `${value.userInfo.outputType}`,
252 }
253 : undefined,
254 groupInfo: value.groupInfo
255 ? {
256 outputType:
257 value.groupInfo.outputType === undefined
258 ? undefined
259 : `${value.groupInfo.outputType}`,
260 }
261 : undefined,
262 outputType:
263 value.outputType === undefined ? undefined : `${value.outputType}`,
264 };
265};
266
267const toProxyProtocolConfig = (
268 value:
269 | beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1ProxyProtocolConfig
270 | undefined,
271): SecurityGatewayProxyProtocolConfig | undefined => {
272 if (value === undefined) return undefined;
273 const metadataHeaders = value.metadataHeaders;
274 return {
275 allowedClientHeaders: value.allowedClientHeaders
276 ? [...value.allowedClientHeaders]
277 : undefined,
278 metadataHeaders:
279 metadataHeaders === undefined
280 ? undefined
281 : Object.fromEntries(
282 Object.entries(metadataHeaders).flatMap(([key, header]) =>
283 header === undefined ? [] : [[key, header]],
284 ),
285 ),
286 gatewayIdentity:
287 value.gatewayIdentity === undefined
288 ? undefined
289 : `${value.gatewayIdentity}`,
290 contextualHeaders: toContextualHeaders(value.contextualHeaders),
291 clientIp: value.clientIp,
292 };
293};
294
295const toAttrs = (
296 item: beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1SecurityGateway,
297 project: string,
298) => {
299 const name = item.name ?? "";
300 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
301 const ownership = parseOwnership(item.displayName);
302 return {
303 name,
304 securityGatewayId: parsed.id,
305 project: parsed.project || project,
306 location: parsed.location,
307 displayName: ownership.text,
308 hubs: toHubs(item.hubs),
309 serviceDiscovery: toServiceDiscovery(item.serviceDiscovery),
310 proxyProtocolConfig: toProxyProtocolConfig(item.proxyProtocolConfig),
311 state: item.state === undefined ? undefined : `${item.state}`,
312 delegatingServiceAccount: item.delegatingServiceAccount,
313 externalIps: item.externalIps ? [...item.externalIps] : [],
314 createTime: item.createTime,
315 updateTime: item.updateTime,
316 };
317};
318
319const getByName = (name: string) =>
320 beyondcorp
321 .getProjectsLocationsSecurityGateways({ name })
322 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
323
324const listOwned = (project: string) =>
325 listAtLocation(project, (parent) =>
326 collectPages(
327 beyondcorp.listProjectsLocationsSecurityGateways.pages({
328 parent,
329 pageSize: 1000,
330 }),
331 (
332 page,
333 ):
334 | readonly beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1SecurityGateway[]
335 | undefined => page.securityGateways,
336 ),
337 ).pipe(
338 Effect.map((items) =>
339 items.filter((item) => hasOwnershipMarker(item.displayName)),
340 ),
341 );
342
343const desiredHubs = (
344 hubs: Record<string, SecurityGatewayHub>,
345): beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1HubMap =>
346 Object.fromEntries(
347 Object.entries(hubs).map(([region, hub]) => [
348 region,
349 { internetGateway: hub.internetGateway ? {} : undefined },
350 ]),
351 );
352
353export const SecurityGatewayProvider = () =>
354 Provider.succeed(SecurityGateway, {
355 stables: ["name", "securityGatewayId", "project", "location", "createTime"],
356
357 diff: Effect.fn(function* ({ news, olds, output }) {
358 if (!isResolved(news)) return undefined;
359 return replaceOnIdentity({
360 previousId: olds?.securityGatewayId ?? output?.securityGatewayId,
361 nextId: news.securityGatewayId
362 ? rfc1035(news.securityGatewayId, "securitygateway")
363 : (olds?.securityGatewayId ?? output?.securityGatewayId),
364 previousLocation: normalizeLocation(
365 olds?.location ?? output?.location,
366 DEFAULT_GLOBAL,
367 ),
368 nextLocation: normalizeLocation(
369 news.location ?? olds?.location ?? output?.location,
370 DEFAULT_GLOBAL,
371 ),
372 });
373 }),
374
375 read: Effect.fn(function* ({ id, olds, output }) {
376 const env = yield* GcpEnvironment.current;
377 const securityGatewayId = yield* toPhysicalId(
378 id,
379 olds?.securityGatewayId,
380 output?.securityGatewayId,
381 "securitygateway",
382 );
383 const location = normalizeLocation(
384 olds?.location ?? output?.location,
385 DEFAULT_GLOBAL,
386 );
387 const name =
388 output?.name ?? resourceName(env.project, location, securityGatewayId);
389 const existing = yield* getByName(name);
390 if (existing === undefined) return undefined;
391 const attrs = toAttrs(existing, env.project);
392 return (yield* ownedByAlchemy(id, existing.displayName))
393 ? attrs
394 : Unowned(attrs);
395 }),
396
397 list: () =>
398 Effect.gen(function* () {
399 const env = yield* GcpEnvironment.current;
400 const items = yield* listOwned(env.project);
401 return items.map((item) => toAttrs(item, env.project));
402 }),
403
404 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
405 const env = yield* GcpEnvironment.current;
406 const securityGatewayId = yield* toPhysicalId(
407 id,
408 news.securityGatewayId,
409 output?.securityGatewayId,
410 "securitygateway",
411 );
412 const location = normalizeLocation(
413 news.location ?? output?.location,
414 DEFAULT_GLOBAL,
415 );
416 const name = resourceName(env.project, location, securityGatewayId);
417 const ownership = yield* createInternalLabels(id);
418 const desiredDisplayName = encodeOwnershipLine(
419 ownership,
420 news.displayName,
421 );
422 // An unset `hubs` keeps the existing hubs (a previous default or an
423 // adopted gateway's) so a changed stack region never moves them.
424 const hubSpec: Record<string, SecurityGatewayHub> = news.hubs ??
425 (olds?.hubs === undefined ? output?.hubs : undefined) ?? {
426 [env.region]: { internetGateway: {} },
427 };
428 const hubs = desiredHubs(hubSpec);
429
430 let current = yield* getByName(output?.name ?? name);
431
432 if (current === undefined) {
433 const created = yield* beyondcorp
434 .createProjectsLocationsSecurityGateways({
435 parent: parentOf(env.project, location),
436 securityGatewayId,
437 body: {
438 displayName: desiredDisplayName,
439 hubs,
440 serviceDiscovery: news.serviceDiscovery,
441 proxyProtocolConfig: news.proxyProtocolConfig,
442 },
443 })
444 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
445 if (created !== undefined) {
446 yield* waitForOperation(created);
447 }
448 current = yield* waitUntilReady(getByName(name), name, (item) =>
449 item.state === undefined ? undefined : `${item.state}`,
450 );
451 }
452
453 if (current === undefined) {
454 return yield* new ResourceNotResolved({ name });
455 }
456
457 const mask = fieldMask([
458 (current.displayName ?? "") !== desiredDisplayName && "display_name",
459 fingerprint(hubKeys(toHubs(current.hubs))) !==
460 fingerprint(hubKeys(hubSpec)) && "hubs",
461 ]);
462
463 if (mask.length > 0) {
464 const operation =
465 yield* beyondcorp.patchProjectsLocationsSecurityGateways({
466 name: current.name ?? name,
467 updateMask: mask,
468 body: {
469 displayName: desiredDisplayName,
470 hubs,
471 },
472 });
473 yield* waitForOperation(operation);
474 current = yield* waitUntilExists(
475 getByName(current.name ?? name),
476 current.name ?? name,
477 );
478 }
479
480 return toAttrs(current, env.project);
481 }),
482
483 delete: Effect.fn(function* ({ output }) {
484 const operation = yield* beyondcorp
485 .deleteProjectsLocationsSecurityGateways({ name: output.name })
486 .pipe(
487 Effect.retry({
488 while: (error) => error._tag === "Conflict",
489 times: 8,
490 schedule: Schedule.spaced("2 seconds"),
491 }),
492 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
493 );
494 if (operation !== undefined) {
495 yield* waitForDeleteOperation(operation);
496 }
497 yield* waitUntilGone(getByName(output.name), output.name);
498 }),
499 });