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 expandName,
17 fieldMask,
18 fingerprint,
19 hasOwnershipMarker,
20 listAtLocation,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseName,
24 parseOwnership,
25 replaceOnIdentity,
26 rfc1035,
27 toPhysicalId,
28 waitForDeleteOperation,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33
34const COLLECTION = "applications";
35
36export type SecurityGatewaysApplicationEndpointMatcher = {
37 /** Hostname pattern (`example.com`, `*.example.com`). */
38 hostname?: string;
39 /** Ports that match this hostname. */
40 ports?: number[];
41};
42
43export type SecurityGatewaysApplicationUpstream = {
44 /** VPC network to forward traffic to. */
45 network?: { name?: string };
46 /** External endpoints to forward traffic to. */
47 external?: {
48 endpoints?: Array<{ hostname?: string; port?: number }>;
49 };
50 /** Optional proxy-protocol configuration for the upstream. */
51 proxyProtocol?: beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1ProxyProtocolConfig;
52 /** Regions where the application sends traffic. */
53 egressPolicy?: { regions?: string[] };
54};
55
56export type SecurityGatewaysApplicationProps = {
57 /**
58 * Parent SecurityGateway resource name
59 * (`projects/{project}/locations/{location}/securityGateways/{securityGateway}`)
60 * or gateway id. Immutable — changing it replaces the application.
61 */
62 securityGateway: string;
63 /**
64 * Application id (the `{application}` segment of
65 * `.../securityGateways/{securityGateway}/applications/{application}`).
66 * If omitted, a unique RFC1035 name is generated. Immutable —
67 * changing it replaces the application.
68 */
69 applicationId?: string;
70 /**
71 * Location of the parent gateway. Inferred from `securityGateway`
72 * when that value is a full resource name. Defaults to `global`.
73 * Immutable — changing it replaces the application.
74 * @default "global"
75 */
76 location?: string;
77 /**
78 * Endpoint matchers. The application matches when any matcher is
79 * satisfied.
80 */
81 endpointMatchers: SecurityGatewaysApplicationEndpointMatcher[];
82 /**
83 * External application schema (`PROXY_GATEWAY`, `API_GATEWAY`).
84 */
85 schema?:
86 | beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1ApplicationSchemaEnum
87 | (string & {});
88 /**
89 * Upstream resources that receive matched traffic.
90 */
91 upstreams?: SecurityGatewaysApplicationUpstream[];
92 /**
93 * Human-readable name. Cannot exceed 64 characters. Application has
94 * no labels field, so Alchemy stamps ownership into this value and
95 * strips it from attributes.
96 */
97 displayName?: string;
98};
99
100export type SecurityGatewaysApplication = Resource<
101 "GCP.BeyondCorp.SecurityGatewaysApplication",
102 SecurityGatewaysApplicationProps,
103 {
104 /** Full resource name `.../securityGateways/{securityGateway}/applications/{application}`. */
105 name: string;
106 /** Application id (last path segment). */
107 applicationId: string;
108 /** Parent SecurityGateway resource name. */
109 securityGateway: string;
110 /** Project id. */
111 project: string;
112 /** Location id (`global`). */
113 location: string;
114 /** Endpoint matchers. */
115 endpointMatchers: SecurityGatewaysApplicationEndpointMatcher[];
116 /** External application schema. */
117 schema: string | undefined;
118 /** Upstream resources. */
119 upstreams: SecurityGatewaysApplicationUpstream[];
120 /** User display name with the Alchemy ownership prefix stripped. */
121 displayName: string | undefined;
122 /** RFC3339 creation timestamp. */
123 createTime: string | undefined;
124 /** RFC3339 last-update timestamp. */
125 updateTime: string | undefined;
126 },
127 never,
128 Providers
129>;
130
131/**
132 * A BeyondCorp Security Gateway application that matches host and
133 * port combinations and forwards traffic to upstreams.
134 *
135 * The API has no labels field, so Alchemy stamps ownership into
136 * `displayName` for `list` / nuke. Changing the parent gateway,
137 * application id, or location replaces the application. Display
138 * name, endpoint matchers, schema, and upstreams update in place.
139 *
140 * ### Creating a SecurityGatewaysApplication
141 * **Example:** Match HTTPS for a hostname
142 * ```typescript
143 * const app = yield* GCP.BeyondCorp.SecurityGatewaysApplication("Web", {
144 * securityGateway: gateway.name,
145 * endpointMatchers: [{ hostname: "app.example.com", ports: [443] }],
146 * });
147 * ```
148 *
149 * **Example:** External upstreams
150 * ```typescript
151 * const app = yield* GCP.BeyondCorp.SecurityGatewaysApplication("Web", {
152 * securityGateway: gateway.name,
153 * applicationId: "app-web",
154 * displayName: "prod web",
155 * endpointMatchers: [{ hostname: "app.example.com", ports: [80, 443] }],
156 * upstreams: [
157 * {
158 * external: {
159 * endpoints: [{ hostname: "origin.example.com", port: 443 }],
160 * },
161 * },
162 * ],
163 * });
164 * ```
165 *
166 * @resource
167 * @category BeyondCorp
168 */
169export const SecurityGatewaysApplication =
170 Resource<SecurityGatewaysApplication>(
171 "GCP.BeyondCorp.SecurityGatewaysApplication",
172 );
173
174const parentGatewayName = (
175 project: string,
176 location: string,
177 securityGateway: string,
178) => expandName(securityGateway, project, location, "securityGateways");
179
180const resourceNameOf = (parent: string, applicationId: string) =>
181 `${parent}/applications/${applicationId}`;
182
183const parentFromName = (name: string) => {
184 const index = name.lastIndexOf("/applications/");
185 return index >= 0 ? name.slice(0, index) : name;
186};
187
188const locationFromParent = (securityGateway: string, fallback: string) => {
189 if (!securityGateway.includes("/locations/")) return fallback;
190 return parseName(securityGateway, "securityGateways", fallback).location;
191};
192
193const toMatchers = (
194 matchers:
195 | beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1EndpointMatcherList
196 | undefined,
197): SecurityGatewaysApplicationEndpointMatcher[] =>
198 (matchers ?? []).map((matcher) => ({
199 hostname: matcher.hostname,
200 ports: matcher.ports ? [...matcher.ports] : undefined,
201 }));
202
203const toUpstreams = (
204 upstreams:
205 | beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1ApplicationUpstreamList
206 | undefined,
207): SecurityGatewaysApplicationUpstream[] =>
208 (upstreams ?? []).map((upstream) => ({
209 network: upstream.network ? { name: upstream.network.name } : undefined,
210 external: upstream.external
211 ? {
212 endpoints: (upstream.external.endpoints ?? []).map((endpoint) => ({
213 hostname: endpoint.hostname,
214 port: endpoint.port,
215 })),
216 }
217 : undefined,
218 proxyProtocol: upstream.proxyProtocol,
219 egressPolicy: upstream.egressPolicy
220 ? {
221 regions: upstream.egressPolicy.regions
222 ? [...upstream.egressPolicy.regions]
223 : undefined,
224 }
225 : undefined,
226 }));
227
228const toAttrs = (
229 item: beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1Application,
230 project: string,
231) => {
232 const name = item.name ?? "";
233 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
234 const ownership = parseOwnership(item.displayName);
235 const parent = parentFromName(name);
236 return {
237 name,
238 applicationId: parsed.id,
239 securityGateway: parent,
240 project: parsed.project || project,
241 location: parsed.location,
242 endpointMatchers: toMatchers(item.endpointMatchers),
243 schema: item.schema === undefined ? undefined : `${item.schema}`,
244 upstreams: toUpstreams(item.upstreams),
245 displayName: ownership.text,
246 createTime: item.createTime,
247 updateTime: item.updateTime,
248 };
249};
250
251const getByName = (name: string) =>
252 beyondcorp
253 .getProjectsLocationsSecurityGatewaysApplications({ name })
254 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
255
256const listOwned = (project: string) =>
257 Effect.gen(function* () {
258 const gateways = yield* listAtLocation(project, (parent) =>
259 collectPages(
260 beyondcorp.listProjectsLocationsSecurityGateways.pages({
261 parent,
262 pageSize: 1000,
263 }),
264 (
265 page,
266 ):
267 | readonly beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1SecurityGateway[]
268 | undefined => page.securityGateways,
269 ),
270 );
271 const nested = yield* Effect.forEach(
272 gateways.filter((gateway) => (gateway.name ?? "").length > 0),
273 (gateway) =>
274 collectPages(
275 beyondcorp.listProjectsLocationsSecurityGatewaysApplications.pages({
276 parent: gateway.name!,
277 pageSize: 1000,
278 }),
279 (
280 page,
281 ):
282 | readonly beyondcorp.GoogleCloudBeyondcorpSecuritygatewaysV1Application[]
283 | undefined => page.applications,
284 ),
285 { concurrency: 4 },
286 );
287 return nested.flat().filter((item) => hasOwnershipMarker(item.displayName));
288 });
289
290export const SecurityGatewaysApplicationProvider = () =>
291 Provider.succeed(SecurityGatewaysApplication, {
292 stables: [
293 "name",
294 "applicationId",
295 "securityGateway",
296 "project",
297 "location",
298 "createTime",
299 ],
300
301 diff: Effect.fn(function* ({ news, olds, output }) {
302 if (!isResolved(news)) return undefined;
303 const previousLocation = normalizeLocation(
304 olds?.location ?? output?.location,
305 DEFAULT_GLOBAL,
306 );
307 return replaceOnIdentity({
308 previousId: olds?.applicationId ?? output?.applicationId,
309 nextId: news.applicationId
310 ? rfc1035(news.applicationId, "application")
311 : (olds?.applicationId ?? output?.applicationId),
312 previousLocation,
313 nextLocation: normalizeLocation(
314 news.location ??
315 locationFromParent(news.securityGateway, previousLocation),
316 DEFAULT_GLOBAL,
317 ),
318 previousParent: olds?.securityGateway ?? output?.securityGateway,
319 nextParent: news.securityGateway,
320 });
321 }),
322
323 read: Effect.fn(function* ({ id, olds, output }) {
324 const env = yield* GcpEnvironment.current;
325 const location = normalizeLocation(
326 olds?.location ??
327 output?.location ??
328 (olds?.securityGateway
329 ? locationFromParent(olds.securityGateway, DEFAULT_GLOBAL)
330 : undefined),
331 DEFAULT_GLOBAL,
332 );
333 const applicationId = yield* toPhysicalId(
334 id,
335 olds?.applicationId,
336 output?.applicationId,
337 "application",
338 );
339 const parent = olds?.securityGateway
340 ? parentGatewayName(env.project, location, olds.securityGateway)
341 : output?.securityGateway;
342 const name =
343 output?.name ??
344 (parent ? resourceNameOf(parent, applicationId) : undefined);
345 if (name === undefined) return undefined;
346 const existing = yield* getByName(name);
347 if (existing === undefined) return undefined;
348 const attrs = toAttrs(existing, env.project);
349 return (yield* ownedByAlchemy(id, existing.displayName))
350 ? attrs
351 : Unowned(attrs);
352 }),
353
354 list: () =>
355 Effect.gen(function* () {
356 const env = yield* GcpEnvironment.current;
357 const items = yield* listOwned(env.project);
358 return items.map((item) => toAttrs(item, env.project));
359 }),
360
361 reconcile: Effect.fn(function* ({ id, news, output }) {
362 const env = yield* GcpEnvironment.current;
363 const location = normalizeLocation(
364 news.location ??
365 output?.location ??
366 locationFromParent(news.securityGateway, DEFAULT_GLOBAL),
367 DEFAULT_GLOBAL,
368 );
369 const applicationId = yield* toPhysicalId(
370 id,
371 news.applicationId,
372 output?.applicationId,
373 "application",
374 );
375 const parent = parentGatewayName(
376 env.project,
377 location,
378 news.securityGateway,
379 );
380 const name = resourceNameOf(parent, applicationId);
381 const ownership = yield* createInternalLabels(id);
382 const desiredDisplayName = encodeOwnershipLine(
383 ownership,
384 news.displayName,
385 );
386 const endpointMatchers = news.endpointMatchers.map((matcher) => ({
387 hostname: matcher.hostname,
388 ports: matcher.ports,
389 }));
390 const upstreams = news.upstreams;
391
392 let current = yield* getByName(output?.name ?? name);
393
394 if (current === undefined) {
395 const created = yield* beyondcorp
396 .createProjectsLocationsSecurityGatewaysApplications({
397 parent,
398 applicationId,
399 body: {
400 displayName: desiredDisplayName,
401 endpointMatchers,
402 schema: news.schema,
403 upstreams,
404 },
405 })
406 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
407 if (created !== undefined) {
408 yield* waitForOperation(created);
409 }
410 current = yield* waitUntilExists(getByName(name), name);
411 }
412
413 if (current === undefined) {
414 return yield* new ResourceNotResolved({ name });
415 }
416
417 const mask = fieldMask([
418 (current.displayName ?? "") !== desiredDisplayName && "display_name",
419 fingerprint(toMatchers(current.endpointMatchers)) !==
420 fingerprint(endpointMatchers) && "endpoint_matchers",
421 (current.schema ?? "") !== (news.schema ?? "") && "schema",
422 fingerprint(toUpstreams(current.upstreams)) !==
423 fingerprint(upstreams) && "upstreams",
424 ]);
425
426 if (mask.length > 0) {
427 const operation =
428 yield* beyondcorp.patchProjectsLocationsSecurityGatewaysApplications({
429 name: current.name ?? name,
430 updateMask: mask,
431 body: {
432 displayName: desiredDisplayName,
433 endpointMatchers,
434 schema: news.schema,
435 upstreams,
436 },
437 });
438 yield* waitForOperation(operation);
439 current = yield* waitUntilExists(
440 getByName(current.name ?? name),
441 current.name ?? name,
442 );
443 }
444
445 return toAttrs(current, env.project);
446 }),
447
448 delete: Effect.fn(function* ({ output }) {
449 const operation = yield* beyondcorp
450 .deleteProjectsLocationsSecurityGatewaysApplications({
451 name: output.name,
452 })
453 .pipe(
454 Effect.retry({
455 while: (error) => error._tag === "Conflict",
456 times: 8,
457 schedule: Schedule.spaced("2 seconds"),
458 }),
459 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
460 );
461 if (operation !== undefined) {
462 yield* waitForDeleteOperation(operation);
463 }
464 yield* waitUntilGone(getByName(output.name), output.name);
465 }),
466 });