1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import * as Test from "@/Test/Alchemy";
4import * as Core from "@/Test/Core";
5import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
6import * as resourcemanager from "@distilled.cloud/gcp/cloudresourcemanager_v3";
7import { describe, expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10import { callProbe, dockerAvailable, expectProbe } from "../bindingHost.ts";
11import AIPlatformBindingsHost, {
12 Agent,
13 Browser,
14 Code,
15 Paused,
16 Resumed,
17 runSandboxes,
18 runSandboxPause,
19 Train,
20} from "./fixtures/bindings-host.ts";
21
22const testOptions = { providers: GCP.providers() };
23const { test, beforeAll, afterAll } = Test.make(testOptions);
24const sharedStack = Core.scratchStack(testOptions, "AIPlatformBindings");
25
26let baseUrl: string;
27let hostAccount: string;
28let names: {
29 engine: string;
30 pipeline: string;
31 sandbox?: string;
32 template?: string;
33 paused?: string;
34 resumed?: string;
35};
36
37/** Every project-level role (and its IAM Condition) `account` holds. */
38const projectGrantsOf = (account: string) =>
39 Effect.gen(function* () {
40 const { project } = yield* GcpEnvironment.current;
41 const policy = yield* resourcemanager.getIamPolicyProjects({
42 resource: `projects/${project}`,
43 body: { options: { requestedPolicyVersion: 3 } },
44 });
45 return (policy.bindings ?? [])
46 .filter((binding) =>
47 (binding.members ?? []).includes(`serviceAccount:${account}`),
48 )
49 .map((binding) => ({
50 role: binding.role,
51 condition: binding.condition?.expression,
52 }))
53 .sort((left, right) => (left.role ?? "").localeCompare(right.role ?? ""));
54 });
55
56/**
57 * Vertex AI resources have no resource-level IAM policy, so every AI
58 * Platform binding grants on the project: aiplatform.viewer for reads,
59 * aiplatform.user for predict / cancel / pause / resume.
60 */
61const PROJECT_GRANTS = [
62 { role: "roles/aiplatform.user", condition: undefined },
63 { role: "roles/aiplatform.viewer", condition: undefined },
64];
65
66const expectProjectGrants = Effect.gen(function* () {
67 expect(yield* projectGrantsOf(hostAccount)).toEqual(PROJECT_GRANTS);
68});
69
70describe.skipIf(!dockerAvailable)(
71 "AIPlatform Bindings",
72 {
73 tags: [
74 "provider:gcp",
75 "provider:gcp:aiplatform",
76 "provider:gcp:run",
77 "live",
78 ],
79 },
80 () => {
81 beforeAll(
82 Effect.gen(function* () {
83 yield* sharedStack.destroy();
84 const out = yield* sharedStack.deploy(
85 Effect.gen(function* () {
86 const host = yield* AIPlatformBindingsHost;
87 const engine = yield* Agent;
88 const pipeline = yield* Train;
89 const sandboxes = runSandboxes
90 ? {
91 sandbox: (yield* Code).name,
92 template: (yield* Browser).name,
93 }
94 : {};
95 const paused = runSandboxPause
96 ? {
97 paused: (yield* Paused).name,
98 resumed: (yield* Resumed).name,
99 }
100 : {};
101 return {
102 uri: host.uri,
103 serviceAccount: host.serviceAccount,
104 names: {
105 engine: engine.name,
106 pipeline: pipeline.name,
107 ...sandboxes,
108 ...paused,
109 },
110 };
111 }),
112 );
113 baseUrl = out.uri!;
114 hostAccount = out.serviceAccount!;
115 names = out.names;
116 }),
117 { timeout: 900_000 },
118 );
119
120 afterAll(sharedStack.destroy(), { timeout: 600_000 });
121
122 describe("GenerateContent", () => {
123 test.provider(
124 "calls Gemini (global and regional) as the host's service account",
125 (_stack) =>
126 Effect.gen(function* () {
127 const out = yield* expectProbe<{
128 text: string;
129 regional: string;
130 modelVersion?: string;
131 }>(baseUrl, "generateContent");
132 expect(out.text.toLowerCase()).toContain("pong");
133 expect(out.regional.toLowerCase()).toContain("ping");
134 expect(out.modelVersion).toContain("gemini-2.5-flash");
135 yield* expectProjectGrants;
136 }),
137 {
138 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
139 timeout: 600_000,
140 },
141 );
142 });
143
144 describe("GetReasoningEngine", () => {
145 test.provider(
146 "reads the reasoning engine as the host's service account",
147 (_stack) =>
148 Effect.gen(function* () {
149 const live = yield* expectProbe<{
150 name?: string;
151 displayName?: string;
152 }>(baseUrl, "getReasoningEngine");
153 const expected = yield* aiplatform.getReasoningEngines({
154 name: names.engine,
155 });
156 expect(live.name).toEqual(names.engine);
157 expect(live.displayName).toEqual(expected.displayName);
158 yield* expectProjectGrants;
159 }),
160 {
161 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
162 timeout: 600_000,
163 },
164 );
165 });
166
167 describe("QueryReasoningEngine", () => {
168 test.provider(
169 "queries the reasoning engine as the host's service account",
170 (_stack) =>
171 Effect.gen(function* () {
172 // The engine has no deployed package, so the query is refused
173 // after authorization — the same answer the deployer gets.
174 const outcome = yield* callProbe(baseUrl, "queryReasoningEngine");
175 expect(outcome.ok ? "ok" : outcome.error._tag).toEqual(
176 "ReasoningEngineNotRunning",
177 );
178 const expected = yield* aiplatform
179 .queryReasoningEngines({
180 name: names.engine,
181 body: { input: { input: "hello" } },
182 })
183 .pipe(
184 Effect.map(() => "ok"),
185 Effect.catchTag("ReasoningEngineNotRunning", (error) =>
186 Effect.succeed(error._tag),
187 ),
188 );
189 expect(expected).toEqual("ReasoningEngineNotRunning");
190 yield* expectProjectGrants;
191 }),
192 {
193 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
194 timeout: 600_000,
195 },
196 );
197 });
198
199 describe("GetTrainingPipeline", () => {
200 test.provider(
201 "reads the training pipeline as the host's service account",
202 (_stack) =>
203 Effect.gen(function* () {
204 const live = yield* expectProbe<{
205 name?: string;
206 displayName?: string;
207 }>(baseUrl, "getTrainingPipeline");
208 expect(live.name).toEqual(names.pipeline);
209 expect(live.displayName).toEqual("alchemy-binding-pipeline");
210 yield* expectProjectGrants;
211 }),
212 {
213 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
214 timeout: 600_000,
215 },
216 );
217 });
218
219 describe("CancelTrainingPipeline", () => {
220 test.provider(
221 "cancels the training pipeline as the host's service account",
222 (_stack) =>
223 Effect.gen(function* () {
224 yield* expectProbe(baseUrl, "cancelTrainingPipeline");
225 const pipeline = yield* aiplatform
226 .getProjectsLocationsTrainingPipelines({ name: names.pipeline })
227 .pipe(
228 Effect.repeat({
229 schedule: Schedule.spaced("5 seconds"),
230 // Wait for CANCELLED (not just CANCELLING): only a
231 // settled pipeline can be deleted on teardown.
232 until: (live) => live.state === "PIPELINE_STATE_CANCELLED",
233 times: 36,
234 }),
235 );
236 expect(pipeline.state).toEqual("PIPELINE_STATE_CANCELLED");
237 yield* expectProjectGrants;
238 }),
239 {
240 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
241 timeout: 600_000,
242 },
243 );
244 });
245
246 describe.skipIf(!runSandboxes)("GetSandboxEnvironment", () => {
247 test.provider(
248 "reads the sandbox environment as the host's service account",
249 (_stack) =>
250 Effect.gen(function* () {
251 const live = yield* expectProbe<{
252 name?: string;
253 displayName?: string;
254 }>(baseUrl, "getSandboxEnvironment");
255 const expected =
256 yield* aiplatform.getReasoningEnginesSandboxEnvironments({
257 name: names.sandbox!,
258 });
259 expect(live.name).toEqual(names.sandbox);
260 expect(live.displayName).toEqual(expected.displayName);
261 expect(live.displayName).toContain("code");
262 yield* expectProjectGrants;
263 }),
264 {
265 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
266 timeout: 600_000,
267 },
268 );
269 });
270
271 describe.skipIf(!runSandboxes)("GetSandboxEnvironmentTemplate", () => {
272 test.provider(
273 "reads the sandbox environment template as the host's service account",
274 (_stack) =>
275 Effect.gen(function* () {
276 const live = yield* expectProbe<{
277 name?: string;
278 displayName?: string;
279 }>(baseUrl, "getSandboxEnvironmentTemplate");
280 const expected =
281 yield* aiplatform.getReasoningEnginesSandboxEnvironmentTemplates({
282 name: names.template!,
283 });
284 expect(live.name).toEqual(names.template);
285 expect(live.displayName).toEqual(expected.displayName);
286 expect(live.displayName).toContain("browser");
287 yield* expectProjectGrants;
288 }),
289 {
290 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
291 timeout: 600_000,
292 },
293 );
294 });
295
296 describe.skipIf(!runSandboxPause)("PauseSandboxEnvironment", () => {
297 test.provider(
298 "pauses the sandbox environment as the host's service account",
299 (_stack) =>
300 Effect.gen(function* () {
301 yield* expectProbe(baseUrl, "pauseSandboxEnvironment");
302 const live = yield* aiplatform
303 .getReasoningEnginesSandboxEnvironments({ name: names.paused! })
304 .pipe(
305 Effect.repeat({
306 schedule: Schedule.spaced("5 seconds"),
307 until: (sandbox) => sandbox.state === "STATE_PAUSED",
308 times: 18,
309 }),
310 );
311 expect(live.state).toEqual("STATE_PAUSED");
312 yield* expectProjectGrants;
313 }),
314 {
315 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
316 timeout: 600_000,
317 },
318 );
319 });
320
321 describe.skipIf(!runSandboxPause)("ResumeSandboxEnvironment", () => {
322 test.provider(
323 "resumes a paused sandbox environment as the host's service account",
324 (_stack) =>
325 Effect.gen(function* () {
326 yield* expectProbe(baseUrl, "resumeSandboxEnvironment");
327 const live = yield* aiplatform
328 .getReasoningEnginesSandboxEnvironments({ name: names.resumed! })
329 .pipe(
330 Effect.repeat({
331 schedule: Schedule.spaced("5 seconds"),
332 until: (sandbox) => sandbox.state === "STATE_RUNNING",
333 times: 18,
334 }),
335 );
336 expect(live.state).toEqual("STATE_RUNNING");
337 yield* expectProjectGrants;
338 }),
339 {
340 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
341 timeout: 600_000,
342 },
343 );
344 });
345 },
346);