1import * as GCP from "@/GCP";
2import { GcpEnvironment } from "@/GCP/Environment";
3import { defaultComputeServiceAccount } from "@/GCP/Host";
4import * as Output from "@/Output";
5import * as Test from "@/Test/Alchemy";
6import * as aiplatform from "@distilled.cloud/gcp/aiplatform_v1";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11
12const { test } = Test.make({ providers: GCP.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// The job provisions a Colab runtime and runs the notebook (2-4 minutes).
20const runLifecycle = !process.env.FAST;
21
22const waitUntilGone = (name: string) =>
23 aiplatform.getProjectsLocationsNotebookExecutionJobs({ name }).pipe(
24 Effect.as("found" as const),
25 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
26 Effect.repeat({
27 schedule: Schedule.spaced("2 seconds"),
28 until: (status) => status === "gone",
29 times: 10,
30 }),
31 );
32
33const emptyNotebook =
34 "eyJuYmZvcm1hdCI6NCwibmJmb3JtYXRfbWlub3IiOjUsIm1ldGFkYXRhIjp7fSwiY2VsbHMiOltdfQ==";
35
36test.provider(
37 "getProjectsLocationsNotebookExecutionJobs on a missing job fails with a typed tag",
38 (stack) =>
39 Effect.gen(function* () {
40 const { project } = yield* GcpEnvironment.current;
41 const parent = `projects/${project}/locations/us-central1`;
42 yield* stack.destroy();
43
44 const error = yield* Effect.flip(
45 aiplatform.getProjectsLocationsNotebookExecutionJobs({
46 name: `${parent}/notebookExecutionJobs/alchemy-missing`,
47 }),
48 );
49 expect(error._tag).toEqual("NotFound");
50 const page = yield* aiplatform.listProjectsLocationsNotebookExecutionJobs(
51 {
52 parent,
53 pageSize: 10,
54 },
55 );
56 expect(
57 (page.notebookExecutionJobs ?? []).map((item) => item.name),
58 ).not.toContain(`${parent}/notebookExecutionJobs/alchemy-missing`);
59
60 yield* stack.destroy();
61 }).pipe(logLevel),
62 {
63 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
64 timeout: 90_000,
65 },
66);
67
68test.provider.skipIf(!runLifecycle)(
69 "create and delete a notebook execution job",
70 (stack) =>
71 Effect.gen(function* () {
72 const { project } = yield* GcpEnvironment.current;
73 const serviceAccount = yield* defaultComputeServiceAccount(project);
74 yield* stack.destroy();
75
76 const created = yield* stack.deploy(
77 Effect.gen(function* () {
78 const template = yield* GCP.AIPlatform.NotebookRuntimeTemplate(
79 "Runtime",
80 {
81 location: "us-central1",
82 displayName: "alchemy-notebook-runtime",
83 machineSpec: { machineType: "e2-standard-4" },
84 networkSpec: { enableInternetAccess: true },
85 // Standard disk keeps the run clear of the regional SSD quota.
86 dataPersistentDiskSpec: {
87 diskType: "pd-standard",
88 diskSizeGb: "100",
89 },
90 labels: { env: "test" },
91 },
92 );
93 const bucket = yield* GCP.Storage.Bucket("NotebookOut", {
94 location: "US-CENTRAL1",
95 forceDestroy: true,
96 });
97 return yield* GCP.AIPlatform.NotebookExecutionJob("Nightly", {
98 location: "us-central1",
99 displayName: "alchemy-notebook-job",
100 notebookRuntimeTemplateResourceName: template.name,
101 directNotebookSource: { content: emptyNotebook },
102 gcsOutputUri: Output.interpolate`gs://${bucket.bucketName}/notebook-out`,
103 serviceAccount,
104 labels: { env: "test" },
105 });
106 }),
107 );
108
109 expect(created.name).toContain("/notebookExecutionJobs/");
110 expect(created.location).toEqual("us-central1");
111 expect(created.labels).toMatchObject({ env: "test" });
112
113 const fetched =
114 yield* aiplatform.getProjectsLocationsNotebookExecutionJobs({
115 name: created.name,
116 });
117 expect(fetched.name).toEqual(created.name);
118
119 yield* stack.destroy();
120
121 const gone = yield* waitUntilGone(created.name);
122 expect(gone).toEqual("gone");
123 }).pipe(logLevel),
124 {
125 tags: ["provider:gcp", "provider:gcp:aiplatform", "live"],
126 timeout: 600_000,
127 },
128);