1import * as vpcaccess from "@distilled.cloud/gcp/vpcaccess_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
12import type { Providers } from "../Providers.ts";
13
14const DEFAULT_NETWORK = "default";
15const DEFAULT_MACHINE_TYPE = "e2-micro";
16const MAX_NAME_LENGTH = 25;
17
18export type ConnectorSubnet = {
19 /**
20 * Subnet name (relative, not a full URL). For
21 * `.../regions/{region}/subnetworks/{subnetName}` pass `{subnetName}`.
22 */
23 name: string;
24 /**
25 * Project that owns the subnet. Defaults to the connector's project.
26 */
27 projectId?: string;
28};
29
30export type ConnectorProps = {
31 /**
32 * Connector id (the `{connector}` segment of
33 * `projects/{project}/locations/{location}/connectors/{connector}`).
34 * If omitted, a unique RFC1035 name is generated from the stack, stage,
35 * and logical id. Must be 1-25 characters and match
36 * `[a-z]([-a-z0-9]*[a-z0-9])?`. Immutable — changing it replaces the
37 * connector.
38 */
39 connectorId?: string;
40 /**
41 * Region of the connector (`us-central1`, …). Immutable — changing it
42 * replaces the connector. `US-CENTRAL1` is accepted and normalized to
43 * `us-central1`.
44 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
45 */
46 location?: string;
47 /**
48 * VPC network name or URL to house the connector. Used with
49 * `ipCidrRange` to create an `aet-*` `/28` subnet. Ignored when
50 * `subnet` is set. Immutable — changing it replaces the connector.
51 * @default "default"
52 */
53 network?: string;
54 /**
55 * Unused `/28` CIDR (RFC 4632, e.g. `"10.8.0.0/28"`) used when the
56 * connector creates its own subnet in `network`. Required unless
57 * `subnet` is set. Immutable — changing it replaces the connector.
58 */
59 ipCidrRange?: string;
60 /**
61 * Existing subnet to house the connector (Shared VPC or a subnet you
62 * manage). Immutable — changing it replaces the connector. When set,
63 * `network` and `ipCidrRange` are not sent.
64 */
65 subnet?: ConnectorSubnet;
66 /**
67 * Machine type of the underlying VMs (`e2-micro`, `e2-standard-4`,
68 * `f1-micro`).
69 * @default "e2-micro"
70 */
71 machineType?: string;
72 /**
73 * Minimum instances in the connector's autoscaling group (2–9). Takes
74 * precedence over `minThroughput`. Prefer this over throughput.
75 */
76 minInstances?: number;
77 /**
78 * Maximum instances in the connector's autoscaling group (3–10). Takes
79 * precedence over `maxThroughput`. Prefer this over throughput.
80 */
81 maxInstances?: number;
82 /**
83 * Minimum throughput in Mbps for `e2-micro` (multiple of 100, 200–900).
84 * Discouraged — use `minInstances`.
85 */
86 minThroughput?: number;
87 /**
88 * Maximum throughput in Mbps for `e2-micro` (multiple of 100, 300–1000).
89 * Discouraged — use `maxInstances`.
90 */
91 maxThroughput?: number;
92};
93
94export type Connector = Resource<
95 "GCP.VpcAccess.Connector",
96 ConnectorProps,
97 {
98 /** Full resource name `projects/{project}/locations/{location}/connectors/{connector}`. */
99 name: string;
100 /** Connector id (last path segment). */
101 connectorId: string;
102 /** Project id. */
103 project: string;
104 /** Location id (`us-central1`). */
105 location: string;
106 /** VPC network name, if the connector created its own subnet. */
107 network: string | undefined;
108 /** `/28` CIDR used when the connector created its own subnet. */
109 ipCidrRange: string | undefined;
110 /** Existing subnet the connector is attached to, if any. */
111 subnet: ConnectorSubnet | undefined;
112 /** Machine type of the underlying VMs. */
113 machineType: string | undefined;
114 /** Minimum instances. */
115 minInstances: number | undefined;
116 /** Maximum instances. */
117 maxInstances: number | undefined;
118 /** Minimum throughput in Mbps. */
119 minThroughput: number | undefined;
120 /** Maximum throughput in Mbps. */
121 maxThroughput: number | undefined;
122 /** Server-reported state (`READY`, `CREATING`, …). */
123 state: string | undefined;
124 /** Projects currently using the connector. */
125 connectedProjects: ReadonlyArray<string>;
126 },
127 never,
128 Providers
129>;
130
131/**
132 * A Serverless VPC Access connector.
133 *
134 * Connectors let Cloud Run, Cloud Functions, and App Engine reach resources
135 * in a VPC. The API has no labels or description field — Alchemy treats
136 * existence at the computed name as ownership, and `list` returns every
137 * connector in the project so `pnpm nuke:gcp` can still clean leaks.
138 *
139 * `connectorId`, `location`, `network`, `ipCidrRange`, and `subnet` are
140 * immutable. `machineType` and min/max instances (or throughput) update
141 * in place.
142 *
143 * ### Creating a Connector
144 * **Example:** Generated name on the default VPC
145 * ```typescript
146 * const connector = yield* GCP.VpcAccess.Connector("Egress", {
147 * ipCidrRange: "10.8.0.0/28",
148 * minInstances: 2,
149 * maxInstances: 3,
150 * });
151 * ```
152 *
153 * **Example:** Explicit id, machine type, and scaling
154 * ```typescript
155 * const connector = yield* GCP.VpcAccess.Connector("Egress", {
156 * connectorId: "app-egress",
157 * location: "us-central1",
158 * network: "default",
159 * ipCidrRange: "10.8.0.0/28",
160 * machineType: "e2-micro",
161 * minInstances: 2,
162 * maxInstances: 4,
163 * });
164 * ```
165 *
166 * ### Existing subnet
167 * **Example:** Attach to a subnet you manage
168 * ```typescript
169 * const connector = yield* GCP.VpcAccess.Connector("Egress", {
170 * subnet: { name: subnet.subnetworkName },
171 * minInstances: 2,
172 * maxInstances: 3,
173 * });
174 * ```
175 *
176 * @resource
177 * @category VpcAccess
178 */
179export const Connector = Resource<Connector>("GCP.VpcAccess.Connector");
180
181export class ConnectorNotResolved extends Data.TaggedError(
182 "GCP.VpcAccess.ConnectorNotResolved",
183)<{
184 name: string;
185}> {}
186
187export class ConnectorRangeMissing extends Data.TaggedError(
188 "GCP.VpcAccess.ConnectorRangeMissing",
189)<{
190 name: string;
191 message: string;
192}> {}
193
194export class ConnectorFailed extends Data.TaggedError(
195 "GCP.VpcAccess.ConnectorFailed",
196)<{
197 name: string;
198 state: string | undefined;
199}> {}
200
201export class ConnectorNotReady extends Data.TaggedError(
202 "GCP.VpcAccess.ConnectorNotReady",
203)<{
204 name: string;
205 state: string | undefined;
206}> {}
207
208export class ConnectorStillExists extends Data.TaggedError(
209 "GCP.VpcAccess.ConnectorStillExists",
210)<{
211 name: string;
212}> {}
213
214const lastSegment = (value: string) => {
215 const trimmed = value.replace(/\/+$/, "");
216 const parts = trimmed.split("/");
217 return parts[parts.length - 1] || trimmed;
218};
219
220const normalizeLocation = (
221 location: string | undefined,
222 defaultLocation: string,
223) => lastSegment(location ?? defaultLocation).toLowerCase();
224
225const linkKey = (value: string | undefined) =>
226 value === undefined || value === "" ? "" : lastSegment(value).toLowerCase();
227
228const subnetKey = (subnet: ConnectorSubnet | undefined) => {
229 if (subnet === undefined) return "";
230 return `${linkKey(subnet.name)}:${(subnet.projectId ?? "").toLowerCase()}`;
231};
232
233const resourceName = (project: string, location: string, connectorId: string) =>
234 `projects/${project}/locations/${location}/connectors/${connectorId}`;
235
236const parentOf = (project: string, location: string) =>
237 `projects/${project}/locations/${location}`;
238
239const parseName = (name: string, defaultLocation: string) => {
240 const parts = name.split("/").filter((part) => part.length > 0);
241 const connectorsAt = parts.lastIndexOf("connectors");
242 const locationsAt = parts.lastIndexOf("locations");
243 const projectsAt = parts.lastIndexOf("projects");
244 return {
245 project:
246 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
247 location:
248 locationsAt >= 0 && parts[locationsAt + 1]
249 ? parts[locationsAt + 1]!
250 : defaultLocation,
251 connectorId:
252 connectorsAt >= 0 && parts[connectorsAt + 1]
253 ? parts[connectorsAt + 1]!
254 : lastSegment(name),
255 };
256};
257
258const toSubnet = (
259 subnet: vpcaccess.Subnet | undefined,
260): ConnectorSubnet | undefined => {
261 if (subnet === undefined) return undefined;
262 const name = subnet.name ? lastSegment(subnet.name) : undefined;
263 if (name === undefined || name.length === 0) {
264 return subnet.projectId
265 ? { name: "", projectId: subnet.projectId }
266 : undefined;
267 }
268 return subnet.projectId ? { name, projectId: subnet.projectId } : { name };
269};
270
271const rfc1035 = (name: string): string => {
272 let next = name
273 .toLowerCase()
274 .replace(/[^a-z0-9-]/g, "-")
275 .replace(/-+/g, "-")
276 .replace(/^-+|-+$/g, "");
277 if (!/^[a-z]/.test(next)) next = `c${next}`;
278 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
279 if (next.length === 0) return "connector";
280 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
281 return next.slice(0, MAX_NAME_LENGTH);
282};
283
284const toId = (id: string, connectorId: string | undefined, existing?: string) =>
285 Effect.gen(function* () {
286 if (connectorId !== undefined) return connectorId;
287 if (existing !== undefined) return existing;
288 return rfc1035(
289 yield* createPhysicalName({
290 id,
291 maxLength: MAX_NAME_LENGTH,
292 lowercase: true,
293 }),
294 );
295 });
296
297const toAttrs = (
298 connector: vpcaccess.Connector,
299 project: string,
300 region: string,
301) => {
302 const name = connector.name ?? "";
303 const parsed = parseName(name, region);
304 const network = connector.network
305 ? lastSegment(connector.network)
306 : undefined;
307 return {
308 name,
309 connectorId: parsed.connectorId,
310 project: parsed.project || project,
311 location: parsed.location,
312 network,
313 ipCidrRange: connector.ipCidrRange,
314 subnet: toSubnet(connector.subnet),
315 machineType: connector.machineType,
316 minInstances: connector.minInstances,
317 maxInstances: connector.maxInstances,
318 minThroughput: connector.minThroughput,
319 maxThroughput: connector.maxThroughput,
320 state: connector.state,
321 connectedProjects: connector.connectedProjects ?? [],
322 };
323};
324
325const getByName = (name: string) =>
326 vpcaccess
327 .getProjectsLocationsConnectors({ name })
328 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
329
330// Connector create/delete takes 2-5 minutes.
331const waitForOperation = (operation: vpcaccess.Operation) =>
332 waitForGcpOperation(
333 operation,
334 (name) => vpcaccess.getProjectsLocationsOperations({ name }),
335 { budget: "10 minutes" },
336 );
337
338const waitUntilReady = (name: string) => {
339 const ready: Effect.Effect<
340 vpcaccess.Connector,
341 | ConnectorNotResolved
342 | ConnectorFailed
343 | ConnectorNotReady
344 | vpcaccess.GetProjectsLocationsConnectorsError,
345 vpcaccess.GcpOpContext
346 > = getByName(name).pipe(
347 Effect.filterOrFail(
348 (connector): connector is vpcaccess.Connector => connector !== undefined,
349 () => new ConnectorNotResolved({ name }),
350 ),
351 Effect.filterOrFail(
352 (connector) => connector.state !== "ERROR",
353 (connector) => new ConnectorFailed({ name, state: connector.state }),
354 ),
355 Effect.filterOrFail(
356 (connector) =>
357 connector.state === "READY" || connector.state === undefined,
358 (connector) => new ConnectorNotReady({ name, state: connector.state }),
359 ),
360 );
361 return ready.pipe(
362 Effect.retry({
363 while: (error) =>
364 error._tag === "GCP.VpcAccess.ConnectorNotResolved" ||
365 error._tag === "GCP.VpcAccess.ConnectorNotReady",
366 times: 10,
367 schedule: Schedule.spaced("8 seconds"),
368 }),
369 );
370};
371
372const waitUntilGone = (name: string) =>
373 getByName(name).pipe(
374 Effect.flatMap((connector) =>
375 connector === undefined
376 ? Effect.void
377 : Effect.fail(new ConnectorStillExists({ name })),
378 ),
379 Effect.retry({
380 while: (error) => error._tag === "GCP.VpcAccess.ConnectorStillExists",
381 times: 10,
382 schedule: Schedule.spaced("20 seconds"),
383 }),
384 );
385
386// A location without the API answers NotFound: nothing lives there.
387const listAt = (parent: string) =>
388 vpcaccess.listProjectsLocationsConnectors
389 .pages({ parent, pageSize: 100 })
390 .pipe(
391 Stream.flatMap((page) => Stream.fromIterable(page.connectors ?? [])),
392 Stream.runCollect,
393 Effect.map((chunk) => Array.from(chunk)),
394 Effect.catchTag("NotFound", () =>
395 Effect.succeed([] as vpcaccess.Connector[]),
396 ),
397 );
398
399const listLocations = (project: string) =>
400 vpcaccess.listProjectsLocations
401 .pages({ name: `projects/${project}`, pageSize: 100 })
402 .pipe(
403 Stream.flatMap((page) => Stream.fromIterable(page.locations ?? [])),
404 Stream.map((location) => location.name ?? ""),
405 Stream.filter((name) => name.length > 0),
406 Stream.runCollect,
407 Effect.map((chunk) => Array.from(chunk)),
408 );
409
410const createBody = (news: ConnectorProps): vpcaccess.Connector => {
411 const body: vpcaccess.Connector = {};
412 if (news.subnet !== undefined) {
413 body.subnet = {
414 name: lastSegment(news.subnet.name),
415 projectId: news.subnet.projectId,
416 };
417 } else {
418 if (news.network !== undefined || news.ipCidrRange !== undefined) {
419 body.network = news.network ? lastSegment(news.network) : DEFAULT_NETWORK;
420 }
421 if (news.ipCidrRange !== undefined) {
422 body.ipCidrRange = news.ipCidrRange;
423 }
424 }
425 if (news.machineType !== undefined) {
426 body.machineType = news.machineType;
427 }
428 if (news.minInstances !== undefined) {
429 body.minInstances = news.minInstances;
430 }
431 if (news.maxInstances !== undefined) {
432 body.maxInstances = news.maxInstances;
433 }
434 if (news.minThroughput !== undefined) {
435 body.minThroughput = news.minThroughput;
436 }
437 if (news.maxThroughput !== undefined) {
438 body.maxThroughput = news.maxThroughput;
439 }
440 return body;
441};
442
443export const ConnectorProvider = () =>
444 Provider.succeed(Connector, {
445 stables: [
446 "name",
447 "connectorId",
448 "project",
449 "location",
450 "network",
451 "ipCidrRange",
452 "subnet",
453 ],
454
455 diff: Effect.fn(function* ({ news, olds, output }) {
456 if (!isResolved(news)) return undefined;
457 const env = yield* GcpEnvironment.current;
458
459 const previousId = olds?.connectorId ?? output?.connectorId;
460 const nextId = news.connectorId ?? previousId;
461 const previousLocation = normalizeLocation(
462 olds?.location ?? output?.location,
463 env.region,
464 );
465 const nextLocation = normalizeLocation(
466 news.location ?? output?.location,
467 env.region,
468 );
469 const idChanged =
470 previousId !== undefined &&
471 nextId !== undefined &&
472 nextId !== previousId;
473 const locationChanged = previousLocation !== nextLocation;
474
475 const previousNetwork = linkKey(olds?.network ?? output?.network);
476 const nextNetwork =
477 news.network !== undefined ? linkKey(news.network) : previousNetwork;
478 const networkChanged =
479 news.network !== undefined && nextNetwork !== previousNetwork;
480
481 const previousCidr = olds?.ipCidrRange ?? output?.ipCidrRange ?? "";
482 const cidrChanged =
483 news.ipCidrRange !== undefined && news.ipCidrRange !== previousCidr;
484
485 const previousSubnet = subnetKey(olds?.subnet ?? output?.subnet);
486 const nextSubnet =
487 news.subnet !== undefined ? subnetKey(news.subnet) : previousSubnet;
488 const subnetChanged =
489 news.subnet !== undefined && nextSubnet !== previousSubnet;
490
491 if (
492 !idChanged &&
493 !locationChanged &&
494 !networkChanged &&
495 !cidrChanged &&
496 !subnetChanged
497 ) {
498 return undefined;
499 }
500 return {
501 action: "replace" as const,
502 deleteFirst: !idChanged && !locationChanged,
503 };
504 }),
505
506 read: Effect.fn(function* ({ id, olds, output }) {
507 const env = yield* GcpEnvironment.current;
508 const connectorId = yield* toId(
509 id,
510 olds?.connectorId,
511 output?.connectorId,
512 );
513 const location = normalizeLocation(
514 olds?.location ?? output?.location,
515 env.region,
516 );
517 const name =
518 output?.name ?? resourceName(env.project, location, connectorId);
519 const existing = yield* getByName(name);
520 if (existing === undefined) return undefined;
521 // Connectors have no labels or description. Existence at the computed
522 // name is ownership — the same approach Cloud KMS KeyRing uses.
523 return toAttrs(existing, env.project, env.region);
524 }),
525
526 list: () =>
527 Effect.gen(function* () {
528 const env = yield* GcpEnvironment.current;
529 const parents = yield* listLocations(env.project);
530 if (parents.length === 0) {
531 parents.push(parentOf(env.project, env.region));
532 }
533 const pages = yield* Effect.forEach(
534 parents,
535 (parent) => listAt(parent),
536 {
537 concurrency: 4,
538 },
539 );
540 return pages
541 .flat()
542 .map((connector) => toAttrs(connector, env.project, env.region));
543 }),
544
545 reconcile: Effect.fn(function* ({ id, news, output }) {
546 const env = yield* GcpEnvironment.current;
547 const connectorId = yield* toId(
548 id,
549 news.connectorId,
550 output?.connectorId,
551 );
552 const location = normalizeLocation(
553 news.location ?? output?.location,
554 env.region,
555 );
556 const name = resourceName(env.project, location, connectorId);
557
558 if (news.subnet === undefined && news.ipCidrRange === undefined) {
559 return yield* new ConnectorRangeMissing({
560 name,
561 message:
562 "VPC Access connectors require `ipCidrRange` (with optional `network`) or `subnet`.",
563 });
564 }
565
566 let current = yield* getByName(name);
567
568 if (current !== undefined && current.state === "DELETING") {
569 yield* waitUntilGone(name);
570 current = undefined;
571 }
572
573 if (current !== undefined && current.state === "ERROR") {
574 return yield* new ConnectorFailed({
575 name,
576 state: current.state,
577 });
578 }
579
580 if (current === undefined) {
581 const created = yield* vpcaccess
582 .createProjectsLocationsConnectors({
583 parent: parentOf(env.project, location),
584 connectorId,
585 body: createBody(news),
586 })
587 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
588 if (created !== undefined) {
589 yield* waitForOperation(created);
590 } else {
591 const raced = yield* getByName(name);
592 if (raced === undefined) {
593 return yield* new ConnectorNotResolved({ name });
594 }
595 }
596 current = yield* waitUntilReady(name);
597 } else if (current.state === "CREATING" || current.state === "UPDATING") {
598 current = yield* waitUntilReady(name);
599 }
600
601 if (current === undefined) {
602 return yield* new ConnectorNotResolved({ name });
603 }
604
605 const desiredMachine = news.machineType ?? DEFAULT_MACHINE_TYPE;
606 const machineChanged =
607 news.machineType !== undefined &&
608 (current.machineType ?? DEFAULT_MACHINE_TYPE) !== desiredMachine;
609 const minChanged =
610 news.minInstances !== undefined &&
611 news.minInstances !== current.minInstances;
612 const maxChanged =
613 news.maxInstances !== undefined &&
614 news.maxInstances !== current.maxInstances;
615 const minThroughputChanged =
616 news.minThroughput !== undefined &&
617 news.minThroughput !== current.minThroughput;
618 const maxThroughputChanged =
619 news.maxThroughput !== undefined &&
620 news.maxThroughput !== current.maxThroughput;
621 const instancesChanged = minChanged || maxChanged;
622
623 if (
624 machineChanged ||
625 instancesChanged ||
626 minThroughputChanged ||
627 maxThroughputChanged
628 ) {
629 const updateMask = [
630 machineChanged ? "machineType" : undefined,
631 instancesChanged ? "minInstances" : undefined,
632 instancesChanged ? "maxInstances" : undefined,
633 minThroughputChanged ? "minThroughput" : undefined,
634 maxThroughputChanged ? "maxThroughput" : undefined,
635 ].filter((field): field is string => field !== undefined);
636
637 const operation = yield* vpcaccess.patchProjectsLocationsConnectors({
638 name,
639 updateMask: updateMask.join(","),
640 body: {
641 name,
642 machineType: news.machineType ?? current.machineType,
643 minInstances: news.minInstances ?? current.minInstances,
644 maxInstances: news.maxInstances ?? current.maxInstances,
645 minThroughput: news.minThroughput ?? current.minThroughput,
646 maxThroughput: news.maxThroughput ?? current.maxThroughput,
647 },
648 });
649 yield* waitForOperation(operation);
650 current = yield* waitUntilReady(name);
651 }
652
653 return toAttrs(current, env.project, env.region);
654 }),
655
656 delete: Effect.fn(function* ({ output }) {
657 const operation = yield* vpcaccess
658 .deleteProjectsLocationsConnectors({ name: output.name })
659 .pipe(
660 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
661 Effect.retry({
662 while: (error) => error._tag === "Conflict",
663 times: 8,
664 schedule: Schedule.spaced("8 seconds"),
665 }),
666 );
667 if (operation !== undefined) {
668 // NOT_FOUND (5): the connector was already gone.
669 yield* waitForOperation(operation).pipe(
670 Effect.catchTag("GCP.OperationFailed", (error) =>
671 error.code === 5 ? Effect.void : Effect.fail(error),
672 ),
673 );
674 }
675 yield* waitUntilGone(output.name);
676 }),
677 });