1import * as networkconnectivity from "@distilled.cloud/gcp/networkconnectivity_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 NetworkConnectivityNotResolved,
14 canonicalizeLink,
15 collectPages,
16 hasAlchemyLabelKeys,
17 lastSegment,
18 normalizeLocation,
19 parentOf,
20 parseName,
21 rfc1035,
22 toNetworkResource,
23 toPhysicalId,
24 toSubnetworkResource,
25 userLabels,
26 waitForOperation,
27 waitUntilGone,
28 waitUntilPresent,
29} from "./internal.ts";
30
31const COLLECTION = "regionalEndpoints";
32const MAX_ID_LENGTH = 46;
33
34export type RegionalEndpointAccessType =
35 | networkconnectivity.RegionalEndpointAccessTypeEnum
36 | (string & {});
37
38export type RegionalEndpointProps = {
39 /**
40 * RegionalEndpoint id (the `{regional_endpoint}` segment of
41 * `projects/{project}/locations/{location}/regionalEndpoints/{regional_endpoint}`).
42 * If omitted, a unique name is generated. Must match
43 * `[-a-z0-9]([-a-z0-9]{0,44})[a-z0-9]`. Immutable — changing it
44 * replaces the endpoint.
45 */
46 regionalEndpointId?: string;
47 /**
48 * Location (`us-central1`, …). Immutable — changing it replaces the
49 * endpoint. `US-CENTRAL1` is accepted and normalized to `us-central1`.
50 * @default the stack's GCP region (`GCP.Region`, else the profile
51 * region, else `us-central1`)
52 */
53 location?: string;
54 /**
55 * Service endpoint this private regional endpoint connects to, e.g.
56 * `"storage.us-central1.p.rep.googleapis.com"`. Immutable — changing
57 * it replaces the endpoint.
58 */
59 targetGoogleApi: string;
60 /**
61 * Access type reflected on the PSC forwarding rule (`REGIONAL` or
62 * `GLOBAL`). Immutable — changing it replaces the endpoint.
63 */
64 accessType: RegionalEndpointAccessType;
65 /**
66 * VPC network
67 * (`projects/{project}/global/networks/{network}` or a Compute
68 * self-link). Immutable — changing it replaces the endpoint.
69 */
70 network?: string;
71 /**
72 * Subnetwork used to allocate the IP
73 * (`projects/{project}/regions/{region}/subnetworks/{subnetwork}` or a
74 * Compute self-link). Immutable — changing it replaces the endpoint.
75 */
76 subnetwork?: string;
77 /**
78 * IP address or Address resource URI. When omitted, an IP is allocated
79 * from `subnetwork`. Immutable — changing it replaces the endpoint.
80 */
81 address?: string;
82 /**
83 * Human-readable description. Set at create; the API has no update.
84 */
85 description?: string;
86 /**
87 * User labels. Alchemy ownership labels are merged in automatically.
88 */
89 labels?: Record<string, string>;
90};
91
92export type RegionalEndpoint = Resource<
93 "GCP.NetworkConnectivity.RegionalEndpoint",
94 RegionalEndpointProps,
95 {
96 /** Full resource name `projects/{project}/locations/{location}/regionalEndpoints/{regional_endpoint}`. */
97 name: string;
98 /** RegionalEndpoint id (last path segment). */
99 regionalEndpointId: string;
100 /** Project id. */
101 project: string;
102 /** Location id. */
103 location: string;
104 /** Target Google API hostname. */
105 targetGoogleApi: string | undefined;
106 /** Access type (`REGIONAL` or `GLOBAL`). */
107 accessType: string | undefined;
108 /** VPC network resource path. */
109 network: string | undefined;
110 /** Subnetwork resource path. */
111 subnetwork: string | undefined;
112 /** Allocated address or Address resource URI. */
113 address: string | undefined;
114 /** Literal IP of the PSC forwarding rule (deprecated; prefer `address`). */
115 ipAddress: string | undefined;
116 /** PSC forwarding rule created on behalf of the customer. */
117 pscForwardingRule: string | undefined;
118 /** User-provided description. */
119 description: string | undefined;
120 /** User labels (Alchemy ownership labels stripped). */
121 labels: Record<string, string>;
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 Private Service Connect regional endpoint for a Google API.
133 *
134 * Identity fields (`regionalEndpointId`, `location`, `targetGoogleApi`,
135 * `accessType`, `network`, `subnetwork`, `address`) replace the
136 * endpoint. The API has no patch method.
137 *
138 * ### Creating a RegionalEndpoint
139 * **Example:** Regional access to Cloud Storage
140 * ```typescript
141 * const network = yield* GCP.Compute.Network("AppVpc", {
142 * autoCreateSubnetworks: false,
143 * });
144 * const subnet = yield* GCP.Compute.Subnetwork("AppSubnet", {
145 * network: network.selfLink ?? network.networkName,
146 * ipCidrRange: "10.20.0.0/24",
147 * privateIpGoogleAccess: true,
148 * });
149 * const endpoint = yield* GCP.NetworkConnectivity.RegionalEndpoint("Storage", {
150 * targetGoogleApi: "storage.us-central1.p.rep.googleapis.com",
151 * accessType: "REGIONAL",
152 * network: network.selfLink ?? network.networkName,
153 * subnetwork: subnet.selfLink ?? subnet.subnetworkName,
154 * labels: { env: "prod" },
155 * });
156 * ```
157 *
158 * @resource
159 * @category NetworkConnectivity
160 */
161export const RegionalEndpoint = Resource<RegionalEndpoint>(
162 "GCP.NetworkConnectivity.RegionalEndpoint",
163);
164
165const resourceName = (
166 project: string,
167 location: string,
168 regionalEndpointId: string,
169) =>
170 `projects/${project}/locations/${location}/regionalEndpoints/${regionalEndpointId}`;
171
172const identityKey = (props: {
173 targetGoogleApi?: string;
174 accessType?: string;
175 network?: string;
176 subnetwork?: string;
177 address?: string;
178}) =>
179 JSON.stringify({
180 targetGoogleApi: (props.targetGoogleApi ?? "").toLowerCase(),
181 accessType: (props.accessType ?? "").toUpperCase(),
182 network: lastSegment(canonicalizeLink(props.network)),
183 subnetwork: lastSegment(canonicalizeLink(props.subnetwork)),
184 address: canonicalizeLink(props.address),
185 });
186
187const toAttrs = (
188 endpoint: networkconnectivity.RegionalEndpoint,
189 project: string,
190 region: string,
191) => {
192 const name = endpoint.name ?? "";
193 const parsed = parseName(name, COLLECTION, region);
194 return {
195 name,
196 regionalEndpointId: parsed.id,
197 project: parsed.project || project,
198 location: parsed.location || region,
199 targetGoogleApi: endpoint.targetGoogleApi,
200 accessType: endpoint.accessType,
201 network: endpoint.network,
202 subnetwork: endpoint.subnetwork,
203 address: endpoint.address,
204 ipAddress: endpoint.ipAddress,
205 pscForwardingRule: endpoint.pscForwardingRule,
206 description: endpoint.description,
207 labels: userLabels(endpoint.labels),
208 createTime: endpoint.createTime,
209 updateTime: endpoint.updateTime,
210 };
211};
212
213const getByName = (name: string) =>
214 networkconnectivity
215 .getProjectsLocationsRegionalEndpoints({ name })
216 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
217
218export const RegionalEndpointProvider = () =>
219 Provider.succeed(RegionalEndpoint, {
220 stables: [
221 "name",
222 "regionalEndpointId",
223 "project",
224 "location",
225 "targetGoogleApi",
226 "accessType",
227 "createTime",
228 ],
229
230 diff: Effect.fn(function* ({ news, olds, output }) {
231 if (!isResolved(news)) return undefined;
232 const env = yield* GcpEnvironment.current;
233 const previousId = olds?.regionalEndpointId ?? output?.regionalEndpointId;
234 const nextId = news.regionalEndpointId
235 ? rfc1035(news.regionalEndpointId, "regional-endpoint", MAX_ID_LENGTH)
236 : previousId;
237 const previousLocation = normalizeLocation(
238 olds?.location ?? output?.location,
239 env.region,
240 );
241 const nextLocation = normalizeLocation(
242 news.location ?? olds?.location ?? output?.location,
243 env.region,
244 );
245 const previousKey = identityKey({
246 targetGoogleApi: olds?.targetGoogleApi ?? output?.targetGoogleApi,
247 accessType: olds?.accessType ?? output?.accessType,
248 network: olds?.network ?? output?.network,
249 subnetwork: olds?.subnetwork ?? output?.subnetwork,
250 address: olds?.address ?? output?.address,
251 });
252 const nextKey = identityKey({
253 targetGoogleApi: news.targetGoogleApi,
254 accessType: news.accessType,
255 network: news.network,
256 subnetwork: news.subnetwork,
257 address: news.address,
258 });
259 if (
260 (previousId !== undefined &&
261 nextId !== undefined &&
262 nextId !== previousId) ||
263 previousLocation !== nextLocation ||
264 previousKey !== nextKey
265 ) {
266 return { action: "replace" as const };
267 }
268 return undefined;
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const env = yield* GcpEnvironment.current;
273 const regionalEndpointId = yield* toPhysicalId(
274 id,
275 olds?.regionalEndpointId,
276 output?.regionalEndpointId,
277 "regional-endpoint",
278 MAX_ID_LENGTH,
279 );
280 const location = normalizeLocation(
281 olds?.location ?? output?.location,
282 env.region,
283 );
284 const name =
285 output?.name ?? resourceName(env.project, location, regionalEndpointId);
286 const existing = yield* getByName(name);
287 if (existing === undefined) return undefined;
288 const attrs = toAttrs(existing, env.project, env.region);
289 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
290 ? attrs
291 : Unowned(attrs);
292 }),
293
294 list: () =>
295 Effect.gen(function* () {
296 const env = yield* GcpEnvironment.current;
297 const items = yield* collectPages(
298 networkconnectivity.listProjectsLocationsRegionalEndpoints.pages({
299 parent: parentOf(env.project, "-"),
300 pageSize: 1000,
301 }),
302 (page) => page.regionalEndpoints,
303 );
304 return items
305 .filter((item) => hasAlchemyLabelKeys(item.labels))
306 .map((item) => toAttrs(item, env.project, env.region));
307 }),
308
309 reconcile: Effect.fn(function* ({ id, news, output }) {
310 const env = yield* GcpEnvironment.current;
311 const regionalEndpointId = yield* toPhysicalId(
312 id,
313 news.regionalEndpointId,
314 output?.regionalEndpointId,
315 "regional-endpoint",
316 MAX_ID_LENGTH,
317 );
318 const location = normalizeLocation(
319 news.location ?? output?.location,
320 env.region,
321 );
322 const name = resourceName(env.project, location, regionalEndpointId);
323 const desiredLabels = {
324 ...toLabels(news.labels),
325 ...(yield* createInternalLabels(id)),
326 };
327 const network =
328 news.network !== undefined
329 ? toNetworkResource(env.project, news.network)
330 : undefined;
331 const subnetwork =
332 news.subnetwork !== undefined
333 ? toSubnetworkResource(env.project, location, news.subnetwork)
334 : undefined;
335
336 let current = yield* getByName(name);
337
338 if (current === undefined) {
339 const created = yield* networkconnectivity
340 .createProjectsLocationsRegionalEndpoints({
341 parent: parentOf(env.project, location),
342 regionalEndpointId,
343 body: {
344 targetGoogleApi: news.targetGoogleApi,
345 accessType: news.accessType,
346 network,
347 subnetwork,
348 address: news.address,
349 description: news.description,
350 labels: desiredLabels,
351 },
352 })
353 .pipe(
354 Effect.retry({
355 while: (error) => error._tag === "Conflict",
356 times: 5,
357 schedule: Schedule.spaced("2 seconds"),
358 }),
359 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
360 );
361 if (created !== undefined) {
362 yield* waitForOperation(created);
363 }
364 current = yield* waitUntilPresent(getByName(name), name);
365 }
366
367 if (current === undefined) {
368 return yield* new NetworkConnectivityNotResolved({ name });
369 }
370
371 current = yield* getByName(current.name ?? name).pipe(
372 Effect.filterOrFail(
373 (value): value is networkconnectivity.RegionalEndpoint =>
374 value !== undefined &&
375 (value.pscForwardingRule !== undefined ||
376 value.address !== undefined ||
377 value.ipAddress !== undefined),
378 () => new NetworkConnectivityNotResolved({ name }),
379 ),
380 Effect.retry({
381 while: (error) =>
382 error._tag === "GCP.NetworkConnectivity.NotResolved",
383 times: 10,
384 schedule: Schedule.spaced("4 seconds"),
385 }),
386 );
387
388 return toAttrs(current, env.project, env.region);
389 }),
390
391 delete: Effect.fn(function* ({ output }) {
392 const operation = yield* networkconnectivity
393 .deleteProjectsLocationsRegionalEndpoints({ name: output.name })
394 .pipe(
395 Effect.retry({
396 while: (error) => error._tag === "Conflict",
397 times: 8,
398 schedule: Schedule.spaced("2 seconds"),
399 }),
400 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
401 );
402 if (operation !== undefined) {
403 yield* waitForOperation(operation, { notFoundOk: true });
404 }
405 yield* waitUntilGone(getByName(output.name), output.name);
406 }),
407 });