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,867 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ComputeProvider,
|
|
3
|
+
ComputeSpec,
|
|
4
|
+
ExecOpts,
|
|
5
|
+
ExecResult,
|
|
6
|
+
ExtensionMap,
|
|
7
|
+
HealthStatus,
|
|
8
|
+
LogEntry,
|
|
9
|
+
LogOpts,
|
|
10
|
+
NetworkExtension,
|
|
11
|
+
ProviderCapabilities,
|
|
12
|
+
ProviderNode,
|
|
13
|
+
ProviderNodeStatus,
|
|
14
|
+
} from "openlattice";
|
|
15
|
+
import type {
|
|
16
|
+
CloudRunProviderConfig,
|
|
17
|
+
CloudRunService,
|
|
18
|
+
CloudRunOperation,
|
|
19
|
+
CloudRunExecution,
|
|
20
|
+
CloudLoggingEntry,
|
|
21
|
+
} from "./types";
|
|
22
|
+
import { GcpAuthManager } from "./auth";
|
|
23
|
+
|
|
24
|
+
export class CloudRunProvider implements ComputeProvider {
|
|
25
|
+
readonly name = "cloudrun";
|
|
26
|
+
readonly capabilities: ProviderCapabilities = {
|
|
27
|
+
restart: true,
|
|
28
|
+
pause: false,
|
|
29
|
+
snapshot: false,
|
|
30
|
+
gpu: false,
|
|
31
|
+
logs: true,
|
|
32
|
+
tailscale: false,
|
|
33
|
+
coldStartMs: 3000,
|
|
34
|
+
maxConcurrent: 0,
|
|
35
|
+
architectures: ["x86_64"],
|
|
36
|
+
persistentStorage: false,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
private readonly config: CloudRunProviderConfig;
|
|
40
|
+
private readonly region: string;
|
|
41
|
+
private readonly auth: GcpAuthManager;
|
|
42
|
+
|
|
43
|
+
constructor(config: CloudRunProviderConfig) {
|
|
44
|
+
if (!config.projectId) {
|
|
45
|
+
throw new Error("[cloudrun] projectId is required");
|
|
46
|
+
}
|
|
47
|
+
this.config = config;
|
|
48
|
+
this.region = config.region ?? "us-central1";
|
|
49
|
+
this.auth = new GcpAuthManager({
|
|
50
|
+
authMethod: config.authMethod,
|
|
51
|
+
serviceAccountKeyPath: config.serviceAccountKeyPath,
|
|
52
|
+
accessToken: config.accessToken,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Required methods ────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async provision(spec: ComputeSpec): Promise<ProviderNode> {
|
|
59
|
+
const serviceId = spec.name ?? `ol-svc-${Date.now()}`;
|
|
60
|
+
const port =
|
|
61
|
+
spec.network?.ports && spec.network.ports.length > 0
|
|
62
|
+
? spec.network.ports[0].port
|
|
63
|
+
: 3000;
|
|
64
|
+
|
|
65
|
+
const labels = sanitizeLabels({
|
|
66
|
+
...this.config.defaultLabels,
|
|
67
|
+
...spec.labels,
|
|
68
|
+
"openlattice-managed": "true",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const cpuString = String(spec.cpu?.cores ?? 1);
|
|
72
|
+
const memoryMiB = spec.memory ? spec.memory.sizeGiB * 1024 : 512;
|
|
73
|
+
const memoryString = `${memoryMiB}Mi`;
|
|
74
|
+
|
|
75
|
+
const serviceBody = {
|
|
76
|
+
template: {
|
|
77
|
+
containers: [
|
|
78
|
+
{
|
|
79
|
+
image: spec.runtime.image,
|
|
80
|
+
ports: [{ containerPort: port }],
|
|
81
|
+
env: spec.runtime.env
|
|
82
|
+
? Object.entries(spec.runtime.env).map(([name, value]) => ({
|
|
83
|
+
name,
|
|
84
|
+
value,
|
|
85
|
+
}))
|
|
86
|
+
: undefined,
|
|
87
|
+
command: spec.runtime.command,
|
|
88
|
+
resources: {
|
|
89
|
+
limits: {
|
|
90
|
+
cpu: cpuString,
|
|
91
|
+
memory: memoryString,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
startupProbe: {
|
|
95
|
+
httpGet: { path: "/health", port },
|
|
96
|
+
initialDelaySeconds: 0,
|
|
97
|
+
periodSeconds: 3,
|
|
98
|
+
failureThreshold: 10,
|
|
99
|
+
},
|
|
100
|
+
livenessProbe: {
|
|
101
|
+
httpGet: { path: "/health", port },
|
|
102
|
+
periodSeconds: 30,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
scaling: {
|
|
107
|
+
minInstanceCount: this.config.minInstances ?? 0,
|
|
108
|
+
maxInstanceCount: this.config.maxInstances ?? 1,
|
|
109
|
+
},
|
|
110
|
+
maxInstanceRequestConcurrency: this.config.concurrency ?? 80,
|
|
111
|
+
...(this.config.serviceAccount
|
|
112
|
+
? { serviceAccount: this.config.serviceAccount }
|
|
113
|
+
: {}),
|
|
114
|
+
...(this.config.vpcConnector
|
|
115
|
+
? {
|
|
116
|
+
vpcAccess: {
|
|
117
|
+
connector: this.config.vpcConnector,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
},
|
|
122
|
+
labels,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Create the service
|
|
126
|
+
const operation = await this.cloudRunRequest<CloudRunOperation>(
|
|
127
|
+
"POST",
|
|
128
|
+
`/services?serviceId=${encodeURIComponent(serviceId)}`,
|
|
129
|
+
serviceBody
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Poll the operation until done
|
|
133
|
+
if (operation.name && !operation.done) {
|
|
134
|
+
await this.pollOperation(operation.name);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Get the service to retrieve its URI
|
|
138
|
+
const service = await this.cloudRunRequest<CloudRunService>(
|
|
139
|
+
"GET",
|
|
140
|
+
`/services/${encodeURIComponent(serviceId)}`
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const serviceUri = service.uri ?? "";
|
|
144
|
+
let host = "";
|
|
145
|
+
try {
|
|
146
|
+
host = new URL(serviceUri).hostname;
|
|
147
|
+
} catch {
|
|
148
|
+
host = serviceUri;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
externalId: serviceId,
|
|
153
|
+
endpoints: serviceUri
|
|
154
|
+
? [
|
|
155
|
+
{
|
|
156
|
+
type: "https",
|
|
157
|
+
host,
|
|
158
|
+
port: 443,
|
|
159
|
+
url: serviceUri,
|
|
160
|
+
},
|
|
161
|
+
]
|
|
162
|
+
: [],
|
|
163
|
+
metadata: {
|
|
164
|
+
publicUrl: serviceUri,
|
|
165
|
+
cloudRunProject: this.config.projectId,
|
|
166
|
+
cloudRunRegion: this.region,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async exec(
|
|
172
|
+
externalId: string,
|
|
173
|
+
command: string[],
|
|
174
|
+
opts?: ExecOpts
|
|
175
|
+
): Promise<ExecResult> {
|
|
176
|
+
// Cloud Run services don't support exec directly.
|
|
177
|
+
// Use Cloud Run Jobs: create a one-off job, run it, retrieve output from logs.
|
|
178
|
+
const jobId = sanitizeJobId(`${externalId}-exec-${Date.now()}`);
|
|
179
|
+
|
|
180
|
+
// Get the service to find the image and env from its template
|
|
181
|
+
const serviceRaw = await this.cloudRunRequest<Record<string, any>>(
|
|
182
|
+
"GET",
|
|
183
|
+
`/services/${encodeURIComponent(externalId)}`
|
|
184
|
+
);
|
|
185
|
+
const image =
|
|
186
|
+
serviceRaw?.template?.containers?.[0]?.image ?? "alpine:latest";
|
|
187
|
+
const env = serviceRaw?.template?.containers?.[0]?.env;
|
|
188
|
+
|
|
189
|
+
// Build the full command with cwd and env support
|
|
190
|
+
let cmd = command;
|
|
191
|
+
if (opts?.cwd || opts?.env) {
|
|
192
|
+
const parts: string[] = [];
|
|
193
|
+
if (opts.cwd) {
|
|
194
|
+
parts.push(`cd ${opts.cwd}`);
|
|
195
|
+
}
|
|
196
|
+
if (opts.env) {
|
|
197
|
+
const envEntries = Object.entries(opts.env);
|
|
198
|
+
for (const [k, v] of envEntries) {
|
|
199
|
+
parts.push(`export ${k}='${(v as string).replace(/'/g, "'\\''")}'`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
parts.push(command.join(" "));
|
|
203
|
+
cmd = ["sh", "-c", parts.join(" && ")];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Create a Cloud Run Job
|
|
207
|
+
const jobBody = {
|
|
208
|
+
template: {
|
|
209
|
+
template: {
|
|
210
|
+
containers: [
|
|
211
|
+
{
|
|
212
|
+
image,
|
|
213
|
+
command: cmd,
|
|
214
|
+
env,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
maxRetries: 0,
|
|
218
|
+
timeout: opts?.timeoutMs
|
|
219
|
+
? `${Math.ceil(opts.timeoutMs / 1000)}s`
|
|
220
|
+
: "600s",
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
launchStage: "GA",
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Create the job
|
|
227
|
+
await this.cloudRunJobRequest<CloudRunOperation>(
|
|
228
|
+
"POST",
|
|
229
|
+
`/jobs?jobId=${encodeURIComponent(jobId)}`,
|
|
230
|
+
jobBody
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Run the job
|
|
234
|
+
const runOp = await this.cloudRunJobRequest<CloudRunOperation>(
|
|
235
|
+
"POST",
|
|
236
|
+
`/jobs/${encodeURIComponent(jobId)}:run`
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Poll the run operation
|
|
240
|
+
if (runOp.name && !runOp.done) {
|
|
241
|
+
await this.pollOperation(runOp.name);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Retrieve logs from Cloud Logging
|
|
245
|
+
const { stdout, stderr } = await this.fetchJobLogs(jobId);
|
|
246
|
+
|
|
247
|
+
opts?.onStdout?.(stdout);
|
|
248
|
+
opts?.onStderr?.(stderr);
|
|
249
|
+
|
|
250
|
+
// Determine exit code from execution status (before cleanup)
|
|
251
|
+
const executions = await this.cloudRunJobRequest<{
|
|
252
|
+
executions?: CloudRunExecution[];
|
|
253
|
+
}>(
|
|
254
|
+
"GET",
|
|
255
|
+
`/jobs/${encodeURIComponent(jobId)}/executions`
|
|
256
|
+
).catch(() => ({ executions: [] }));
|
|
257
|
+
|
|
258
|
+
const execution = executions.executions?.[0];
|
|
259
|
+
const succeeded = execution?.conditions?.some(
|
|
260
|
+
(c) =>
|
|
261
|
+
c.type === "Completed" && c.state === "CONDITION_SUCCEEDED"
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Clean up the job (best effort, after we've read status)
|
|
265
|
+
try {
|
|
266
|
+
await this.cloudRunJobRequest<void>(
|
|
267
|
+
"DELETE",
|
|
268
|
+
`/jobs/${encodeURIComponent(jobId)}`
|
|
269
|
+
);
|
|
270
|
+
} catch {
|
|
271
|
+
// Best effort cleanup
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
exitCode: succeeded ? 0 : 1,
|
|
276
|
+
stdout,
|
|
277
|
+
stderr,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async destroy(externalId: string): Promise<void> {
|
|
282
|
+
try {
|
|
283
|
+
await this.cloudRunRequest<void>(
|
|
284
|
+
"DELETE",
|
|
285
|
+
`/services/${encodeURIComponent(externalId)}`
|
|
286
|
+
);
|
|
287
|
+
} catch (err: unknown) {
|
|
288
|
+
if (!isNotFound(err)) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`[cloudrun] destroy failed: ${err instanceof Error ? err.message : String(err)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async inspect(externalId: string): Promise<ProviderNodeStatus> {
|
|
297
|
+
try {
|
|
298
|
+
const service = await this.cloudRunRequest<CloudRunService>(
|
|
299
|
+
"GET",
|
|
300
|
+
`/services/${encodeURIComponent(externalId)}`
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
status: mapCloudRunState(service),
|
|
305
|
+
};
|
|
306
|
+
} catch (err: unknown) {
|
|
307
|
+
if (isNotFound(err)) {
|
|
308
|
+
return { status: "terminated" };
|
|
309
|
+
}
|
|
310
|
+
return { status: "unknown" };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Optional: stop / start ──────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
async stop(externalId: string): Promise<void> {
|
|
317
|
+
try {
|
|
318
|
+
await this.cloudRunRequest<CloudRunOperation>(
|
|
319
|
+
"PATCH",
|
|
320
|
+
`/services/${encodeURIComponent(externalId)}`,
|
|
321
|
+
{
|
|
322
|
+
traffic: [
|
|
323
|
+
{
|
|
324
|
+
percent: 0,
|
|
325
|
+
type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST",
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
}
|
|
329
|
+
);
|
|
330
|
+
} catch (err: unknown) {
|
|
331
|
+
if (!isNotFound(err)) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`[cloudrun] stop failed: ${err instanceof Error ? err.message : String(err)}`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async start(externalId: string): Promise<void> {
|
|
340
|
+
await this.cloudRunRequest<CloudRunOperation>(
|
|
341
|
+
"PATCH",
|
|
342
|
+
`/services/${encodeURIComponent(externalId)}`,
|
|
343
|
+
{
|
|
344
|
+
traffic: [
|
|
345
|
+
{
|
|
346
|
+
percent: 100,
|
|
347
|
+
type: "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST",
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ── Optional: logs ──────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
logs(externalId: string, opts?: LogOpts): AsyncIterable<LogEntry> {
|
|
357
|
+
const self = this;
|
|
358
|
+
|
|
359
|
+
if (opts?.follow) {
|
|
360
|
+
// Follow mode: poll Cloud Logging periodically for new entries
|
|
361
|
+
return {
|
|
362
|
+
[Symbol.asyncIterator]() {
|
|
363
|
+
let done = false;
|
|
364
|
+
let buffer: LogEntry[] = [];
|
|
365
|
+
let lastTimestamp: Date | undefined = opts?.since;
|
|
366
|
+
const pollIntervalMs = 2000;
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
async next(): Promise<IteratorResult<LogEntry>> {
|
|
370
|
+
while (!done) {
|
|
371
|
+
if (buffer.length > 0) {
|
|
372
|
+
return { value: buffer.shift()!, done: false };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const entries = await self.fetchLogs(externalId, {
|
|
376
|
+
...opts,
|
|
377
|
+
since: lastTimestamp,
|
|
378
|
+
tail: opts?.tail,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
if (entries.length > 0) {
|
|
382
|
+
// Advance past the latest timestamp to avoid duplicates
|
|
383
|
+
lastTimestamp = new Date(
|
|
384
|
+
entries[entries.length - 1].timestamp.getTime() + 1
|
|
385
|
+
);
|
|
386
|
+
buffer = entries;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// No new entries — wait before polling again
|
|
391
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { value: undefined as any, done: true };
|
|
395
|
+
},
|
|
396
|
+
async return() {
|
|
397
|
+
done = true;
|
|
398
|
+
buffer = [];
|
|
399
|
+
return { value: undefined as any, done: true };
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Non-follow mode: single batch fetch
|
|
407
|
+
return {
|
|
408
|
+
[Symbol.asyncIterator]() {
|
|
409
|
+
let done = false;
|
|
410
|
+
let buffer: LogEntry[] = [];
|
|
411
|
+
let initialized = false;
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
async next(): Promise<IteratorResult<LogEntry>> {
|
|
415
|
+
if (buffer.length > 0) {
|
|
416
|
+
return { value: buffer.shift()!, done: false };
|
|
417
|
+
}
|
|
418
|
+
if (done) {
|
|
419
|
+
return { value: undefined as any, done: true };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (!initialized) {
|
|
423
|
+
initialized = true;
|
|
424
|
+
const entries = await self.fetchLogs(externalId, opts);
|
|
425
|
+
buffer = entries;
|
|
426
|
+
if (buffer.length > 0) {
|
|
427
|
+
return { value: buffer.shift()!, done: false };
|
|
428
|
+
}
|
|
429
|
+
done = true;
|
|
430
|
+
return { value: undefined as any, done: true };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
done = true;
|
|
434
|
+
return { value: undefined as any, done: true };
|
|
435
|
+
},
|
|
436
|
+
async return() {
|
|
437
|
+
done = true;
|
|
438
|
+
buffer = [];
|
|
439
|
+
return { value: undefined as any, done: true };
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Optional: healthCheck ─────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
449
|
+
const start = Date.now();
|
|
450
|
+
try {
|
|
451
|
+
await this.cloudRunRequest<{ services?: CloudRunService[] }>(
|
|
452
|
+
"GET",
|
|
453
|
+
"/services"
|
|
454
|
+
);
|
|
455
|
+
return {
|
|
456
|
+
healthy: true,
|
|
457
|
+
latencyMs: Date.now() - start,
|
|
458
|
+
};
|
|
459
|
+
} catch (err: unknown) {
|
|
460
|
+
return {
|
|
461
|
+
healthy: false,
|
|
462
|
+
latencyMs: Date.now() - start,
|
|
463
|
+
message: `[cloudrun] API unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Optional: getCost ─────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
async getCost(
|
|
471
|
+
externalId: string
|
|
472
|
+
): Promise<{ totalUsd: number }> {
|
|
473
|
+
// Estimate cost based on Cloud Run pricing.
|
|
474
|
+
// This is a rough estimate; actual costs depend on usage patterns.
|
|
475
|
+
try {
|
|
476
|
+
const serviceRaw = await this.cloudRunRequest<Record<string, any>>(
|
|
477
|
+
"GET",
|
|
478
|
+
`/services/${encodeURIComponent(externalId)}`
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const cpuCores = parseFloat(
|
|
482
|
+
serviceRaw?.template?.containers?.[0]?.resources?.limits?.cpu ?? "1"
|
|
483
|
+
);
|
|
484
|
+
const memoryStr: string =
|
|
485
|
+
serviceRaw?.template?.containers?.[0]?.resources?.limits?.memory ??
|
|
486
|
+
"512Mi";
|
|
487
|
+
const memoryGiB = parseMemoryToGiB(memoryStr);
|
|
488
|
+
|
|
489
|
+
// Assume the service has been running since creation
|
|
490
|
+
// Cloud Run pricing (per-second when active):
|
|
491
|
+
// CPU: $0.00002400/vCPU-second
|
|
492
|
+
// Memory: $0.00000250/GiB-second
|
|
493
|
+
const createTime = serviceRaw?.createTime;
|
|
494
|
+
const uptimeSeconds = createTime
|
|
495
|
+
? (Date.now() - new Date(createTime).getTime()) / 1000
|
|
496
|
+
: 0;
|
|
497
|
+
|
|
498
|
+
const cpuCost = cpuCores * uptimeSeconds * 0.000024;
|
|
499
|
+
const memoryCost = memoryGiB * uptimeSeconds * 0.0000025;
|
|
500
|
+
const totalUsd = Math.round((cpuCost + memoryCost) * 100) / 100;
|
|
501
|
+
|
|
502
|
+
return { totalUsd };
|
|
503
|
+
} catch {
|
|
504
|
+
return { totalUsd: 0 };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── Optional: extensions ──────────────────────────────────────
|
|
509
|
+
|
|
510
|
+
getExtension<K extends keyof ExtensionMap>(
|
|
511
|
+
externalId: string,
|
|
512
|
+
extension: K
|
|
513
|
+
): ExtensionMap[K] | undefined {
|
|
514
|
+
if (extension === "network") {
|
|
515
|
+
return this.createNetworkExtension(externalId) as ExtensionMap[K];
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── Private helpers ───────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
private async cloudRunRequest<T>(
|
|
523
|
+
method: string,
|
|
524
|
+
path: string,
|
|
525
|
+
body?: unknown
|
|
526
|
+
): Promise<T> {
|
|
527
|
+
const base = `https://run.googleapis.com/v2/projects/${this.config.projectId}/locations/${this.region}`;
|
|
528
|
+
const url = `${base}${path}`;
|
|
529
|
+
const token = await this.auth.getToken();
|
|
530
|
+
const maxRetries = 3;
|
|
531
|
+
|
|
532
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
533
|
+
const response = await fetch(url, {
|
|
534
|
+
method,
|
|
535
|
+
headers: {
|
|
536
|
+
Authorization: `Bearer ${token}`,
|
|
537
|
+
"Content-Type": "application/json",
|
|
538
|
+
},
|
|
539
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Retry on 429 with exponential backoff
|
|
543
|
+
if (response.status === 429 && attempt < maxRetries) {
|
|
544
|
+
const backoffMs = Math.pow(2, attempt) * 1000;
|
|
545
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!response.ok) {
|
|
550
|
+
const text = await response.text();
|
|
551
|
+
const err = new Error(
|
|
552
|
+
`[cloudrun] ${method} ${path} failed (${response.status}): ${text}`
|
|
553
|
+
);
|
|
554
|
+
(err as any).statusCode = response.status;
|
|
555
|
+
throw err;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (
|
|
559
|
+
response.status === 204 ||
|
|
560
|
+
response.headers.get("content-length") === "0"
|
|
561
|
+
) {
|
|
562
|
+
return undefined as T;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return response.json() as Promise<T>;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
throw new Error(
|
|
569
|
+
`[cloudrun] ${method} ${path} failed: max retries exceeded`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private async cloudRunJobRequest<T>(
|
|
574
|
+
method: string,
|
|
575
|
+
path: string,
|
|
576
|
+
body?: unknown
|
|
577
|
+
): Promise<T> {
|
|
578
|
+
const base = `https://run.googleapis.com/v2/projects/${this.config.projectId}/locations/${this.region}`;
|
|
579
|
+
const url = `${base}${path}`;
|
|
580
|
+
const token = await this.auth.getToken();
|
|
581
|
+
|
|
582
|
+
const response = await fetch(url, {
|
|
583
|
+
method,
|
|
584
|
+
headers: {
|
|
585
|
+
Authorization: `Bearer ${token}`,
|
|
586
|
+
"Content-Type": "application/json",
|
|
587
|
+
},
|
|
588
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
if (!response.ok) {
|
|
592
|
+
const text = await response.text();
|
|
593
|
+
const err = new Error(
|
|
594
|
+
`[cloudrun] ${method} ${path} failed (${response.status}): ${text}`
|
|
595
|
+
);
|
|
596
|
+
(err as any).statusCode = response.status;
|
|
597
|
+
throw err;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (
|
|
601
|
+
response.status === 204 ||
|
|
602
|
+
response.headers.get("content-length") === "0"
|
|
603
|
+
) {
|
|
604
|
+
return undefined as T;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return response.json() as Promise<T>;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private async pollOperation(
|
|
611
|
+
operationName: string,
|
|
612
|
+
maxWaitMs: number = 120_000
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const url = `https://run.googleapis.com/v2/${operationName}`;
|
|
615
|
+
const startTime = Date.now();
|
|
616
|
+
let pollIntervalMs = 1000;
|
|
617
|
+
|
|
618
|
+
while (Date.now() - startTime < maxWaitMs) {
|
|
619
|
+
// Refresh token each iteration to avoid expiry on long operations
|
|
620
|
+
const token = await this.auth.getToken();
|
|
621
|
+
const response = await fetch(url, {
|
|
622
|
+
headers: {
|
|
623
|
+
Authorization: `Bearer ${token}`,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
if (!response.ok) {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`[cloudrun] poll operation failed (${response.status}): ${await response.text()}`
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const op = (await response.json()) as CloudRunOperation;
|
|
634
|
+
|
|
635
|
+
if (op.done) {
|
|
636
|
+
if (op.error) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
`[cloudrun] operation failed: ${op.error.message ?? "unknown error"}`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
645
|
+
pollIntervalMs = Math.min(pollIntervalMs * 1.5, 5000);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
throw new Error("[cloudrun] operation timed out");
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
private async fetchLogs(
|
|
652
|
+
serviceId: string,
|
|
653
|
+
opts?: LogOpts
|
|
654
|
+
): Promise<LogEntry[]> {
|
|
655
|
+
const token = await this.auth.getToken();
|
|
656
|
+
const filter = [
|
|
657
|
+
`resource.type="cloud_run_revision"`,
|
|
658
|
+
`resource.labels.service_name="${serviceId}"`,
|
|
659
|
+
`resource.labels.project_id="${this.config.projectId}"`,
|
|
660
|
+
];
|
|
661
|
+
|
|
662
|
+
if (opts?.since) {
|
|
663
|
+
filter.push(`timestamp>="${opts.since.toISOString()}"`);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const body: Record<string, unknown> = {
|
|
667
|
+
resourceNames: [`projects/${this.config.projectId}`],
|
|
668
|
+
filter: filter.join(" AND "),
|
|
669
|
+
orderBy: opts?.follow ? "timestamp asc" : "timestamp desc",
|
|
670
|
+
pageSize: opts?.tail ?? 100,
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const response = await fetch(
|
|
674
|
+
"https://logging.googleapis.com/v2/entries:list",
|
|
675
|
+
{
|
|
676
|
+
method: "POST",
|
|
677
|
+
headers: {
|
|
678
|
+
Authorization: `Bearer ${token}`,
|
|
679
|
+
"Content-Type": "application/json",
|
|
680
|
+
},
|
|
681
|
+
body: JSON.stringify(body),
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
if (!response.ok) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const data = (await response.json()) as {
|
|
690
|
+
entries?: CloudLoggingEntry[];
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
return (data.entries ?? []).map((entry) => ({
|
|
694
|
+
timestamp: new Date(entry.timestamp ?? Date.now()),
|
|
695
|
+
message:
|
|
696
|
+
entry.textPayload ?? JSON.stringify(entry.jsonPayload ?? {}),
|
|
697
|
+
stream:
|
|
698
|
+
entry.severity === "ERROR" || entry.severity === "CRITICAL"
|
|
699
|
+
? ("stderr" as const)
|
|
700
|
+
: ("stdout" as const),
|
|
701
|
+
}));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private async fetchJobLogs(
|
|
705
|
+
jobId: string
|
|
706
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
707
|
+
const token = await this.auth.getToken();
|
|
708
|
+
const filter = [
|
|
709
|
+
`resource.type="cloud_run_job"`,
|
|
710
|
+
`resource.labels.job_name="${jobId}"`,
|
|
711
|
+
`resource.labels.project_id="${this.config.projectId}"`,
|
|
712
|
+
];
|
|
713
|
+
|
|
714
|
+
const body = {
|
|
715
|
+
resourceNames: [`projects/${this.config.projectId}`],
|
|
716
|
+
filter: filter.join(" AND "),
|
|
717
|
+
orderBy: "timestamp asc",
|
|
718
|
+
pageSize: 1000,
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const response = await fetch(
|
|
722
|
+
"https://logging.googleapis.com/v2/entries:list",
|
|
723
|
+
{
|
|
724
|
+
method: "POST",
|
|
725
|
+
headers: {
|
|
726
|
+
Authorization: `Bearer ${token}`,
|
|
727
|
+
"Content-Type": "application/json",
|
|
728
|
+
},
|
|
729
|
+
body: JSON.stringify(body),
|
|
730
|
+
}
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
if (!response.ok) {
|
|
734
|
+
return { stdout: "", stderr: "" };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const data = (await response.json()) as {
|
|
738
|
+
entries?: CloudLoggingEntry[];
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const stdoutLines: string[] = [];
|
|
742
|
+
const stderrLines: string[] = [];
|
|
743
|
+
|
|
744
|
+
for (const entry of data.entries ?? []) {
|
|
745
|
+
const message =
|
|
746
|
+
entry.textPayload ?? JSON.stringify(entry.jsonPayload ?? {});
|
|
747
|
+
if (
|
|
748
|
+
entry.severity === "ERROR" ||
|
|
749
|
+
entry.severity === "CRITICAL"
|
|
750
|
+
) {
|
|
751
|
+
stderrLines.push(message);
|
|
752
|
+
} else {
|
|
753
|
+
stdoutLines.push(message);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return {
|
|
758
|
+
stdout: stdoutLines.join("\n"),
|
|
759
|
+
stderr: stderrLines.join("\n"),
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private createNetworkExtension(
|
|
764
|
+
externalId: string
|
|
765
|
+
): NetworkExtension {
|
|
766
|
+
const projectId = this.config.projectId;
|
|
767
|
+
const region = this.region;
|
|
768
|
+
return {
|
|
769
|
+
async getUrl(port: number): Promise<string> {
|
|
770
|
+
// Cloud Run services have a single public HTTPS endpoint.
|
|
771
|
+
// All traffic goes through the Google-managed TLS proxy on port 443.
|
|
772
|
+
if (port === 443 || port === 80) {
|
|
773
|
+
return `https://${externalId}-${projectId}.${region}.run.app`;
|
|
774
|
+
}
|
|
775
|
+
// Non-standard ports are not directly supported by Cloud Run.
|
|
776
|
+
// Return the base URL — the container port is mapped via the service config.
|
|
777
|
+
return `https://${externalId}-${projectId}.${region}.run.app`;
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ── Utility functions ─────────────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
function mapCloudRunState(
|
|
786
|
+
service: CloudRunService
|
|
787
|
+
): "running" | "stopped" | "paused" | "terminated" | "unknown" {
|
|
788
|
+
const condition = service.terminalCondition;
|
|
789
|
+
if (condition?.state === "CONDITION_SUCCEEDED") {
|
|
790
|
+
// Check if traffic is routed — if 0% traffic, treat as stopped
|
|
791
|
+
const totalTraffic =
|
|
792
|
+
service.traffic?.reduce((sum, t) => sum + (t.percent ?? 0), 0) ?? 100;
|
|
793
|
+
if (totalTraffic === 0) {
|
|
794
|
+
return "stopped";
|
|
795
|
+
}
|
|
796
|
+
return "running";
|
|
797
|
+
}
|
|
798
|
+
if (condition?.state === "CONDITION_FAILED") {
|
|
799
|
+
return "stopped";
|
|
800
|
+
}
|
|
801
|
+
// Service exists but condition not yet resolved
|
|
802
|
+
if (service.reconciling) {
|
|
803
|
+
return "running";
|
|
804
|
+
}
|
|
805
|
+
return "unknown";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function sanitizeLabels(
|
|
809
|
+
labels: Record<string, string>
|
|
810
|
+
): Record<string, string> {
|
|
811
|
+
const sanitized: Record<string, string> = {};
|
|
812
|
+
let count = 0;
|
|
813
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
814
|
+
if (count >= 64) break;
|
|
815
|
+
const safeKey = key
|
|
816
|
+
.toLowerCase()
|
|
817
|
+
.replace(/[^a-z0-9_-]/g, "_")
|
|
818
|
+
.slice(0, 63);
|
|
819
|
+
const safeValue = value
|
|
820
|
+
.toLowerCase()
|
|
821
|
+
.replace(/[^a-z0-9_-]/g, "_")
|
|
822
|
+
.slice(0, 63);
|
|
823
|
+
sanitized[safeKey] = safeValue;
|
|
824
|
+
count++;
|
|
825
|
+
}
|
|
826
|
+
return sanitized;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function parseMemoryToGiB(memStr: string): number {
|
|
830
|
+
const match = memStr.match(/^(\d+)(Mi|Gi|M|G)?$/);
|
|
831
|
+
if (!match) return 0.5;
|
|
832
|
+
const value = parseInt(match[1], 10);
|
|
833
|
+
const unit = match[2] ?? "Mi";
|
|
834
|
+
switch (unit) {
|
|
835
|
+
case "Gi":
|
|
836
|
+
case "G":
|
|
837
|
+
return value;
|
|
838
|
+
case "Mi":
|
|
839
|
+
case "M":
|
|
840
|
+
return value / 1024;
|
|
841
|
+
default:
|
|
842
|
+
return value / 1024;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Sanitize a job ID to meet Cloud Run constraints:
|
|
848
|
+
* must match [a-z]([-a-z0-9]*[a-z0-9])?, max 49 characters.
|
|
849
|
+
*/
|
|
850
|
+
function sanitizeJobId(raw: string): string {
|
|
851
|
+
const sanitized = raw
|
|
852
|
+
.toLowerCase()
|
|
853
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
854
|
+
.replace(/^[^a-z]+/, "") // must start with a letter
|
|
855
|
+
.replace(/-+$/g, "") // must not end with a hyphen
|
|
856
|
+
.replace(/-{2,}/g, "-") // collapse consecutive hyphens
|
|
857
|
+
.slice(0, 49);
|
|
858
|
+
// Trim trailing hyphens after slicing
|
|
859
|
+
return sanitized.replace(/-+$/, "") || "ol-exec-job";
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function isNotFound(err: unknown): boolean {
|
|
863
|
+
if (err && typeof err === "object") {
|
|
864
|
+
return (err as any).statusCode === 404;
|
|
865
|
+
}
|
|
866
|
+
return false;
|
|
867
|
+
}
|