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 { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 DEFAULT_GATEWAY_TYPE,
14 DEFAULT_HOST_TYPE,
15 ResourceNotResolved,
16 collectPages,
17 hasAlchemyLabelMap,
18 listAtLocation,
19 normalizeLocation,
20 parentOf,
21 parseName,
22 replaceOnIdentity,
23 rfc1035,
24 toPhysicalId,
25 userLabels,
26 waitForDeleteOperation,
27 waitForOperation,
28 waitUntilGone,
29 waitUntilReady,
30} from "./internal.ts";
31
32const COLLECTION = "appGateways";
33
34export type AppGatewayAllocatedConnection = {
35 /** PSC URI of an allocated connection. */
36 pscUri?: string;
37 /** Ingress port of an allocated connection. */
38 ingressPort?: number;
39};
40
41export type AppGatewayProps = {
42 /**
43 * AppGateway id (the `{appGateway}` segment of
44 * `projects/{project}/locations/{location}/appGateways/{appGateway}`).
45 * If omitted, a unique RFC1035 name is generated. Must be 4-63
46 * characters matching `[a-z]([a-z0-9-]{0,61}[a-z0-9])?`. Immutable —
47 * changing it replaces the gateway.
48 */
49 appGatewayId?: string;
50 /**
51 * Region (`us-central1`, …). Immutable — changing it replaces the
52 * gateway. `US-CENTRAL1` is accepted and normalized to `us-central1`.
53 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
54 */
55 location?: string;
56 /**
57 * Network connectivity type. Immutable — changing it replaces the
58 * gateway.
59 * @default "TCP_PROXY"
60 */
61 type?: beyondcorp.AppGatewayTypeEnum | (string & {});
62 /**
63 * Hosting type. Immutable — changing it replaces the gateway.
64 * @default "GCP_REGIONAL_MIG"
65 */
66 hostType?: beyondcorp.AppGatewayHostTypeEnum | (string & {});
67 /**
68 * Human-readable name. Cannot exceed 64 characters. AppGateway has no
69 * update API, so this is set at create only.
70 */
71 displayName?: string;
72 /**
73 * User labels. Alchemy ownership labels are merged in automatically.
74 * AppGateway has no update API, so labels are set at create only.
75 */
76 labels?: Record<string, string>;
77};
78
79export type AppGateway = Resource<
80 "GCP.BeyondCorp.AppGateway",
81 AppGatewayProps,
82 {
83 /** Full resource name `projects/{project}/locations/{location}/appGateways/{appGateway}`. */
84 name: string;
85 /** AppGateway id (last path segment). */
86 appGatewayId: string;
87 /** Project id. */
88 project: string;
89 /** Location id. */
90 location: string;
91 /** Network connectivity type. */
92 type: string | undefined;
93 /** Hosting type. */
94 hostType: string | undefined;
95 /** Human-readable name. */
96 displayName: string | undefined;
97 /** User labels (Alchemy ownership labels stripped). */
98 labels: Record<string, string>;
99 /** Server-reported state (`CREATED`, `CREATING`, …). */
100 state: string | undefined;
101 /** Server-defined URI. */
102 uri: string | undefined;
103 /** Allocated PSC connections. */
104 allocatedConnections: AppGatewayAllocatedConnection[];
105 /** Server-generated resource uid. */
106 uid: string | undefined;
107 /** RFC3339 creation timestamp. */
108 createTime: string | undefined;
109 /** RFC3339 last-update timestamp. */
110 updateTime: string | undefined;
111 },
112 never,
113 Providers
114>;
115
116/**
117 * A BeyondCorp AppGateway that provisions the GCP components used to
118 * reach a remote application.
119 *
120 * Changing `appGatewayId`, `location`, `type`, or `hostType` replaces
121 * the gateway. The API has no patch method — display name and labels
122 * are applied at create only.
123 *
124 * ### Creating an AppGateway
125 * **Example:** Generated name
126 * ```typescript
127 * const gateway = yield* GCP.BeyondCorp.AppGateway("Edge", {});
128 * ```
129 *
130 * **Example:** Explicit id, type, and labels
131 * ```typescript
132 * const gateway = yield* GCP.BeyondCorp.AppGateway("Edge", {
133 * appGatewayId: "app-edge",
134 * location: "us-central1",
135 * type: "TCP_PROXY",
136 * hostType: "GCP_REGIONAL_MIG",
137 * displayName: "edge gateway",
138 * labels: { env: "prod" },
139 * });
140 * ```
141 *
142 * @resource
143 * @category BeyondCorp
144 */
145export const AppGateway = Resource<AppGateway>("GCP.BeyondCorp.AppGateway");
146
147const resourceName = (
148 project: string,
149 location: string,
150 appGatewayId: string,
151) => `projects/${project}/locations/${location}/appGateways/${appGatewayId}`;
152
153const toAttrs = (
154 item: beyondcorp.AppGateway,
155 project: string,
156 region: string,
157) => {
158 const name = item.name ?? "";
159 const parsed = parseName(name, COLLECTION, region);
160 return {
161 name,
162 appGatewayId: parsed.id,
163 project: parsed.project || project,
164 location: parsed.location,
165 type: item.type === undefined ? undefined : `${item.type}`,
166 hostType: item.hostType === undefined ? undefined : `${item.hostType}`,
167 displayName: item.displayName,
168 labels: userLabels(item.labels),
169 state: item.state === undefined ? undefined : `${item.state}`,
170 uri: item.uri,
171 allocatedConnections: (item.allocatedConnections ?? []).map(
172 (connection) => ({
173 pscUri: connection.pscUri,
174 ingressPort: connection.ingressPort,
175 }),
176 ),
177 uid: item.uid,
178 createTime: item.createTime,
179 updateTime: item.updateTime,
180 };
181};
182
183const getByName = (name: string) =>
184 beyondcorp
185 .getProjectsLocationsAppGateways({ name })
186 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
187
188const listOwned = (project: string) =>
189 listAtLocation(project, (parent) =>
190 collectPages(
191 beyondcorp.listProjectsLocationsAppGateways.pages({
192 parent,
193 pageSize: 1000,
194 }),
195 (page): readonly beyondcorp.AppGateway[] | undefined => page.appGateways,
196 ),
197 ).pipe(
198 Effect.map((items) =>
199 items.filter((item) => hasAlchemyLabelMap(item.labels)),
200 ),
201 );
202
203export const AppGatewayProvider = () =>
204 Provider.succeed(AppGateway, {
205 stables: [
206 "name",
207 "appGatewayId",
208 "project",
209 "location",
210 "uid",
211 "createTime",
212 ],
213
214 diff: Effect.fn(function* ({ news, olds, output }) {
215 if (!isResolved(news)) return undefined;
216 const env = yield* GcpEnvironment.current;
217 const previousType = olds?.type ?? output?.type ?? DEFAULT_GATEWAY_TYPE;
218 const nextType = news.type ?? previousType;
219 const previousHost =
220 olds?.hostType ?? output?.hostType ?? DEFAULT_HOST_TYPE;
221 const nextHost = news.hostType ?? previousHost;
222 return replaceOnIdentity({
223 previousId: olds?.appGatewayId ?? output?.appGatewayId,
224 nextId: news.appGatewayId
225 ? rfc1035(news.appGatewayId, "appgateway")
226 : (olds?.appGatewayId ?? output?.appGatewayId),
227 previousLocation: normalizeLocation(
228 olds?.location ?? output?.location,
229 env.region,
230 ),
231 nextLocation: normalizeLocation(
232 news.location ?? olds?.location ?? output?.location,
233 env.region,
234 ),
235 extra: previousType !== nextType || previousHost !== nextHost,
236 });
237 }),
238
239 read: Effect.fn(function* ({ id, olds, output }) {
240 const env = yield* GcpEnvironment.current;
241 const appGatewayId = yield* toPhysicalId(
242 id,
243 olds?.appGatewayId,
244 output?.appGatewayId,
245 "appgateway",
246 );
247 const location = normalizeLocation(
248 olds?.location ?? output?.location,
249 env.region,
250 );
251 const name =
252 output?.name ?? resourceName(env.project, location, appGatewayId);
253 const existing = yield* getByName(name);
254 if (existing === undefined) return undefined;
255 const attrs = toAttrs(existing, env.project, env.region);
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 const items = yield* listOwned(env.project);
265 return items.map((item) => toAttrs(item, env.project, env.region));
266 }),
267
268 reconcile: Effect.fn(function* ({ id, news, output }) {
269 const env = yield* GcpEnvironment.current;
270 const appGatewayId = yield* toPhysicalId(
271 id,
272 news.appGatewayId,
273 output?.appGatewayId,
274 "appgateway",
275 );
276 const location = normalizeLocation(
277 news.location ?? output?.location,
278 env.region,
279 );
280 const name = resourceName(env.project, location, appGatewayId);
281 const desiredLabels = {
282 ...toLabels(news.labels),
283 ...(yield* createInternalLabels(id)),
284 };
285 const type = news.type ?? DEFAULT_GATEWAY_TYPE;
286 const hostType = news.hostType ?? DEFAULT_HOST_TYPE;
287
288 let current = yield* getByName(output?.name ?? name);
289
290 if (current === undefined) {
291 const created = yield* beyondcorp
292 .createProjectsLocationsAppGateways({
293 parent: parentOf(env.project, location),
294 appGatewayId,
295 body: {
296 type,
297 hostType,
298 displayName: news.displayName,
299 labels: desiredLabels,
300 },
301 })
302 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
303 if (created !== undefined) {
304 yield* waitForOperation(created);
305 }
306 current = yield* waitUntilReady(getByName(name), name, (item) =>
307 item.state === undefined ? undefined : `${item.state}`,
308 );
309 }
310
311 if (current === undefined) {
312 return yield* new ResourceNotResolved({ name });
313 }
314
315 return toAttrs(current, env.project, env.region);
316 }),
317
318 delete: Effect.fn(function* ({ output }) {
319 const operation = yield* beyondcorp
320 .deleteProjectsLocationsAppGateways({ name: output.name })
321 .pipe(
322 Effect.retry({
323 while: (error) => error._tag === "Conflict",
324 times: 8,
325 schedule: Schedule.spaced("2 seconds"),
326 }),
327 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
328 );
329 if (operation !== undefined) {
330 yield* waitForDeleteOperation(operation);
331 }
332 yield* waitUntilGone(getByName(output.name), output.name);
333 }),
334 });