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.
@@ -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
+ }