1import * as GCP from "@/GCP";
2import * as Test from "@/Test/Alchemy";
3import * as Core from "@/Test/Core";
4import * as ml from "@distilled.cloud/gcp/ml_v1";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { dockerAvailable, expectProbe } from "../bindingHost.ts";
8import { runLifecycle, runVersionLifecycle } from "./common.ts";
9import MlBindingsHost, { Classifier, V1 } from "./fixtures/bindings-host.ts";
10
11const testOptions = { providers: GCP.providers() };
12const { test, beforeAll, afterAll } = Test.make(testOptions);
13const sharedStack = Core.scratchStack(testOptions, "MlBindings");
14
15let baseUrl: string;
16let hostAccount: string;
17let modelName: string;
18let versionName: string | undefined;
19
20/** Every binding grants `roles/ml.modelUser` on the (parent) model only. */
21const expectModelGrant = Effect.gen(function* () {
22 const policy = yield* ml.getIamPolicyProjectsModels({
23 resource: modelName,
24 "options.requestedPolicyVersion": 3,
25 });
26 const roles = (policy.bindings ?? [])
27 .filter((binding) =>
28 (binding.members ?? []).includes(`serviceAccount:${hostAccount}`),
29 )
30 .map((binding) => binding.role);
31 expect(roles).toEqual(["roles/ml.modelUser"]);
32});
33
34// ml.googleapis.com is deprecated; see ./common.ts for the gates.
35describe.skipIf(!dockerAvailable || !runLifecycle)(
36 "ML Bindings",
37 { tags: ["provider:gcp", "provider:gcp:ml", "provider:gcp:run", "live"] },
38 () => {
39 beforeAll(
40 Effect.gen(function* () {
41 yield* sharedStack.destroy();
42 const out = yield* sharedStack.deploy(
43 Effect.gen(function* () {
44 const host = yield* MlBindingsHost;
45 const model = yield* Classifier;
46 const version = runVersionLifecycle ? yield* V1 : undefined;
47 return {
48 uri: host.uri,
49 serviceAccount: host.serviceAccount,
50 model: model.name,
51 version: version?.name,
52 };
53 }),
54 );
55 baseUrl = out.uri!;
56 hostAccount = out.serviceAccount!;
57 modelName = out.model;
58 versionName = out.version;
59 }),
60 { timeout: 900_000 },
61 );
62
63 afterAll(sharedStack.destroy(), { timeout: 600_000 });
64
65 describe("GetModel", () => {
66 test.provider(
67 "reads the model as the host's service account",
68 (_stack) =>
69 Effect.gen(function* () {
70 const out = yield* expectProbe<ml.GoogleCloudMlV1__Model>(
71 baseUrl,
72 "getModel",
73 );
74 const direct = yield* ml.getProjectsModels({ name: modelName });
75 expect(out.name).toEqual(modelName);
76 expect(out.description).toEqual("binding probe");
77 expect(out.etag).toEqual(direct.etag);
78 yield* expectModelGrant;
79 }),
80 { tags: ["provider:gcp", "provider:gcp:ml", "live"], timeout: 600_000 },
81 );
82 });
83
84 // Prediction needs a deployed (default) version.
85 describe.skipIf(!runVersionLifecycle)("Predict", () => {
86 test.provider(
87 "predicts against the model's default version",
88 (_stack) =>
89 Effect.gen(function* () {
90 const out = yield* expectProbe<ml.GoogleApi__HttpBody>(
91 baseUrl,
92 "predict",
93 );
94 const body = JSON.parse(
95 Buffer.from(out.data ?? "", "base64").toString("utf8"),
96 ) as { predictions?: unknown[] };
97 expect(body.predictions).toHaveLength(1);
98 const model = yield* ml.getProjectsModels({ name: modelName });
99 expect(model.defaultVersion?.name).toEqual(versionName);
100 yield* expectModelGrant;
101 }),
102 { tags: ["provider:gcp", "provider:gcp:ml", "live"], timeout: 600_000 },
103 );
104 });
105
106 describe.skipIf(!runVersionLifecycle)("GetVersion", () => {
107 test.provider(
108 "reads the version as the host's service account",
109 (_stack) =>
110 Effect.gen(function* () {
111 const out = yield* expectProbe<ml.GoogleCloudMlV1__Version>(
112 baseUrl,
113 "getVersion",
114 );
115 const direct = yield* ml.getProjectsModelsVersions({
116 name: versionName!,
117 });
118 expect(out.name).toEqual(versionName);
119 expect(out.state).toEqual(direct.state);
120 yield* expectModelGrant;
121 }),
122 { tags: ["provider:gcp", "provider:gcp:ml", "live"], timeout: 600_000 },
123 );
124 });
125 },
126);