@moldesk/runtime 0.0.4 → 0.0.6
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/dist/cloud/credentials.d.ts +61 -0
- package/dist/cloud/credentials.js +161 -0
- package/dist/cloud/credentials.test.d.ts +1 -0
- package/dist/cloud/credentials.test.js +124 -0
- package/dist/cloud/execution.d.ts +197 -0
- package/dist/cloud/execution.js +18 -0
- package/dist/cloud/runpod-backend.d.ts +73 -0
- package/dist/cloud/runpod-backend.js +272 -0
- package/dist/cloud/runpod.d.ts +41 -0
- package/dist/cloud/runpod.js +276 -0
- package/dist/cloud/runpod.test.d.ts +1 -0
- package/dist/cloud/runpod.test.js +146 -0
- package/dist/cloud/types.d.ts +121 -0
- package/dist/cloud/types.js +6 -0
- package/dist/cloud/worker-client.d.ts +62 -0
- package/dist/cloud/worker-client.js +306 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +5 -0
- package/package.json +3 -2
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { MoldeskError } from "../providers.js";
|
|
3
|
+
import { createKeyringBackend } from "./credentials.js";
|
|
4
|
+
import { EXECUTION_CONTRACT_VERSION, WORKER_PORT, toWireJob, } from "./execution.js";
|
|
5
|
+
import { WorkerClient } from "./worker-client.js";
|
|
6
|
+
export const MIN_WORKER_READY_TIMEOUT_MS = 5 * 60_000;
|
|
7
|
+
export const VOLUME_MOUNT = "/workspace";
|
|
8
|
+
const TOKEN_SERVICE = "moldesk-worker";
|
|
9
|
+
export function createKeyringTokenStore(backend = createKeyringBackend()) {
|
|
10
|
+
return {
|
|
11
|
+
set: (runId, token) => backend.set(TOKEN_SERVICE, runId, token),
|
|
12
|
+
get: (runId) => backend.get(TOKEN_SERVICE, runId),
|
|
13
|
+
delete: async (runId) => void (await backend.delete(TOKEN_SERVICE, runId)),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Container start command: fetch the pinned bundle, verify its checksum, and run it. The image must
|
|
18
|
+
* provide Node 20+, curl, tar and sha256sum. Nothing here comes from the user's input.
|
|
19
|
+
*/
|
|
20
|
+
export const WORKER_BOOTSTRAP_SCRIPT = [
|
|
21
|
+
"set -euo pipefail",
|
|
22
|
+
'command -v node >/dev/null || { echo "node is required in the worker image" >&2; exit 127; }',
|
|
23
|
+
"mkdir -p /opt/moldesk && cd /opt/moldesk",
|
|
24
|
+
'curl -fsSL "$MOLDESK_WORKER_BUNDLE_URL" -o worker.tgz',
|
|
25
|
+
'echo "$MOLDESK_WORKER_BUNDLE_SHA256 worker.tgz" | sha256sum -c -',
|
|
26
|
+
"tar -xzf worker.tgz",
|
|
27
|
+
"export MOLDESK_MODELS_DIR=/opt/moldesk/worker/models",
|
|
28
|
+
"exec node worker/main.js",
|
|
29
|
+
].join("\n");
|
|
30
|
+
function backendError(code, message, remediation, details, cause) {
|
|
31
|
+
return new MoldeskError({ code, message, remediation, details, cause });
|
|
32
|
+
}
|
|
33
|
+
export class RunpodExecutionBackend {
|
|
34
|
+
options;
|
|
35
|
+
clients = new Map();
|
|
36
|
+
tracked = new Map();
|
|
37
|
+
/** Intents already confirmed deleted, so release() stays idempotent. */
|
|
38
|
+
deletedIntents = new Set();
|
|
39
|
+
sleep;
|
|
40
|
+
readyTimeoutMs;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.options = options;
|
|
43
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
44
|
+
this.readyTimeoutMs = Math.max(options.readyTimeoutMs ?? 10 * 60_000, MIN_WORKER_READY_TIMEOUT_MS);
|
|
45
|
+
}
|
|
46
|
+
endpoint(podId) {
|
|
47
|
+
return (this.options.endpointFor ?? ((id) => `https://${id}-${WORKER_PORT}.proxy.runpod.net`))(podId);
|
|
48
|
+
}
|
|
49
|
+
client(endpoint, token) {
|
|
50
|
+
return (this.options.createClient ?? ((url, secret) => new WorkerClient({ endpoint: url, token: secret })))(endpoint, token);
|
|
51
|
+
}
|
|
52
|
+
async clientFor(handle) {
|
|
53
|
+
const cached = this.clients.get(handle.runId);
|
|
54
|
+
if (cached)
|
|
55
|
+
return cached;
|
|
56
|
+
const token = await this.options.tokenStore.get(handle.runId);
|
|
57
|
+
if (!token)
|
|
58
|
+
throw backendError("CLOUD_WORKER_TOKEN_MISSING", "The worker token for this run is not in the credential store.", "Results can no longer be fetched through this client; the Pod will be terminated.");
|
|
59
|
+
const created = this.client(handle.endpoint, token);
|
|
60
|
+
this.clients.set(handle.runId, created);
|
|
61
|
+
return created;
|
|
62
|
+
}
|
|
63
|
+
async start(job, context) {
|
|
64
|
+
if (!this.options.workerBundle) {
|
|
65
|
+
throw backendError("CLOUD_WORKER_BUNDLE_UNCONFIGURED", "No worker release bundle is configured.", "Cloud execution needs a published, checksummed worker bundle; none is configured in this build.");
|
|
66
|
+
}
|
|
67
|
+
if (job.cachePolicy === "ephemeral")
|
|
68
|
+
return (await this.startGpu(job, context, undefined)).handle;
|
|
69
|
+
return this.startPersistent(job, context);
|
|
70
|
+
}
|
|
71
|
+
async startPersistent(job, context) {
|
|
72
|
+
const cache = context.cache;
|
|
73
|
+
if (!cache)
|
|
74
|
+
throw backendError("CLOUD_CACHE_LEDGER_MISSING", "Persistent runs need a cache journal.", "This is an internal error.");
|
|
75
|
+
const known = cache.lookup();
|
|
76
|
+
let volumeId = job.reuseVolumeId ?? known?.volumeId;
|
|
77
|
+
let warm = known !== undefined;
|
|
78
|
+
if (!volumeId) {
|
|
79
|
+
const intent = context.ledger.beginCreate("volume");
|
|
80
|
+
const volume = await this.options.provider.createVolume({ name: intent.name, sizeGb: job.volumeGb, dataCenterId: job.datacenterId });
|
|
81
|
+
context.ledger.created(intent.intentId, volume.id);
|
|
82
|
+
volumeId = volume.id;
|
|
83
|
+
cache.begin({ volumeId, datacenterId: job.datacenterId });
|
|
84
|
+
warm = false;
|
|
85
|
+
}
|
|
86
|
+
if (warm) {
|
|
87
|
+
// Warm: the GPU Pod verifies the volume's cache itself; local READY alone is never trusted.
|
|
88
|
+
context.onProgress?.({ stage: "provisioning", message: "Reusing retained volume" });
|
|
89
|
+
const attempt = await this.startGpu(job, context, volumeId, { watchCacheFailure: true });
|
|
90
|
+
if (attempt.handle)
|
|
91
|
+
return attempt.handle;
|
|
92
|
+
cache.failed();
|
|
93
|
+
context.onProgress?.({ stage: "preparing_assets", message: "Cache missing or invalid; rebuilding on a CPU machine" });
|
|
94
|
+
}
|
|
95
|
+
await this.prepareOnCpu(job, context, volumeId, cache);
|
|
96
|
+
const handle = await this.startGpu(job, context, volumeId);
|
|
97
|
+
return handle.handle;
|
|
98
|
+
}
|
|
99
|
+
/** Cold path: CPU Pod prepares the volume, is terminated and confirmed gone, then capacity is rechecked. */
|
|
100
|
+
async prepareOnCpu(job, context, volumeId, cache) {
|
|
101
|
+
if (!cache.acquireLease(3600)) {
|
|
102
|
+
throw backendError("CLOUD_CACHE_LOCKED", "Another process in this installation is preparing this cache.", "Wait for it to finish.");
|
|
103
|
+
}
|
|
104
|
+
let cpuPodId;
|
|
105
|
+
let cpuIntent;
|
|
106
|
+
try {
|
|
107
|
+
cache.begin({ volumeId, datacenterId: job.datacenterId });
|
|
108
|
+
context.onProgress?.({ stage: "provisioning", message: "Starting CPU machine to prepare model assets" });
|
|
109
|
+
const token = randomBytes(32).toString("hex");
|
|
110
|
+
const runId = `${job.runId}-prep`;
|
|
111
|
+
await this.options.tokenStore.set(runId, token);
|
|
112
|
+
const pod = await this.createWorkerPod(job, context, { role: "cpu", volumeId, token });
|
|
113
|
+
cpuPodId = pod.pod.id;
|
|
114
|
+
cpuIntent = pod.intentId;
|
|
115
|
+
const client = this.client(this.endpoint(pod.pod.id), token);
|
|
116
|
+
await client.waitUntilReady({ timeoutMs: this.readyTimeoutMs, intervalMs: this.options.pollIntervalMs, signal: context.signal, abortIf: () => this.podFatal(pod.pod.id) });
|
|
117
|
+
await client.start({ ...toWireJob({ ...job, inputs: [] }, { mode: "prepare-assets", volumeMount: VOLUME_MOUNT }), runId });
|
|
118
|
+
const snapshot = await this.waitForTerminal(client, runId, context);
|
|
119
|
+
if (snapshot.state !== "succeeded" || !snapshot.runtime?.cache) {
|
|
120
|
+
cache.failed();
|
|
121
|
+
throw backendError(snapshot.error?.code ?? "CLOUD_ASSET_PREPARATION_FAILED", snapshot.error?.message ?? "Asset preparation failed on the CPU machine.", "The volume cache is not marked ready; retry.");
|
|
122
|
+
}
|
|
123
|
+
cache.verifying();
|
|
124
|
+
cache.ready({ sizeBytes: snapshot.runtime.cache.sizeBytes, checksumManifestDigest: snapshot.runtime.cache.checksumManifestSha256 });
|
|
125
|
+
await this.options.tokenStore.delete(runId).catch(() => undefined);
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
cache.failed();
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
// Terminate and confirm the CPU Pod before any GPU is requested, whatever happened.
|
|
133
|
+
if (cpuPodId && cpuIntent)
|
|
134
|
+
await this.terminateAndConfirm(cpuPodId, cpuIntent, context);
|
|
135
|
+
cache.releaseLease();
|
|
136
|
+
}
|
|
137
|
+
// No capacity is reserved by the quote: re-check before renting the GPU.
|
|
138
|
+
const catalog = await this.options.provider.getCatalog();
|
|
139
|
+
const gpu = catalog.gpus.find((candidate) => candidate.id === job.gpuId);
|
|
140
|
+
const available = gpu && gpu.availability !== "NONE" && gpu.dataCenters.some((dc) => dc.id === job.datacenterId && dc.availability !== "NONE");
|
|
141
|
+
if (!available) {
|
|
142
|
+
throw backendError("CLOUD_CAPACITY_UNAVAILABLE", `${job.gpuId} no longer has capacity in ${job.datacenterId}. The prepared volume is retained.`, "Retry later; the cache is ready and the next run will skip preparation. Delete the volume with `moldesk cloud cache delete` if you no longer want it.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async createWorkerPod(job, context, params) {
|
|
146
|
+
const bundle = this.options.workerBundle;
|
|
147
|
+
const intent = context.ledger.beginCreate(params.role);
|
|
148
|
+
const tracked = this.tracked.get(job.runId) ?? [];
|
|
149
|
+
const entry = { intentId: intent.intentId };
|
|
150
|
+
tracked.push(entry);
|
|
151
|
+
this.tracked.set(job.runId, tracked);
|
|
152
|
+
const pod = await this.options.provider.createPod({
|
|
153
|
+
name: intent.name,
|
|
154
|
+
image: params.role === "gpu" ? job.images.gpu : job.images.cpu,
|
|
155
|
+
containerDiskGb: job.containerDiskGb,
|
|
156
|
+
dataCenterId: job.datacenterId,
|
|
157
|
+
...(params.role === "gpu" ? { gpu: { id: job.gpuId, count: 1 } } : { cpu: { id: "cpu3c", vcpuCount: 2 } }),
|
|
158
|
+
...(params.volumeId ? { volume: { id: params.volumeId, mountPath: VOLUME_MOUNT } } : {}),
|
|
159
|
+
ports: [`${WORKER_PORT}/http`],
|
|
160
|
+
env: {
|
|
161
|
+
MOLDESK_WORKER_TOKEN: params.token,
|
|
162
|
+
MOLDESK_WORKER_DEADLINE_AT: job.deadlineAt,
|
|
163
|
+
MOLDESK_WORKER_PORT: String(WORKER_PORT),
|
|
164
|
+
MOLDESK_WORKER_ROOT: "/job",
|
|
165
|
+
MOLDESK_WORKER_BUNDLE_URL: bundle.url,
|
|
166
|
+
MOLDESK_WORKER_BUNDLE_SHA256: bundle.sha256,
|
|
167
|
+
},
|
|
168
|
+
entrypoint: ["/bin/bash", "-c"],
|
|
169
|
+
cmd: [WORKER_BOOTSTRAP_SCRIPT],
|
|
170
|
+
});
|
|
171
|
+
context.ledger.created(intent.intentId, pod.id);
|
|
172
|
+
entry.podId = pod.id;
|
|
173
|
+
return { pod, intentId: intent.intentId };
|
|
174
|
+
}
|
|
175
|
+
/** Returns a reason string when the Pod can never serve (so booting should stop waiting). */
|
|
176
|
+
async podFatal(podId) {
|
|
177
|
+
const pod = await this.options.provider.getPod(podId).catch(() => undefined);
|
|
178
|
+
if (pod === null)
|
|
179
|
+
return "The Pod disappeared before the worker started.";
|
|
180
|
+
if (pod && (pod.status === "ERROR" || pod.status === "EXITED" || pod.status === "TERMINATED"))
|
|
181
|
+
return `The Pod entered ${pod.status} before the worker started.`;
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
async waitForTerminal(client, runId, context) {
|
|
185
|
+
let after = 0;
|
|
186
|
+
for (;;) {
|
|
187
|
+
if (context.signal?.aborted) {
|
|
188
|
+
await client.cancel(runId).catch(() => undefined);
|
|
189
|
+
throw backendError("RUN_CANCELLED", "Cancelled.", "None.");
|
|
190
|
+
}
|
|
191
|
+
const snapshot = await client.status(runId, after);
|
|
192
|
+
after = snapshot.logs.cursor;
|
|
193
|
+
if (snapshot.state === "succeeded" || snapshot.state === "failed" || snapshot.state === "cancelled")
|
|
194
|
+
return snapshot;
|
|
195
|
+
await this.sleep(this.options.pollIntervalMs ?? 5000);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Starts a GPU worker. With `watchCacheFailure`, a cache-missing/invalid failure returns no handle so the caller can rebuild. */
|
|
199
|
+
async startGpu(job, context, volumeId, options = {}) {
|
|
200
|
+
const token = randomBytes(32).toString("hex");
|
|
201
|
+
await this.options.tokenStore.set(job.runId, token);
|
|
202
|
+
context.onProgress?.({ stage: "provisioning" });
|
|
203
|
+
const { pod, intentId } = await this.createWorkerPod(job, context, { role: "gpu", volumeId, token });
|
|
204
|
+
const endpoint = this.endpoint(pod.id);
|
|
205
|
+
const client = this.client(endpoint, token);
|
|
206
|
+
this.clients.set(job.runId, client);
|
|
207
|
+
context.onProgress?.({ stage: "booting" });
|
|
208
|
+
await client.waitUntilReady({ timeoutMs: this.readyTimeoutMs, intervalMs: this.options.pollIntervalMs, signal: context.signal, abortIf: () => this.podFatal(pod.id) });
|
|
209
|
+
context.onProgress?.({ stage: "uploading" });
|
|
210
|
+
for (const file of job.inputs)
|
|
211
|
+
await client.uploadInput(job.runId, file);
|
|
212
|
+
await client.start(toWireJob(job, { mode: "execute", ...(volumeId ? { volumeMount: VOLUME_MOUNT } : {}) }));
|
|
213
|
+
if (options.watchCacheFailure) {
|
|
214
|
+
// The worker verifies the volume cache first; a bad cache fails before anything expensive.
|
|
215
|
+
for (let i = 0; i < 120; i += 1) {
|
|
216
|
+
const snapshot = await client.status(job.runId);
|
|
217
|
+
if (snapshot.state === "failed" && (snapshot.error?.code === "CLOUD_CACHE_MISSING" || snapshot.error?.code === "CLOUD_CACHE_INVALID")) {
|
|
218
|
+
await this.terminateAndConfirm(pod.id, intentId, context);
|
|
219
|
+
this.clients.delete(job.runId);
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
if (snapshot.state !== "running" || snapshot.stage !== "preparing_assets")
|
|
223
|
+
break;
|
|
224
|
+
await this.sleep(this.options.pollIntervalMs ?? 2000);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { handle: { version: EXECUTION_CONTRACT_VERSION, runId: job.runId, podId: pod.id, ...(volumeId ? { volumeId } : {}), endpoint } };
|
|
228
|
+
}
|
|
229
|
+
/** Terminate through the provider, then require the provider to show the Pod gone before marking it deleted. */
|
|
230
|
+
async terminateAndConfirm(podId, intentId, context) {
|
|
231
|
+
context.ledger.beginDelete(intentId);
|
|
232
|
+
try {
|
|
233
|
+
await this.options.provider.terminatePod(podId);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
const deadline = (this.options.now ?? Date.now)() + (this.options.terminationWaitMs ?? 60_000);
|
|
239
|
+
for (;;) {
|
|
240
|
+
const pod = await this.options.provider.getPod(podId).catch(() => undefined);
|
|
241
|
+
if (pod === null || pod?.status === "TERMINATED") {
|
|
242
|
+
context.ledger.deleted(intentId);
|
|
243
|
+
this.deletedIntents.add(intentId);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
if ((this.options.now ?? Date.now)() >= deadline)
|
|
247
|
+
return false;
|
|
248
|
+
await this.sleep(2000);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
reconnect(runId, podId, volumeId) {
|
|
252
|
+
return { version: EXECUTION_CONTRACT_VERSION, runId, podId, ...(volumeId ? { volumeId } : {}), endpoint: this.endpoint(podId) };
|
|
253
|
+
}
|
|
254
|
+
async observe(handle, after = 0) {
|
|
255
|
+
return (await this.clientFor(handle)).status(handle.runId, after);
|
|
256
|
+
}
|
|
257
|
+
async cancel(handle) {
|
|
258
|
+
await (await this.clientFor(handle)).cancel(handle.runId);
|
|
259
|
+
}
|
|
260
|
+
async collect(handle, localOutputDir, options = {}) {
|
|
261
|
+
return (await this.clientFor(handle)).collect(handle.runId, localOutputDir, options);
|
|
262
|
+
}
|
|
263
|
+
async release(handle, context) {
|
|
264
|
+
for (const entry of this.tracked.get(handle.runId) ?? []) {
|
|
265
|
+
if (!entry.podId || this.deletedIntents.has(entry.intentId))
|
|
266
|
+
continue;
|
|
267
|
+
await this.terminateAndConfirm(entry.podId, entry.intentId, context);
|
|
268
|
+
}
|
|
269
|
+
this.clients.delete(handle.runId);
|
|
270
|
+
await this.options.tokenStore.delete(handle.runId).catch(() => undefined);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CloudAccountCheck, CloudCatalog, CloudDeleteResult, CloudPod, CloudProvider, CloudVolume, CreatePodRequest, CreateVolumeRequest } from "./types.js";
|
|
2
|
+
export declare const RUNPOD_API_BASE = "https://api.runpod.io/v2";
|
|
3
|
+
/** Runpod's edge rejects default HTTP-client User-Agents (observed: Cloudflare 403, error code 1010). */
|
|
4
|
+
export declare const RUNPOD_USER_AGENT = "moldesk-runpod/1";
|
|
5
|
+
export interface RunpodProviderOptions {
|
|
6
|
+
/** Resolved per request so the key never lives in a long-lived field or log. */
|
|
7
|
+
getApiKey: () => Promise<string>;
|
|
8
|
+
fetch?: typeof fetch;
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
now?: () => Date;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
requestTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare class RunpodProvider implements CloudProvider {
|
|
15
|
+
private readonly options;
|
|
16
|
+
readonly name = "runpod";
|
|
17
|
+
private readonly fetchFn;
|
|
18
|
+
private readonly sleep;
|
|
19
|
+
private readonly now;
|
|
20
|
+
private readonly baseUrl;
|
|
21
|
+
private readonly timeoutMs;
|
|
22
|
+
constructor(options: RunpodProviderOptions);
|
|
23
|
+
validateCredentials(): Promise<CloudAccountCheck>;
|
|
24
|
+
getCatalog(): Promise<CloudCatalog>;
|
|
25
|
+
createPod(request: CreatePodRequest): Promise<CloudPod>;
|
|
26
|
+
getPod(id: string): Promise<CloudPod | null>;
|
|
27
|
+
listPods(): Promise<CloudPod[]>;
|
|
28
|
+
terminatePod(id: string): Promise<CloudDeleteResult>;
|
|
29
|
+
createVolume(request: CreateVolumeRequest): Promise<CloudVolume>;
|
|
30
|
+
getVolume(id: string): Promise<CloudVolume | null>;
|
|
31
|
+
listVolumes(): Promise<CloudVolume[]>;
|
|
32
|
+
deleteVolume(id: string): Promise<CloudDeleteResult>;
|
|
33
|
+
/**
|
|
34
|
+
* One HTTP call with status-to-error mapping. Only `retry` calls (reads and idempotent
|
|
35
|
+
* terminate/delete) are retried, on 429/5xx/network failure. Creates are never retried; a
|
|
36
|
+
* failure that may have taken effect is surfaced as CLOUD_AMBIGUOUS_CREATE.
|
|
37
|
+
*/
|
|
38
|
+
private request;
|
|
39
|
+
private send;
|
|
40
|
+
private mapStatus;
|
|
41
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { MoldeskError } from "../providers.js";
|
|
2
|
+
export const RUNPOD_API_BASE = "https://api.runpod.io/v2";
|
|
3
|
+
/** Runpod's edge rejects default HTTP-client User-Agents (observed: Cloudflare 403, error code 1010). */
|
|
4
|
+
export const RUNPOD_USER_AGENT = "moldesk-runpod/1";
|
|
5
|
+
const POD_STATUSES = ["PROVISIONING", "STARTING", "RUNNING", "EXITED", "ERROR", "TERMINATED"];
|
|
6
|
+
const IDEMPOTENT_RETRIES = 3;
|
|
7
|
+
const MAX_BODY_CHARS = 500;
|
|
8
|
+
function cloudError(code, message, remediation, details, cause) {
|
|
9
|
+
return new MoldeskError({ code, message, remediation, details, cause });
|
|
10
|
+
}
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function asString(value, field) {
|
|
15
|
+
if (typeof value !== "string")
|
|
16
|
+
throw cloudError("CLOUD_BAD_RESPONSE", `Runpod response is missing "${field}".`, "Retry; if it persists the Runpod API may have changed.");
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function asNumber(value, field) {
|
|
20
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
21
|
+
throw cloudError("CLOUD_BAD_RESPONSE", `Runpod response has an invalid "${field}".`, "Retry; if it persists the Runpod API may have changed.");
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function asArray(value, field) {
|
|
26
|
+
if (!Array.isArray(value))
|
|
27
|
+
throw cloudError("CLOUD_BAD_RESPONSE", `Runpod response is missing list "${field}".`, "Retry; if it persists the Runpod API may have changed.");
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function asRecord(value, field) {
|
|
31
|
+
if (!isRecord(value))
|
|
32
|
+
throw cloudError("CLOUD_BAD_RESPONSE", `Runpod response has an invalid "${field}".`, "Retry; if it persists the Runpod API may have changed.");
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function parsePod(value) {
|
|
36
|
+
const pod = asRecord(value, "pod");
|
|
37
|
+
const status = asString(pod.status, "pod.status");
|
|
38
|
+
if (!POD_STATUSES.includes(status)) {
|
|
39
|
+
throw cloudError("CLOUD_BAD_RESPONSE", `Runpod returned an unknown pod status "${status}".`, "Update MoleculeDesk or retry.");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
id: asString(pod.id, "pod.id"),
|
|
43
|
+
name: typeof pod.name === "string" ? pod.name : "",
|
|
44
|
+
status,
|
|
45
|
+
dataCenterId: typeof pod.dataCenterId === "string" ? pod.dataCenterId : null,
|
|
46
|
+
costUsdPerHour: typeof pod.cost === "number" ? pod.cost : null,
|
|
47
|
+
createdAt: typeof pod.createdAt === "string" ? pod.createdAt : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function parseVolume(value) {
|
|
51
|
+
const volume = asRecord(value, "volume");
|
|
52
|
+
return {
|
|
53
|
+
id: asString(volume.id, "volume.id"),
|
|
54
|
+
name: typeof volume.name === "string" ? volume.name : "",
|
|
55
|
+
sizeGb: asNumber(volume.size, "volume.size"),
|
|
56
|
+
dataCenterId: asString(volume.dataCenter, "volume.dataCenter"),
|
|
57
|
+
type: typeof volume.type === "string" ? volume.type : null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export class RunpodProvider {
|
|
61
|
+
options;
|
|
62
|
+
name = "runpod";
|
|
63
|
+
fetchFn;
|
|
64
|
+
sleep;
|
|
65
|
+
now;
|
|
66
|
+
baseUrl;
|
|
67
|
+
timeoutMs;
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
this.fetchFn = options.fetch ?? fetch;
|
|
71
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
72
|
+
this.now = options.now ?? (() => new Date());
|
|
73
|
+
this.baseUrl = options.baseUrl ?? RUNPOD_API_BASE;
|
|
74
|
+
this.timeoutMs = options.requestTimeoutMs ?? 30_000;
|
|
75
|
+
}
|
|
76
|
+
async validateCredentials() {
|
|
77
|
+
// Smallest read-only call; a restricted key may lack Pod list access (403), which is reported as such.
|
|
78
|
+
await this.request("GET", "/pods", undefined, { retry: true });
|
|
79
|
+
return { ok: true, checkedAt: this.now().toISOString() };
|
|
80
|
+
}
|
|
81
|
+
async getCatalog() {
|
|
82
|
+
const [gpus, cpus, dataCenters] = await Promise.all([
|
|
83
|
+
this.request("GET", "/catalog/gpus?include=AVAILABILITY&product=POD", undefined, { retry: true }),
|
|
84
|
+
this.request("GET", "/catalog/cpus", undefined, { retry: true }),
|
|
85
|
+
this.request("GET", "/catalog/datacenters", undefined, { retry: true }),
|
|
86
|
+
]);
|
|
87
|
+
return {
|
|
88
|
+
retrievedAt: this.now().toISOString(),
|
|
89
|
+
gpus: asArray(asRecord(gpus.body, "gpus").gpus, "gpus").map((raw) => {
|
|
90
|
+
const gpu = asRecord(raw, "gpu");
|
|
91
|
+
const price = asRecord(gpu.price, "gpu.price");
|
|
92
|
+
const availability = typeof gpu.availability === "string" ? gpu.availability : "UNKNOWN";
|
|
93
|
+
return {
|
|
94
|
+
id: asString(gpu.id, "gpu.id"),
|
|
95
|
+
name: typeof gpu.name === "string" ? gpu.name : asString(gpu.id, "gpu.id"),
|
|
96
|
+
memoryGb: asNumber(gpu.memory, "gpu.memory"),
|
|
97
|
+
priceUsdPerHour: asNumber(price.secure, "gpu.price.secure"),
|
|
98
|
+
availability: (["NONE", "LOW", "MEDIUM", "HIGH"].includes(availability) ? availability : "UNKNOWN"),
|
|
99
|
+
dataCenters: Array.isArray(gpu.dataCenters)
|
|
100
|
+
? gpu.dataCenters.filter(isRecord).map((dc) => ({ id: asString(dc.id, "gpu.dataCenters.id"), availability: String(dc.availability ?? "UNKNOWN") }))
|
|
101
|
+
: [],
|
|
102
|
+
cudaVersions: Array.isArray(gpu.cudaVersions)
|
|
103
|
+
? gpu.cudaVersions.filter(isRecord).filter((v) => v.available === true).map((v) => String(v.version))
|
|
104
|
+
: [],
|
|
105
|
+
};
|
|
106
|
+
}),
|
|
107
|
+
cpus: asArray(asRecord(cpus.body, "cpus").cpus, "cpus").map((raw) => {
|
|
108
|
+
const cpu = asRecord(raw, "cpu");
|
|
109
|
+
const vcpu = asRecord(cpu.vcpu, "cpu.vcpu");
|
|
110
|
+
return {
|
|
111
|
+
id: asString(cpu.id, "cpu.id"),
|
|
112
|
+
name: typeof cpu.name === "string" ? cpu.name : asString(cpu.id, "cpu.id"),
|
|
113
|
+
priceUsdPerVcpuHour: asNumber(asRecord(cpu.price, "cpu.price").securePerVcpu, "cpu.price.securePerVcpu"),
|
|
114
|
+
ramGbPerVcpu: asNumber(cpu.ramGbPerVcpu, "cpu.ramGbPerVcpu"),
|
|
115
|
+
minVcpu: asNumber(vcpu.min, "cpu.vcpu.min"),
|
|
116
|
+
maxVcpu: asNumber(vcpu.max, "cpu.vcpu.max"),
|
|
117
|
+
};
|
|
118
|
+
}),
|
|
119
|
+
dataCenters: asArray(asRecord(dataCenters.body, "datacenters").dataCenters, "dataCenters").map((raw) => {
|
|
120
|
+
const dc = asRecord(raw, "dataCenter");
|
|
121
|
+
return {
|
|
122
|
+
id: asString(dc.id, "dataCenter.id"),
|
|
123
|
+
region: typeof dc.region === "string" ? dc.region : "",
|
|
124
|
+
networkVolumeTypes: Array.isArray(dc.networkVolumeTypes) ? dc.networkVolumeTypes.map(String) : [],
|
|
125
|
+
};
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async createPod(request) {
|
|
130
|
+
if ((request.gpu ? 1 : 0) + (request.cpu ? 1 : 0) !== 1) {
|
|
131
|
+
throw cloudError("CLOUD_REQUEST_REJECTED", "A pod needs exactly one of gpu or cpu.", "Fix the pod request.");
|
|
132
|
+
}
|
|
133
|
+
const body = {
|
|
134
|
+
name: request.name,
|
|
135
|
+
image: request.image,
|
|
136
|
+
disk: request.containerDiskGb,
|
|
137
|
+
dataCenterIds: [request.dataCenterId],
|
|
138
|
+
...(request.gpu ? { gpu: { id: request.gpu.id, count: request.gpu.count } } : {}),
|
|
139
|
+
...(request.cpu ? { cpu: { id: request.cpu.id, vcpuCount: request.cpu.vcpuCount } } : {}),
|
|
140
|
+
...(request.volume ? { mounts: { network: [{ volumeId: request.volume.id, path: request.volume.mountPath }] } } : {}),
|
|
141
|
+
...(request.ports ? { ports: request.ports } : {}),
|
|
142
|
+
...(request.env ? { env: request.env } : {}),
|
|
143
|
+
// `args` as an {entrypoint, cmd} object is the form verified live against the v2 API.
|
|
144
|
+
...(request.entrypoint || request.cmd
|
|
145
|
+
? { args: JSON.stringify({ ...(request.entrypoint ? { entrypoint: request.entrypoint } : {}), ...(request.cmd ? { cmd: request.cmd } : {}) }) }
|
|
146
|
+
: {}),
|
|
147
|
+
};
|
|
148
|
+
const result = await this.request("POST", "/pods", body, { retry: false, ambiguousOnFailure: true });
|
|
149
|
+
return parsePod(result.body);
|
|
150
|
+
}
|
|
151
|
+
async getPod(id) {
|
|
152
|
+
const result = await this.request("GET", `/pods/${encodeURIComponent(id)}`, undefined, { retry: true, allow404: true });
|
|
153
|
+
return result.status === 404 ? null : parsePod(result.body);
|
|
154
|
+
}
|
|
155
|
+
async listPods() {
|
|
156
|
+
const result = await this.request("GET", "/pods", undefined, { retry: true });
|
|
157
|
+
return asArray(asRecord(result.body, "pods").pods, "pods").map(parsePod);
|
|
158
|
+
}
|
|
159
|
+
async terminatePod(id) {
|
|
160
|
+
const result = await this.request("POST", `/pods/${encodeURIComponent(id)}/action`, { action: "terminate" }, { retry: true, allow404: true });
|
|
161
|
+
return { id, alreadyAbsent: result.status === 404 };
|
|
162
|
+
}
|
|
163
|
+
async createVolume(request) {
|
|
164
|
+
const body = { name: request.name, size: request.sizeGb, dataCenter: request.dataCenterId };
|
|
165
|
+
const result = await this.request("POST", "/network-volumes", body, { retry: false, ambiguousOnFailure: true });
|
|
166
|
+
return parseVolume(result.body);
|
|
167
|
+
}
|
|
168
|
+
async getVolume(id) {
|
|
169
|
+
const result = await this.request("GET", `/network-volumes/${encodeURIComponent(id)}`, undefined, { retry: true, allow404: true });
|
|
170
|
+
return result.status === 404 ? null : parseVolume(result.body);
|
|
171
|
+
}
|
|
172
|
+
async listVolumes() {
|
|
173
|
+
const result = await this.request("GET", "/network-volumes", undefined, { retry: true });
|
|
174
|
+
return asArray(asRecord(result.body, "volumes").networkVolumes, "networkVolumes").map(parseVolume);
|
|
175
|
+
}
|
|
176
|
+
async deleteVolume(id) {
|
|
177
|
+
const result = await this.request("DELETE", `/network-volumes/${encodeURIComponent(id)}`, undefined, { retry: true, allow404: true });
|
|
178
|
+
return { id, alreadyAbsent: result.status === 404 };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* One HTTP call with status-to-error mapping. Only `retry` calls (reads and idempotent
|
|
182
|
+
* terminate/delete) are retried, on 429/5xx/network failure. Creates are never retried; a
|
|
183
|
+
* failure that may have taken effect is surfaced as CLOUD_AMBIGUOUS_CREATE.
|
|
184
|
+
*/
|
|
185
|
+
async request(method, path, body, flags) {
|
|
186
|
+
const attempts = flags.retry ? IDEMPOTENT_RETRIES : 1;
|
|
187
|
+
let lastError;
|
|
188
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
189
|
+
let result;
|
|
190
|
+
try {
|
|
191
|
+
result = await this.send(method, path, body);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
lastError = flags.ambiguousOnFailure
|
|
195
|
+
? cloudError("CLOUD_AMBIGUOUS_CREATE", "The Runpod create request did not complete; the resource may exist.", "List owned resources and match by name before retrying.", { path }, error)
|
|
196
|
+
: cloudError("CLOUD_UNAVAILABLE", "Could not reach Runpod.", "Check your connection and retry.", { path }, error);
|
|
197
|
+
if (attempt < attempts) {
|
|
198
|
+
await this.sleep(500 * 2 ** (attempt - 1));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
throw lastError;
|
|
202
|
+
}
|
|
203
|
+
if (result.status >= 200 && result.status < 300)
|
|
204
|
+
return result;
|
|
205
|
+
if (result.status === 404 && flags.allow404)
|
|
206
|
+
return result;
|
|
207
|
+
const retryable = result.status === 429 || result.status >= 500;
|
|
208
|
+
if (retryable && attempt < attempts) {
|
|
209
|
+
await this.sleep(result.retryAfterSeconds !== undefined ? result.retryAfterSeconds * 1000 : 500 * 2 ** (attempt - 1));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
throw this.mapStatus(result, path, flags.ambiguousOnFailure === true);
|
|
213
|
+
}
|
|
214
|
+
throw lastError;
|
|
215
|
+
}
|
|
216
|
+
async send(method, path, body) {
|
|
217
|
+
const key = await this.options.getApiKey();
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
220
|
+
try {
|
|
221
|
+
const response = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
222
|
+
method,
|
|
223
|
+
headers: {
|
|
224
|
+
Authorization: `Bearer ${key}`,
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
"User-Agent": RUNPOD_USER_AGENT,
|
|
227
|
+
},
|
|
228
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
229
|
+
signal: controller.signal,
|
|
230
|
+
});
|
|
231
|
+
const text = await response.text();
|
|
232
|
+
let parsed;
|
|
233
|
+
if (text) {
|
|
234
|
+
try {
|
|
235
|
+
parsed = JSON.parse(text);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
parsed = text.slice(0, MAX_BODY_CHARS);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const retryAfter = Number(response.headers.get("retry-after"));
|
|
242
|
+
return {
|
|
243
|
+
status: response.status,
|
|
244
|
+
body: parsed,
|
|
245
|
+
...(Number.isFinite(retryAfter) && retryAfter >= 0 && response.headers.get("retry-after") ? { retryAfterSeconds: retryAfter } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
mapStatus(result, path, isCreate) {
|
|
253
|
+
const detail = isRecord(result.body) && typeof result.body.detail === "string" ? result.body.detail.slice(0, MAX_BODY_CHARS) : undefined;
|
|
254
|
+
const details = { status: result.status, path, ...(detail ? { detail } : {}) };
|
|
255
|
+
switch (result.status) {
|
|
256
|
+
case 401:
|
|
257
|
+
return cloudError("CLOUD_AUTH_FAILED", "Runpod rejected the API key.", "Run `moldesk cloud connect runpod` with a valid key.", details);
|
|
258
|
+
case 402:
|
|
259
|
+
return cloudError("CLOUD_PAYMENT_REQUIRED", "Runpod reports insufficient credit.", "Add credit to the Runpod account.", details);
|
|
260
|
+
case 403:
|
|
261
|
+
return cloudError("CLOUD_FORBIDDEN", "The Runpod API key lacks permission for this action.", "Use a key with Pod and network volume permissions.", details);
|
|
262
|
+
case 429:
|
|
263
|
+
return cloudError("CLOUD_RATE_LIMITED", "Runpod rate limit reached.", "Wait and retry.", { ...details, retryAfterSeconds: result.retryAfterSeconds });
|
|
264
|
+
default:
|
|
265
|
+
if (result.status === 400 && detail && /no longer any instances|no instances available|not enough free/i.test(detail)) {
|
|
266
|
+
return cloudError("CLOUD_CAPACITY_UNAVAILABLE", "Runpod has no capacity for the requested resources.", "Pick another GPU or datacenter, or retry later. Capacity is never reserved by a quote.", details);
|
|
267
|
+
}
|
|
268
|
+
if (result.status >= 500) {
|
|
269
|
+
return isCreate
|
|
270
|
+
? cloudError("CLOUD_AMBIGUOUS_CREATE", "Runpod failed while creating a resource; it may exist.", "List owned resources and match by name before retrying.", details)
|
|
271
|
+
: cloudError("CLOUD_UNAVAILABLE", "Runpod is temporarily unavailable.", "Retry shortly.", details);
|
|
272
|
+
}
|
|
273
|
+
return cloudError("CLOUD_REQUEST_REJECTED", `Runpod rejected the request (${result.status}).`, "Check the request parameters.", details);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|