1import * as Effect from "effect/Effect";
2import * as Layer from "effect/Layer";
3import * as Redacted from "effect/Redacted";
4import * as Output from "../../Output.ts";
5import { bindGcpHost } from "../Host.ts";
6import { CLOUD_SQL_MOUNT_PATH } from "../HostRuntime.ts";
7import { grantFor } from "../HttpBinding.ts";
8import { ReadSecret } from "../SecretManager/ReadSecret.ts";
9import { ReadSecretHttp } from "../SecretManager/ReadSecretHttp.ts";
10import {
11 Connect,
12 PasswordMissing,
13 type ConnectionInfo,
14 type ConnectOptions,
15} from "./Connect.ts";
16import type { Instance } from "./Instance.ts";
17
18const socketUrl = (options: {
19 databaseVersion: string | undefined;
20 socketPath: string;
21 database: string;
22 username: string;
23 password: string;
24}) => {
25 const mysql = (options.databaseVersion ?? "")
26 .toUpperCase()
27 .startsWith("MYSQL_");
28 // The socket travels as a query parameter; `localhost` only satisfies
29 // URL parsers that reject credentials with an empty host.
30 const url = new URL(
31 `${mysql ? "mysql" : "postgresql"}://localhost/${encodeURIComponent(options.database)}`,
32 );
33 url.username = encodeURIComponent(options.username);
34 url.password = encodeURIComponent(options.password);
35 url.searchParams.set(mysql ? "socketPath" : "host", options.socketPath);
36 return url.toString();
37};
38
39/**
40 * HTTP implementation of {@link Connect}: reads the password through
41 * `GCP.SecretManager.ReadSecret` and connects over the Cloud Run
42 * `cloudsql` socket volume.
43 *
44 * @layer
45 * @provides GCP.SQL.Connect
46 * @category SQL
47 */
48export const ConnectHttp = Layer.effect(
49 Connect,
50 Effect.gen(function* () {
51 const readSecret = yield* ReadSecret;
52 return Effect.fn(function* (instance: Instance, options: ConnectOptions) {
53 yield* bindGcpHost({
54 tag: "GCP.SQL.Connect",
55 resource: instance,
56 // Cloud SQL has no instance-level IAM policy; an IAM Condition
57 // scopes the project grant to this instance.
58 iam: [
59 grantFor(
60 { role: "roles/cloudsql.client", scopeByCondition: true },
61 Output.interpolate`projects/${instance.project}/instances/${instance.instanceName}`,
62 ),
63 ],
64 cloudSqlInstances: [
65 Output.interpolate`${instance.project}:${instance.region}:${instance.instanceName}`,
66 ],
67 });
68 const password = yield* readSecret(options.passwordSecret);
69 const project = yield* instance.project;
70 const region = yield* instance.region;
71 const instanceName = yield* instance.instanceName;
72 const databaseVersion = yield* instance.databaseVersion;
73 const database = yield* options.database.databaseName;
74 const username = yield* options.user.userName;
75 const secretName = yield* options.passwordSecret.name;
76
77 return Effect.gen(function* () {
78 const value = yield* password.access();
79 if (value === undefined) {
80 return yield* new PasswordMissing({ secret: yield* secretName });
81 }
82 const connectionName = `${yield* project}:${yield* region}:${yield* instanceName}`;
83 const socketPath = `${CLOUD_SQL_MOUNT_PATH}/${connectionName}`;
84 const databaseName = yield* database;
85 const userName = yield* username;
86 const info: ConnectionInfo = {
87 connectionName,
88 socketPath,
89 database: databaseName,
90 username: userName,
91 password: Redacted.make(value),
92 url: Redacted.make(
93 socketUrl({
94 databaseVersion: yield* databaseVersion,
95 socketPath,
96 database: databaseName,
97 username: userName,
98 password: value,
99 }),
100 ),
101 };
102 return info;
103 });
104 });
105 }),
106).pipe(Layer.provide(ReadSecretHttp));