1import * as networkservices from "@distilled.cloud/gcp/networkservices_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_GLOBAL,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 resourceName,
26 rfc1035,
27 sameNumberList,
28 sameStringList,
29 toPhysicalId,
30 userLabels,
31 waitForOperation,
32 waitUntilGone,
33 waitUntilPresent,
34} from "./internal.ts";
35
36const COLLECTION = "gateways";
37const DEFAULT_TYPE = "OPEN_MESH";
38const DEFAULT_PORTS = [443];
39
40export type GatewayType = networkservices.GatewayTypeEnum | (string & {});
41export type GatewayIpVersion =
42 | networkservices.GatewayIpVersionEnum
43 | (string & {});
44export type GatewayEnvoyHeaders =
45 | networkservices.GatewayEnvoyHeadersEnum
46 | (string & {});
47export type GatewayRoutingMode =
48 | networkservices.GatewayRoutingModeEnum
49 | (string & {});
50
51export type GatewayProps = {
52 /**
53 * Gateway id (the `{gateway}` segment of
54 * `projects/{project}/locations/{location}/gateways/{gateway}`). If
55 * omitted, a unique name is generated from the stack, stage, and
56 * logical id. Must be 1-63 characters and not start with a number.
57 * Immutable — changing it replaces the gateway.
58 */
59 gatewayId?: string;
60 /**
61 * Location (`global`, `us-central1`, …). Immutable — changing it
62 * replaces the gateway. `US-CENTRAL1` is accepted and normalized to
63 * `us-central1`.
64 * @default "global"
65 */
66 location?: string;
67 /**
68 * Customer-managed gateway type. Immutable — changing it replaces the
69 * gateway.
70 * @default "OPEN_MESH"
71 */
72 type?: GatewayType;
73 /**
74 * Ports (1-65535) the proxy binds. `SECURE_WEB_GATEWAY` is limited to
75 * 5 ports. Mutually exclusive with `allPorts`.
76 * @default [443]
77 */
78 ports?: number[];
79 /**
80 * Listen on every port. Mutually exclusive with `ports`. Only applies
81 * to `SECURE_WEB_GATEWAY`.
82 * @default false
83 */
84 allPorts?: boolean;
85 /**
86 * Zero or one IPv4/IPv6 address. Empty allocates from the subnetwork.
87 * Only applies to `SECURE_WEB_GATEWAY`.
88 */
89 addresses?: string[];
90 /**
91 * Merge key for configuration across Gateway instances. Max 64
92 * characters; must start with a letter. Defaults to the gateway id.
93 * Immutable — changing it replaces the gateway.
94 */
95 scope?: string;
96 /**
97 * ServerTlsPolicy URL used to terminate TLS. Empty disables TLS
98 * termination.
99 */
100 serverTlsPolicy?: string;
101 /**
102 * Certificate Manager certificate URLs presented during TLS. Only
103 * applies to `SECURE_WEB_GATEWAY`.
104 */
105 certificateUrls?: string[];
106 /**
107 * GatewaySecurityPolicy URL applied to inbound connections. Only
108 * applies to `SECURE_WEB_GATEWAY`.
109 */
110 gatewaySecurityPolicy?: string;
111 /**
112 * VPC network resource name, e.g.
113 * `projects/{project}/global/networks/{network}`. Immutable. Only
114 * applies to `SECURE_WEB_GATEWAY`.
115 */
116 network?: string;
117 /**
118 * Subnetwork resource name, e.g.
119 * `projects/{project}/regions/{region}/subnetworks/{subnetwork}`.
120 * Immutable. Only applies to `SECURE_WEB_GATEWAY`.
121 */
122 subnetwork?: string;
123 /**
124 * IP version (`IPV4` or `IPV6`).
125 */
126 ipVersion?: GatewayIpVersion;
127 /**
128 * Whether Envoy inserts internal debug headers on upstream requests.
129 */
130 envoyHeaders?: GatewayEnvoyHeaders;
131 /**
132 * Routing mode. Required for `SECURE_WEB_GATEWAY`.
133 */
134 routingMode?: GatewayRoutingMode;
135 /**
136 * Allow clients outside the gateway region. Only applies to
137 * `SECURE_WEB_GATEWAY`.
138 * @default false
139 */
140 allowGlobalAccess?: boolean;
141 /**
142 * Human-readable description. Max length 1024 characters.
143 */
144 description?: string;
145 /**
146 * User labels. Alchemy ownership labels are merged in automatically.
147 */
148 labels?: Record<string, string>;
149};
150
151export type Gateway = Resource<
152 "GCP.NetworkServices.Gateway",
153 GatewayProps,
154 {
155 /** Full resource name `projects/{project}/locations/{location}/gateways/{gateway}`. */
156 name: string;
157 /** Gateway id (last path segment). */
158 gatewayId: string;
159 /** Project id. */
160 project: string;
161 /** Location id (`global`, `us-central1`, …). */
162 location: string;
163 /** Server-defined resource URL. */
164 selfLink: string | undefined;
165 /** Gateway type. */
166 type: string;
167 /** Bound ports. */
168 ports: number[];
169 /** Whether the gateway listens on every port. */
170 allPorts: boolean;
171 /** Bound addresses. */
172 addresses: string[];
173 /** Configuration merge scope. */
174 scope: string | undefined;
175 /** ServerTlsPolicy URL, if set. */
176 serverTlsPolicy: string | undefined;
177 /** Certificate Manager certificate URLs. */
178 certificateUrls: string[];
179 /** GatewaySecurityPolicy URL, if set. */
180 gatewaySecurityPolicy: string | undefined;
181 /** VPC network resource name, if set. */
182 network: string | undefined;
183 /** Subnetwork resource name, if set. */
184 subnetwork: string | undefined;
185 /** IP version, if set. */
186 ipVersion: string | undefined;
187 /** Envoy debug-header setting, if set. */
188 envoyHeaders: string | undefined;
189 /** Routing mode, if set. */
190 routingMode: string | undefined;
191 /** Whether clients outside the region are allowed. */
192 allowGlobalAccess: boolean;
193 /** User-provided description. */
194 description: string | undefined;
195 /** User labels (Alchemy ownership labels stripped). */
196 labels: Record<string, string>;
197 /** RFC3339 creation timestamp. */
198 createTime: string | undefined;
199 /** RFC3339 last-update timestamp. */
200 updateTime: string | undefined;
201 },
202 never,
203 Providers
204>;
205
206/**
207 * A Network Services Gateway — the ip:port a Mesh or Secure Web Gateway
208 * proxy listens on, plus TLS and routing policy.
209 *
210 * Changing `gatewayId`, `location`, `type`, `network`, or `subnetwork`
211 * replaces the gateway. Description, labels, ports, scope, TLS, and
212 * routing fields update in place.
213 *
214 * ### Creating a Gateway
215 * **Example:** Open mesh gateway
216 * ```typescript
217 * const gateway = yield* GCP.NetworkServices.Gateway("Mesh", {
218 * type: "OPEN_MESH",
219 * ports: [443],
220 * });
221 * ```
222 *
223 * **Example:** Named gateway with labels
224 * ```typescript
225 * const gateway = yield* GCP.NetworkServices.Gateway("Mesh", {
226 * gatewayId: "app-mesh",
227 * description: "prod mesh",
228 * labels: { env: "prod" },
229 * type: "OPEN_MESH",
230 * ports: [80, 443],
231 * scope: "prod",
232 * });
233 * ```
234 *
235 * @resource
236 * @category NetworkServices
237 */
238export const Gateway = Resource<Gateway>("GCP.NetworkServices.Gateway");
239
240const toAttrs = (gateway: networkservices.Gateway, project: string) => {
241 const name = gateway.name ?? "";
242 const parsed = parseName(name, COLLECTION, DEFAULT_GLOBAL);
243 return {
244 name,
245 gatewayId: parsed.id,
246 project: parsed.project || project,
247 location: parsed.location || DEFAULT_GLOBAL,
248 selfLink: gateway.selfLink,
249 type: gateway.type ?? DEFAULT_TYPE,
250 ports: gateway.ports ?? [],
251 allPorts: gateway.allPorts === true,
252 addresses: gateway.addresses ?? [],
253 scope: gateway.scope,
254 serverTlsPolicy: gateway.serverTlsPolicy,
255 certificateUrls: gateway.certificateUrls ?? [],
256 gatewaySecurityPolicy: gateway.gatewaySecurityPolicy,
257 network: gateway.network,
258 subnetwork: gateway.subnetwork,
259 ipVersion: gateway.ipVersion,
260 envoyHeaders: gateway.envoyHeaders,
261 routingMode: gateway.routingMode,
262 allowGlobalAccess: gateway.allowGlobalAccess === true,
263 description: gateway.description,
264 labels: userLabels(gateway.labels),
265 createTime: gateway.createTime,
266 updateTime: gateway.updateTime,
267 };
268};
269
270const getByName = (name: string) =>
271 networkservices
272 .getProjectsLocationsGateways({ name })
273 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
274
275export const GatewayProvider = () =>
276 Provider.succeed(Gateway, {
277 stables: [
278 "name",
279 "gatewayId",
280 "project",
281 "location",
282 "selfLink",
283 "createTime",
284 ],
285
286 diff: Effect.fn(function* ({ news, olds, output }) {
287 if (!isResolved(news)) return undefined;
288 const previousId = olds?.gatewayId ?? output?.gatewayId;
289 const nextId = news.gatewayId
290 ? rfc1035(news.gatewayId, "gateway")
291 : previousId;
292 const previousLocation = normalizeLocation(
293 olds?.location ?? output?.location,
294 DEFAULT_GLOBAL,
295 );
296 const nextLocation = normalizeLocation(
297 news.location ?? olds?.location ?? output?.location,
298 DEFAULT_GLOBAL,
299 );
300 const previousType = olds?.type ?? output?.type ?? DEFAULT_TYPE;
301 const nextType = news.type ?? previousType;
302 const previousNetwork = olds?.network ?? output?.network ?? "";
303 const nextNetwork = news.network ?? previousNetwork;
304 const previousSubnetwork = olds?.subnetwork ?? output?.subnetwork ?? "";
305 const nextSubnetwork = news.subnetwork ?? previousSubnetwork;
306 const previousScope = olds?.scope ?? output?.scope ?? previousId ?? "";
307 const nextScope = news.scope ?? previousScope;
308 if (
309 (previousId !== undefined &&
310 nextId !== undefined &&
311 nextId !== previousId) ||
312 previousLocation !== nextLocation ||
313 previousType !== nextType ||
314 previousNetwork !== nextNetwork ||
315 previousSubnetwork !== nextSubnetwork ||
316 (previousScope.length > 0 && nextScope !== previousScope)
317 ) {
318 return { action: "replace" as const };
319 }
320 return undefined;
321 }),
322
323 read: Effect.fn(function* ({ id, olds, output }) {
324 const env = yield* GcpEnvironment.current;
325 const gatewayId = yield* toPhysicalId(
326 id,
327 olds?.gatewayId,
328 output?.gatewayId,
329 "gateway",
330 );
331 const location = normalizeLocation(
332 olds?.location ?? output?.location,
333 DEFAULT_GLOBAL,
334 );
335 const name =
336 output?.name ??
337 resourceName(env.project, location, COLLECTION, gatewayId);
338 const existing = yield* getByName(name);
339 if (existing === undefined) return undefined;
340 const attrs = toAttrs(existing, env.project);
341 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
342 ? attrs
343 : Unowned(attrs);
344 }),
345
346 list: () =>
347 Effect.gen(function* () {
348 const env = yield* GcpEnvironment.current;
349 const items = yield* collectPages(
350 networkservices.listProjectsLocationsGateways.pages({
351 parent: parentOf(env.project, "-"),
352 pageSize: 1000,
353 }),
354 (page) => page.gateways,
355 );
356 return items
357 .filter((item) => hasAlchemyLabelKeys(item.labels))
358 .map((item) => toAttrs(item, env.project));
359 }),
360
361 reconcile: Effect.fn(function* ({ id, news, output }) {
362 const env = yield* GcpEnvironment.current;
363 const gatewayId = yield* toPhysicalId(
364 id,
365 news.gatewayId,
366 output?.gatewayId,
367 "gateway",
368 );
369 const location = normalizeLocation(
370 news.location ?? output?.location,
371 DEFAULT_GLOBAL,
372 );
373 const name = resourceName(env.project, location, COLLECTION, gatewayId);
374 const desiredLabels = {
375 ...toLabels(news.labels),
376 ...(yield* createInternalLabels(id)),
377 };
378 const type = news.type ?? DEFAULT_TYPE;
379 const allPorts = news.allPorts === true;
380 const ports = allPorts ? undefined : (news.ports ?? DEFAULT_PORTS);
381 const allowGlobalAccess = news.allowGlobalAccess === true;
382 const scope = news.scope ?? gatewayId;
383
384 let current = yield* getByName(name);
385
386 if (current === undefined) {
387 const created = yield* networkservices
388 .createProjectsLocationsGateways({
389 parent: parentOf(env.project, location),
390 gatewayId,
391 body: {
392 type,
393 labels: desiredLabels,
394 description: news.description,
395 ports,
396 allPorts: allPorts ? true : undefined,
397 addresses: news.addresses,
398 scope,
399 serverTlsPolicy: news.serverTlsPolicy,
400 certificateUrls: news.certificateUrls,
401 gatewaySecurityPolicy: news.gatewaySecurityPolicy,
402 network: news.network,
403 subnetwork: news.subnetwork,
404 ipVersion: news.ipVersion,
405 envoyHeaders: news.envoyHeaders,
406 routingMode: news.routingMode,
407 allowGlobalAccess: allowGlobalAccess ? true : undefined,
408 },
409 })
410 .pipe(
411 Effect.retry({
412 while: (error) => error._tag === "Conflict",
413 times: 5,
414 schedule: Schedule.spaced("2 seconds"),
415 }),
416 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
417 );
418 if (created !== undefined) {
419 yield* waitForOperation(created);
420 }
421 current = yield* waitUntilPresent(getByName(name), name);
422 }
423
424 const observedLabels = tagRecord(current.labels);
425 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
426 const labelsChanged = upsert.length > 0 || removed.length > 0;
427 const descriptionChanged =
428 (current.description ?? "") !== (news.description ?? "");
429 const portsChanged = !sameNumberList(current.ports, ports ?? []);
430 const allPortsChanged = (current.allPorts === true) !== allPorts;
431 const addressesChanged = !sameStringList(
432 current.addresses,
433 news.addresses,
434 );
435 const scopeChanged = (current.scope ?? "") !== scope;
436 const serverTlsChanged =
437 (current.serverTlsPolicy ?? "") !== (news.serverTlsPolicy ?? "");
438 const certsChanged = !sameStringList(
439 current.certificateUrls,
440 news.certificateUrls,
441 );
442 const securityChanged =
443 (current.gatewaySecurityPolicy ?? "") !==
444 (news.gatewaySecurityPolicy ?? "");
445 const ipVersionChanged =
446 (current.ipVersion ?? "") !== (news.ipVersion ?? "");
447 const envoyChanged =
448 (current.envoyHeaders ?? "") !== (news.envoyHeaders ?? "");
449 const routingChanged =
450 (current.routingMode ?? "") !== (news.routingMode ?? "");
451 const globalAccessChanged =
452 (current.allowGlobalAccess === true) !== allowGlobalAccess;
453
454 const updateMask = changedFields([
455 ["labels", labelsChanged],
456 ["description", descriptionChanged],
457 ["ports", portsChanged],
458 ["allPorts", allPortsChanged],
459 ["addresses", addressesChanged],
460 ["scope", scopeChanged],
461 ["serverTlsPolicy", serverTlsChanged],
462 ["certificateUrls", certsChanged],
463 ["gatewaySecurityPolicy", securityChanged],
464 ["ipVersion", ipVersionChanged],
465 ["envoyHeaders", envoyChanged],
466 ["routingMode", routingChanged],
467 ["allowGlobalAccess", globalAccessChanged],
468 ]);
469
470 if (updateMask.length > 0) {
471 const operation = yield* networkservices.patchProjectsLocationsGateways(
472 {
473 name: current.name ?? name,
474 updateMask: updateMask.join(","),
475 body: {
476 name: current.name ?? name,
477 type,
478 labels: desiredLabels,
479 description: news.description,
480 ports,
481 allPorts: allPorts ? true : false,
482 addresses: news.addresses ?? [],
483 scope,
484 serverTlsPolicy: news.serverTlsPolicy,
485 certificateUrls: news.certificateUrls ?? [],
486 gatewaySecurityPolicy: news.gatewaySecurityPolicy,
487 ipVersion: news.ipVersion,
488 envoyHeaders: news.envoyHeaders,
489 routingMode: news.routingMode,
490 allowGlobalAccess,
491 },
492 },
493 );
494 yield* waitForOperation(operation);
495 current = yield* waitUntilPresent(
496 getByName(current.name ?? name),
497 current.name ?? name,
498 );
499 }
500
501 return toAttrs(current, env.project);
502 }),
503
504 delete: Effect.fn(function* ({ output }) {
505 const operation = yield* networkservices
506 .deleteProjectsLocationsGateways({ name: output.name })
507 .pipe(
508 Effect.retry({
509 while: (error) => error._tag === "Conflict",
510 times: 8,
511 schedule: Schedule.spaced("2 seconds"),
512 }),
513 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
514 );
515 if (operation !== undefined) {
516 yield* waitForOperation(operation, { notFoundOk: true });
517 }
518 yield* waitUntilGone(getByName(output.name), output.name);
519 }),
520 });