1import * as dm from "@distilled.cloud/gcp/datamigration_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { tagRecord } from "../../Tags.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { createInternalLabels, hasAlchemyLabels, toLabels } from "../Labels.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 fingerprint,
15 hasAlchemyLabelMap,
16 locationParent,
17 networkOf,
18 normalizeLocation,
19 parseName,
20 replaceOnIdentity,
21 ResourceNotResolved,
22 toPhysicalId,
23 userLabels,
24 waitForOperation,
25 waitUntilExists,
26 waitUntilGone,
27} from "./internal.ts";
28
29export type PrivateConnectionState =
30 | dm.PrivateConnectionStateEnum
31 | (string & {});
32export type VpcPeeringConfig = dm.VpcPeeringConfig;
33export type PscInterfaceConfig = dm.PscInterfaceConfig;
34type Status = dm.Status;
35
36export type PrivateConnectionProps = {
37 /**
38 * Private connection id (the `{privateConnection}` segment of
39 * `projects/{project}/locations/{location}/privateConnections/{privateConnection}`).
40 * If omitted, a unique RFC1035 name is generated. Immutable — changing
41 * it replaces the connection.
42 */
43 privateConnectionId?: string;
44 /**
45 * Region (`us-central1`, …). Immutable — changing it replaces the
46 * connection. `US-CENTRAL1` is accepted and normalized to `us-central1`.
47 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
48 */
49 location?: string;
50 /**
51 * User-friendly display name. The API has no patch method — changing
52 * display name replaces the connection.
53 */
54 displayName?: string;
55 /**
56 * User labels. Alchemy ownership labels are merged in automatically.
57 * The API has no patch method — changing labels replaces the
58 * connection.
59 */
60 labels?: Record<string, string>;
61 /**
62 * VPC peering configuration. Mutually exclusive with
63 * `pscInterfaceConfig`. Immutable — changing it replaces the
64 * connection. `vpcName` may be a full resource name or a bare network
65 * id.
66 */
67 vpcPeeringConfig?: {
68 vpcName: string;
69 subnet: string;
70 };
71 /**
72 * PSC interface configuration. Mutually exclusive with
73 * `vpcPeeringConfig`. Immutable — changing it replaces the connection.
74 */
75 pscInterfaceConfig?: PscInterfaceConfig;
76 /**
77 * Skip validations on create.
78 */
79 skipValidation?: boolean;
80 /**
81 * PSC interface only: resolve the tenant project without creating.
82 */
83 validateOnly?: boolean;
84};
85
86export type PrivateConnection = Resource<
87 "GCP.DataMigration.PrivateConnection",
88 PrivateConnectionProps,
89 {
90 /** Full resource name. */
91 name: string;
92 /** Private connection id (last path segment). */
93 privateConnectionId: string;
94 /** Project id. */
95 project: string;
96 /** Location id. */
97 location: string;
98 /** User-friendly display name. */
99 displayName: string | undefined;
100 /** User labels (Alchemy ownership labels stripped). */
101 labels: Record<string, string>;
102 /** VPC peering configuration. */
103 vpcPeeringConfig: VpcPeeringConfig | undefined;
104 /** PSC interface configuration. */
105 pscInterfaceConfig: PscInterfaceConfig | undefined;
106 /** Server-reported state. */
107 state: string | undefined;
108 /** Failure details when `state` is `FAILED`. */
109 error: Status | undefined;
110 /** RFC3339 creation timestamp. */
111 createTime: string | undefined;
112 /** RFC3339 last-update timestamp. */
113 updateTime: string | undefined;
114 },
115 never,
116 Providers
117>;
118
119/**
120 * A Database Migration Service private connection: VPC peering or a PSC
121 * interface between DMS and a consumer network.
122 *
123 * The API has no patch method. `privateConnectionId`, `location`,
124 * `vpcPeeringConfig`, `pscInterfaceConfig`, display name, and labels are
125 * replacement triggers.
126 *
127 * ### Creating a Private Connection
128 * **Example:** VPC peering
129 * ```typescript
130 * const network = yield* GCP.Compute.Network("DmsVpc", {
131 * autoCreateSubnetworks: false,
132 * });
133 * const peering = yield* GCP.DataMigration.PrivateConnection("DmsPeer", {
134 * displayName: "dms-peer",
135 * vpcPeeringConfig: {
136 * vpcName: network.selfLink ?? network.networkName,
137 * subnet: "10.8.0.0/29",
138 * },
139 * labels: { env: "test" },
140 * });
141 * ```
142 *
143 * @resource
144 * @category DataMigration
145 */
146export const PrivateConnection = Resource<PrivateConnection>(
147 "GCP.DataMigration.PrivateConnection",
148);
149
150const resourceName = (
151 project: string,
152 location: string,
153 privateConnectionId: string,
154) =>
155 `${locationParent(project, location)}/privateConnections/${privateConnectionId}`;
156
157const peeringOf = (
158 value:
159 | PrivateConnectionProps["vpcPeeringConfig"]
160 | VpcPeeringConfig
161 | undefined,
162 project: string,
163) =>
164 value?.vpcName === undefined && value?.subnet === undefined
165 ? undefined
166 : {
167 vpcName: value?.vpcName ? networkOf(value.vpcName, project) : undefined,
168 subnet: value?.subnet,
169 };
170
171const kindOf = (value: {
172 vpcPeeringConfig?: unknown;
173 pscInterfaceConfig?: unknown;
174}) => (value.vpcPeeringConfig ? "vpc" : value.pscInterfaceConfig ? "psc" : "");
175
176const toAttrs = (item: dm.PrivateConnection, project: string) => {
177 const name = item.name ?? "";
178 const parsed = parseName(name, "privateConnections");
179 return {
180 name,
181 privateConnectionId: parsed.id,
182 project: parsed.project || project,
183 location: parsed.location,
184 displayName: item.displayName,
185 labels: userLabels(item.labels),
186 vpcPeeringConfig: item.vpcPeeringConfig,
187 pscInterfaceConfig: item.pscInterfaceConfig,
188 state: item.state,
189 error: item.error,
190 createTime: item.createTime,
191 updateTime: item.updateTime,
192 };
193};
194
195const getByName = (name: string) =>
196 name.length === 0
197 ? Effect.succeed(undefined)
198 : dm
199 .getProjectsLocationsPrivateConnections({ name })
200 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
201
202const listOwned = (project: string, region: string) =>
203 dm.listProjectsLocationsPrivateConnections
204 .pages({
205 parent: `projects/${project}/locations/-`,
206 pageSize: 1000,
207 })
208 .pipe(
209 Stream.flatMap((page) =>
210 Stream.fromIterable(page.privateConnections ?? []),
211 ),
212 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
213 Stream.runCollect,
214 Effect.map((chunk) => Array.from(chunk)),
215 Effect.catchTag("NotFound", () =>
216 dm.listProjectsLocationsPrivateConnections
217 .pages({
218 parent: locationParent(project, region),
219 pageSize: 1000,
220 })
221 .pipe(
222 Stream.flatMap((page) =>
223 Stream.fromIterable(page.privateConnections ?? []),
224 ),
225 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
226 Stream.runCollect,
227 Effect.map((chunk) => Array.from(chunk)),
228 Effect.catchTag("NotFound", () =>
229 Effect.succeed([] as dm.PrivateConnection[]),
230 ),
231 ),
232 ),
233 );
234
235export const PrivateConnectionProvider = () =>
236 Provider.succeed(PrivateConnection, {
237 stables: [
238 "name",
239 "privateConnectionId",
240 "project",
241 "location",
242 "createTime",
243 ],
244
245 diff: Effect.fn(function* ({ news, olds, output }) {
246 if (!isResolved(news)) return undefined;
247 const env = yield* GcpEnvironment.current;
248 const previousKind = kindOf({
249 vpcPeeringConfig: olds?.vpcPeeringConfig ?? output?.vpcPeeringConfig,
250 pscInterfaceConfig:
251 olds?.pscInterfaceConfig ?? output?.pscInterfaceConfig,
252 });
253 const nextKind = kindOf(news) || previousKind;
254 const previousPeering = fingerprint(
255 olds?.vpcPeeringConfig ?? output?.vpcPeeringConfig,
256 );
257 const nextPeering = fingerprint(news.vpcPeeringConfig);
258 const previousPsc = fingerprint(
259 olds?.pscInterfaceConfig ?? output?.pscInterfaceConfig,
260 );
261 const nextPsc = fingerprint(news.pscInterfaceConfig);
262 const previousDisplay = olds?.displayName ?? output?.displayName;
263 const nextDisplay = news.displayName ?? previousDisplay;
264 const previousLabels = fingerprint(olds?.labels ?? output?.labels);
265 const nextLabels = fingerprint(
266 news.labels ?? olds?.labels ?? output?.labels,
267 );
268 return replaceOnIdentity({
269 previousId: olds?.privateConnectionId ?? output?.privateConnectionId,
270 nextId:
271 news.privateConnectionId ??
272 olds?.privateConnectionId ??
273 output?.privateConnectionId,
274 previousLocation: normalizeLocation(
275 olds?.location ?? output?.location,
276 env.region,
277 ),
278 nextLocation: normalizeLocation(
279 news.location ?? olds?.location ?? output?.location,
280 env.region,
281 ),
282 extra:
283 (olds !== undefined || output !== undefined) &&
284 (previousKind !== nextKind ||
285 (news.vpcPeeringConfig !== undefined &&
286 previousPeering !== nextPeering) ||
287 (news.pscInterfaceConfig !== undefined &&
288 previousPsc !== nextPsc) ||
289 previousDisplay !== nextDisplay ||
290 previousLabels !== nextLabels),
291 });
292 }),
293
294 read: Effect.fn(function* ({ id, olds, output }) {
295 const env = yield* GcpEnvironment.current;
296 const privateConnectionId = yield* toPhysicalId(
297 id,
298 olds?.privateConnectionId,
299 output?.privateConnectionId,
300 "pconn",
301 );
302 const location = normalizeLocation(
303 olds?.location ?? output?.location,
304 env.region,
305 );
306 const name =
307 output?.name ??
308 resourceName(env.project, location, privateConnectionId);
309 const existing = yield* getByName(name);
310 if (existing === undefined) return undefined;
311 const attrs = toAttrs(existing, env.project);
312 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
313 ? attrs
314 : Unowned(attrs);
315 }),
316
317 list: () =>
318 Effect.gen(function* () {
319 const env = yield* GcpEnvironment.current;
320 const items = yield* listOwned(env.project, env.region);
321 return items.map((item) => toAttrs(item, env.project));
322 }),
323
324 reconcile: Effect.fn(function* ({ id, news, output }) {
325 const env = yield* GcpEnvironment.current;
326 const privateConnectionId = yield* toPhysicalId(
327 id,
328 news.privateConnectionId,
329 output?.privateConnectionId,
330 "pconn",
331 );
332 const location = normalizeLocation(
333 news.location ?? output?.location,
334 env.region,
335 );
336 const name = resourceName(env.project, location, privateConnectionId);
337 const desiredLabels = {
338 ...toLabels(news.labels),
339 ...(yield* createInternalLabels(id)),
340 };
341 const displayName = news.displayName ?? privateConnectionId;
342 const vpcPeeringConfig = peeringOf(news.vpcPeeringConfig, env.project);
343
344 let current = yield* getByName(output?.name ?? name);
345
346 if (current === undefined) {
347 const created = yield* dm
348 .createProjectsLocationsPrivateConnections({
349 parent: locationParent(env.project, location),
350 privateConnectionId,
351 skipValidation: news.skipValidation,
352 validateOnly: news.validateOnly,
353 body: {
354 displayName,
355 labels: desiredLabels,
356 vpcPeeringConfig,
357 pscInterfaceConfig: news.pscInterfaceConfig,
358 },
359 })
360 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
361 if (created !== undefined) {
362 yield* waitForOperation(created);
363 }
364 current = yield* waitUntilExists(getByName(name), name);
365 }
366
367 if (current === undefined) {
368 return yield* new ResourceNotResolved({ name });
369 }
370
371 return toAttrs(current, env.project);
372 }),
373
374 delete: Effect.fn(function* ({ output }) {
375 yield* Effect.gen(function* () {
376 const operation = yield* dm
377 .deleteProjectsLocationsPrivateConnections({ name: output.name })
378 .pipe(
379 Effect.retry({
380 while: (error) => error._tag === "Conflict",
381 times: 8,
382 schedule: Schedule.spaced("2 seconds"),
383 }),
384 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
385 );
386 if (operation !== undefined) {
387 yield* waitForOperation(operation, { notFoundOk: true });
388 }
389 }).pipe(
390 // Tearing down the VPC peering occasionally ends with a server-side
391 // DEADLINE_EXCEEDED ("Operation timed out."); a fresh delete succeeds.
392 Effect.retry({
393 while: (error) =>
394 error._tag === "GCP.OperationFailed" && error.code === 4,
395 times: 5,
396 schedule: Schedule.spaced("10 seconds"),
397 }),
398 );
399 yield* waitUntilGone(getByName(output.name), output.name);
400 }),
401 });