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 {
11 createInternalLabels,
12 diffLabels,
13 hasAlchemyLabels,
14 toLabels,
15} from "../Labels.ts";
16import type { Providers } from "../Providers.ts";
17import {
18 NetworkConnectivityNotResolved,
19 changedFields,
20 collectPages,
21 hasAlchemyLabelKeys,
22 normalizeLocation,
23 parentOf,
24 parseName,
25 rfc1035,
26 sameJson,
27 toNetworkResource,
28 toPhysicalId,
29 userLabels,
30 waitForOperation,
31 waitUntilGone,
32 waitUntilPresent,
33} from "./internal.ts";
34
35const COLLECTION = "serviceConnectionMaps";
36
37export type AutomatedDnsCreationSpec = {
38 /** DNS suffix with trailing dot, e.g. `"internal."`. */
39 dnsSuffix?: string;
40 /** Hostname (first label of the FQDN). */
41 hostname?: string;
42 /** TTL as a duration string, e.g. `"30s"`. */
43 ttl?: string;
44};
45
46export type ProducerPscConfig = {
47 /** Service attachment URI. */
48 serviceAttachmentUri?: string;
49 /** Optional spec for automatically creating a DNS record. */
50 automatedDnsCreationSpec?: AutomatedDnsCreationSpec;
51};
52
53export type ConsumerPscConfig = {
54 /** Consumer project id or number. */
55 project?: string;
56 /** Consumer instance project path (`projects/{project}`). */
57 consumerInstanceProject?: string;
58 /** Consumer VPC (`projects/{project}/global/networks/{network}`). */
59 network?: string;
60 /** Disable global access on the PSC endpoint. */
61 disableGlobalAccess?: boolean;
62 /** Requested IP version (`IPV4` or `IPV6`). */
63 ipVersion?: string;
64 /** Static IP map from VIP to service attachment. */
65 serviceAttachmentIpAddressMap?: Record<string, string>;
66 /** Producer instance metadata. Immutable on the producer. */
67 producerInstanceMetadata?: Record<string, string>;
68 /** Deprecated producer instance id. */
69 producerInstanceId?: string;
70 /** Output-only overall state. */
71 state?: string;
72};
73
74export type ServiceConnectionMapProps = {
75 /**
76 * Map id (the `{service_connection_map}` segment). If omitted, a unique
77 * name is generated. Immutable — changing it replaces the map.
78 */
79 serviceConnectionMapId?: string;
80 /**
81 * Location (`us-central1`, …). Immutable — changing it replaces the
82 * map. `US-CENTRAL1` is accepted and normalized to `us-central1`.
83 * @default the stack's GCP region (`GCP.Region`, else the profile
84 * region, else `us-central1`)
85 */
86 location?: string;
87 /**
88 * Service class identifier this map is for. Callers need
89 * `networkconnectivity.serviceClasses.use`.
90 */
91 serviceClass?: string;
92 /**
93 * Consumer-provided token that authenticates PSC connection creation.
94 */
95 token?: string;
96 /**
97 * Producer-side PSC configurations (service attachments).
98 */
99 producerPscConfigs?: ProducerPscConfig[];
100 /**
101 * Consumer-side PSC configurations (allowed networks/projects).
102 */
103 consumerPscConfigs?: ConsumerPscConfig[];
104 /**
105 * Human-readable description.
106 */
107 description?: string;
108 /**
109 * User labels. Alchemy ownership labels are merged in automatically.
110 */
111 labels?: Record<string, string>;
112};
113
114export type ServiceConnectionMap = Resource<
115 "GCP.NetworkConnectivity.ServiceConnectionMap",
116 ServiceConnectionMapProps,
117 {
118 /** Full resource name `projects/{project}/locations/{location}/serviceConnectionMaps/{id}`. */
119 name: string;
120 /** Map id (last path segment). */
121 serviceConnectionMapId: string;
122 /** Project id. */
123 project: string;
124 /** Location id. */
125 location: string;
126 /** Service class identifier. */
127 serviceClass: string | undefined;
128 /** Service class URI (output-only). */
129 serviceClassUri: string | undefined;
130 /** Consumer-provided token. */
131 token: string | undefined;
132 /** Producer PSC configs. */
133 producerPscConfigs: ProducerPscConfig[];
134 /** Consumer PSC configs. */
135 consumerPscConfigs: ConsumerPscConfig[];
136 /** Infrastructure used for connections (`PSC`, …). */
137 infrastructure: string | undefined;
138 /** User-provided description. */
139 description: string | undefined;
140 /** User labels (Alchemy ownership labels stripped). */
141 labels: Record<string, string>;
142 /** Server etag. */
143 etag: string | undefined;
144 /** RFC3339 creation timestamp. */
145 createTime: string | undefined;
146 /** RFC3339 last-update timestamp. */
147 updateTime: string | undefined;
148 },
149 never,
150 Providers
151>;
152
153/**
154 * A PSC Service Connection Map that pairs producer service attachments
155 * with consumer networks for Cross-Cloud / producer-consumer
156 * connectivity.
157 *
158 * Changing `serviceConnectionMapId` or `location` replaces the map.
159 * Description, labels, token, and PSC configs update in place.
160 *
161 * ### Creating a ServiceConnectionMap
162 * **Example:** Producer map for a service class
163 * ```typescript
164 * const map = yield* GCP.NetworkConnectivity.ServiceConnectionMap("Sql", {
165 * serviceClass: "gcp-cloud-sql",
166 * description: "sql psc map",
167 * labels: { env: "prod" },
168 * });
169 * ```
170 *
171 * ### Updating a ServiceConnectionMap
172 * **Example:** Description and labels
173 * ```typescript
174 * const map = yield* GCP.NetworkConnectivity.ServiceConnectionMap("Sql", {
175 * serviceClass: "gcp-cloud-sql",
176 * description: "sql psc map v2",
177 * labels: { env: "prod", role: "psc" },
178 * });
179 * ```
180 *
181 * @resource
182 * @category NetworkConnectivity
183 */
184export const ServiceConnectionMap = Resource<ServiceConnectionMap>(
185 "GCP.NetworkConnectivity.ServiceConnectionMap",
186);
187
188const resourceName = (
189 project: string,
190 location: string,
191 serviceConnectionMapId: string,
192) =>
193 `projects/${project}/locations/${location}/serviceConnectionMaps/${serviceConnectionMapId}`;
194
195const toStringMap = (
196 value: Record<string, string | undefined> | null | undefined,
197): Record<string, string> | undefined => {
198 if (value === undefined || value === null) return undefined;
199 const entries = Object.entries(value).filter(
200 (entry): entry is [string, string] => entry[1] !== undefined,
201 );
202 return entries.length > 0 ? Object.fromEntries(entries) : undefined;
203};
204
205const toProducer = (
206 config: ProducerPscConfig | networkconnectivity.ProducerPscConfig,
207): ProducerPscConfig => ({
208 serviceAttachmentUri: config.serviceAttachmentUri,
209 automatedDnsCreationSpec: config.automatedDnsCreationSpec
210 ? {
211 dnsSuffix: config.automatedDnsCreationSpec.dnsSuffix,
212 hostname: config.automatedDnsCreationSpec.hostname,
213 ttl: config.automatedDnsCreationSpec.ttl,
214 }
215 : undefined,
216});
217
218const toConsumer = (
219 config: ConsumerPscConfig | networkconnectivity.ConsumerPscConfig,
220 project: string,
221): ConsumerPscConfig => ({
222 project: config.project,
223 consumerInstanceProject: config.consumerInstanceProject,
224 network:
225 config.network !== undefined
226 ? toNetworkResource(project, config.network)
227 : undefined,
228 disableGlobalAccess: config.disableGlobalAccess,
229 ipVersion: config.ipVersion,
230 serviceAttachmentIpAddressMap: toStringMap(
231 config.serviceAttachmentIpAddressMap,
232 ),
233 producerInstanceMetadata: toStringMap(config.producerInstanceMetadata),
234 producerInstanceId: config.producerInstanceId,
235 state: config.state,
236});
237
238const desiredConsumer = (config: ConsumerPscConfig, project: string) => ({
239 project: config.project,
240 consumerInstanceProject: config.consumerInstanceProject,
241 network:
242 config.network !== undefined
243 ? toNetworkResource(project, config.network)
244 : undefined,
245 disableGlobalAccess: config.disableGlobalAccess,
246 ipVersion: config.ipVersion,
247 serviceAttachmentIpAddressMap: toStringMap(
248 config.serviceAttachmentIpAddressMap,
249 ),
250 producerInstanceMetadata: toStringMap(config.producerInstanceMetadata),
251 producerInstanceId: config.producerInstanceId,
252});
253
254const toAttrs = (
255 map: networkconnectivity.ServiceConnectionMap,
256 project: string,
257 region: string,
258) => {
259 const name = map.name ?? "";
260 const parsed = parseName(name, COLLECTION, region);
261 return {
262 name,
263 serviceConnectionMapId: parsed.id,
264 project: parsed.project || project,
265 location: parsed.location || region,
266 serviceClass: map.serviceClass,
267 serviceClassUri: map.serviceClassUri,
268 token: map.token,
269 producerPscConfigs: (map.producerPscConfigs ?? []).map(toProducer),
270 consumerPscConfigs: (map.consumerPscConfigs ?? []).map((config) =>
271 toConsumer(config, parsed.project || project),
272 ),
273 infrastructure: map.infrastructure,
274 description: map.description,
275 labels: userLabels(map.labels),
276 etag: map.etag,
277 createTime: map.createTime,
278 updateTime: map.updateTime,
279 };
280};
281
282const getByName = (name: string) =>
283 networkconnectivity
284 .getProjectsLocationsServiceConnectionMaps({ name })
285 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
286
287export const ServiceConnectionMapProvider = () =>
288 Provider.succeed(ServiceConnectionMap, {
289 stables: [
290 "name",
291 "serviceConnectionMapId",
292 "project",
293 "location",
294 "createTime",
295 ],
296
297 diff: Effect.fn(function* ({ news, olds, output }) {
298 if (!isResolved(news)) return undefined;
299 const env = yield* GcpEnvironment.current;
300 const previousId =
301 olds?.serviceConnectionMapId ?? output?.serviceConnectionMapId;
302 const nextId = news.serviceConnectionMapId
303 ? rfc1035(news.serviceConnectionMapId, "service-connection-map")
304 : previousId;
305 const previousLocation = normalizeLocation(
306 olds?.location ?? output?.location,
307 env.region,
308 );
309 const nextLocation = normalizeLocation(
310 news.location ?? olds?.location ?? output?.location,
311 env.region,
312 );
313 if (
314 (previousId !== undefined &&
315 nextId !== undefined &&
316 nextId !== previousId) ||
317 previousLocation !== nextLocation
318 ) {
319 return { action: "replace" as const };
320 }
321 return undefined;
322 }),
323
324 read: Effect.fn(function* ({ id, olds, output }) {
325 const env = yield* GcpEnvironment.current;
326 const serviceConnectionMapId = yield* toPhysicalId(
327 id,
328 olds?.serviceConnectionMapId,
329 output?.serviceConnectionMapId,
330 "service-connection-map",
331 );
332 const location = normalizeLocation(
333 olds?.location ?? output?.location,
334 env.region,
335 );
336 const name =
337 output?.name ??
338 resourceName(env.project, location, serviceConnectionMapId);
339 const existing = yield* getByName(name);
340 if (existing === undefined) return undefined;
341 const attrs = toAttrs(existing, env.project, env.region);
342 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
343 ? attrs
344 : Unowned(attrs);
345 }),
346
347 list: () =>
348 Effect.gen(function* () {
349 const env = yield* GcpEnvironment.current;
350 const items = yield* collectPages(
351 networkconnectivity.listProjectsLocationsServiceConnectionMaps.pages({
352 parent: parentOf(env.project, "-"),
353 pageSize: 1000,
354 }),
355 (page) => page.serviceConnectionMaps,
356 );
357 return items
358 .filter((item) => hasAlchemyLabelKeys(item.labels))
359 .map((item) => toAttrs(item, env.project, env.region));
360 }),
361
362 reconcile: Effect.fn(function* ({ id, news, output }) {
363 const env = yield* GcpEnvironment.current;
364 const serviceConnectionMapId = yield* toPhysicalId(
365 id,
366 news.serviceConnectionMapId,
367 output?.serviceConnectionMapId,
368 "service-connection-map",
369 );
370 const location = normalizeLocation(
371 news.location ?? output?.location,
372 env.region,
373 );
374 const name = resourceName(env.project, location, serviceConnectionMapId);
375 const desiredLabels = {
376 ...toLabels(news.labels),
377 ...(yield* createInternalLabels(id)),
378 };
379 const producerPscConfigs = (news.producerPscConfigs ?? []).map(
380 toProducer,
381 );
382 const consumerPscConfigs = (news.consumerPscConfigs ?? []).map((config) =>
383 desiredConsumer(config, env.project),
384 );
385
386 let current = yield* getByName(name);
387
388 if (current === undefined) {
389 const created = yield* networkconnectivity
390 .createProjectsLocationsServiceConnectionMaps({
391 parent: parentOf(env.project, location),
392 serviceConnectionMapId,
393 body: {
394 serviceClass: news.serviceClass,
395 token: news.token,
396 producerPscConfigs,
397 consumerPscConfigs,
398 description: news.description,
399 labels: desiredLabels,
400 },
401 })
402 .pipe(
403 Effect.retry({
404 while: (error) => error._tag === "Conflict",
405 times: 5,
406 schedule: Schedule.spaced("2 seconds"),
407 }),
408 Effect.catchTag("Conflict", () => Effect.succeed(undefined)),
409 );
410 if (created !== undefined) {
411 yield* waitForOperation(created);
412 }
413 current = yield* waitUntilPresent(getByName(name), name);
414 }
415
416 if (current === undefined) {
417 return yield* new NetworkConnectivityNotResolved({ name });
418 }
419
420 const observedLabels = tagRecord(current.labels);
421 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
422 const labelsChanged = upsert.length > 0 || removed.length > 0;
423 const descriptionChanged =
424 (current.description ?? "") !== (news.description ?? "");
425 const tokenChanged = (current.token ?? "") !== (news.token ?? "");
426 const producerChanged = !sameJson(
427 (current.producerPscConfigs ?? []).map(toProducer),
428 producerPscConfigs,
429 );
430 const consumerChanged = !sameJson(
431 (current.consumerPscConfigs ?? []).map((config) =>
432 desiredConsumer(toConsumer(config, env.project), env.project),
433 ),
434 consumerPscConfigs,
435 );
436 const updateMask = changedFields([
437 ["labels", labelsChanged],
438 ["description", descriptionChanged],
439 ["token", tokenChanged],
440 ["producerPscConfigs", producerChanged],
441 ["consumerPscConfigs", consumerChanged],
442 ]);
443
444 if (updateMask.length > 0) {
445 const operation =
446 yield* networkconnectivity.patchProjectsLocationsServiceConnectionMaps(
447 {
448 name: current.name ?? name,
449 updateMask: updateMask.join(","),
450 body: {
451 name: current.name ?? name,
452 labels: desiredLabels,
453 description: news.description,
454 token: news.token,
455 producerPscConfigs,
456 consumerPscConfigs,
457 etag: current.etag,
458 },
459 },
460 );
461 yield* waitForOperation(operation);
462 current = yield* waitUntilPresent(
463 getByName(current.name ?? name),
464 current.name ?? name,
465 );
466 }
467
468 return toAttrs(current, env.project, env.region);
469 }),
470
471 delete: Effect.fn(function* ({ output }) {
472 const operation = yield* networkconnectivity
473 .deleteProjectsLocationsServiceConnectionMaps({
474 name: output.name,
475 etag: output.etag,
476 })
477 .pipe(
478 Effect.retry({
479 while: (error) => error._tag === "Conflict",
480 times: 8,
481 schedule: Schedule.spaced("2 seconds"),
482 }),
483 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
484 );
485 if (operation !== undefined) {
486 yield* waitForOperation(operation, { notFoundOk: true });
487 }
488 yield* waitUntilGone(getByName(output.name), output.name);
489 }),
490 });