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 {
12 createInternalLabels,
13 diffLabels,
14 hasAlchemyLabels,
15 toLabels,
16} from "../Labels.ts";
17import type { Providers } from "../Providers.ts";
18import {
19 fieldMask,
20 fingerprint,
21 hasAlchemyLabelMap,
22 locationParent,
23 normalizeLocation,
24 parseName,
25 replaceOnIdentity,
26 ResourceNotResolved,
27 toPhysicalId,
28 userLabels,
29 waitForOperation,
30 waitUntilExists,
31 waitUntilGone,
32} from "./internal.ts";
33
34export type ConnectionProfileProvider =
35 | dm.ConnectionProfileProviderEnum
36 | (string & {});
37export type ConnectionProfileState =
38 | dm.ConnectionProfileStateEnum
39 | (string & {});
40export type ConnectionProfileRole =
41 | dm.ConnectionProfileRoleEnum
42 | (string & {});
43export type MySqlConnectionProfile = dm.MySqlConnectionProfile;
44export type PostgreSqlConnectionProfile = dm.PostgreSqlConnectionProfile;
45export type OracleConnectionProfile = dm.OracleConnectionProfile;
46export type SqlServerConnectionProfile = dm.SqlServerConnectionProfile;
47export type CloudSqlConnectionProfile = dm.CloudSqlConnectionProfile;
48export type CloudSqlSettings = dm.CloudSqlSettings;
49export type AlloyDbConnectionProfile = dm.AlloyDbConnectionProfile;
50export type AlloyDbSettings = dm.AlloyDbSettings;
51export type SslConfig = dm.SslConfig;
52export type Status = dm.Status;
53
54export type ConnectionProfileProps = {
55 /**
56 * Connection profile id (the `{connectionProfile}` segment of
57 * `projects/{project}/locations/{location}/connectionProfiles/{connectionProfile}`).
58 * If omitted, a unique RFC1035 name is generated. Immutable — changing it
59 * replaces the profile.
60 */
61 connectionProfileId?: string;
62 /**
63 * Region (`us-central1`, …). Immutable — changing it replaces the
64 * profile. `US-CENTRAL1` is accepted and normalized to `us-central1`.
65 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
66 */
67 location?: string;
68 /**
69 * User-friendly display name.
70 */
71 displayName?: string;
72 /**
73 * User labels. Alchemy ownership labels are merged in automatically.
74 */
75 labels?: Record<string, string>;
76 /**
77 * Database provider (`CLOUDSQL`, `RDS`, `AURORA`, `ALLOYDB`,
78 * `AZURE_DATABASE`).
79 */
80 provider?: ConnectionProfileProvider;
81 /**
82 * Connection profile role (`SOURCE` or `DESTINATION`).
83 */
84 role?: ConnectionProfileRole;
85 /**
86 * MySQL source/destination connection. Mutually exclusive with the other
87 * engine blocks. Switching engines replaces the profile.
88 */
89 mysql?: MySqlConnectionProfile;
90 /**
91 * PostgreSQL source/destination connection. Mutually exclusive with the
92 * other engine blocks. Switching engines replaces the profile.
93 */
94 postgresql?: PostgreSqlConnectionProfile;
95 /**
96 * Oracle source connection. Mutually exclusive with the other engine
97 * blocks. Switching engines replaces the profile.
98 */
99 oracle?: OracleConnectionProfile;
100 /**
101 * SQL Server source connection. Mutually exclusive with the other engine
102 * blocks. Switching engines replaces the profile.
103 */
104 sqlserver?: SqlServerConnectionProfile;
105 /**
106 * Cloud SQL destination. `settings` are immutable after create —
107 * changing them replaces the profile. Creating this engine provisions a
108 * Cloud SQL replica. Delete always force-deletes that replica.
109 */
110 cloudsql?: CloudSqlConnectionProfile;
111 /**
112 * AlloyDB destination. `clusterId` and `settings` are immutable —
113 * changing them replaces the profile.
114 */
115 alloydb?: AlloyDbConnectionProfile;
116 /**
117 * Oracle only: create without validating connectivity.
118 */
119 skipValidation?: boolean;
120 /**
121 * Oracle only: validate without creating resources.
122 */
123 validateOnly?: boolean;
124};
125
126export type ConnectionProfile = Resource<
127 "GCP.DataMigration.ConnectionProfile",
128 ConnectionProfileProps,
129 {
130 /** Full resource name. */
131 name: string;
132 /** Connection profile id (last path segment). */
133 connectionProfileId: string;
134 /** Project id. */
135 project: string;
136 /** Location id. */
137 location: string;
138 /** User-friendly display name. */
139 displayName: string | undefined;
140 /** User labels (Alchemy ownership labels stripped). */
141 labels: Record<string, string>;
142 /** Database provider. */
143 provider: string | undefined;
144 /** Source or destination role. */
145 role: string | undefined;
146 /** Server-reported state. */
147 state: string | undefined;
148 /** MySQL connection (password omitted). */
149 mysql: MySqlConnectionProfile | undefined;
150 /** PostgreSQL connection (password omitted). */
151 postgresql: PostgreSqlConnectionProfile | undefined;
152 /** Oracle connection (password omitted). */
153 oracle: OracleConnectionProfile | undefined;
154 /** SQL Server connection (password omitted). */
155 sqlserver: SqlServerConnectionProfile | undefined;
156 /** Cloud SQL destination (root password omitted). */
157 cloudsql: CloudSqlConnectionProfile | undefined;
158 /** AlloyDB destination (initial user password omitted). */
159 alloydb: AlloyDbConnectionProfile | undefined;
160 /** Failure details when `state` is `FAILED`. */
161 error: Status | undefined;
162 /** RFC3339 creation timestamp. */
163 createTime: string | undefined;
164 /** RFC3339 last-update timestamp. */
165 updateTime: string | undefined;
166 },
167 never,
168 Providers
169>;
170
171/**
172 * A Database Migration Service connection profile: credentials and
173 * connectivity for a source database, or settings used to provision a
174 * Cloud SQL / AlloyDB destination.
175 *
176 * `connectionProfileId`, `location`, and the engine kind
177 * (`mysql` / `postgresql` / `oracle` / `sqlserver` / `cloudsql` /
178 * `alloydb`) plus immutable Cloud SQL / AlloyDB settings are replacement
179 * triggers. Display name, labels, role, provider, and credential fields
180 * update in place. Passwords are input-only and never returned.
181 *
182 * ### Creating a Connection Profile
183 * **Example:** MySQL source
184 * ```typescript
185 * const source = yield* GCP.DataMigration.ConnectionProfile("MysqlSrc", {
186 * mysql: {
187 * host: "10.0.0.8",
188 * port: 3306,
189 * username: "dms",
190 * password: process.env.MYSQL_PASSWORD,
191 * },
192 * labels: { env: "test" },
193 * });
194 * ```
195 *
196 * **Example:** Cloud SQL destination
197 * ```typescript
198 * const dest = yield* GCP.DataMigration.ConnectionProfile("MysqlDest", {
199 * cloudsql: {
200 * settings: {
201 * sourceId: source.name,
202 * databaseVersion: "MYSQL_8_0",
203 * tier: "db-n1-standard-1",
204 * rootPassword: process.env.MYSQL_ROOT_PASSWORD,
205 * },
206 * },
207 * });
208 * ```
209 *
210 * @resource
211 * @category DataMigration
212 */
213export const ConnectionProfile = Resource<ConnectionProfile>(
214 "GCP.DataMigration.ConnectionProfile",
215);
216
217const resourceName = (
218 project: string,
219 location: string,
220 connectionProfileId: string,
221) =>
222 `${locationParent(project, location)}/connectionProfiles/${connectionProfileId}`;
223
224const kindOf = (value: {
225 mysql?: unknown;
226 postgresql?: unknown;
227 oracle?: unknown;
228 sqlserver?: unknown;
229 cloudsql?: unknown;
230 alloydb?: unknown;
231}) =>
232 value.mysql
233 ? "mysql"
234 : value.postgresql
235 ? "postgresql"
236 : value.oracle
237 ? "oracle"
238 : value.sqlserver
239 ? "sqlserver"
240 : value.cloudsql
241 ? "cloudsql"
242 : value.alloydb
243 ? "alloydb"
244 : "";
245
246const publicSsl = (ssl: SslConfig | undefined) =>
247 ssl === undefined
248 ? undefined
249 : {
250 type: ssl.type,
251 sslFlags: ssl.sslFlags,
252 };
253
254const publicMysql = (mysql: MySqlConnectionProfile | undefined) =>
255 mysql === undefined
256 ? undefined
257 : {
258 host: mysql.host,
259 port: mysql.port,
260 username: mysql.username,
261 cloudSqlId: mysql.cloudSqlId,
262 passwordSet: mysql.passwordSet,
263 ssl: publicSsl(mysql.ssl),
264 };
265
266const publicPostgresql = (
267 postgresql: PostgreSqlConnectionProfile | undefined,
268) =>
269 postgresql === undefined
270 ? undefined
271 : {
272 host: postgresql.host,
273 port: postgresql.port,
274 username: postgresql.username,
275 database: postgresql.database,
276 cloudSqlId: postgresql.cloudSqlId,
277 alloydbClusterId: postgresql.alloydbClusterId,
278 enableIamAuthentication: postgresql.enableIamAuthentication,
279 privateConnectivity: postgresql.privateConnectivity,
280 privateServiceConnectConnectivity:
281 postgresql.privateServiceConnectConnectivity,
282 forwardSshConnectivity: postgresql.forwardSshConnectivity
283 ? {
284 hostname: postgresql.forwardSshConnectivity.hostname,
285 port: postgresql.forwardSshConnectivity.port,
286 username: postgresql.forwardSshConnectivity.username,
287 }
288 : undefined,
289 passwordSet: postgresql.passwordSet,
290 ssl: publicSsl(postgresql.ssl),
291 };
292
293const publicOracle = (oracle: OracleConnectionProfile | undefined) =>
294 oracle === undefined
295 ? undefined
296 : {
297 host: oracle.host,
298 port: oracle.port,
299 username: oracle.username,
300 databaseService: oracle.databaseService,
301 privateConnectivity: oracle.privateConnectivity,
302 forwardSshConnectivity: oracle.forwardSshConnectivity
303 ? {
304 hostname: oracle.forwardSshConnectivity.hostname,
305 port: oracle.forwardSshConnectivity.port,
306 username: oracle.forwardSshConnectivity.username,
307 }
308 : undefined,
309 oracleAsmConfig: oracle.oracleAsmConfig
310 ? {
311 hostname: oracle.oracleAsmConfig.hostname,
312 port: oracle.oracleAsmConfig.port,
313 asmService: oracle.oracleAsmConfig.asmService,
314 username: oracle.oracleAsmConfig.username,
315 ssl: publicSsl(oracle.oracleAsmConfig.ssl),
316 }
317 : undefined,
318 passwordSet: oracle.passwordSet,
319 ssl: publicSsl(oracle.ssl),
320 };
321
322const publicSqlserver = (sqlserver: SqlServerConnectionProfile | undefined) =>
323 sqlserver === undefined
324 ? undefined
325 : {
326 host: sqlserver.host,
327 port: sqlserver.port,
328 username: sqlserver.username,
329 database: sqlserver.database,
330 cloudSqlId: sqlserver.cloudSqlId,
331 cloudSqlProjectId: sqlserver.cloudSqlProjectId,
332 dbmPort: sqlserver.dbmPort,
333 backups: sqlserver.backups,
334 privateConnectivity: sqlserver.privateConnectivity,
335 privateServiceConnectConnectivity:
336 sqlserver.privateServiceConnectConnectivity,
337 passwordSet: sqlserver.passwordSet,
338 ssl: publicSsl(sqlserver.ssl),
339 };
340
341const publicCloudsql = (cloudsql: CloudSqlConnectionProfile | undefined) =>
342 cloudsql === undefined
343 ? undefined
344 : {
345 cloudSqlId: cloudsql.cloudSqlId,
346 publicIp: cloudsql.publicIp,
347 privateIp: cloudsql.privateIp,
348 additionalPublicIp: cloudsql.additionalPublicIp,
349 settings: cloudsql.settings
350 ? {
351 ...cloudsql.settings,
352 rootPassword: undefined,
353 }
354 : undefined,
355 };
356
357const publicAlloydb = (alloydb: AlloyDbConnectionProfile | undefined) =>
358 alloydb === undefined
359 ? undefined
360 : {
361 clusterId: alloydb.clusterId,
362 settings: alloydb.settings
363 ? {
364 ...alloydb.settings,
365 initialUser: alloydb.settings.initialUser
366 ? {
367 user: alloydb.settings.initialUser.user,
368 passwordSet: alloydb.settings.initialUser.passwordSet,
369 }
370 : undefined,
371 }
372 : undefined,
373 };
374
375const toAttrs = (profile: dm.ConnectionProfile, project: string) => {
376 const name = profile.name ?? "";
377 const parsed = parseName(name, "connectionProfiles");
378 return {
379 name,
380 connectionProfileId: parsed.id,
381 project: parsed.project || project,
382 location: parsed.location,
383 displayName: profile.displayName,
384 labels: userLabels(profile.labels),
385 provider: profile.provider,
386 role: profile.role,
387 state: profile.state,
388 mysql: publicMysql(profile.mysql),
389 postgresql: publicPostgresql(profile.postgresql),
390 oracle: publicOracle(profile.oracle),
391 sqlserver: publicSqlserver(profile.sqlserver),
392 cloudsql: publicCloudsql(profile.cloudsql),
393 alloydb: publicAlloydb(profile.alloydb),
394 error: profile.error,
395 createTime: profile.createTime,
396 updateTime: profile.updateTime,
397 };
398};
399
400const getByName = (name: string) =>
401 name.length === 0
402 ? Effect.succeed(undefined)
403 : dm
404 .getProjectsLocationsConnectionProfiles({ name })
405 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
406
407const listOwned = (project: string, region: string) =>
408 dm.listProjectsLocationsConnectionProfiles
409 .pages({
410 parent: `projects/${project}/locations/-`,
411 pageSize: 1000,
412 })
413 .pipe(
414 Stream.flatMap((page) =>
415 Stream.fromIterable(page.connectionProfiles ?? []),
416 ),
417 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
418 Stream.runCollect,
419 Effect.map((chunk) => Array.from(chunk)),
420 Effect.catchTag("NotFound", () =>
421 dm.listProjectsLocationsConnectionProfiles
422 .pages({
423 parent: locationParent(project, region),
424 pageSize: 1000,
425 })
426 .pipe(
427 Stream.flatMap((page) =>
428 Stream.fromIterable(page.connectionProfiles ?? []),
429 ),
430 Stream.filter((item) => hasAlchemyLabelMap(item.labels)),
431 Stream.runCollect,
432 Effect.map((chunk) => Array.from(chunk)),
433 Effect.catchTag("NotFound", () =>
434 Effect.succeed([] as dm.ConnectionProfile[]),
435 ),
436 ),
437 ),
438 );
439
440const secretChanged = (
441 newsHasSecret: boolean,
442 publicPrevious: unknown,
443 publicNext: unknown,
444) => newsHasSecret || fingerprint(publicPrevious) !== fingerprint(publicNext);
445
446export const ConnectionProfileProvider = () =>
447 Provider.succeed(ConnectionProfile, {
448 stables: [
449 "name",
450 "connectionProfileId",
451 "project",
452 "location",
453 "createTime",
454 ],
455
456 diff: Effect.fn(function* ({ news, olds, output }) {
457 if (!isResolved(news)) return undefined;
458 const env = yield* GcpEnvironment.current;
459 const previousKind = kindOf({
460 mysql: olds?.mysql ?? output?.mysql,
461 postgresql: olds?.postgresql ?? output?.postgresql,
462 oracle: olds?.oracle ?? output?.oracle,
463 sqlserver: olds?.sqlserver ?? output?.sqlserver,
464 cloudsql: olds?.cloudsql ?? output?.cloudsql,
465 alloydb: olds?.alloydb ?? output?.alloydb,
466 });
467 const nextKind = kindOf(news) || previousKind;
468 const previousCloudsql = fingerprint(
469 publicCloudsql(olds?.cloudsql ?? output?.cloudsql)?.settings,
470 );
471 const nextCloudsql = fingerprint(publicCloudsql(news.cloudsql)?.settings);
472 const previousAlloy = fingerprint({
473 clusterId: olds?.alloydb?.clusterId ?? output?.alloydb?.clusterId,
474 settings: publicAlloydb(olds?.alloydb ?? output?.alloydb)?.settings,
475 });
476 const nextAlloy = fingerprint({
477 clusterId: news.alloydb?.clusterId,
478 settings: publicAlloydb(news.alloydb)?.settings,
479 });
480 return replaceOnIdentity({
481 previousId: olds?.connectionProfileId ?? output?.connectionProfileId,
482 nextId:
483 news.connectionProfileId ??
484 olds?.connectionProfileId ??
485 output?.connectionProfileId,
486 previousLocation: normalizeLocation(
487 olds?.location ?? output?.location,
488 env.region,
489 ),
490 nextLocation: normalizeLocation(
491 news.location ?? olds?.location ?? output?.location,
492 env.region,
493 ),
494 extra:
495 previousKind !== nextKind ||
496 (nextKind === "cloudsql" &&
497 news.cloudsql !== undefined &&
498 previousCloudsql !== nextCloudsql) ||
499 (nextKind === "alloydb" &&
500 news.alloydb !== undefined &&
501 previousAlloy !== nextAlloy),
502 });
503 }),
504
505 read: Effect.fn(function* ({ id, olds, output }) {
506 const env = yield* GcpEnvironment.current;
507 const connectionProfileId = yield* toPhysicalId(
508 id,
509 olds?.connectionProfileId,
510 output?.connectionProfileId,
511 "profile",
512 );
513 const location = normalizeLocation(
514 olds?.location ?? output?.location,
515 env.region,
516 );
517 const name =
518 output?.name ??
519 resourceName(env.project, location, connectionProfileId);
520 const existing = yield* getByName(name);
521 if (existing === undefined) return undefined;
522 const attrs = toAttrs(existing, env.project);
523 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
524 ? attrs
525 : Unowned(attrs);
526 }),
527
528 list: () =>
529 Effect.gen(function* () {
530 const env = yield* GcpEnvironment.current;
531 const items = yield* listOwned(env.project, env.region);
532 return items.map((item) => toAttrs(item, env.project));
533 }),
534
535 reconcile: Effect.fn(function* ({ id, news, output }) {
536 const env = yield* GcpEnvironment.current;
537 const connectionProfileId = yield* toPhysicalId(
538 id,
539 news.connectionProfileId,
540 output?.connectionProfileId,
541 "profile",
542 );
543 const location = normalizeLocation(
544 news.location ?? output?.location,
545 env.region,
546 );
547 const name = resourceName(env.project, location, connectionProfileId);
548 const desiredLabels = {
549 ...toLabels(news.labels),
550 ...(yield* createInternalLabels(id)),
551 };
552 const displayName = news.displayName ?? connectionProfileId;
553 const body: dm.ConnectionProfile = {
554 displayName,
555 labels: desiredLabels,
556 provider: news.provider,
557 role: news.role,
558 mysql: news.mysql,
559 postgresql: news.postgresql,
560 oracle: news.oracle,
561 sqlserver: news.sqlserver,
562 cloudsql: news.cloudsql,
563 alloydb: news.alloydb,
564 };
565
566 let current = yield* getByName(output?.name ?? name);
567
568 if (current === undefined) {
569 const created = yield* dm
570 .createProjectsLocationsConnectionProfiles({
571 parent: locationParent(env.project, location),
572 connectionProfileId,
573 skipValidation: news.skipValidation,
574 validateOnly: news.validateOnly,
575 body,
576 })
577 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
578 if (created !== undefined) {
579 yield* waitForOperation(created);
580 }
581 current = yield* waitUntilExists(getByName(name), name);
582 }
583
584 if (current === undefined) {
585 return yield* new ResourceNotResolved({ name });
586 }
587
588 const observedLabels = tagRecord(current.labels);
589 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
590 const labelsChanged = upsert.length > 0 || removed.length > 0;
591 const displayNameChanged = (current.displayName ?? "") !== displayName;
592 const providerChanged =
593 news.provider !== undefined &&
594 (current.provider ?? "") !== news.provider;
595 const roleChanged =
596 news.role !== undefined && (current.role ?? "") !== news.role;
597 const mysqlChanged =
598 news.mysql !== undefined &&
599 secretChanged(
600 news.mysql.password !== undefined ||
601 news.mysql.ssl?.caCertificate !== undefined ||
602 news.mysql.ssl?.clientCertificate !== undefined ||
603 news.mysql.ssl?.clientKey !== undefined,
604 publicMysql(current.mysql),
605 publicMysql(news.mysql),
606 );
607 const postgresqlChanged =
608 news.postgresql !== undefined &&
609 secretChanged(
610 news.postgresql.password !== undefined ||
611 news.postgresql.ssl?.caCertificate !== undefined ||
612 news.postgresql.ssl?.clientCertificate !== undefined ||
613 news.postgresql.ssl?.clientKey !== undefined ||
614 news.postgresql.forwardSshConnectivity?.password !== undefined ||
615 news.postgresql.forwardSshConnectivity?.privateKey !== undefined,
616 publicPostgresql(current.postgresql),
617 publicPostgresql(news.postgresql),
618 );
619 const oracleChanged =
620 news.oracle !== undefined &&
621 secretChanged(
622 news.oracle.password !== undefined ||
623 news.oracle.ssl?.caCertificate !== undefined ||
624 news.oracle.oracleAsmConfig?.password !== undefined,
625 publicOracle(current.oracle),
626 publicOracle(news.oracle),
627 );
628 const sqlserverChanged =
629 news.sqlserver !== undefined &&
630 secretChanged(
631 news.sqlserver.password !== undefined ||
632 news.sqlserver.ssl?.caCertificate !== undefined,
633 publicSqlserver(current.sqlserver),
634 publicSqlserver(news.sqlserver),
635 );
636 const mask = fieldMask([
637 labelsChanged && "labels",
638 displayNameChanged && "displayName",
639 providerChanged && "provider",
640 roleChanged && "role",
641 mysqlChanged && "mysql",
642 postgresqlChanged && "postgresql",
643 oracleChanged && "oracle",
644 sqlserverChanged && "sqlserver",
645 ]);
646
647 if (mask.length > 0) {
648 const operation = yield* dm.patchProjectsLocationsConnectionProfiles({
649 name: current.name ?? name,
650 updateMask: mask,
651 skipValidation: news.skipValidation,
652 validateOnly: news.validateOnly,
653 body: {
654 name: current.name ?? name,
655 displayName,
656 labels: desiredLabels,
657 provider: news.provider,
658 role: news.role,
659 mysql: news.mysql,
660 postgresql: news.postgresql,
661 oracle: news.oracle,
662 sqlserver: news.sqlserver,
663 },
664 });
665 yield* waitForOperation(operation);
666 current = yield* waitUntilExists(
667 getByName(current.name ?? name),
668 current.name ?? name,
669 );
670 }
671
672 return toAttrs(current, env.project);
673 }),
674
675 delete: Effect.fn(function* ({ output }) {
676 const operation = yield* dm
677 .deleteProjectsLocationsConnectionProfiles({
678 name: output.name,
679 force: true,
680 })
681 .pipe(
682 Effect.retry({
683 while: (error) => error._tag === "Conflict",
684 times: 8,
685 schedule: Schedule.spaced("2 seconds"),
686 }),
687 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
688 );
689 if (operation !== undefined) {
690 yield* waitForOperation(operation, { notFoundOk: true });
691 }
692 yield* waitUntilGone(getByName(output.name), output.name);
693 }),
694 });