1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
5import * as secretmanager from "@distilled.cloud/gcp/secretmanager_v1";
6import * as sqladmin from "@distilled.cloud/gcp/sqladmin_v1";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { dockerAvailable, expectProbe } from "../bindingHost.ts";
10import SqlBindingsHost, {
11 Db,
12 USER_PASSWORD,
13} from "./fixtures/bindings-host.ts";
14
15const testOptions = { providers: GCP.providers() };
16const { test, beforeAll, afterAll } = Test.make(testOptions);
17const sharedStack = Core.scratchStack(testOptions, "SqlBindings");
18
19// Cloud SQL instances take well over 5 minutes to provision.
20const runLifecycle = !!process.env.GCP_TEST_SLOW && !process.env.FAST;
21
22let baseUrl: string;
23let hostAccount: string;
24let project: string;
25let region: string;
26let instanceName: string;
27let databaseName: string;
28let userName: string;
29let secretName: string;
30
31/** IAM Condition the bindings scope their project grants with. */
32const instanceCondition = () => {
33 const name = `projects/${project}/instances/${instanceName}`;
34 return `resource.name == "${name}" || resource.name.startsWith("${name}/")`;
35};
36
37/**
38 * Project-level roles held by the host's service account, with their IAM
39 * Condition. Cloud SQL has no instance-level IAM policy.
40 */
41const projectRoles = () =>
42 resourcemanager
43 .getIamPolicyProjects({
44 resource: `projects/${project}`,
45 body: { options: { requestedPolicyVersion: 3 } },
46 })
47 .pipe(
48 Effect.map((policy) =>
49 (policy.bindings ?? [])
50 .filter((binding) =>
51 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
52 )
53 .map((binding) => ({
54 role: binding.role,
55 condition: binding.condition?.expression,
56 }))
57 .sort((a, b) => (a.role ?? "").localeCompare(b.role ?? "")),
58 ),
59 );
60
61const expectRole = (role: string) =>
62 Effect.gen(function* () {
63 expect(yield* projectRoles()).toContainEqual({
64 role,
65 condition: instanceCondition(),
66 });
67 });
68
69describe.skipIf(!dockerAvailable || !runLifecycle)(
70 "SQL Bindings",
71 {
72 tags: ["provider:gcp", "provider:gcp:sql", "provider:gcp:run", "live"],
73 },
74 () => {
75 beforeAll(
76 Effect.gen(function* () {
77 yield* sharedStack.destroy();
78 const out = yield* sharedStack.deploy(
79 Effect.gen(function* () {
80 const host = yield* SqlBindingsHost;
81 const { instance, database, user, password } = yield* Db;
82 return {
83 uri: host.uri,
84 serviceAccount: host.serviceAccount,
85 project: host.project,
86 region: instance.region,
87 instance: instance.instanceName,
88 database: database.databaseName,
89 user: user.userName,
90 secret: password.name,
91 };
92 }),
93 );
94 baseUrl = out.uri!;
95 hostAccount = out.serviceAccount!;
96 project = out.project;
97 region = out.region;
98 instanceName = out.instance;
99 databaseName = out.database;
100 userName = out.user;
101 secretName = out.secret;
102
103 // The user's password, where Connect and the Data API read it.
104 const payload = yield* Effect.sync(() =>
105 Buffer.from(USER_PASSWORD, "utf8").toString("base64"),
106 );
107 yield* Core.withProviders(
108 secretmanager.addVersionProjectsLocationsSecrets({
109 parent: secretName,
110 body: { payload: { data: payload } },
111 }),
112 testOptions,
113 "SqlBindings",
114 );
115 }),
116 { timeout: 1_800_000 },
117 );
118
119 afterAll(sharedStack.destroy(), { timeout: 1_200_000 });
120
121 describe("Connect", () => {
122 test.provider(
123 "queries the database over the Cloud SQL socket as the host",
124 (_stack) =>
125 Effect.gen(function* () {
126 const out = yield* expectProbe<{
127 connectionName: string;
128 socketPath: string;
129 username: string;
130 database: string;
131 passwordMatches: boolean;
132 rows: { user: string; database: string }[];
133 }>(baseUrl, "connect");
134 expect(out.connectionName).toEqual(
135 `${project}:${region}:${instanceName}`,
136 );
137 expect(out.socketPath).toEqual(
138 `/cloudsql/${project}:${region}:${instanceName}`,
139 );
140 expect(out.username).toEqual(userName);
141 expect(out.database).toEqual(databaseName);
142 expect(out.passwordMatches).toEqual(true);
143 expect(out.rows).toEqual([
144 { user: userName, database: databaseName },
145 ]);
146
147 yield* expectRole("roles/cloudsql.client");
148 const secretPolicy =
149 yield* secretmanager.getIamPolicyProjectsLocationsSecrets({
150 resource: secretName,
151 });
152 expect(
153 (secretPolicy.bindings ?? [])
154 .filter((binding) =>
155 (binding.members ?? []).includes(
156 `serviceAccount:${hostAccount}`,
157 ),
158 )
159 .map((binding) => binding.role),
160 ).toEqual(["roles/secretmanager.secretAccessor"]);
161 }),
162 {
163 tags: ["provider:gcp", "provider:gcp:sql", "live"],
164 timeout: 900_000,
165 },
166 );
167 });
168
169 describe("ExecuteSql", () => {
170 test.provider(
171 "runs a statement through the Data API as the host",
172 (_stack) =>
173 Effect.gen(function* () {
174 const out = yield* expectProbe<{
175 status: { code?: number } | undefined;
176 columns: string[];
177 values: string[];
178 }>(baseUrl, "executeSql");
179 expect(out.status?.code ?? 0).toEqual(0);
180 expect(out.columns).toEqual(["who", "answer"]);
181 expect(out.values).toEqual([userName, "42"]);
182 yield* expectRole("roles/cloudsql.instanceUser");
183 }),
184 {
185 tags: ["provider:gcp", "provider:gcp:sql", "live"],
186 timeout: 900_000,
187 },
188 );
189 });
190
191 describe("GetInstance", () => {
192 test.provider(
193 "reads the bound instance as the host",
194 (_stack) =>
195 Effect.gen(function* () {
196 const out = yield* expectProbe<{
197 name: string;
198 state: string;
199 connectionName: string;
200 }>(baseUrl, "getInstance");
201 const live = yield* sqladmin.getInstances({
202 project,
203 instance: instanceName,
204 });
205 expect(out.name).toEqual(instanceName);
206 expect(out.state).toEqual("RUNNABLE");
207 expect(out.connectionName).toEqual(live.connectionName);
208 yield* expectRole("roles/cloudsql.viewer");
209 }),
210 {
211 tags: ["provider:gcp", "provider:gcp:sql", "live"],
212 timeout: 900_000,
213 },
214 );
215 });
216
217 describe("GetUser", () => {
218 test.provider(
219 "reads the bound user as the host",
220 (_stack) =>
221 Effect.gen(function* () {
222 const out = yield* expectProbe<{ name: string; instance: string }>(
223 baseUrl,
224 "getUser",
225 );
226 expect(out.name).toEqual(userName);
227 expect(out.instance).toEqual(instanceName);
228 // users.get does not match an instance-scoped IAM Condition.
229 expect(yield* projectRoles()).toContainEqual({
230 role: "roles/cloudsql.viewer",
231 condition: undefined,
232 });
233 }),
234 {
235 tags: ["provider:gcp", "provider:gcp:sql", "live"],
236 timeout: 900_000,
237 },
238 );
239 });
240
241 describe("grants", () => {
242 test.provider(
243 "the host holds only the bindings' roles, instance-scoped except GetUser's",
244 (_stack) =>
245 Effect.gen(function* () {
246 // Wait for the probes above so every grant is in place.
247 yield* expectProbe(baseUrl, "getInstance");
248 const roles = yield* projectRoles();
249 expect(roles).toHaveLength(4);
250 expect(roles).toEqual(
251 expect.arrayContaining([
252 ...[
253 "roles/cloudsql.client",
254 "roles/cloudsql.instanceUser",
255 "roles/cloudsql.viewer",
256 ].map((role) => ({ role, condition: instanceCondition() })),
257 { role: "roles/cloudsql.viewer", condition: undefined },
258 ]),
259 );
260 }),
261 {
262 tags: ["provider:gcp", "provider:gcp:sql", "live"],
263 timeout: 900_000,
264 },
265 );
266 });
267 },
268);