@nodetool-ai/compute 0.8.1 → 0.8.2

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 @@
1
+ {"version":3,"file":"verda-api.js","sourceRoot":"","sources":["../../src/providers/verda-api.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,75 @@
1
+ import { type ProviderInstance, type ProvisionResult, type WorkerProvider, type WorkerSpec, type WorkerStatus } from "./types.js";
2
+ import { VerdaApiClient, type VerdaCredentials } from "./verda-api.js";
3
+ export declare class VerdaProvider implements WorkerProvider {
4
+ private readonly api;
5
+ constructor(credentials: VerdaCredentials, api?: VerdaApiClient);
6
+ provision(spec: WorkerSpec): Promise<ProvisionResult>;
7
+ status(ref: string): Promise<WorkerStatus>;
8
+ /**
9
+ * Pause: release the GPU compute, retain the OS volume and its model cache.
10
+ *
11
+ * This DELETES the Verda instance. A `shutdown` would keep billing the full
12
+ * instance rate (Verda removed hibernate for the same reason), so it is not
13
+ * the operation the cost guard needs.
14
+ */
15
+ stop(ref: string): Promise<void>;
16
+ /**
17
+ * Resume: create a NEW machine that boots from the retained OS volume.
18
+ *
19
+ * Returns a new provider ref (the instance id changed). Verda can refuse for
20
+ * lack of capacity, which surfaces as a distinct error rather than a retry.
21
+ */
22
+ resume(ref: string): Promise<ProvisionResult>;
23
+ /**
24
+ * Destroy the worker AND its OS volume — the real teardown that ends every
25
+ * charge. The volume is deleted PERMANENTLY: a trashed volume still occupies
26
+ * the storage quota and can be restored (at a charge for the deleted
27
+ * interval), which is not what "terminate" promises. The model cache is gone.
28
+ */
29
+ terminate(ref: string): Promise<void>;
30
+ list(): Promise<ProviderInstance[]>;
31
+ private resultFor;
32
+ /**
33
+ * Pick the guest OS image. An explicit `osImage` is validated against the
34
+ * live catalog (a typo would otherwise fail deep inside provisioning); with
35
+ * none set, prefer an image that ships CUDA and Docker, which the bootstrap
36
+ * script needs.
37
+ */
38
+ private resolveOsImage;
39
+ /**
40
+ * Resolve the required `location_code` against LIVE availability — a catalog
41
+ * entry is not a reservation, so this is re-read immediately before each create.
42
+ * An explicit region is verified rather than trusted.
43
+ */
44
+ private resolveLocation;
45
+ /** Register the spec's public key, reusing an identical existing key. */
46
+ private ensureSshKey;
47
+ /**
48
+ * Create the startup script that turns a bare VM into a NodeTool worker.
49
+ *
50
+ * The script is idempotent: Verda runs it on every boot, including after a
51
+ * resume, and it must not fail on a machine that already has the container.
52
+ */
53
+ private createStartupScript;
54
+ /** `POST /v1/instances`, returning the accepted instance id. */
55
+ private createInstance;
56
+ /**
57
+ * Delete an instance together with every volume attached to it, permanently.
58
+ *
59
+ * The volume ids are read back from the instance and NAMED explicitly: an
60
+ * empty `volume_ids` retains them all, and omitting the field deletes only
61
+ * the OS volume while detaching the rest — both leave billable storage
62
+ * behind. Returns false when the instance no longer exists.
63
+ */
64
+ private destroyWithVolumes;
65
+ /**
66
+ * `PUT /v1/instances` delete. `volumeIds: []` retains every attached volume;
67
+ * a non-empty list deletes exactly those.
68
+ */
69
+ private deleteInstance;
70
+ /** `GET /v1/instances/{id}`, or null when it no longer exists. */
71
+ private getInstance;
72
+ /** Poll until the instance reports `running`, or fail on a terminal state. */
73
+ private waitForReady;
74
+ }
75
+ //# sourceMappingURL=verda.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verda.d.ts","sourceRoot":"","sources":["../../src/providers/verda.ts"],"names":[],"mappings":"AA2BA,OAAO,EAIL,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,YAAY,EAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EAEL,cAAc,EAEd,KAAK,gBAAgB,EACtB,MAAM,gBAAgB,CAAC;AAiJxB,qBAAa,aAAc,YAAW,cAAc;IAClD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAiB;IAErC,YAAY,WAAW,EAAE,gBAAgB,EAAE,GAAG,CAAC,EAAE,cAAc,EAE9D;IAEK,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,eAAe,CAAC,CA2E1D;IAEK,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAa/C;IAED;;;;;;OAMG;IACG,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAerC;IAED;;;;;OAKG;IACG,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC,CAgDlD;IAED;;;;;OAKG;IACG,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAgB1C;IAEK,IAAI,IAAI,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAUxC;IAID,OAAO,CAAC,SAAS;IASjB;;;;;OAKG;YACW,cAAc;IAwC5B;;;;OAIG;YACW,eAAe;IAkC7B,yEAAyE;YAC3D,YAAY;IAkB1B;;;;;OAKG;YACW,mBAAmB;IA0CjC,gEAAgE;YAClD,cAAc;IAmB5B;;;;;;;OAOG;YACW,kBAAkB;IAqBhC;;;OAGG;YACW,cAAc;IA6B5B,kEAAkE;YACpD,WAAW;IAoBzB,8EAA8E;YAChE,YAAY;CA2B3B"}
@@ -0,0 +1,560 @@
1
+ // Verda-backed `WorkerProvider` (https://api.verda.com/v1).
2
+ //
3
+ // Verda rents VIRTUAL MACHINES, not containers. The NodeTool worker image is
4
+ // something the guest RUNS, so provisioning is two-layered: pick a CUDA+Docker
5
+ // OS image for the machine, then hand Verda a startup script that runs the
6
+ // worker image on it. `WorkerSpec.osImage` names the former, `WorkerSpec.image`
7
+ // the latter.
8
+ //
9
+ // Lifecycle deliberately does NOT mirror RunPod/Vast, because Verda's does not:
10
+ //
11
+ // * Verda bills a SHUT-DOWN instance at the full rate, and removed its
12
+ // hibernate action for exactly that reason. Mapping `stop` to `shutdown`
13
+ // would make the idle reaper stop workers while saving nothing, so `stop`
14
+ // instead DELETES the instance while retaining its OS volume — the
15
+ // "release compute, keep disks" operation. The model cache survives; the
16
+ // GPU bill ends.
17
+ // * `resume` therefore CREATES A NEW MACHINE from the retained OS volume. It
18
+ // returns a new `providerRef`, which the manager persists.
19
+ // * `terminate` deletes compute AND permanently deletes the OS volume, so no
20
+ // billable storage is left behind in the 96-hour trash window.
21
+ //
22
+ // Because `stop` destroys the instance record, everything `resume` needs to
23
+ // rebuild the machine is encoded in the provider ref (see `encodeRef`).
24
+ //
25
+ // Credentials are injected by the `WorkerManager`; this provider never reads
26
+ // `process.env`.
27
+ import { DEFAULT_VOLUME_GB, WORKER_HF_HOME, WORKER_VOLUME_MOUNT, } from "./types.js";
28
+ import { assertSafeId, VerdaApiClient, VerdaApiError, } from "./verda-api.js";
29
+ /** Port the worker serves on, published from the container to the VM. */
30
+ const WORKER_PORT = 7777;
31
+ /** Pay-as-you-go only. Long-term contracts are prepaid and are never chosen
32
+ * on the user's behalf; spot instances can be discontinued mid-workflow. */
33
+ const CONTRACT = "PAY_AS_YOU_GO";
34
+ /** Name of the container the startup script runs, used for idempotent reruns. */
35
+ const CONTAINER_NAME = "nodetool-worker";
36
+ /**
37
+ * Encode a create plan as an opaque provider handle.
38
+ *
39
+ * `provision` and `list` both build this from the SAME `GET` representation, so
40
+ * a live instance encodes to the identical string in both paths and the
41
+ * manager's orphan reconcile matches it by equality.
42
+ */
43
+ function encodeRef(ref) {
44
+ const ordered = {
45
+ i: ref.i,
46
+ v: ref.v,
47
+ t: ref.t,
48
+ l: ref.l,
49
+ s: ref.s,
50
+ k: ref.k,
51
+ h: ref.h,
52
+ };
53
+ return `verda:${Buffer.from(JSON.stringify(ordered)).toString("base64url")}`;
54
+ }
55
+ /** Decode a provider handle produced by `encodeRef`. */
56
+ function decodeRef(ref) {
57
+ if (!ref.startsWith("verda:")) {
58
+ throw new Error(`Not a Verda worker handle: ${JSON.stringify(ref)}`);
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(Buffer.from(ref.slice("verda:".length), "base64url").toString("utf8"));
63
+ }
64
+ catch {
65
+ throw new Error(`Corrupt Verda worker handle: ${JSON.stringify(ref)}`);
66
+ }
67
+ const plan = parsed;
68
+ if (typeof plan?.v !== "string" || typeof plan?.t !== "string") {
69
+ throw new Error(`Incomplete Verda worker handle: ${JSON.stringify(ref)}`);
70
+ }
71
+ return plan;
72
+ }
73
+ /** Build the handle for an instance as the API currently reports it. */
74
+ function refFromInstance(instance) {
75
+ return encodeRef({
76
+ i: instance.id ?? "",
77
+ v: instance.os_volume_id ?? "",
78
+ t: instance.instance_type ?? "",
79
+ l: instance.location ?? "",
80
+ s: instance.startup_script_id || undefined,
81
+ k: instance.ssh_key_ids?.length ? instance.ssh_key_ids : undefined,
82
+ h: instance.hostname || undefined,
83
+ });
84
+ }
85
+ /**
86
+ * Map Verda's instance status onto the shared `WorkerStatus`.
87
+ *
88
+ * Unknown states map to `error`, not to a hopeful `provisioning`: a state this
89
+ * code does not recognise must not read as progress to the poll loop.
90
+ */
91
+ function mapInstanceStatus(status) {
92
+ switch (status) {
93
+ case "running":
94
+ return "running";
95
+ case "provisioning":
96
+ case "ordered":
97
+ case "new":
98
+ case "validating":
99
+ case "deploying":
100
+ return "provisioning";
101
+ case "offline":
102
+ // Shut down but still allocated — and still billed. Not our paused state.
103
+ return "stopped";
104
+ case "deleting":
105
+ return "stopping";
106
+ case "discontinued":
107
+ case "notfound":
108
+ return "terminated";
109
+ default:
110
+ // error, no_capacity, installation_failed, unknown, and anything new.
111
+ return "error";
112
+ }
113
+ }
114
+ /** Single-quote a value for safe interpolation into the bootstrap script. */
115
+ function shellQuote(value) {
116
+ return `'${value.replace(/'/g, `'\\''`)}'`;
117
+ }
118
+ export class VerdaProvider {
119
+ api;
120
+ constructor(credentials, api) {
121
+ this.api = api ?? new VerdaApiClient(credentials);
122
+ }
123
+ async provision(spec) {
124
+ // The startup script publishes 7777 with Docker, and Verda's own guide
125
+ // warns that a UFW rule does NOT block a Docker-published port. The worker
126
+ // is therefore reachable from the internet the moment it boots, so refuse
127
+ // to launch one that accepts unauthenticated requests.
128
+ if (!spec.token) {
129
+ throw new Error("Verda workers publish port 7777 on a public IP, so a bearer token is " +
130
+ "required. Use a profile with token_policy 'generate' or 'fixed'.");
131
+ }
132
+ const instanceType = spec.gpu?.trim();
133
+ if (!instanceType) {
134
+ throw new Error("Verda requires an instance type (e.g. \"1H100.80S.30V\"). Set the " +
135
+ "profile's gpu field to a type from GET /v1/instance-types.");
136
+ }
137
+ const [osImage, locationCode] = await Promise.all([
138
+ this.resolveOsImage(spec.osImage),
139
+ this.resolveLocation(instanceType, spec.region),
140
+ ]);
141
+ const sshKeyIds = spec.sshPublicKey
142
+ ? [await this.ensureSshKey(spec.name, spec.sshPublicKey)]
143
+ : [];
144
+ const scriptId = await this.createStartupScript(spec);
145
+ const hostname = sanitizeHostname(spec.name);
146
+ const create = {
147
+ instance_type: instanceType,
148
+ image: osImage,
149
+ location_code: locationCode,
150
+ hostname,
151
+ description: `NodeTool worker (${spec.name})`,
152
+ contract: CONTRACT,
153
+ startup_script_id: scriptId,
154
+ os_volume: {
155
+ name: `${hostname}-os`,
156
+ size: spec.disk ?? DEFAULT_VOLUME_GB,
157
+ },
158
+ };
159
+ if (sshKeyIds.length > 0) {
160
+ create.ssh_key_ids = sshKeyIds;
161
+ }
162
+ // `POST /v1/instances` answers 202 (accepted) — the machine does not exist
163
+ // yet. Persist nothing until the id is known, then treat every later
164
+ // failure as owning a billing resource that must be torn down.
165
+ const instanceId = await this.createInstance(create);
166
+ try {
167
+ const instance = await this.waitForReady(instanceId);
168
+ if (!instance.ip) {
169
+ throw new Error(`Verda instance ${instanceId} became running without an IP address`);
170
+ }
171
+ return {
172
+ providerRef: refFromInstance(instance),
173
+ wsUrl: `ws://${instance.ip}:${WORKER_PORT}`,
174
+ token: spec.token,
175
+ status: "running",
176
+ costUsd: instance.price_per_hour,
177
+ };
178
+ }
179
+ catch (err) {
180
+ // Destroy compute AND the just-created OS volume: nothing here is worth
181
+ // retaining, and an orphan would bill until a reconcile scan found it.
182
+ // The volumes must be NAMED — an empty list retains every one of them.
183
+ await this.destroyWithVolumes(instanceId).catch(() => {
184
+ // Best-effort teardown; the provision failure below is the real error.
185
+ });
186
+ throw err;
187
+ }
188
+ }
189
+ async status(ref) {
190
+ const plan = decodeRef(ref);
191
+ if (!plan.i) {
192
+ // Compute was released by `stop`; only the retained volume remains.
193
+ return "stopped";
194
+ }
195
+ const instance = await this.getInstance(plan.i);
196
+ if (!instance) {
197
+ // The machine is gone but the OS volume was retained — this is the state
198
+ // `stop` leaves behind, and it is resumable.
199
+ return "stopped";
200
+ }
201
+ return mapInstanceStatus(instance.status);
202
+ }
203
+ /**
204
+ * Pause: release the GPU compute, retain the OS volume and its model cache.
205
+ *
206
+ * This DELETES the Verda instance. A `shutdown` would keep billing the full
207
+ * instance rate (Verda removed hibernate for the same reason), so it is not
208
+ * the operation the cost guard needs.
209
+ */
210
+ async stop(ref) {
211
+ const plan = decodeRef(ref);
212
+ if (!plan.i) {
213
+ return;
214
+ }
215
+ if (!plan.v) {
216
+ throw new Error("Refusing to pause a Verda worker with no recorded OS volume: " +
217
+ "releasing its compute would destroy the machine with nothing to " +
218
+ "resume from. Terminate it instead.");
219
+ }
220
+ // `volume_ids: []` is the documented "retain ALL attached volumes" form.
221
+ // Omitting the field deletes the OS volume instead, so it is always sent.
222
+ await this.deleteInstance(plan.i, { volumeIds: [], permanent: false });
223
+ }
224
+ /**
225
+ * Resume: create a NEW machine that boots from the retained OS volume.
226
+ *
227
+ * Returns a new provider ref (the instance id changed). Verda can refuse for
228
+ * lack of capacity, which surfaces as a distinct error rather than a retry.
229
+ */
230
+ async resume(ref) {
231
+ const plan = decodeRef(ref);
232
+ if (plan.i) {
233
+ const existing = await this.getInstance(plan.i);
234
+ if (existing && mapInstanceStatus(existing.status) === "running") {
235
+ // Already live (a stop that failed after the caller gave up, say).
236
+ return this.resultFor(existing);
237
+ }
238
+ }
239
+ if (!plan.v) {
240
+ throw new Error("This Verda worker has no retained OS volume to resume from.");
241
+ }
242
+ const create = {
243
+ instance_type: plan.t,
244
+ // A customized OS volume id stands in for an image type: the machine
245
+ // boots the retained disk, model cache and all.
246
+ image: assertSafeId(plan.v, "volume id"),
247
+ location_code: plan.l,
248
+ hostname: plan.h ?? CONTAINER_NAME,
249
+ description: "NodeTool worker (resumed)",
250
+ contract: CONTRACT,
251
+ };
252
+ if (plan.s)
253
+ create.startup_script_id = plan.s;
254
+ if (plan.k?.length)
255
+ create.ssh_key_ids = plan.k;
256
+ const instanceId = await this.createInstance(create);
257
+ try {
258
+ const instance = await this.waitForReady(instanceId);
259
+ if (!instance.ip) {
260
+ throw new Error(`Verda instance ${instanceId} resumed without an IP address`);
261
+ }
262
+ return this.resultFor(instance);
263
+ }
264
+ catch (err) {
265
+ // Release the failed machine but KEEP the volume: it is the user's model
266
+ // cache and predates this resume attempt.
267
+ await this.deleteInstance(instanceId, {
268
+ volumeIds: [],
269
+ permanent: false,
270
+ }).catch(() => {
271
+ // Best-effort; surface the resume failure below.
272
+ });
273
+ throw err;
274
+ }
275
+ }
276
+ /**
277
+ * Destroy the worker AND its OS volume — the real teardown that ends every
278
+ * charge. The volume is deleted PERMANENTLY: a trashed volume still occupies
279
+ * the storage quota and can be restored (at a charge for the deleted
280
+ * interval), which is not what "terminate" promises. The model cache is gone.
281
+ */
282
+ async terminate(ref) {
283
+ const plan = decodeRef(ref);
284
+ if (plan.i) {
285
+ const destroyed = await this.destroyWithVolumes(plan.i, plan.v);
286
+ if (destroyed) {
287
+ return;
288
+ }
289
+ }
290
+ // Compute is already released; the retained volume is what still bills.
291
+ if (plan.v) {
292
+ await this.api.request("PUT", "/v1/volumes", {
293
+ action: "delete",
294
+ id: assertSafeId(plan.v, "volume id"),
295
+ is_permanent: true,
296
+ });
297
+ }
298
+ }
299
+ async list() {
300
+ const { body } = await this.api.request("GET", "/v1/instances");
301
+ const instances = Array.isArray(body) ? body : [];
302
+ return instances.map((instance) => ({
303
+ providerRef: refFromInstance(instance),
304
+ status: mapInstanceStatus(instance.status),
305
+ }));
306
+ }
307
+ // --- Internals ----------------------------------------------------------
308
+ resultFor(instance) {
309
+ return {
310
+ providerRef: refFromInstance(instance),
311
+ wsUrl: `ws://${instance.ip}:${WORKER_PORT}`,
312
+ status: "running",
313
+ costUsd: instance.price_per_hour,
314
+ };
315
+ }
316
+ /**
317
+ * Pick the guest OS image. An explicit `osImage` is validated against the
318
+ * live catalog (a typo would otherwise fail deep inside provisioning); with
319
+ * none set, prefer an image that ships CUDA and Docker, which the bootstrap
320
+ * script needs.
321
+ */
322
+ async resolveOsImage(requested) {
323
+ const { body } = await this.api.request("GET", "/v1/images");
324
+ const images = Array.isArray(body) ? body : [];
325
+ const types = images
326
+ .map((image) => image.image_type)
327
+ .filter((type) => Boolean(type));
328
+ if (requested) {
329
+ if (!types.includes(requested)) {
330
+ throw new Error(`Verda OS image "${requested}" is not in the catalog. ` +
331
+ `Available: ${types.join(", ")}`);
332
+ }
333
+ return requested;
334
+ }
335
+ const describes = (image) => [image.image_type, image.name, ...(image.details ?? [])]
336
+ .filter(Boolean)
337
+ .join(" ")
338
+ .toLowerCase();
339
+ const preferred = images.find((image) => {
340
+ const text = describes(image);
341
+ return text.includes("cuda") && text.includes("docker");
342
+ });
343
+ const fallback = images.find((image) => describes(image).includes("docker"));
344
+ const chosen = (preferred ?? fallback)?.image_type;
345
+ if (!chosen) {
346
+ throw new Error("No Verda OS image with CUDA and Docker found. Set the profile's " +
347
+ `osImage explicitly. Available: ${types.join(", ")}`);
348
+ }
349
+ return chosen;
350
+ }
351
+ /**
352
+ * Resolve the required `location_code` against LIVE availability — a catalog
353
+ * entry is not a reservation, so this is re-read immediately before each create.
354
+ * An explicit region is verified rather than trusted.
355
+ */
356
+ async resolveLocation(instanceType, requested) {
357
+ const { body } = await this.api.request("GET", "/v1/instance-availability");
358
+ const rows = Array.isArray(body) ? body : [];
359
+ const withType = rows.filter((row) => (row.availabilities ?? []).includes(instanceType));
360
+ if (requested) {
361
+ const match = withType.find((row) => row.location_code === requested);
362
+ if (!match) {
363
+ throw new Error(`Verda instance type "${instanceType}" is not available in ` +
364
+ `"${requested}". Available in: ` +
365
+ `${withType.map((r) => r.location_code).join(", ") || "no location"}`);
366
+ }
367
+ return requested;
368
+ }
369
+ const first = withType[0]?.location_code;
370
+ if (!first) {
371
+ throw new Error(`No Verda location currently has capacity for instance type ` +
372
+ `"${instanceType}".`);
373
+ }
374
+ return first;
375
+ }
376
+ /** Register the spec's public key, reusing an identical existing key. */
377
+ async ensureSshKey(name, publicKey) {
378
+ const key = publicKey.trim();
379
+ const { body } = await this.api.request("GET", "/v1/sshkeys");
380
+ const existing = Array.isArray(body)
381
+ ? body.find((entry) => entry.key?.trim() === key)
382
+ : undefined;
383
+ if (existing?.id) {
384
+ return existing.id;
385
+ }
386
+ const created = await this.api.request("POST", "/v1/sshkeys", {
387
+ name: `nodetool-${sanitizeHostname(name)}`,
388
+ key,
389
+ });
390
+ return extractId(created.body, "SSH key");
391
+ }
392
+ /**
393
+ * Create the startup script that turns a bare VM into a NodeTool worker.
394
+ *
395
+ * The script is idempotent: Verda runs it on every boot, including after a
396
+ * resume, and it must not fail on a machine that already has the container.
397
+ */
398
+ async createStartupScript(spec) {
399
+ const env = { ...(spec.env ?? {}) };
400
+ if (spec.token) {
401
+ env.NODETOOL_WORKER_TOKEN = spec.token;
402
+ }
403
+ // Cache HF models on the OS volume so they survive a pause/resume.
404
+ env.HF_HOME = WORKER_HF_HOME;
405
+ const envFlags = Object.entries(env)
406
+ .map(([key, value]) => ` -e ${shellQuote(`${key}=${value}`)} \\`)
407
+ .join("\n");
408
+ const script = `#!/bin/bash
409
+ # NodeTool worker bootstrap. Rerun-safe: Verda runs it on every boot.
410
+ set -euo pipefail
411
+
412
+ mkdir -p ${WORKER_HF_HOME}
413
+
414
+ # Reuse the container across reboots; only pull and create it when absent.
415
+ if docker inspect ${CONTAINER_NAME} >/dev/null 2>&1; then
416
+ docker start ${CONTAINER_NAME}
417
+ exit 0
418
+ fi
419
+
420
+ docker pull ${shellQuote(spec.image)}
421
+ docker run -d \\
422
+ --name ${CONTAINER_NAME} \\
423
+ --restart unless-stopped \\
424
+ --gpus all \\
425
+ -p ${WORKER_PORT}:${WORKER_PORT} \\
426
+ -v ${WORKER_VOLUME_MOUNT}:${WORKER_VOLUME_MOUNT} \\
427
+ ${envFlags}
428
+ ${shellQuote(spec.image)}
429
+ `;
430
+ const created = await this.api.request("POST", "/v1/scripts", {
431
+ name: `nodetool-${sanitizeHostname(spec.name)}-${Date.now()}`,
432
+ script,
433
+ });
434
+ return extractId(created.body, "startup script");
435
+ }
436
+ /** `POST /v1/instances`, returning the accepted instance id. */
437
+ async createInstance(create) {
438
+ try {
439
+ const { body } = await this.api.request("POST", "/v1/instances", create);
440
+ return extractId(body, "instance");
441
+ }
442
+ catch (err) {
443
+ if (err instanceof VerdaApiError && err.isNoCapacity) {
444
+ throw new Error(`Verda has no capacity for instance type ` +
445
+ `"${String(create.instance_type)}" in "${String(create.location_code)}".`);
446
+ }
447
+ throw err;
448
+ }
449
+ }
450
+ /**
451
+ * Delete an instance together with every volume attached to it, permanently.
452
+ *
453
+ * The volume ids are read back from the instance and NAMED explicitly: an
454
+ * empty `volume_ids` retains them all, and omitting the field deletes only
455
+ * the OS volume while detaching the rest — both leave billable storage
456
+ * behind. Returns false when the instance no longer exists.
457
+ */
458
+ async destroyWithVolumes(instanceId, extraVolumeId) {
459
+ const instance = await this.getInstance(instanceId);
460
+ if (!instance) {
461
+ return false;
462
+ }
463
+ const volumeIds = [
464
+ ...new Set([
465
+ instance.os_volume_id,
466
+ ...(instance.volume_ids ?? []),
467
+ extraVolumeId,
468
+ ].filter((id) => Boolean(id))),
469
+ ];
470
+ await this.deleteInstance(instanceId, { volumeIds, permanent: true });
471
+ return true;
472
+ }
473
+ /**
474
+ * `PUT /v1/instances` delete. `volumeIds: []` retains every attached volume;
475
+ * a non-empty list deletes exactly those.
476
+ */
477
+ async deleteInstance(instanceId, opts) {
478
+ const body = {
479
+ action: "delete",
480
+ id: assertSafeId(instanceId, "instance id"),
481
+ volume_ids: opts.volumeIds.map((id) => assertSafeId(id, "volume id")),
482
+ };
483
+ if (opts.permanent && opts.volumeIds.length > 0) {
484
+ body.delete_permanently = true;
485
+ }
486
+ const { status, body: result } = await this.api.request("PUT", "/v1/instances", body);
487
+ // 204 means the action was already satisfied. 202 accepted it. 207 means a
488
+ // bulk action partly failed — with a single id, that is a failure.
489
+ if (status === 207) {
490
+ const failure = Array.isArray(result)
491
+ ? result.find((entry) => entry.status === "error")
492
+ : undefined;
493
+ throw new Error(`Verda refused to delete instance ${instanceId}: ` +
494
+ `${failure?.message ?? "unknown error"}`);
495
+ }
496
+ }
497
+ /** `GET /v1/instances/{id}`, or null when it no longer exists. */
498
+ async getInstance(id) {
499
+ try {
500
+ const { body } = await this.api.request("GET", `/v1/instances/${assertSafeId(id, "instance id")}`);
501
+ // A deleted instance can answer 200 with the `notfound` status rather
502
+ // than a 404, so both shapes are treated as gone.
503
+ if (!body || body.status === "notfound") {
504
+ return null;
505
+ }
506
+ return body;
507
+ }
508
+ catch (err) {
509
+ if (err instanceof VerdaApiError && err.status === 404) {
510
+ return null;
511
+ }
512
+ throw err;
513
+ }
514
+ }
515
+ /** Poll until the instance reports `running`, or fail on a terminal state. */
516
+ async waitForReady(id, opts = {}) {
517
+ const deadline = Date.now() + (opts.timeoutMs ?? 600_000);
518
+ const intervalMs = opts.intervalMs ?? 10_000;
519
+ let last;
520
+ while (Date.now() < deadline) {
521
+ const instance = await this.getInstance(id);
522
+ last = instance?.status;
523
+ const status = mapInstanceStatus(instance?.status);
524
+ if (instance && status === "running") {
525
+ return instance;
526
+ }
527
+ if (status === "error" || status === "terminated") {
528
+ throw new Error(`Verda instance ${id} reached terminal status ` +
529
+ `"${instance?.status ?? "deleted"}"`);
530
+ }
531
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
532
+ }
533
+ throw new Error(`Verda instance ${id} did not become ready within the timeout ` +
534
+ `(last status: ${last ?? "unknown"})`);
535
+ }
536
+ }
537
+ /** Pull a resource id out of a create response, which may be a bare string. */
538
+ function extractId(body, label) {
539
+ if (typeof body === "string" && body.trim()) {
540
+ return body.trim().replace(/^"|"$/g, "");
541
+ }
542
+ if (body && typeof body === "object") {
543
+ const id = body.id;
544
+ if (typeof id === "string" && id) {
545
+ return id;
546
+ }
547
+ }
548
+ throw new Error(`Verda ${label} creation returned no id`);
549
+ }
550
+ /** Reduce a profile name to a valid hostname label. */
551
+ function sanitizeHostname(name) {
552
+ const cleaned = name
553
+ .toLowerCase()
554
+ .replace(/[^a-z0-9-]/g, "-")
555
+ .replace(/-+/g, "-")
556
+ .replace(/^-|-$/g, "")
557
+ .slice(0, 60);
558
+ return cleaned || "nodetool-worker";
559
+ }
560
+ //# sourceMappingURL=verda.js.map