openlattice-cloudrun 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +130 -0
- package/dist/cloudrun-provider.d.ts +28 -0
- package/dist/cloudrun-provider.js +641 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.js +2 -0
- package/package.json +37 -0
- package/src/auth.ts +152 -0
- package/src/cloudrun-provider.ts +867 -0
- package/src/index.ts +2 -0
- package/src/types.ts +96 -0
- package/tests/cloudrun-provider.test.ts +1081 -0
- package/tests/conformance.test.ts +26 -0
- package/tests/integration.test.ts +89 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1081 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { CloudRunProvider } from "../src/cloudrun-provider";
|
|
3
|
+
|
|
4
|
+
// ── Mock fetch ──────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => {
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
return { mockFetch };
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
vi.stubGlobal("fetch", mocks.mockFetch);
|
|
12
|
+
|
|
13
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function mockFetchResponse(
|
|
16
|
+
status: number,
|
|
17
|
+
body: unknown,
|
|
18
|
+
headers?: Record<string, string>
|
|
19
|
+
) {
|
|
20
|
+
return {
|
|
21
|
+
ok: status >= 200 && status < 300,
|
|
22
|
+
status,
|
|
23
|
+
headers: {
|
|
24
|
+
get: (key: string) => headers?.[key] ?? null,
|
|
25
|
+
},
|
|
26
|
+
json: () => Promise.resolve(body),
|
|
27
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PROJECT_ID = "test-project";
|
|
32
|
+
const REGION = "us-central1";
|
|
33
|
+
const BASE = `https://run.googleapis.com/v2/projects/${PROJECT_ID}/locations/${REGION}`;
|
|
34
|
+
|
|
35
|
+
function setupDefaultMocks() {
|
|
36
|
+
mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
37
|
+
const method = opts?.method ?? "GET";
|
|
38
|
+
|
|
39
|
+
// POST /services — create service (returns operation)
|
|
40
|
+
if (method === "POST" && url.includes("/services?serviceId=")) {
|
|
41
|
+
return Promise.resolve(
|
|
42
|
+
mockFetchResponse(200, {
|
|
43
|
+
name: "operations/op-123",
|
|
44
|
+
done: true,
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// GET /services/{id} — get service
|
|
50
|
+
if (method === "GET" && url.match(/\/services\/[^/?]+$/)) {
|
|
51
|
+
return Promise.resolve(
|
|
52
|
+
mockFetchResponse(200, {
|
|
53
|
+
name: `projects/${PROJECT_ID}/locations/${REGION}/services/test-svc`,
|
|
54
|
+
uri: "https://test-svc-abc123.us-central1.run.app",
|
|
55
|
+
terminalCondition: {
|
|
56
|
+
type: "Ready",
|
|
57
|
+
state: "CONDITION_SUCCEEDED",
|
|
58
|
+
},
|
|
59
|
+
traffic: [{ percent: 100, type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" }],
|
|
60
|
+
template: {
|
|
61
|
+
containers: [
|
|
62
|
+
{
|
|
63
|
+
image: "python:3.12",
|
|
64
|
+
resources: {
|
|
65
|
+
limits: { cpu: "1", memory: "512Mi" },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// GET /services — list services (healthCheck)
|
|
75
|
+
if (method === "GET" && url.endsWith("/services")) {
|
|
76
|
+
return Promise.resolve(
|
|
77
|
+
mockFetchResponse(200, { services: [] })
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// PATCH /services/{id} — update service (stop/start)
|
|
82
|
+
if (method === "PATCH" && url.match(/\/services\/[^/?]+$/)) {
|
|
83
|
+
return Promise.resolve(
|
|
84
|
+
mockFetchResponse(200, {
|
|
85
|
+
name: "operations/op-456",
|
|
86
|
+
done: true,
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// DELETE /services/{id} — destroy
|
|
92
|
+
if (method === "DELETE" && url.includes("/services/")) {
|
|
93
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// POST /jobs — create job (for exec)
|
|
97
|
+
if (method === "POST" && url.includes("/jobs?jobId=")) {
|
|
98
|
+
return Promise.resolve(
|
|
99
|
+
mockFetchResponse(200, {
|
|
100
|
+
name: "operations/op-job-create",
|
|
101
|
+
done: true,
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// POST /jobs/{id}:run — run job
|
|
107
|
+
if (method === "POST" && url.includes(":run")) {
|
|
108
|
+
return Promise.resolve(
|
|
109
|
+
mockFetchResponse(200, {
|
|
110
|
+
name: "operations/op-job-run",
|
|
111
|
+
done: true,
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// GET /jobs/{id}/executions — list executions
|
|
117
|
+
if (method === "GET" && url.includes("/executions")) {
|
|
118
|
+
return Promise.resolve(
|
|
119
|
+
mockFetchResponse(200, {
|
|
120
|
+
executions: [
|
|
121
|
+
{
|
|
122
|
+
name: "exec-1",
|
|
123
|
+
conditions: [
|
|
124
|
+
{ type: "Completed", state: "CONDITION_SUCCEEDED" },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// DELETE /jobs/{id} — cleanup job
|
|
133
|
+
if (method === "DELETE" && url.includes("/jobs/")) {
|
|
134
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// POST logging API — fetch logs
|
|
138
|
+
if (url.includes("logging.googleapis.com")) {
|
|
139
|
+
return Promise.resolve(
|
|
140
|
+
mockFetchResponse(200, {
|
|
141
|
+
entries: [
|
|
142
|
+
{
|
|
143
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
144
|
+
textPayload: "hello from logs",
|
|
145
|
+
severity: "DEFAULT",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
})
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Tests ───────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("CloudRunProvider", () => {
|
|
159
|
+
let provider: CloudRunProvider;
|
|
160
|
+
|
|
161
|
+
const defaultConfig = {
|
|
162
|
+
projectId: PROJECT_ID,
|
|
163
|
+
region: REGION,
|
|
164
|
+
authMethod: "token" as const,
|
|
165
|
+
accessToken: "test-token",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
vi.clearAllMocks();
|
|
170
|
+
setupDefaultMocks();
|
|
171
|
+
provider = new CloudRunProvider(defaultConfig);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("constructor", () => {
|
|
175
|
+
it("sets name to 'cloudrun'", () => {
|
|
176
|
+
expect(provider.name).toBe("cloudrun");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("declares expected capabilities", () => {
|
|
180
|
+
expect(provider.capabilities.restart).toBe(true);
|
|
181
|
+
expect(provider.capabilities.pause).toBe(false);
|
|
182
|
+
expect(provider.capabilities.snapshot).toBe(false);
|
|
183
|
+
expect(provider.capabilities.gpu).toBe(false);
|
|
184
|
+
expect(provider.capabilities.logs).toBe(true);
|
|
185
|
+
expect(provider.capabilities.tailscale).toBe(false);
|
|
186
|
+
expect(provider.capabilities.architectures).toContain("x86_64");
|
|
187
|
+
expect(provider.capabilities.persistentStorage).toBe(false);
|
|
188
|
+
expect(provider.capabilities.coldStartMs).toBe(3000);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("throws if projectId is missing", () => {
|
|
192
|
+
expect(
|
|
193
|
+
() =>
|
|
194
|
+
new CloudRunProvider({
|
|
195
|
+
projectId: "",
|
|
196
|
+
authMethod: "token",
|
|
197
|
+
accessToken: "tok",
|
|
198
|
+
})
|
|
199
|
+
).toThrow("projectId");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("defaults region to us-central1", () => {
|
|
203
|
+
const p = new CloudRunProvider({
|
|
204
|
+
projectId: "my-proj",
|
|
205
|
+
authMethod: "token",
|
|
206
|
+
accessToken: "tok",
|
|
207
|
+
});
|
|
208
|
+
expect(p.name).toBe("cloudrun");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("provision", () => {
|
|
213
|
+
it("creates a service and returns node info", async () => {
|
|
214
|
+
const node = await provider.provision({
|
|
215
|
+
name: "test-svc",
|
|
216
|
+
runtime: { image: "python:3.12" },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(node.externalId).toBe("test-svc");
|
|
220
|
+
expect(node.endpoints).toHaveLength(1);
|
|
221
|
+
expect(node.endpoints[0].type).toBe("https");
|
|
222
|
+
expect(node.endpoints[0].port).toBe(443);
|
|
223
|
+
expect(node.endpoints[0].url).toContain("run.app");
|
|
224
|
+
expect(node.metadata?.publicUrl).toContain("run.app");
|
|
225
|
+
expect(node.metadata?.cloudRunProject).toBe(PROJECT_ID);
|
|
226
|
+
expect(node.metadata?.cloudRunRegion).toBe(REGION);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("sends correct image in service body", async () => {
|
|
230
|
+
await provider.provision({
|
|
231
|
+
name: "test-svc",
|
|
232
|
+
runtime: { image: "nginx:latest" },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
236
|
+
(c: any[]) =>
|
|
237
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
238
|
+
);
|
|
239
|
+
expect(createCall).toBeDefined();
|
|
240
|
+
const body = JSON.parse(createCall[1].body);
|
|
241
|
+
expect(body.template.containers[0].image).toBe("nginx:latest");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("uses custom port when specified", async () => {
|
|
245
|
+
await provider.provision({
|
|
246
|
+
name: "test-svc",
|
|
247
|
+
runtime: { image: "python:3.12" },
|
|
248
|
+
network: { ports: [{ port: 8080 }] },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
252
|
+
(c: any[]) =>
|
|
253
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
254
|
+
);
|
|
255
|
+
const body = JSON.parse(createCall[1].body);
|
|
256
|
+
expect(body.template.containers[0].ports[0].containerPort).toBe(8080);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("defaults port to 3000", async () => {
|
|
260
|
+
await provider.provision({
|
|
261
|
+
name: "test-svc",
|
|
262
|
+
runtime: { image: "python:3.12" },
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
266
|
+
(c: any[]) =>
|
|
267
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
268
|
+
);
|
|
269
|
+
const body = JSON.parse(createCall[1].body);
|
|
270
|
+
expect(body.template.containers[0].ports[0].containerPort).toBe(3000);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("sets resource limits from spec", async () => {
|
|
274
|
+
await provider.provision({
|
|
275
|
+
name: "test-svc",
|
|
276
|
+
runtime: { image: "python:3.12" },
|
|
277
|
+
cpu: { cores: 2 },
|
|
278
|
+
memory: { sizeGiB: 4 },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
282
|
+
(c: any[]) =>
|
|
283
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
284
|
+
);
|
|
285
|
+
const body = JSON.parse(createCall[1].body);
|
|
286
|
+
expect(body.template.containers[0].resources.limits.cpu).toBe("2");
|
|
287
|
+
expect(body.template.containers[0].resources.limits.memory).toBe(
|
|
288
|
+
"4096Mi"
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("passes environment variables", async () => {
|
|
293
|
+
await provider.provision({
|
|
294
|
+
name: "test-svc",
|
|
295
|
+
runtime: {
|
|
296
|
+
image: "python:3.12",
|
|
297
|
+
env: { FOO: "bar", BAZ: "qux" },
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
302
|
+
(c: any[]) =>
|
|
303
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
304
|
+
);
|
|
305
|
+
const body = JSON.parse(createCall[1].body);
|
|
306
|
+
const envArr = body.template.containers[0].env;
|
|
307
|
+
expect(envArr).toContainEqual({ name: "FOO", value: "bar" });
|
|
308
|
+
expect(envArr).toContainEqual({ name: "BAZ", value: "qux" });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("passes custom command", async () => {
|
|
312
|
+
await provider.provision({
|
|
313
|
+
name: "test-svc",
|
|
314
|
+
runtime: {
|
|
315
|
+
image: "python:3.12",
|
|
316
|
+
command: ["python", "server.py"],
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
321
|
+
(c: any[]) =>
|
|
322
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
323
|
+
);
|
|
324
|
+
const body = JSON.parse(createCall[1].body);
|
|
325
|
+
expect(body.template.containers[0].command).toEqual([
|
|
326
|
+
"python",
|
|
327
|
+
"server.py",
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("sanitizes labels", async () => {
|
|
332
|
+
await provider.provision({
|
|
333
|
+
name: "test-svc",
|
|
334
|
+
runtime: { image: "python:3.12" },
|
|
335
|
+
labels: { "My.Label": "Some Value!" },
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
339
|
+
(c: any[]) =>
|
|
340
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
341
|
+
);
|
|
342
|
+
const body = JSON.parse(createCall[1].body);
|
|
343
|
+
expect(body.labels).toHaveProperty("my_label");
|
|
344
|
+
expect(body.labels["my_label"]).toBe("some_value_");
|
|
345
|
+
expect(body.labels["openlattice-managed"]).toBe("true");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("applies scaling config", async () => {
|
|
349
|
+
const customProvider = new CloudRunProvider({
|
|
350
|
+
...defaultConfig,
|
|
351
|
+
minInstances: 1,
|
|
352
|
+
maxInstances: 5,
|
|
353
|
+
concurrency: 40,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await customProvider.provision({
|
|
357
|
+
name: "test-svc",
|
|
358
|
+
runtime: { image: "python:3.12" },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
362
|
+
(c: any[]) =>
|
|
363
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
364
|
+
);
|
|
365
|
+
const body = JSON.parse(createCall[1].body);
|
|
366
|
+
expect(body.template.scaling.minInstanceCount).toBe(1);
|
|
367
|
+
expect(body.template.scaling.maxInstanceCount).toBe(5);
|
|
368
|
+
expect(body.template.maxInstanceRequestConcurrency).toBe(40);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("generates service ID when name not provided", async () => {
|
|
372
|
+
await provider.provision({
|
|
373
|
+
runtime: { image: "python:3.12" },
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
377
|
+
(c: any[]) =>
|
|
378
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
379
|
+
);
|
|
380
|
+
expect(createCall[0]).toMatch(/serviceId=ol-svc-/);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("polls operation if not immediately done", async () => {
|
|
384
|
+
let pollCount = 0;
|
|
385
|
+
mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
386
|
+
const method = opts?.method ?? "GET";
|
|
387
|
+
|
|
388
|
+
if (method === "POST" && url.includes("/services?serviceId=")) {
|
|
389
|
+
return Promise.resolve(
|
|
390
|
+
mockFetchResponse(200, {
|
|
391
|
+
name: "operations/op-pending",
|
|
392
|
+
done: false,
|
|
393
|
+
})
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Operation polling
|
|
398
|
+
if (url.includes("operations/op-pending")) {
|
|
399
|
+
pollCount++;
|
|
400
|
+
return Promise.resolve(
|
|
401
|
+
mockFetchResponse(200, {
|
|
402
|
+
name: "operations/op-pending",
|
|
403
|
+
done: pollCount >= 2,
|
|
404
|
+
})
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// GET service
|
|
409
|
+
if (method === "GET" && url.match(/\/services\/[^/?]+$/)) {
|
|
410
|
+
return Promise.resolve(
|
|
411
|
+
mockFetchResponse(200, {
|
|
412
|
+
uri: "https://test-svc-abc.us-central1.run.app",
|
|
413
|
+
terminalCondition: { state: "CONDITION_SUCCEEDED" },
|
|
414
|
+
traffic: [{ percent: 100 }],
|
|
415
|
+
})
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const node = await provider.provision({
|
|
423
|
+
name: "test-svc",
|
|
424
|
+
runtime: { image: "python:3.12" },
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(node.externalId).toBe("test-svc");
|
|
428
|
+
expect(pollCount).toBeGreaterThanOrEqual(2);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("includes VPC connector when configured", async () => {
|
|
432
|
+
const customProvider = new CloudRunProvider({
|
|
433
|
+
...defaultConfig,
|
|
434
|
+
vpcConnector: "my-connector",
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await customProvider.provision({
|
|
438
|
+
name: "test-svc",
|
|
439
|
+
runtime: { image: "python:3.12" },
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
443
|
+
(c: any[]) =>
|
|
444
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
445
|
+
);
|
|
446
|
+
const body = JSON.parse(createCall[1].body);
|
|
447
|
+
expect(body.template.vpcAccess.connector).toBe("my-connector");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("includes service account when configured", async () => {
|
|
451
|
+
const customProvider = new CloudRunProvider({
|
|
452
|
+
...defaultConfig,
|
|
453
|
+
serviceAccount: "sa@project.iam.gserviceaccount.com",
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
await customProvider.provision({
|
|
457
|
+
name: "test-svc",
|
|
458
|
+
runtime: { image: "python:3.12" },
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
462
|
+
(c: any[]) =>
|
|
463
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
464
|
+
);
|
|
465
|
+
const body = JSON.parse(createCall[1].body);
|
|
466
|
+
expect(body.template.serviceAccount).toBe(
|
|
467
|
+
"sa@project.iam.gserviceaccount.com"
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
describe("exec", () => {
|
|
473
|
+
it("creates and runs a Cloud Run Job", async () => {
|
|
474
|
+
const result = await provider.exec("test-svc", ["echo", "hello"]);
|
|
475
|
+
|
|
476
|
+
// Should have created a job
|
|
477
|
+
const createJobCall = mocks.mockFetch.mock.calls.find(
|
|
478
|
+
(c: any[]) =>
|
|
479
|
+
c[1]?.method === "POST" && c[0].includes("/jobs?jobId=")
|
|
480
|
+
);
|
|
481
|
+
expect(createJobCall).toBeDefined();
|
|
482
|
+
|
|
483
|
+
// Should have run the job
|
|
484
|
+
const runJobCall = mocks.mockFetch.mock.calls.find(
|
|
485
|
+
(c: any[]) =>
|
|
486
|
+
c[1]?.method === "POST" && c[0].includes(":run")
|
|
487
|
+
);
|
|
488
|
+
expect(runJobCall).toBeDefined();
|
|
489
|
+
|
|
490
|
+
expect(result.exitCode).toBe(0);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("wraps command with cwd and env", async () => {
|
|
494
|
+
await provider.exec("test-svc", ["ls"], {
|
|
495
|
+
cwd: "/app",
|
|
496
|
+
env: { FOO: "bar" },
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const createJobCall = mocks.mockFetch.mock.calls.find(
|
|
500
|
+
(c: any[]) =>
|
|
501
|
+
c[1]?.method === "POST" && c[0].includes("/jobs?jobId=")
|
|
502
|
+
);
|
|
503
|
+
const body = JSON.parse(createJobCall[1].body);
|
|
504
|
+
expect(body.template.template.containers[0].command).toEqual([
|
|
505
|
+
"sh",
|
|
506
|
+
"-c",
|
|
507
|
+
"cd /app && export FOO='bar' && ls",
|
|
508
|
+
]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("calls onStdout and onStderr callbacks", async () => {
|
|
512
|
+
const onStdout = vi.fn();
|
|
513
|
+
const onStderr = vi.fn();
|
|
514
|
+
|
|
515
|
+
await provider.exec("test-svc", ["echo", "hello"], {
|
|
516
|
+
onStdout,
|
|
517
|
+
onStderr,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
expect(onStdout).toHaveBeenCalled();
|
|
521
|
+
expect(onStderr).toHaveBeenCalled();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("sanitizes job ID to meet Cloud Run constraints", async () => {
|
|
525
|
+
// Use a long service name that would exceed 49 chars
|
|
526
|
+
const longName = "a".repeat(60);
|
|
527
|
+
await provider.exec(longName, ["echo", "hello"]);
|
|
528
|
+
|
|
529
|
+
const createJobCall = mocks.mockFetch.mock.calls.find(
|
|
530
|
+
(c: any[]) =>
|
|
531
|
+
c[1]?.method === "POST" && c[0].includes("/jobs?jobId=")
|
|
532
|
+
);
|
|
533
|
+
expect(createJobCall).toBeDefined();
|
|
534
|
+
// Extract jobId from URL
|
|
535
|
+
const url = new URL(createJobCall[0]);
|
|
536
|
+
const jobId = url.searchParams.get("jobId")!;
|
|
537
|
+
expect(jobId.length).toBeLessThanOrEqual(49);
|
|
538
|
+
expect(jobId).toMatch(/^[a-z]([-a-z0-9]*[a-z0-9])?$/);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("returns exit code 1 when execution fails", async () => {
|
|
542
|
+
mocks.mockFetch.mockImplementation((url: string, opts?: RequestInit) => {
|
|
543
|
+
const method = opts?.method ?? "GET";
|
|
544
|
+
|
|
545
|
+
if (method === "GET" && url.match(/\/services\/[^/?]+$/)) {
|
|
546
|
+
return Promise.resolve(
|
|
547
|
+
mockFetchResponse(200, {
|
|
548
|
+
template: {
|
|
549
|
+
containers: [{ image: "python:3.12" }],
|
|
550
|
+
},
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (method === "POST" && url.includes("/jobs?jobId=")) {
|
|
556
|
+
return Promise.resolve(
|
|
557
|
+
mockFetchResponse(200, { name: "ops/op", done: true })
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (method === "POST" && url.includes(":run")) {
|
|
562
|
+
return Promise.resolve(
|
|
563
|
+
mockFetchResponse(200, { name: "ops/op", done: true })
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (method === "GET" && url.includes("/executions")) {
|
|
568
|
+
return Promise.resolve(
|
|
569
|
+
mockFetchResponse(200, {
|
|
570
|
+
executions: [
|
|
571
|
+
{
|
|
572
|
+
name: "exec-1",
|
|
573
|
+
conditions: [
|
|
574
|
+
{ type: "Completed", state: "CONDITION_FAILED" },
|
|
575
|
+
],
|
|
576
|
+
},
|
|
577
|
+
],
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (url.includes("logging.googleapis.com")) {
|
|
583
|
+
return Promise.resolve(
|
|
584
|
+
mockFetchResponse(200, {
|
|
585
|
+
entries: [
|
|
586
|
+
{
|
|
587
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
588
|
+
textPayload: "error: something went wrong",
|
|
589
|
+
severity: "ERROR",
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
})
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (method === "DELETE") {
|
|
597
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const result = await provider.exec("test-svc", [
|
|
604
|
+
"sh",
|
|
605
|
+
"-c",
|
|
606
|
+
"exit 1",
|
|
607
|
+
]);
|
|
608
|
+
|
|
609
|
+
expect(result.exitCode).toBe(1);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
describe("destroy", () => {
|
|
614
|
+
it("deletes the service", async () => {
|
|
615
|
+
await provider.destroy("test-svc");
|
|
616
|
+
|
|
617
|
+
const deleteCall = mocks.mockFetch.mock.calls.find(
|
|
618
|
+
(c: any[]) => c[1]?.method === "DELETE"
|
|
619
|
+
);
|
|
620
|
+
expect(deleteCall).toBeDefined();
|
|
621
|
+
expect(deleteCall[0]).toContain("/services/test-svc");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("is idempotent for 404", async () => {
|
|
625
|
+
mocks.mockFetch.mockImplementation(
|
|
626
|
+
(url: string, opts?: RequestInit) => {
|
|
627
|
+
if (opts?.method === "DELETE") {
|
|
628
|
+
return Promise.resolve(
|
|
629
|
+
mockFetchResponse(404, { error: "not found" })
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
633
|
+
}
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
await expect(
|
|
637
|
+
provider.destroy("nonexistent")
|
|
638
|
+
).resolves.toBeUndefined();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("throws on non-404 errors", async () => {
|
|
642
|
+
mocks.mockFetch.mockImplementation(
|
|
643
|
+
(url: string, opts?: RequestInit) => {
|
|
644
|
+
if (opts?.method === "DELETE") {
|
|
645
|
+
return Promise.resolve(
|
|
646
|
+
mockFetchResponse(500, { error: "internal error" })
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
650
|
+
}
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
await expect(provider.destroy("test-svc")).rejects.toThrow(
|
|
654
|
+
"destroy failed"
|
|
655
|
+
);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe("inspect", () => {
|
|
660
|
+
it("returns running for succeeded condition with traffic", async () => {
|
|
661
|
+
const status = await provider.inspect("test-svc");
|
|
662
|
+
expect(status.status).toBe("running");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("returns stopped for succeeded condition with 0 traffic", async () => {
|
|
666
|
+
mocks.mockFetch.mockImplementation(
|
|
667
|
+
(url: string, opts?: RequestInit) => {
|
|
668
|
+
if (
|
|
669
|
+
(opts?.method ?? "GET") === "GET" &&
|
|
670
|
+
url.match(/\/services\/[^/?]+$/)
|
|
671
|
+
) {
|
|
672
|
+
return Promise.resolve(
|
|
673
|
+
mockFetchResponse(200, {
|
|
674
|
+
terminalCondition: {
|
|
675
|
+
state: "CONDITION_SUCCEEDED",
|
|
676
|
+
},
|
|
677
|
+
traffic: [{ percent: 0 }],
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const status = await provider.inspect("test-svc");
|
|
686
|
+
expect(status.status).toBe("stopped");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("returns stopped for failed condition", async () => {
|
|
690
|
+
mocks.mockFetch.mockImplementation(
|
|
691
|
+
(url: string, opts?: RequestInit) => {
|
|
692
|
+
if (
|
|
693
|
+
(opts?.method ?? "GET") === "GET" &&
|
|
694
|
+
url.match(/\/services\/[^/?]+$/)
|
|
695
|
+
) {
|
|
696
|
+
return Promise.resolve(
|
|
697
|
+
mockFetchResponse(200, {
|
|
698
|
+
terminalCondition: {
|
|
699
|
+
state: "CONDITION_FAILED",
|
|
700
|
+
message: "deployment failed",
|
|
701
|
+
},
|
|
702
|
+
})
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
const status = await provider.inspect("test-svc");
|
|
710
|
+
expect(status.status).toBe("stopped");
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("returns terminated for 404", async () => {
|
|
714
|
+
mocks.mockFetch.mockImplementation(() => {
|
|
715
|
+
return Promise.resolve(
|
|
716
|
+
mockFetchResponse(404, { error: "not found" })
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const status = await provider.inspect("nonexistent");
|
|
721
|
+
expect(status.status).toBe("terminated");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("returns unknown for other errors", async () => {
|
|
725
|
+
mocks.mockFetch.mockRejectedValue(new Error("network error"));
|
|
726
|
+
|
|
727
|
+
const status = await provider.inspect("test-svc");
|
|
728
|
+
expect(status.status).toBe("unknown");
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("returns running for reconciling service", async () => {
|
|
732
|
+
mocks.mockFetch.mockImplementation(
|
|
733
|
+
(url: string, opts?: RequestInit) => {
|
|
734
|
+
if (
|
|
735
|
+
(opts?.method ?? "GET") === "GET" &&
|
|
736
|
+
url.match(/\/services\/[^/?]+$/)
|
|
737
|
+
) {
|
|
738
|
+
return Promise.resolve(
|
|
739
|
+
mockFetchResponse(200, {
|
|
740
|
+
reconciling: true,
|
|
741
|
+
terminalCondition: {},
|
|
742
|
+
})
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
746
|
+
}
|
|
747
|
+
);
|
|
748
|
+
|
|
749
|
+
const status = await provider.inspect("test-svc");
|
|
750
|
+
expect(status.status).toBe("running");
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
describe("stop / start", () => {
|
|
755
|
+
it("stop patches traffic to 0%", async () => {
|
|
756
|
+
await provider.stop("test-svc");
|
|
757
|
+
|
|
758
|
+
const patchCall = mocks.mockFetch.mock.calls.find(
|
|
759
|
+
(c: any[]) => c[1]?.method === "PATCH"
|
|
760
|
+
);
|
|
761
|
+
expect(patchCall).toBeDefined();
|
|
762
|
+
const body = JSON.parse(patchCall[1].body);
|
|
763
|
+
expect(body.traffic[0].percent).toBe(0);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("start patches traffic to 100%", async () => {
|
|
767
|
+
await provider.start("test-svc");
|
|
768
|
+
|
|
769
|
+
const patchCall = mocks.mockFetch.mock.calls.find(
|
|
770
|
+
(c: any[]) => c[1]?.method === "PATCH"
|
|
771
|
+
);
|
|
772
|
+
expect(patchCall).toBeDefined();
|
|
773
|
+
const body = JSON.parse(patchCall[1].body);
|
|
774
|
+
expect(body.traffic[0].percent).toBe(100);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("stop is idempotent for 404", async () => {
|
|
778
|
+
mocks.mockFetch.mockImplementation(
|
|
779
|
+
(url: string, opts?: RequestInit) => {
|
|
780
|
+
if (opts?.method === "PATCH") {
|
|
781
|
+
return Promise.resolve(
|
|
782
|
+
mockFetchResponse(404, { error: "not found" })
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
786
|
+
}
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
await expect(
|
|
790
|
+
provider.stop("nonexistent")
|
|
791
|
+
).resolves.toBeUndefined();
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe("logs", () => {
|
|
796
|
+
it("returns log entries from Cloud Logging", async () => {
|
|
797
|
+
const entries: any[] = [];
|
|
798
|
+
for await (const entry of provider.logs("test-svc")) {
|
|
799
|
+
entries.push(entry);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
expect(entries).toHaveLength(1);
|
|
803
|
+
expect(entries[0].message).toBe("hello from logs");
|
|
804
|
+
expect(entries[0].stream).toBe("stdout");
|
|
805
|
+
expect(entries[0].timestamp).toBeInstanceOf(Date);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it("maps ERROR severity to stderr", async () => {
|
|
809
|
+
mocks.mockFetch.mockImplementation(
|
|
810
|
+
(url: string) => {
|
|
811
|
+
if (url.includes("logging.googleapis.com")) {
|
|
812
|
+
return Promise.resolve(
|
|
813
|
+
mockFetchResponse(200, {
|
|
814
|
+
entries: [
|
|
815
|
+
{
|
|
816
|
+
timestamp: "2024-01-01T00:00:00Z",
|
|
817
|
+
textPayload: "something failed",
|
|
818
|
+
severity: "ERROR",
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
})
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
825
|
+
}
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
const entries: any[] = [];
|
|
829
|
+
for await (const entry of provider.logs("test-svc")) {
|
|
830
|
+
entries.push(entry);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
expect(entries[0].stream).toBe("stderr");
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("passes tail option as pageSize", async () => {
|
|
837
|
+
const entries: any[] = [];
|
|
838
|
+
for await (const entry of provider.logs("test-svc", { tail: 5 })) {
|
|
839
|
+
entries.push(entry);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const loggingCall = mocks.mockFetch.mock.calls.find(
|
|
843
|
+
(c: any[]) => c[0].includes("logging.googleapis.com")
|
|
844
|
+
);
|
|
845
|
+
const body = JSON.parse(loggingCall[1].body);
|
|
846
|
+
expect(body.pageSize).toBe(5);
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it("passes since option as timestamp filter", async () => {
|
|
850
|
+
const since = new Date("2024-06-01T00:00:00Z");
|
|
851
|
+
const entries: any[] = [];
|
|
852
|
+
for await (const entry of provider.logs("test-svc", { since })) {
|
|
853
|
+
entries.push(entry);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const loggingCall = mocks.mockFetch.mock.calls.find(
|
|
857
|
+
(c: any[]) => c[0].includes("logging.googleapis.com")
|
|
858
|
+
);
|
|
859
|
+
const body = JSON.parse(loggingCall[1].body);
|
|
860
|
+
expect(body.filter).toContain("2024-06-01T00:00:00.000Z");
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
it("uses ascending order in follow mode", async () => {
|
|
864
|
+
let pollCount = 0;
|
|
865
|
+
mocks.mockFetch.mockImplementation(
|
|
866
|
+
(url: string, opts?: RequestInit) => {
|
|
867
|
+
if (url.includes("logging.googleapis.com")) {
|
|
868
|
+
pollCount++;
|
|
869
|
+
if (pollCount === 1) {
|
|
870
|
+
return Promise.resolve(
|
|
871
|
+
mockFetchResponse(200, {
|
|
872
|
+
entries: [
|
|
873
|
+
{
|
|
874
|
+
timestamp: "2024-01-01T00:00:01Z",
|
|
875
|
+
textPayload: "first log",
|
|
876
|
+
severity: "DEFAULT",
|
|
877
|
+
},
|
|
878
|
+
],
|
|
879
|
+
})
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
// Second poll returns empty to stop follow
|
|
883
|
+
return Promise.resolve(
|
|
884
|
+
mockFetchResponse(200, { entries: [] })
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
888
|
+
}
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
const entries: any[] = [];
|
|
892
|
+
const iter = provider.logs("test-svc", { follow: true })[Symbol.asyncIterator]();
|
|
893
|
+
const first = await iter.next();
|
|
894
|
+
if (!first.done) entries.push(first.value);
|
|
895
|
+
await iter.return!();
|
|
896
|
+
|
|
897
|
+
expect(entries).toHaveLength(1);
|
|
898
|
+
expect(entries[0].message).toBe("first log");
|
|
899
|
+
|
|
900
|
+
// Verify ascending order was used
|
|
901
|
+
const loggingCall = mocks.mockFetch.mock.calls.find(
|
|
902
|
+
(c: any[]) => c[0].includes("logging.googleapis.com")
|
|
903
|
+
);
|
|
904
|
+
const body = JSON.parse(loggingCall[1].body);
|
|
905
|
+
expect(body.orderBy).toBe("timestamp asc");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("returns empty when logging API fails", async () => {
|
|
909
|
+
mocks.mockFetch.mockImplementation(
|
|
910
|
+
(url: string) => {
|
|
911
|
+
if (url.includes("logging.googleapis.com")) {
|
|
912
|
+
return Promise.resolve(
|
|
913
|
+
mockFetchResponse(403, { error: "forbidden" })
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
917
|
+
}
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
const entries: any[] = [];
|
|
921
|
+
for await (const entry of provider.logs("test-svc")) {
|
|
922
|
+
entries.push(entry);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
expect(entries).toHaveLength(0);
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
describe("healthCheck", () => {
|
|
930
|
+
it("returns healthy when API is reachable", async () => {
|
|
931
|
+
const health = await provider.healthCheck();
|
|
932
|
+
expect(health.healthy).toBe(true);
|
|
933
|
+
expect(health.latencyMs).toBeTypeOf("number");
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("returns unhealthy when API fails", async () => {
|
|
937
|
+
mocks.mockFetch.mockRejectedValue(new Error("network error"));
|
|
938
|
+
|
|
939
|
+
const health = await provider.healthCheck();
|
|
940
|
+
expect(health.healthy).toBe(false);
|
|
941
|
+
expect(health.message).toContain("unreachable");
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
describe("getCost", () => {
|
|
946
|
+
it("returns estimated cost based on resource usage", async () => {
|
|
947
|
+
mocks.mockFetch.mockImplementation(
|
|
948
|
+
(url: string, opts?: RequestInit) => {
|
|
949
|
+
if (
|
|
950
|
+
(opts?.method ?? "GET") === "GET" &&
|
|
951
|
+
url.match(/\/services\/[^/?]+$/)
|
|
952
|
+
) {
|
|
953
|
+
return Promise.resolve(
|
|
954
|
+
mockFetchResponse(200, {
|
|
955
|
+
createTime: new Date(
|
|
956
|
+
Date.now() - 3600 * 1000
|
|
957
|
+
).toISOString(), // 1 hour ago
|
|
958
|
+
template: {
|
|
959
|
+
containers: [
|
|
960
|
+
{
|
|
961
|
+
resources: {
|
|
962
|
+
limits: { cpu: "2", memory: "1024Mi" },
|
|
963
|
+
},
|
|
964
|
+
},
|
|
965
|
+
],
|
|
966
|
+
},
|
|
967
|
+
})
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
971
|
+
}
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
const cost = await provider.getCost("test-svc");
|
|
975
|
+
expect(cost.totalUsd).toBeTypeOf("number");
|
|
976
|
+
expect(cost.totalUsd).toBeGreaterThan(0);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("returns 0 on error", async () => {
|
|
980
|
+
mocks.mockFetch.mockRejectedValue(new Error("network error"));
|
|
981
|
+
|
|
982
|
+
const cost = await provider.getCost("test-svc");
|
|
983
|
+
expect(cost.totalUsd).toBe(0);
|
|
984
|
+
});
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
describe("getExtension", () => {
|
|
988
|
+
it("returns network extension", () => {
|
|
989
|
+
const ext = provider.getExtension("test-svc", "network");
|
|
990
|
+
expect(ext).toBeDefined();
|
|
991
|
+
expect(ext).toHaveProperty("getUrl");
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
it("network extension returns run.app URL", async () => {
|
|
995
|
+
const ext = provider.getExtension("test-svc", "network")!;
|
|
996
|
+
const url = await ext.getUrl(443);
|
|
997
|
+
expect(url).toContain("run.app");
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("returns undefined for unsupported extensions", () => {
|
|
1001
|
+
expect(provider.getExtension("test-svc", "files")).toBeUndefined();
|
|
1002
|
+
expect(provider.getExtension("test-svc", "metrics")).toBeUndefined();
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
describe("retry logic", () => {
|
|
1007
|
+
it("retries on 429 with exponential backoff", async () => {
|
|
1008
|
+
let callCount = 0;
|
|
1009
|
+
mocks.mockFetch.mockImplementation(
|
|
1010
|
+
(url: string, opts?: RequestInit) => {
|
|
1011
|
+
const method = opts?.method ?? "GET";
|
|
1012
|
+
if (method === "GET" && url.endsWith("/services")) {
|
|
1013
|
+
callCount++;
|
|
1014
|
+
if (callCount <= 2) {
|
|
1015
|
+
return Promise.resolve(
|
|
1016
|
+
mockFetchResponse(429, { error: "rate limited" })
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
return Promise.resolve(
|
|
1020
|
+
mockFetchResponse(200, { services: [] })
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
return Promise.resolve(mockFetchResponse(200, {}));
|
|
1024
|
+
}
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
const health = await provider.healthCheck();
|
|
1028
|
+
expect(health.healthy).toBe(true);
|
|
1029
|
+
expect(callCount).toBe(3);
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
describe("label sanitization", () => {
|
|
1035
|
+
const provider = new CloudRunProvider({
|
|
1036
|
+
projectId: "test",
|
|
1037
|
+
authMethod: "token",
|
|
1038
|
+
accessToken: "tok",
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
// We test label sanitization indirectly through provision
|
|
1042
|
+
it("lowercases and replaces invalid characters", async () => {
|
|
1043
|
+
setupDefaultMocks();
|
|
1044
|
+
await provider.provision({
|
|
1045
|
+
name: "test-svc",
|
|
1046
|
+
runtime: { image: "alpine" },
|
|
1047
|
+
labels: { "My.Label@Key": "Some Value!Here" },
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
1051
|
+
(c: any[]) =>
|
|
1052
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
1053
|
+
);
|
|
1054
|
+
const body = JSON.parse(createCall[1].body);
|
|
1055
|
+
expect(body.labels["my_label_key"]).toBe("some_value_here");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("truncates to 63 characters", async () => {
|
|
1059
|
+
vi.clearAllMocks();
|
|
1060
|
+
setupDefaultMocks();
|
|
1061
|
+
const longKey = "a".repeat(100);
|
|
1062
|
+
const longValue = "b".repeat(100);
|
|
1063
|
+
|
|
1064
|
+
await provider.provision({
|
|
1065
|
+
name: "test-svc",
|
|
1066
|
+
runtime: { image: "alpine" },
|
|
1067
|
+
labels: { [longKey]: longValue },
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const createCall = mocks.mockFetch.mock.calls.find(
|
|
1071
|
+
(c: any[]) =>
|
|
1072
|
+
c[1]?.method === "POST" && c[0].includes("/services?serviceId=")
|
|
1073
|
+
);
|
|
1074
|
+
const body = JSON.parse(createCall[1].body);
|
|
1075
|
+
const keys = Object.keys(body.labels).filter(
|
|
1076
|
+
(k: string) => k !== "openlattice-managed"
|
|
1077
|
+
);
|
|
1078
|
+
expect(keys[0].length).toBeLessThanOrEqual(63);
|
|
1079
|
+
expect(body.labels[keys[0]].length).toBeLessThanOrEqual(63);
|
|
1080
|
+
});
|
|
1081
|
+
});
|