1import * as GCP from "@/GCP";
2import { zipFiles } from "@/Util/zip.ts";
3import * as Test from "@/Test/Alchemy";
4import * as cloudfunctions from "@distilled.cloud/gcp/cloudfunctions_v2";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import { GcpEnvironment } from "@/GCP/Environment";
12
13const { test } = Test.make({ providers: GCP.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const LOCATION = "us-central1";
21
22// Gen2 function create/update/delete is a multi-minute LRO. Set
23// GCP_TEST_CLOUDFUNCTIONS=1 to run the lifecycle; default recapture
24// keeps the list probe only.
25// gen2 function builds take 2-4 minutes.
26const runLifecycle = !process.env.FAST;
27
28const waitUntilGone = (name: string) =>
29 cloudfunctions.getProjectsLocationsFunctions({ name }).pipe(
30 Effect.as("found" as const),
31 Effect.catchTag("NotFound", () => Effect.succeed("gone" as const)),
32 Effect.repeat({
33 schedule: Schedule.spaced("3 seconds"),
34 until: (status) => status === "gone",
35 times: 10,
36 }),
37 );
38
39const uploadSource = Effect.fn(function* () {
40 const { project } = yield* GcpEnvironment.current;
41 const archive = yield* zipFiles([
42 {
43 path: "index.js",
44 content: `exports.helloHttp = (req, res) => { res.status(200).send("ok"); };\n`,
45 },
46 {
47 path: "package.json",
48 content: JSON.stringify({
49 name: "hello",
50 private: true,
51 main: "index.js",
52 }),
53 },
54 ]);
55 const uploaded =
56 yield* cloudfunctions.generateUploadUrlProjectsLocationsFunctions({
57 parent: `projects/${project}/locations/${LOCATION}`,
58 body: { environment: "GEN_2" },
59 });
60 if (uploaded.uploadUrl === undefined) {
61 return yield* Effect.die(
62 new Error("generateUploadUrl did not return uploadUrl"),
63 );
64 }
65 const client = yield* HttpClient.HttpClient;
66 const bytes = yield* Effect.sync(() => new Uint8Array(archive));
67 const response = yield* client.execute(
68 HttpClientRequest.put(uploaded.uploadUrl).pipe(
69 HttpClientRequest.bodyUint8Array(bytes, "application/zip"),
70 ),
71 );
72
73 if (response.status < 200 || response.status >= 300) {
74 return yield* Effect.die(
75 new Error(`source upload failed with HTTP ${response.status}`),
76 );
77 }
78 if (uploaded.storageSource === undefined) {
79 return yield* Effect.die(
80 new Error("generateUploadUrl did not return storageSource"),
81 );
82 }
83 return uploaded.storageSource;
84});
85
86test.provider(
87 "lists functions",
88 (stack) =>
89 Effect.gen(function* () {
90 const { project } = yield* GcpEnvironment.current;
91 yield* stack.destroy();
92 const page = yield* cloudfunctions.listProjectsLocationsFunctions({
93 parent: `projects/${project}/locations/-`,
94 pageSize: 10,
95 });
96 for (const fn of page.functions ?? []) {
97 expect(fn.name).toMatch(
98 new RegExp(`^projects/${project}/locations/[^/]+/functions/`),
99 );
100 }
101 yield* stack.destroy();
102 }).pipe(logLevel),
103 {
104 tags: ["provider:gcp", "provider:gcp:cloudfunctions", "live"],
105 timeout: 60_000,
106 },
107);
108
109test.provider.skipIf(!runLifecycle)(
110 "create, update, and delete a function",
111 (stack) =>
112 Effect.gen(function* () {
113 yield* stack.destroy();
114
115 const storageSource = yield* uploadSource();
116
117 const created = yield* stack.deploy(
118 Effect.gen(function* () {
119 return yield* GCP.CloudFunctions.Function("Hello", {
120 location: LOCATION,
121 description: "test http function",
122 labels: { env: "test" },
123 buildConfig: {
124 runtime: "nodejs20",
125 entryPoint: "helloHttp",
126 source: { storageSource },
127 },
128 serviceConfig: {
129 timeoutSeconds: 60,
130 maxInstanceCount: 1,
131 },
132 });
133 }),
134 );
135
136 expect(created.name).toContain("/functions/");
137 expect(created.functionId).toEqual(expect.any(String));
138 expect(created.location).toEqual(LOCATION);
139 expect(created.environment).toEqual("GEN_2");
140 expect(created.description).toEqual("test http function");
141 expect(created.labels).toMatchObject({ env: "test" });
142 expect(created.runtime).toEqual("nodejs20");
143 expect(created.entryPoint).toEqual("helloHttp");
144 expect(created.state).toEqual("ACTIVE");
145
146 const fetched = yield* cloudfunctions.getProjectsLocationsFunctions({
147 name: created.name,
148 });
149 expect(fetched.name).toEqual(created.name);
150 expect(fetched.labels?.env).toEqual("test");
151 expect(fetched.description).toEqual("test http function");
152 expect(fetched.buildConfig?.runtime).toEqual("nodejs20");
153 expect(fetched.serviceConfig?.timeoutSeconds).toEqual(60);
154
155 const downloaded =
156 yield* cloudfunctions.generateDownloadUrlProjectsLocationsFunctions({
157 name: created.name,
158 body: {},
159 });
160 expect(downloaded.downloadUrl).toEqual(expect.any(String));
161
162 const updated = yield* stack.deploy(
163 Effect.gen(function* () {
164 return yield* GCP.CloudFunctions.Function("Hello", {
165 functionId: created.functionId,
166 location: LOCATION,
167 description: "prod http function",
168 labels: { env: "prod", role: "http" },
169 });
170 }),
171 );
172
173 expect(updated.name).toEqual(created.name);
174 expect(updated.description).toEqual("prod http function");
175 expect(updated.labels).toMatchObject({ env: "prod", role: "http" });
176
177 const refetched = yield* cloudfunctions.getProjectsLocationsFunctions({
178 name: created.name,
179 });
180 expect(refetched.description).toEqual("prod http function");
181 expect(refetched.labels?.env).toEqual("prod");
182 expect(refetched.labels?.role).toEqual("http");
183
184 yield* stack.destroy();
185
186 const gone = yield* waitUntilGone(created.name);
187 expect(gone).toEqual("gone");
188 }).pipe(logLevel),
189 {
190 tags: ["provider:gcp", "provider:gcp:cloudfunctions", "live"],
191 timeout: 240_000,
192 },
193);