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 { 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_CONNECTION_TYPE,
19 DEFAULT_GATEWAY_KIND,
20 ResourceNotResolved,
21 collectPages,
22 expandName,
23 fieldMask,
24 fingerprint,
25 hasAlchemyLabelMap,
26 lastSegment,
27 listAtLocation,
28 normalizeLocation,
29 parentOf,
30 parseName,
31 replaceOnIdentity,
32 rfc1035,
33 sameStringList,
34 sameText,
35 toPhysicalId,
36 userLabels,
37 waitForDeleteOperation,
38 waitForOperation,
39 waitUntilExists,
40 waitUntilGone,
41 waitUntilReady,
42} from "./internal.ts";
43
44const COLLECTION = "appConnections";
45
46export type AppConnectionApplicationEndpoint = {
47 /** Hostname or IP of the remote application. */
48 host?: string;
49 /** Port of the remote application. */
50 port?: number;
51};
52
53export type AppConnectionGateway = {
54 /**
55 * Hosting type of the gateway.
56 * @default "GCP_REGIONAL_MIG"
57 */
58 type?:
59 | beyondcorp.GoogleCloudBeyondcorpAppconnectionsV1AppConnectionGatewayTypeEnum
60 | (string & {});
61 /**
62 * AppGateway resource name
63 * (`projects/{project}/locations/{location}/appGateways/{appGateway}`)
64 * or gateway id.
65 */
66 appGateway?: string;
67 /** Ingress port reserved on the gateway. Output only. */
68 ingressPort?: number;
69 /** Server-defined URI. Output only. */
70 uri?: string;
71 /** L7 private service connection. Output only. */
72 l7psc?: string;
73};
74
75export type AppConnectionProps = {
76 /**
77 * AppConnection id (the `{appConnection}` segment of
78 * `projects/{project}/locations/{location}/appConnections/{appConnection}`).
79 * If omitted, a unique RFC1035 name is generated. Must be 4-63
80 * characters matching `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable —
81 * changing it replaces the connection.
82 */
83 appConnectionId?: string;
84 /**
85 * Region (`us-central1`, …). Immutable — changing it replaces the
86 * connection. `US-CENTRAL1` is accepted and normalized to `us-central1`.
87 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
88 */
89 location?: string;
90 /**
91 * Network connectivity type. Immutable — changing it replaces the
92 * connection.
93 * @default "TCP_PROXY"
94 */
95 type?:
96 | beyondcorp.GoogleCloudBeyondcorpAppconnectionsV1AppConnectionTypeEnum
97 | (string & {});
98 /**
99 * Remote application endpoint. Host and port are required.
100 */
101 applicationEndpoint: AppConnectionApplicationEndpoint;
102 /**
103 * Gateway used by the connection. The nested `appGateway` is
104 * immutable — changing it replaces the connection.
105 */
106 gateway?: AppConnectionGateway;
107 /**
108 * AppConnector resource names authorized to associate with this
109 * connection.
110 */
111 connectors?: string[];
112 /**
113 * Human-readable name. Cannot exceed 64 characters.
114 */
115 displayName?: string;
116 /**
117 * User labels. Alchemy ownership labels are merged in automatically.
118 */
119 labels?: Record<string, string>;
120};
121
122export type AppConnection = Resource<
123 "GCP.BeyondCorp.AppConnection",
124 AppConnectionProps,
125 {
126 /** Full resource name `projects/{project}/locations/{location}/appConnections/{appConnection}`. */
127 name: string;
128 /** AppConnection id (last path segment). */
129 appConnectionId: string;
130 /** Project id. */
131 project: string;
132 /** Location id. */
133 location: string;
134 /** Network connectivity type. */
135 type: string | undefined;
136 /** Remote application endpoint. */
137 applicationEndpoint: AppConnectionApplicationEndpoint | undefined;
138 /** Gateway used by the connection. */
139 gateway: AppConnectionGateway | undefined;
140 /** Authorized AppConnector names. */
141 connectors: string[];
142 /** Human-readable name. */
143 displayName: string | undefined;
144 /** User labels (Alchemy ownership labels stripped). */
145 labels: Record<string, string>;
146 /** Server-reported state (`CREATED`, `CREATING`, …). */
147 state: string | undefined;
148 /** Server-generated resource uid. */
149 uid: string | undefined;
150 /** RFC3339 creation timestamp. */
151 createTime: string | undefined;
152 /** RFC3339 last-update timestamp. */
153 updateTime: string | undefined;
154 },
155 never,
156 Providers
157>;
158
159/**
160 * A BeyondCorp AppConnection that links a remote application endpoint
161 * to an AppGateway and optional AppConnectors.
162 *
163 * Changing `appConnectionId`, `location`, `type`, or
164 * `gateway.appGateway` replaces the connection. Display name, labels,
165 * application endpoint, and connectors update in place.
166 *
167 * ### Creating an AppConnection
168 * **Example:** TCP proxy through an AppGateway
169 * ```typescript
170 * const connection = yield* GCP.BeyondCorp.AppConnection("App", {
171 * applicationEndpoint: { host: "10.0.0.4", port: 8080 },
172 * gateway: { appGateway: gateway.name },
173 * connectors: [connector.name],
174 * });
175 * ```
176 *
177 * **Example:** Explicit id and labels
178 * ```typescript
179 * const connection = yield* GCP.BeyondCorp.AppConnection("App", {
180 * appConnectionId: "app-tcp",
181 * type: "TCP_PROXY",
182 * applicationEndpoint: { host: "app.internal", port: 443 },
183 * gateway: { appGateway: gateway.name },
184 * displayName: "prod app",
185 * labels: { env: "prod" },
186 * });
187 * ```
188 *
189 * @resource
190 * @category BeyondCorp
191 */
192export const AppConnection = Resource<AppConnection>(
193 "GCP.BeyondCorp.AppConnection",
194);
195
196const resourceName = (
197 project: string,
198 location: string,
199 appConnectionId: string,
200) =>
201 `projects/${project}/locations/${location}/appConnections/${appConnectionId}`;
202
203const expandConnectors = (
204 connectors: readonly string[] | undefined,
205 project: string,
206 location: string,
207) =>
208 (connectors ?? []).map((connector) =>
209 expandName(connector, project, location, "appConnectors"),
210 );
211
212const expandGateway = (
213 gateway: AppConnectionGateway | undefined,
214 project: string,
215 location: string,
216): AppConnectionGateway | undefined => {
217 if (gateway === undefined) return undefined;
218 const appGateway = gateway.appGateway
219 ? expandName(gateway.appGateway, project, location, "appGateways")
220 : undefined;
221 return {
222 type: gateway.type ?? DEFAULT_GATEWAY_KIND,
223 appGateway,
224 ingressPort: gateway.ingressPort,
225 uri: gateway.uri,
226 l7psc: gateway.l7psc,
227 };
228};
229
230const toGateway = (
231 gateway:
232 | beyondcorp.GoogleCloudBeyondcorpAppconnectionsV1AppConnectionGateway
233 | undefined,
234): AppConnectionGateway | undefined => {
235 if (gateway === undefined) return undefined;
236 return {
237 type: gateway.type === undefined ? undefined : `${gateway.type}`,
238 appGateway: gateway.appGateway,
239 ingressPort: gateway.ingressPort,
240 uri: gateway.uri,
241 l7psc: gateway.l7psc,
242 };
243};
244
245const toAttrs = (
246 item: beyondcorp.GoogleCloudBeyondcorpAppconnectionsV1AppConnection,
247 project: string,
248 region: string,
249) => {
250 const name = item.name ?? "";
251 const parsed = parseName(name, COLLECTION, region);
252 return {
253 name,
254 appConnectionId: parsed.id,
255 project: parsed.project || project,
256 location: parsed.location,
257 type: item.type === undefined ? undefined : `${item.type}`,
258 applicationEndpoint: item.applicationEndpoint
259 ? {
260 host: item.applicationEndpoint.host,
261 port: item.applicationEndpoint.port,
262 }
263 : undefined,
264 gateway: toGateway(item.gateway),
265 connectors: item.connectors ? [...item.connectors] : [],
266 displayName: item.displayName,
267 labels: userLabels(item.labels),
268 state: item.state === undefined ? undefined : `${item.state}`,
269 uid: item.uid,
270 createTime: item.createTime,
271 updateTime: item.updateTime,
272 };
273};
274
275const getByName = (name: string) =>
276 beyondcorp
277 .getProjectsLocationsAppConnections({ name })
278 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
279
280const listOwned = (project: string) =>
281 listAtLocation(project, (parent) =>
282 collectPages(
283 beyondcorp.listProjectsLocationsAppConnections.pages({
284 parent,
285 pageSize: 1000,
286 }),
287 (
288 page,
289 ):
290 | readonly beyondcorp.GoogleCloudBeyondcorpAppconnectionsV1AppConnection[]
291 | undefined => page.appConnections,
292 ),
293 ).pipe(
294 Effect.map((items) =>
295 items.filter((item) => hasAlchemyLabelMap(item.labels)),
296 ),
297 );
298
299export const AppConnectionProvider = () =>
300 Provider.succeed(AppConnection, {
301 stables: [
302 "name",
303 "appConnectionId",
304 "project",
305 "location",
306 "uid",
307 "createTime",
308 ],
309
310 diff: Effect.fn(function* ({ news, olds, output }) {
311 if (!isResolved(news)) return undefined;
312 const env = yield* GcpEnvironment.current;
313 const previousType =
314 olds?.type ?? output?.type ?? DEFAULT_CONNECTION_TYPE;
315 const nextType = news.type ?? previousType;
316 const previousGateway = lastSegment(
317 olds?.gateway?.appGateway ?? output?.gateway?.appGateway ?? "",
318 );
319 const nextGateway = lastSegment(news.gateway?.appGateway ?? "");
320 return replaceOnIdentity({
321 previousId: olds?.appConnectionId ?? output?.appConnectionId,
322 nextId: news.appConnectionId
323 ? rfc1035(news.appConnectionId, "appconnection")
324 : (olds?.appConnectionId ?? output?.appConnectionId),
325 previousLocation: normalizeLocation(
326 olds?.location ?? output?.location,
327 env.region,
328 ),
329 nextLocation: normalizeLocation(
330 news.location ?? olds?.location ?? output?.location,
331 env.region,
332 ),
333 extra:
334 previousType !== nextType ||
335 (previousGateway.length > 0 &&
336 nextGateway.length > 0 &&
337 previousGateway !== nextGateway),
338 });
339 }),
340
341 read: Effect.fn(function* ({ id, olds, output }) {
342 const env = yield* GcpEnvironment.current;
343 const appConnectionId = yield* toPhysicalId(
344 id,
345 olds?.appConnectionId,
346 output?.appConnectionId,
347 "appconnection",
348 );
349 const location = normalizeLocation(
350 olds?.location ?? output?.location,
351 env.region,
352 );
353 const name =
354 output?.name ?? resourceName(env.project, location, appConnectionId);
355 const existing = yield* getByName(name);
356 if (existing === undefined) return undefined;
357 const attrs = toAttrs(existing, env.project, env.region);
358 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
359 ? attrs
360 : Unowned(attrs);
361 }),
362
363 list: () =>
364 Effect.gen(function* () {
365 const env = yield* GcpEnvironment.current;
366 const items = yield* listOwned(env.project);
367 return items.map((item) => toAttrs(item, env.project, env.region));
368 }),
369
370 reconcile: Effect.fn(function* ({ id, news, output }) {
371 const env = yield* GcpEnvironment.current;
372 const appConnectionId = yield* toPhysicalId(
373 id,
374 news.appConnectionId,
375 output?.appConnectionId,
376 "appconnection",
377 );
378 const location = normalizeLocation(
379 news.location ?? output?.location,
380 env.region,
381 );
382 const name = resourceName(env.project, location, appConnectionId);
383 const desiredLabels = {
384 ...toLabels(news.labels),
385 ...(yield* createInternalLabels(id)),
386 };
387 const type = news.type ?? DEFAULT_CONNECTION_TYPE;
388 const applicationEndpoint = {
389 host: news.applicationEndpoint.host,
390 port: news.applicationEndpoint.port,
391 };
392 const gateway = expandGateway(news.gateway, env.project, location);
393 const connectors = expandConnectors(
394 news.connectors,
395 env.project,
396 location,
397 );
398
399 let current = yield* getByName(output?.name ?? name);
400
401 if (current === undefined) {
402 const created = yield* beyondcorp
403 .createProjectsLocationsAppConnections({
404 parent: parentOf(env.project, location),
405 appConnectionId,
406 body: {
407 type,
408 applicationEndpoint,
409 gateway: gateway
410 ? {
411 type: gateway.type,
412 appGateway: gateway.appGateway,
413 }
414 : undefined,
415 connectors,
416 displayName: news.displayName,
417 labels: desiredLabels,
418 },
419 })
420 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
421 if (created !== undefined) {
422 yield* waitForOperation(created);
423 }
424 current = yield* waitUntilReady(getByName(name), name, (item) =>
425 item.state === undefined ? undefined : `${item.state}`,
426 );
427 }
428
429 if (current === undefined) {
430 return yield* new ResourceNotResolved({ name });
431 }
432
433 const observedLabels = tagRecord(current.labels);
434 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
435 const endpointChanged =
436 fingerprint(current.applicationEndpoint) !==
437 fingerprint(applicationEndpoint);
438 const connectorsChanged = !sameStringList(current.connectors, connectors);
439 const mask = fieldMask([
440 (upsert.length > 0 || removed.length > 0) && "labels",
441 !sameText(current.displayName, news.displayName) && "display_name",
442 endpointChanged && "application_endpoint",
443 connectorsChanged && "connectors",
444 ]);
445
446 if (mask.length > 0) {
447 const operation =
448 yield* beyondcorp.patchProjectsLocationsAppConnections({
449 name: current.name ?? name,
450 updateMask: mask,
451 body: {
452 displayName: news.displayName,
453 labels: desiredLabels,
454 applicationEndpoint,
455 connectors,
456 },
457 });
458 yield* waitForOperation(operation);
459 current = yield* waitUntilExists(
460 getByName(current.name ?? name),
461 current.name ?? name,
462 );
463 }
464
465 return toAttrs(current, env.project, env.region);
466 }),
467
468 delete: Effect.fn(function* ({ output }) {
469 const operation = yield* beyondcorp
470 .deleteProjectsLocationsAppConnections({ name: output.name })
471 .pipe(
472 Effect.retry({
473 while: (error) => error._tag === "Conflict",
474 times: 8,
475 schedule: Schedule.spaced("2 seconds"),
476 }),
477 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
478 );
479 if (operation !== undefined) {
480 yield* waitForDeleteOperation(operation);
481 }
482 yield* waitUntilGone(getByName(output.name), output.name);
483 }),
484 });